From 0bb61f53c6122481c2091c6cd74b28cc77803754 Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu <106619546+bilawalsidhu@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:26:41 -0500 Subject: [PATCH] fix(transit): keep the real silhouettes under sensor presets, make the selected trail visible, put Transit in Movement (#596) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Changes Follow-up to #587 after the owner's first look at the merged transit layer on Google 3D tiles. - **Sensor presets keep the real silhouettes.** Under NVG, thermal (white-hot, black-hot, Ironbow) and noir, vehicles are the same silhouettes as in normal view, filled opaque white with a 2 px dark halo, at the CRT display sizes (26 px fleet, 39 px selected). The solid rounded "signature" bodies are gone. - **The selected vehicle's trail is visible.** The retained history is now draped onto 3D tiles and terrain (`GroundPolylinePrimitive`, `ClassificationType.BOTH`, the way the traffic heat-lines are drawn), 3 px core over a 5 px dark backing, per-segment age colour, a short draped head clipped to the sprite. Two defects kept it off screen before: Cesium's per-instance attribute getters return copies, so the renderer's show/colour edits were discarded, and a marker did not recover after the camera jumped to it, which hid the trail with it. Trail geometry is now prepared on selection independent of floor resolution and marker visibility; sparse long intervals (rail) keep a bounded marker corridor instead of dropping the interval; classification survives map-source changes. - **Transit sits in the Movement panel group** between Street Traffic and Bike Share (the panel orders by `PANEL_GROUPS`, not by catalog order; the catalog is moved too). ## How it was verified - `npm run format:check` (854 files), `npm run check:boundaries`, `npm test` (4,062 passing), `npm run build` clean; tracking regression 109/109 in a real browser on Google 3D tiles. - Browser harness (`scripts/qa-transit.mjs`), keyed on Google 3D tiles and keyless: keyed 194 pass, with the trail pixel check passing at 300, 600 and 900 m; the remaining keyed failures are the per-frame allocation gate already recorded as a follow-up from #587 (CPU and frame time within budget), four readability lines at threshold edges, and the card-anchor check now counting frames where the selected marker is legitimately off screen; keyless 193 pass with the remaining misses being threshold edges on the thin tram silhouette (centre 0.83–0.88 against 0.85) and one bus ring 0.28 against 0.25 on a pure-white roof. - New harness checks: `Google 3D selected trail visible at 6 of 8 projected samples` (repeated off/on framebuffer captures at 300/600/900 m, samples outside the sprite footprint, a background-change negative control; fails on main, passes here), a camera-arrival check (select a moving vehicle, `setView` onto it, marker shown and trail body drawn within 2 s), `PANEL_ORDER` pinned, raster tests comparing every sensor raster's mask and aspect ratio to the normal silhouette. - Direct look on a keyed build: a live CapMetro bus at 30 m/s selected through the layer API draws its draped trail along the highway lane (harness capture `keyed-600m-trail-visible.jpg`). - Adversarial review by Codex Astra before the last two fix passes (stack-switch classification, marker corridor under the vertex cap, pixel-gate loophole), all three fixed with regressions. Docs: `docs/CURRENT-STATE.md` and `CHANGELOG.md` updated in this PR. No data-source changes. --- CHANGELOG.md | 13 +- README.md | 2 +- docs/CURRENT-STATE.md | 87 ++++- scripts/format-scope.json | 2 + scripts/qa-transit-browser.mjs | 144 ++++++-- scripts/qa-transit-recovery.mjs | 330 +++++++++++++++++ scripts/qa-transit-scenes.mjs | 466 +++++++++++++++++++++++++ scripts/qa-transit.mjs | 8 + src/app/constructCatalog.js | 2 +- src/app/constructCatalog.test.mjs | 5 + src/data/contactTrailRenderer.js | 184 +++++++--- src/data/contactTrailRenderer.test.mjs | 191 ++++++++-- src/data/transitIcons.js | 25 +- src/data/transitIcons.test.mjs | 152 ++++++-- src/data/transitPresetStyle.js | 8 +- src/data/transitPresetStyle.test.mjs | 14 +- src/layers/transit/lifecycle.test.mjs | 199 ++++++++++- src/layers/transit/qaMetrics.js | 72 ++++ src/layers/transit/qaMetrics.test.mjs | 103 ++++++ src/layers/transit/rendering.js | 7 + src/layers/transit/trails.js | 45 ++- src/tooling/transitQa.test.mjs | 59 ++++ src/ui/layerPanel.js | 1 + src/ui/layerPanel.test.mjs | 31 ++ 24 files changed, 1945 insertions(+), 205 deletions(-) create mode 100644 scripts/qa-transit-recovery.mjs create mode 100644 src/ui/layerPanel.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index d0f2b022e..ae98dbd56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - Separate application-shell responsibilities and state ownership while preserving the layer, scene and voice API. Revoke pending globe-reset callbacks on disposal and detach old Directions services when replacing a data manager. +- Transit: keep the normal vehicle silhouettes under NVG, thermal and noir (opaque white, CRT sizing, a 2 px dark halo) instead of solid bodies; drape the selected vehicle's trail onto Google 3D tiles and terrain so retained history is actually visible, with the head clipped to the sprite and markers recovering as soon as the camera arrives; place Transit in the Movement panel between Street Traffic and Bike Share. - Extract a portable Director shot runner and connect existing scene playback to it. Preserve authored content, project files, camera/layer behavior and source @@ -86,14 +87,12 @@ - Allow compatible endpoints and server-selected models through construction options. - Cancel pending token/SDP requests on Stop or teardown and reject expired secrets. - ## Configurable geospatial services - Compose geocoding, place context and routes through independent providers. - Allow compatible endpoint configuration without changing voice tools or annotation behavior. - Isolate configured source caches and reject results after cancellation. - ## ALPR camera locations - Port Manjunath's (@manjunath22466) cyan camera badges, coral selection brackets, @@ -323,7 +322,6 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md - Press backtick (`) to toggle a rendered-frame-rate readout beneath the logo. Typing fields retain the key; monitoring stops when hidden. - - Extract vessel feed, store, rendering, selection, trail and card components with explicit source and scene services. - Bound contact retention for incomplete vessel observations, preserve source freshness and refresh history references in place. - Cancel pending vessel history during selection and layer teardown. @@ -407,10 +405,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md - Bound CCTV media response headers to 15 seconds and cancel error bodies. Cap buffered snapshot downloads at 16 MiB while streaming. - - Cancel the active location lookup when its controls are disposed. - ### Fixed - `DATA_SOURCES.md` states what the project does with camera frame content: a @@ -461,7 +457,6 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md - GBFS rejects upstream redirects, caps streamed responses at 5 MiB, and keeps its deadline active through body reads. Rejected downloads are cancelled. - - Split Overpass/installation search, regional briefing/weather, local voice handlers and standalone key setup into focused modules. Preserve routes, source behavior, tool schemas and credential restrictions. @@ -491,7 +486,6 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md fetching, AIS records/tracks and shared request helpers; preserve existing routes, local setup, fallback behavior and rendering. - ### Added - **Directions layer** — keyless A→B directions without a geocoder or a @@ -535,7 +529,6 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md - Rename standalone browser startup to `src/standalone/` and add a Node-only `gods-eye-view/build/vite` export with checked package ownership. - ### Development - The CCTV launcher and preview-server tests resolve their temporary fixture @@ -594,7 +587,6 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md First-run QA now checks the existing attribution Escape-close/focus-return behavior while preserving the launcher-underneath regression checks. - - Datacenter and dam factories are available through scoped package exports with explicit context, overlay and render callbacks. The standalone app uses the same implementation and bundled datasets. @@ -757,6 +749,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [0.1.0] — 2026-08-31 — One-click install, keyless boot, Provider Settings ### Added + - **One-click install** via Pinokio. Keyless boot lands on a live Esri World Imagery satellite globe with keyless terrain; OSM takes over automatically if Esri is unreachable, and the globe continues without terrain if its source is @@ -772,6 +765,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md test suite out of the box (#81 — thanks @ethanstoner). ### Changed + - README rewritten keyless-first around the provider ladder: zero keys → free Cesium ion (eligible personal, non-commercial use) → billing-enabled Google Maps. @@ -781,6 +775,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md and say so plainly when enrichment is unavailable instead of guessing. ### Security + - Provider Settings answers only local, unproxied requests and disables itself entirely whenever the server is shared. Public datacenter and dam datasets omit contact-oriented fields (see the dataset READMEs). diff --git a/README.md b/README.md index 6558a10b5..4224bacc0 100644 --- a/README.md +++ b/README.md @@ -284,9 +284,9 @@ Fifteen layers and map sources. **Thirteen have a keyless path.** Some offer add | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | | 📹 **CCTV Mesh** | ~3,600 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · New South Wales (Live Traffic NSW) · Calgary. Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | +| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 | | 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | | 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | -| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 | | 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 | | 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) | | 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a1fc4c078..55b5f39aa 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2450,9 +2450,9 @@ its criteria cannot be silently ignored. | Traffic | OSM Overpass (+ optional TomTom live flow) | `src/data/traffic.js` | `/api/overpass` + `/api/tomtom` | viewport-driven | | CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | +| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | | Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | | Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | -| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | | Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static | | Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static | | Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static | @@ -2540,7 +2540,9 @@ What a card needs is a resolved ground anchor: a step whose ground cell has not answered yet opens nothing rather than a card hanging at sea level. The cells are re-read for up to 90 seconds, which outlasts a cold terrain round trip; reroute, CLEAR and disable cancel that. -Transit is off by default, and share links use token `j`. The layer polls +Transit is off by default, and share links use token `j`. The movement panel order +places Transit directly after Street Traffic and before Bike Share. Cameras and +utilities retain their own groups. The layer polls registered feeds near the camera every 15 seconds, below a 3,000 km altitude gate. Seven keyless regions are available: Boston, Austin, Minneapolis–St Paul, Helsinki, the Netherlands, Norway and South East Queensland. Mode silhouettes @@ -2565,10 +2567,19 @@ clipped to the displayed time. The browser retains up to 15 minutes and 128 fixes per vehicle, under a global 32 MiB history budget. Capacity pressure may shorten that history. Only the selected vehicle has a trail: completed segments are batched primitives, and a small moving head ends at the marker. Trail -geometry is bounded to 2,048 prepared vertices; history changes rebuild the -body, while ordinary playback updates the head and segment visibility. -Both normal and depth-fail instance colours follow the age ramp once per display -second or style change. Subdivision crossings update only changed visibility +geometry is bounded to 640 prepared vertices, prioritizing the active corridor +and shortening the oldest geometry first. Active and future marker corridors +retain their own 640-vertex budget; long report intervals use coarser subdivisions +than 25 m instead of dropping an admitted vehicle. Supported scenes use ground polylines +classified against both terrain and 3D tiles, including after map-stack changes, +with per-segment age colours. History changes rebuild the body. The head is one +short draped corridor, clipped to displayed time by a material uniform; only +subdivision crossings rebuild it. Ordinary frames upload no head geometry. +Without ground-polyline support, ordinary primitives retain a mode-coloured +0.55-alpha depth-fail path, including the head, without dimming it to near-black. +Instance attribute updates write back the same typed array returned by Cesium; +its getters return copies, so reading a second time discards a visibility or colour edit. +Normal instance colours follow the age ramp once per display second or style change. Subdivision crossings update only changed visibility attributes. Deselecting releases selection geometry; marker paths retain only active and future corridors. @@ -2586,22 +2597,27 @@ ingestion, eviction uses receipt-ordered queues, and capacity loss is disclosed when history is recreated. **Surface and route limitations.** Heights are aligned to work with Google 3D -tiles. Unknown historical surfaces leave gaps in a trail. Sparse reports can +tiles. Draped history is prepared on selection even while marker floors are unresolved; +Cesium resolves its surface. Without ground-polyline support, unknown historical +surfaces leave gaps. A selected body follows layer enablement and vehicle existence, +so an off-screen head does not hide an on-screen tail. Sparse reports can cut across corners; these paths are not surveyed route geometry. Subway markers are projected to street level because the feed does not provide depth, and the selected card explains that choice. Actual street-level views still require visual inspection. **Appearance.** Normal fleet frames are 20 px, growing to 30 px on selection; -NVG, thermal and noir frames are 30/36 px. Source rasters are 48/96 px before padding. -External halos are 1/1.5 px in normal and 3/3 px in sensors, calculated from the +CRT, NVG, thermal and noir frames are 26/39 px. Source rasters are 48/96 px before padding. +External halos are 1/1.5 px in normal, 1.25/1.25 px in CRT and 2/2 px in sensors, calculated from the final unpadded display size. Six modes, two sizes and three profiles cap the raster cache at 36 variants. Normal mode colours are bus `#5EF08A`, tram `#FFC24A`, subway `#FF4538`, rail `#D9A6FF`, ferry `#5FD6FF`, and unknown -`#D8DDE5`. Selection keeps the mode colour. Sensor bodies use solid rounded envelopes with at least 60% opaque white coverage -of the padded display footprint, without interior panel lines. Sensor input is opaque white with a -fully opaque black halo; black-hot reverses the contrast through the -thermal effect. Creation, mode changes, selection, deselection, map presets and +`#D8DDE5`. Selection keeps the mode colour. Sensors reuse each normal silhouette's exact +outer path, filled opaque white without interior panel lines, with an opaque +`#05080C` halo. Only the external padding grows; the unpadded silhouette remains +at CRT size. There is no envelope body. Black-hot reverses the contrast and +Ironbow maps the white input to its hottest palette entry through the thermal +effect; sprite code never applies a red tint. Creation, mode changes, selection, deselection, map presets and Cockpit vision use the same sprite styling function. Sensor signatures remain in-scene. The thresholds in `qaMetrics.js` require @@ -2611,14 +2627,17 @@ of its phosphor peak white (0.65992 from RGB 0.16/1/0.22), halo minimum ≤0.25 with the centre minus sampled background reported without gating. Each preset needs at least six verified, unobscured sprites; insufficient coverage is UNEXERCISED. The sampler excludes overlapping footprints, including otherwise ineligible -sprites, and verifies pick ownership across all nine sampled core pixels. +sprites, and chooses nine source-raster points in the widest solid white band, avoiding +window/panel gaps. Equal-width tram bands prefer the upper solid body over the +geometric centre; the nine-point spacing and all thresholds remain unchanged. +The sampler then verifies pick ownership at all nine points. Noir samples also exclude its deliberately vignetted outer field, using shader settings and screen position rather than observed brightness. Selected-trail diagnostics report every condition even when selection fails. Transit brackets use the normal-composite overlay surface: a 1.25 px mode-colour stroke over 3.25 px dark backing. Other contact themes retain their existing -behavior. The selected trail uses a 2 px core over 4 px backing, mode-coloured +behavior. The selected trail uses a 3 px core over 5 px backing, mode-coloured in normal and white input in sensors. Alpha falls linearly from 0.80 at the head to 0.45 at two minutes, 0.20 at ten minutes and 0.08 at fifteen minutes. @@ -2641,7 +2660,10 @@ heights, and reuses caller-owned sample and segment storage. collections above CCTV and Bikeshare. Traffic, Bikeshare and Transit share camera sensitivity claims; the original sensitivity returns after the last release. Visibility checks combine the camera frustum and ellipsoid occlusion at no more -than 4 Hz; missing map bounds do not admit the globe's far side. Frame work +than 4 Hz; missing map bounds do not admit the globe's far side. Camera arrivals +rebuild an unprepared or cold marker corridor during that visibility sweep, +adopt a loaded surface, and update both heightPending and surfaceReady diagnostics. +A resolved display sample clears its pending-height gate without waiting for a poll. Frame work visits moving visible vehicles, up to 64 pending height changes, and the selected head. Rotation visits visible membership at 5 Hz, with selected rotation per frame. The transit render hold exists only for visible positional or orientation @@ -2655,6 +2677,15 @@ destruction also releases collections and registrations. QA_BASE_URL=http://localhost:4305 QA_TAG=keyless node scripts/qa-transit.mjs ``` +`QA_BASE_URL=http://localhost:4305 node scripts/qa-transit-recovery.mjs` is the +focused keyless camera-arrival regression. With destination tiles loaded, it +injects nine disclosed moving-bus reports and delays their floor acquisition, +selects the hidden vehicle, then releases the floor and calls camera.setView. +Within two seconds it requires a visible marker, a prepared visible body and +at least six of eight paired off/on trail pixel samples. It uses production +selection, visibility and geometry owners and writes screenshots plus JSON; +it does not establish Google 3D acceptance. + The full run samples completed frames at 1920×1080 CSS pixels, records DPR, and measures moving fleets of 800 and 3,000 vehicles with 128 fixes each and DETECT on. Street/metro targets are respectively: transit frame CPU p95 ≤1/4 ms, frame @@ -2685,6 +2716,30 @@ injected playback independently of live fleet activity. Thermal checks verify style activation before reading generated controls; DETECT checks set density and exercise the real OFF/restore button. Cesium render errors fail selection acceptance. +The `trail-visible` section requires a visible Google 3D tileset. It selects a +scripted straight bus with CapMetro metadata under the isolated `qa-trail-capmetro` +feed ID, with nine retained fixes, runs for 20 seconds at each +300/600/900 m view (55/45/45 degrees), then freezes playback and waits for sustained tile and primitive readiness. +It captures off/on/off/on framebuffer patches at eight distinct projected surface +points outside the padded sprite and selection bracket. `Google 3D selected trail visible at 6 of 8 projected samples` +prints the selected key, fix count, elapsed display time, path length, hit count, +per-point coordinates/hits and renderer strategy. At least six points need a +repeatably changed green core or dark backing at the same pixels. Repeated off +and on channels and their contrast deltas must agree within 8 RGB levels; +background refinement, overlapping samples and unchanged dark roads fail. The dark +backing must reduce summed RGB by at least 40%, and either match needs RGB +distance of at least 40, so a faint depth-fail wash cannot satisfy visibility. Artifacts are +`-m-trail-visible.jpg` and matching JSON. Run the same harness +against the base and patched keyed builds; partial runs remain diagnostic. +Node checks run Cesium's final geometry pipeline, including batch IDs and +position encoding: 1,576 bytes per head subdivision and 2,014,128 bytes for the +1,278-instance body. The body plus a separate 81,792-byte instance reserve totals +2,095,920 bytes, below the 2 MiB body limit; the head remains below its separate +2 KiB/frame geometry limit. Ordinary frames upload no head geometry. These are +pipeline buffer measurements, not measured GPU time or instance-texture uploads. The same 20-second browser interval scopes +WebGL uploads to the trail body/head, forces one body rebuild, reports head CPU +p95/max, and gates body uploads at 2 MiB and head uploads at 2 KiB/frame. + Required empty or unexercised checks fail acceptance. The passing final line is `HARNESS: PASS (0 unexercised)`. A `QA_SECTIONS` subset is diagnostic only and cannot claim full acceptance. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b86632108..031aedf31 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -181,6 +181,7 @@ "src/ui/layers.js", "src/ui/layerPanel.js", "src/ui/layerKeyRequirement.test.mjs", + "src/ui/layerPanel.test.mjs", "src/ui/clearLayersControl.js", "scripts/qa-layer-panel.mjs", "scripts/qa-directions.mjs", @@ -723,6 +724,7 @@ "scripts/qa-transit-controls.mjs", "src/tooling/transitQa.test.mjs", "scripts/qa-transit-browser.mjs", + "scripts/qa-transit-recovery.mjs", "scripts/format-runtime.json", "src/tooling/transitService.test.mjs", "src/sources/overpassFeatures.test.mjs" diff --git a/scripts/qa-transit-browser.mjs b/scripts/qa-transit-browser.mjs index 1e71e484e..eb7b59985 100644 --- a/scripts/qa-transit-browser.mjs +++ b/scripts/qa-transit-browser.mjs @@ -367,7 +367,7 @@ export async function sampleTransitPixels(page, palette) { const luma = (r, g, b) => (0.299 * r + 0.587 * g + 0.114 * b) / 255; const readLuma = (cx, cy) => { const px = Math.floor(cx * scale); - const py = Math.floor(canvas.height - 1 - cy * scale); + const py = canvas.height - 1 - Math.floor(cy * scale); const buf = new Uint8Array(4); gl.readPixels(px, py, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, buf); return luma(buf[0], buf[1], buf[2]); @@ -399,46 +399,117 @@ export async function sampleTransitPixels(page, palette) { continue; } } + // Pick nine points from a solid patch of the source silhouette, + // before examining framebuffer brightness. Avoid panels/window gaps. + let corePoints = null, + widestBand = -1; + const cosCore = Math.cos(s.rotation), + sinCore = Math.sin(s.rotation); + // A tram's parallel sides have equally wide bands. Prefer its upper + // body band over the geometric centre when those widths tie. + const offsets = + s.mode === 'tram' + ? [-0.2, 0.2, -0.1, 0.1, 0] + : [0, -0.1, 0.1, -0.2, 0.2]; + for (const offset of offsets) { + const candidates = []; + let solid = true, + bandWidth = Infinity; + // Rank source geometry, never the observed framebuffer brightness. + // A thin tram needs the widest opaque white band, with room for + // the pixel filter on each side of the sampled patch. + for (let dy = -1; dy <= 1; dy++) { + const ry = Math.floor( + ((dy + offset * s.rect.height) / s.rect.height + 0.5) * + s.raster.height, + ); + const middle = Math.floor(s.raster.width / 2); + const white = (x) => + x >= 0 && + x < s.raster.width && + [0, 1, 2, 3].every( + (c) => + s.raster.data[(ry * s.raster.width + x) * 4 + c] >= 240, + ); + let left = middle, + right = middle; + while (white(left - 1)) left--; + while (white(right + 1)) right++; + bandWidth = Math.min( + bandWidth, + white(middle) ? right - left + 1 : 0, + ); + } + for (let dx = -1; dx <= 1; dx++) + for (let dy = -1; dy <= 1; dy++) { + const localY = dy + offset * s.rect.height; + const rx = Math.floor( + (dx / s.rect.width + 0.5) * s.raster.width, + ); + const ry = Math.floor( + (localY / s.rect.height + 0.5) * s.raster.height, + ); + const i = (ry * s.raster.width + rx) * 4; + if ([0, 1, 2, 3].some((c) => s.raster.data[i + c] < 240)) + solid = false; + candidates.push( + screenPoint( + s.screenX + dx * cosCore + localY * sinCore, + s.screenY - dx * sinCore + localY * cosCore, + ), + ); + } + if (solid && bandWidth > widestBand) { + corePoints = candidates; + widestBand = bandWidth; + } + } + if (!corePoints) { + skipped.push({ + key: s.key, + reason: 'no solid silhouette interior for nine samples', + }); + continue; + } // Picking is independent of rendered brightness: an overlapping // label or sprite must not masquerade as a dim sensor signature. // Check the whole sampled core, not just its centre pick. let coreOwned = true; if (scene.pick) { const framebuffer = gl.getParameter?.(gl.FRAMEBUFFER_BINDING); - for (let dx = -1; dx <= 1; dx++) - for (let dy = -1; dy <= 1; dy++) { - let u = (s.x + dx) / width, - v = 1 - (s.y + dy) / height; - if (style === 'surveillance') { - const x = u * 2 - 1, - y = v * 2 - 1, - r2 = x * x + y * y; - const d = - 1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075; - u = (x * d + 1) / 2; - v = (y * d + 1) / 2; - } - if (sensor) { - const grid = 1 + (pixelation - 1) * intensity; - u += - ((Math.floor((u * canvas.width) / grid) * grid) / - canvas.width - - u) * - intensity; - v += - ((Math.floor((v * canvas.height) / grid) * grid) / - canvas.height - - v) * - intensity; - } - const picked = scene.pick( - { x: u * width, y: (1 - v) * height }, - 1, - 1, - ); - if (picked?.id !== s.key && picked?.primitive?.id !== s.key) - coreOwned = false; + for (const core of corePoints) { + let u = core.x / width, + v = 1 - core.y / height; + if (style === 'surveillance') { + const x = u * 2 - 1, + y = v * 2 - 1, + r2 = x * x + y * y; + const d = + 1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075; + u = (x * d + 1) / 2; + v = (y * d + 1) / 2; } + if (sensor) { + const grid = 1 + (pixelation - 1) * intensity; + u += + ((Math.floor((u * canvas.width) / grid) * grid) / + canvas.width - + u) * + intensity; + v += + ((Math.floor((v * canvas.height) / grid) * grid) / + canvas.height - + v) * + intensity; + } + const picked = scene.pick( + { x: u * width, y: (1 - v) * height }, + 1, + 1, + ); + if (picked?.id !== s.key && picked?.primitive?.id !== s.key) + coreOwned = false; + } if (gl.bindFramebuffer) gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); } @@ -451,9 +522,8 @@ export async function sampleTransitPixels(page, palette) { continue; } let centre = 0; - for (let dx = -1; dx <= 1; dx += 1) - for (let dy = -1; dy <= 1; dy += 1) - centre += readLuma(s.x + dx, s.y + dy) / 9; + for (const core of corePoints) + centre += readLuma(core.x, core.y) / corePoints.length; let ringMin = 1; let ringMax = 0; // Sample the actual rotated raster halo, not a fixed-radius circle diff --git a/scripts/qa-transit-recovery.mjs b/scripts/qa-transit-recovery.mjs new file mode 100644 index 000000000..e5fadda49 --- /dev/null +++ b/scripts/qa-transit-recovery.mjs @@ -0,0 +1,330 @@ +/** Camera-jump regression against an existing server; never starts a server. */ +import puppeteer from 'puppeteer'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js'; + +const base = process.env.QA_BASE_URL || 'http://localhost:4305'; +const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery'; +await mkdir(shots, { recursive: true }); +const browser = await puppeteer.launch({ + headless: false, + executablePath: + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + args: [ + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + ], +}); +try { + const page = await browser.newPage(); + await page.setViewport({ width: 1920, height: 1080 }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto( + `${base}/?welcome=0#lat=30.271&lon=-97.7431&alt=600&heading=0&pitch=-60`, + ); + await page.waitForFunction(() => + window.__godsEyeView?.dataManager?.layers.has('transit'), + ); + await page.evaluate(async () => { + const app = window.__godsEyeView; + await app.dataManager.setEnabled('transit', true, { source: 'qa' }); + }); + await page.waitForFunction( + () => { + const scene = window.__godsEyeView.viewer.scene; + return scene.globe.tilesLoaded && scene.globe.show; + }, + { timeout: 60000 }, + ); + await page.waitForFunction( + () => { + const cover = document.getElementById('loading-screen'); + return !cover || cover.classList.contains('hidden'); + }, + { timeout: 60000 }, + ); + await page.evaluate(() => { + const app = window.__godsEyeView, + camera = app.viewer.camera; + const C = camera.positionCartographic.constructor, + lat = 30.267 + 90 / 111320; + const floor = + app.viewer.scene.globe.getHeight(C.fromDegrees(-97.7431, lat)) || 0; + camera.setView({ + destination: C.toCartesian( + C.fromDegrees( + -97.7431, + lat - 600 / Math.tan(Math.PI / 3) / 111320, + floor + 600, + ), + ), + orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 }, + }); + }); + await new Promise((resolve) => setTimeout(resolve, 2000)); + await page.waitForFunction( + () => window.__godsEyeView.viewer.scene.globe.tilesLoaded, + { timeout: 60000 }, + ); + await new Promise((resolve) => setTimeout(resolve, 1500)); + const result = await page.evaluate(async () => { + const app = window.__godsEyeView, + scene = app.viewer.scene, + camera = app.viewer.camera; + const layer = app.dataManager.layers.get('transit').module; + const state = layer._transitStateForTest(), + parts = layer._transitPartsForTest(); + const { getRegisteredTransitFeed } = + await import('/src/data/transitFeeds.js'); + const { setRate } = await import('/src/data/contactPlayback.js'); + const C = camera.positionCartographic.constructor; + const feed = { + ...getRegisteredTransitFeed('capmetro-austin'), + id: 'qa-camera-recovery', + }; + const aim = (lat, height = 600) => + camera.setView({ + destination: C.toCartesian( + C.fromDegrees( + -97.7431, + lat - 600 / Math.tan(Math.PI / 3) / 111320, + height, + ), + ), + orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 }, + }); + parts.selection.clearSelection(); + parts.ingestion.abortAllInFlight(); + state._activeFeeds.clear(); + for (const key of state._vehicles.keys()) + parts.ingestion.removeVehicle(key); + // Model an outstanding floor lookup while reports arrive off screen. + // Only acquisition is controlled; selection, visibility, paths and rendering + // use their real owners. Release to the loaded terrain at the camera jump. + const requestHeight = parts.height.requestHeight; + parts.height.requestHeight = (lat, lon) => ({ + cell: `${lat},${lon}`, + height: null, + prior: null, + }); + aim(30.28); + const now = Date.now(); + for (let i = 0; i < 9; i++) { + const timestamp = now - 135000 + i * 15000; + parts.ingestion.applySnapshot( + feed, + { + fetchedAt: timestamp, + vehicles: [ + { + id: 'moving-bus', + routeId: '1', + tripId: 'qa', + lat: 30.267 + (i * 90) / 111320, + lon: -97.7431, + timestamp: timestamp / 1000, + timestampSource: 'vehicle', + bearing: 0, + }, + ], + }, + { stale: false }, + ); + } + const entry = state._vehicles.get(`${feed.id}:moving-bus`); + entry.qaFixture = true; // Protect this disclosed fixture from live feed eviction. + parts.rendering.sampleIdle(entry); + const before = { + shown: entry.marker.show, + heightPending: entry.heightPending, + phase: entry.sample.phase, + fixes: entry.track.count, + }; + clearTimeout(state._floorTimer); + state._floorTimer = null; + const started = performance.now(); + parts.selection.selectVehicle(entry.key); + const preparedAtSelection = !!parts.trails.diagnostics()?.body; + parts.height.requestHeight = requestHeight; + const floor = + scene.globe.getHeight( + C.fromDegrees(entry.sample.lon, entry.sample.lat), + ) || 0; + aim(entry.sample.lat, floor + 600); + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + let trail; + while (performance.now() - started < 1400) { + trail = parts.trails.diagnostics(); + if (entry.marker.show && trail?.body?.show && trail.body.ready) break; + scene.requestRender(); + await wait(25); + } + const status = { + before, + preparedAtSelection, + markerShow: entry.marker.show, + body: !!trail?.body, + bodyShow: trail?.body?.show === true, + visibility: layer + ._transitVisibilityForTest() + .find((e) => e.key === entry.key), + recoveryMs: performance.now() - started, + }; + if (!entry.marker.show || !trail?.body?.show || !trail.body.ready) + return { + ...status, + reason: 'marker or body did not recover before pixel deadline', + }; + setRate(entry.track, 0, { + wallNowMs: Date.now(), + monoNowMs: performance.now(), + }); + parts.rendering.sampleIdle(entry); + parts.rendering.schedulePlayback(entry); + const V = entry.marker.position.constructor; + const points = entry.trailSegments + .filter((s) => s.toT <= entry.sample.displayT) + .flatMap((s) => s.positions); + const lengths = [0]; + for (let i = 1; i < points.length; i++) + lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i])); + const canvas = scene.canvas, + gl = scene.context._gl, + scale = canvas.width / canvas.clientWidth; + const mul = (m, v) => + [0, 1, 2, 3].map( + (i) => + m[i] * v[0] + m[i + 4] * v[1] + m[i + 8] * v[2] + m[i + 12] * v[3], + ); + const project = (p) => { + const q = mul( + camera.frustum.projectionMatrix, + mul(camera.viewMatrix, [p.x, p.y, p.z, 1]), + ); + return { + x: ((q[0] / q[3]) * 0.5 + 0.5) * canvas.clientWidth, + y: (0.5 - (q[1] / q[3]) * 0.5) * canvas.clientHeight, + inFront: q[3] > 0, + }; + }; + const samples = Array.from({ length: 8 }, (_, i) => { + const d = (lengths.at(-1) * (i + 1)) / 9; + let j = 1; + while (j < lengths.length - 1 && lengths[j] < d) j++; + const p = V.lerp( + points[j - 1], + points[j], + (d - lengths[j - 1]) / Math.max(0.001, lengths[j] - lengths[j - 1]), + new V(), + ); + const cart = C.fromCartesian(p); + cart.height = scene.globe.getHeight(cart) || 0; + return { + ...project(C.toCartesian(cart)), + radius: (Math.SQRT2 * Math.ceil(3 * scale)) / scale, + }; + }); + const sprite = { + ...project(entry.marker.position), + radius: Math.hypot(entry.marker.width, entry.marker.height) / 2 + 4, + }; + const read = () => + samples.map(({ x, y }) => { + const r = Math.ceil(3 * scale), + px = Math.round(x * scale), + py = Math.round(canvas.height - 1 - y * scale); + if ( + px < r || + py < r || + px + r >= canvas.width || + py + r >= canvas.height + ) + return null; + const data = new Uint8Array((2 * r + 1) ** 2 * 4); + gl.readPixels( + px - r, + py - r, + 2 * r + 1, + 2 * r + 1, + gl.RGBA, + gl.UNSIGNED_BYTE, + data, + ); + return Array.from(data); + }); + const frame = () => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + remove(); + reject(new Error('No recovery frame')); + }, 500); + const remove = scene.postRender.addEventListener(() => { + remove(); + clearTimeout(timer); + resolve(read()); + }); + scene.requestRender(); + }); + let visible = true; + const remove = scene.preRender.addEventListener(() => { + trail.body.show = visible; + if (trail.headPrimitive) + trail.headPrimitive.show = visible && trail.head.show; + }); + const captures = []; + try { + for (const show of [false, true, false, true]) { + visible = show; + await frame(); + captures.push(await frame()); + } + } finally { + remove(); + parts.trails.update(); + } + const [off, on, offAgain, onAgain] = captures; + return { + ...status, + samples, + sprite, + off, + on, + offAgain, + onAgain, + tilesReady: + scene.globe.tilesLoaded && + parts.trails.diagnostics().body === trail.body, + elapsedMs: performance.now() - started, + attributes: Array.from({ length: 4 }, (_, i) => { + const a = trail.body.getGeometryInstanceAttributes(`${i}:1`); + return { show: Array.from(a.show), color: Array.from(a.color) }; + }), + }; + }); + const pixels = reduceTrailPixels(result.on, result.off, result); + result.present = pixels.present; + result.pixelPass = pixels.pass; + result.pixelDetails = pixels; + result.errors = errors; + await page.screenshot({ path: `${shots}/recovery.png` }); + await writeFile(`${shots}/recovery.json`, JSON.stringify(result, null, 2)); + for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key]; + const pass = + result.before?.shown === false && + result.before?.phase === 'playing' && + result.preparedAtSelection && + result.markerShow && + result.body && + result.bodyShow && + result.elapsedMs <= 2000 && + pixels.pass && + !errors.length; + console.log( + `${pass ? 'PASS' : 'FAIL'} select moving bus, setView, marker + body + >=6/8 trail pixels within 2 s`, + ); + console.log(JSON.stringify(result)); + process.exitCode = pass ? 0 : 1; +} finally { + await browser.close(); +} diff --git a/scripts/qa-transit-scenes.mjs b/scripts/qa-transit-scenes.mjs index 94ec134bb..1ceb45b22 100644 --- a/scripts/qa-transit-scenes.mjs +++ b/scripts/qa-transit-scenes.mjs @@ -1,3 +1,12 @@ +import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js'; + +// Not registered or active: camera discovery and live polls never own this feed. +/** CapMetro metadata under a private QA identity for selected-trail pixels. */ +export const TRAIL_VISIBILITY_FEED = Object.freeze({ + ...getRegisteredTransitFeed('capmetro-austin'), + id: 'qa-trail-capmetro', +}); + import { reportTransitVisibility } from './qa-transit-browser.mjs'; import { writeFile } from 'node:fs/promises'; import { @@ -5,6 +14,7 @@ import { reduceMotion, reduceBackground, reduceSensorContrast, + reduceTrailPixels, } from '../src/layers/transit/qaMetrics.js'; /** CDP includes objects collected during the sample, not just surviving objects. */ @@ -581,3 +591,459 @@ export async function runBostonLive({ JSON.stringify(evidence, null, 2), ); } + +/** Google 3D proof: paired framebuffer reads reject coincidental road colours. */ +export async function runTrailVisibility({ + page, + check, + wait, + shots, + tag, + selectStyle, + altitude = 600, + pitch = 45, +}) { + const name = 'Google 3D selected trail visible at 6 of 8 projected samples'; + await selectStyle(page, 'normal'); + await page.evaluate( + async ({ altitude, pitch }) => { + const app = window.__godsEyeView; + await app.dataManager.setEnabled('transit', true, { source: 'qa' }); + if (app.mapStackController.isStackAvailable('photoreal')) + await app.mapStackController.setStack('photoreal'); + const camera = app.viewer.camera, + C = camera.positionCartographic.constructor; + camera.setView({ + destination: C.toCartesian( + C.fromDegrees( + -97.7431, + 30.26725 - altitude / Math.tan((pitch * Math.PI) / 180) / 111320, + altitude, + ), + ), + orientation: { heading: 0, pitch: (-pitch * Math.PI) / 180, roll: 0 }, + }); + }, + { altitude, pitch }, + ); + await wait(8000); + const setup = await page.evaluate( + async ({ altitude, pitch, feed }) => { + const app = window.__godsEyeView, + scene = app.viewer.scene; + const layer = app.dataManager.layers.get('transit').module; + const state = layer._transitStateForTest(), + parts = layer._transitPartsForTest(); + const google = + !!app.tileset && + app.tileset.show !== false && + app.mapStackController.getActiveId() === 'photoreal'; + if (!google) + return { google: false, reason: 'visible Google 3D tileset required' }; + const C = scene.camera.positionCartographic.constructor; + const centreLat = 30.267 + 90 / 111320; + const floor = scene.sampleHeight(C.fromDegrees(-97.7431, centreLat)); + if (Number.isFinite(floor)) + scene.camera.setView({ + destination: C.toCartesian( + C.fromDegrees( + -97.7431, + centreLat - altitude / Math.tan((pitch * Math.PI) / 180) / 111320, + floor + altitude, + ), + ), + orientation: { heading: 0, pitch: (-pitch * Math.PI) / 180, roll: 0 }, + }); + parts.selection.clearSelection(); + parts.ingestion.abortAllInFlight(); + state._activeFeeds.clear(); + for (const key of state._vehicles.keys()) + parts.ingestion.removeVehicle(key); + clearTimeout(state._cameraDebounceTimer); + state._cameraDebounceTimer = null; + const { seek, setRate } = await import('/src/data/contactPlayback.js'); + const now = Date.now(); + // Nine straight street fixes, 30 m apart. Use the production surface path. + for (let i = 0; i < 9; i++) { + const timestamp = (now - 120000 + i * 15000) / 1000; + parts.ingestion.applySnapshot( + feed, + { + fetchedAt: timestamp * 1000, + vehicles: [ + { + id: 'trail-visible', + routeId: '1', + tripId: 'trail-visible', + lat: 30.267 + (i * 30) / 111320, + lon: -97.7431, + timestamp, + timestampSource: 'vehicle', + bearing: 0, + }, + ], + }, + { stale: false }, + ); + } + const entry = state._vehicles.get(`${feed.id}:trail-visible`); + entry.qaFixture = true; + const clocks = { wallNowMs: now, monoNowMs: performance.now() }; + seek(entry.track, now - 45000, clocks); + setRate(entry.track, 1, clocks); + parts.rendering.sampleIdle(entry); + parts.height.anchorFloors(); + parts.rendering.refreshVisibility(); + parts.rendering.maintainPresentation(); + parts.selection.selectVehicle(entry.key); + return { + google, + key: entry.key, + fixes: entry.track.count, + startDisplayT: now - 45000, + startLat: entry.sample.lat, + }; + }, + { altitude, pitch, feed: TRAIL_VISIBILITY_FEED }, + ); + if (!setup.google) { + check(name, false, JSON.stringify(setup), { unexercised: true }); + return; + } + const cost = await page.evaluate(async (key) => { + const app = window.__godsEyeView, + scene = app.viewer.scene; + const layer = app.dataManager.layers.get('transit').module; + const parts = layer._transitPartsForTest(), + entry = layer._transitStateForTest()._vehicles.get(key); + const gl = scene.context._gl, + originals = new Map(), + wrapped = new Map(); + let owner = null, + headBytes = 0, + bodyBytes = 0, + headUploadMax = 0, + bodyUploadMax = 0, + frames = 0; + const headCpu = []; + const wrap = (primitive, kind) => { + if (!primitive || wrapped.has(primitive)) return; + const update = primitive.update; + wrapped.set(primitive, update); + primitive.update = function (frameState) { + const before = performance.now(); + owner = kind; + try { + return update.call(this, frameState); + } finally { + owner = null; + if (kind === 'head') headCpu.push(performance.now() - before); + } + }; + }; + for (const name of ['bufferData', 'bufferSubData']) { + const original = gl[name]; + originals.set(name, original); + gl[name] = function (...args) { + if (owner) { + const value = args[name === 'bufferData' ? 1 : 2], + offset = args[3] || 0, + length = args[4]; + const bytes = + typeof value === 'number' + ? value + : value + ? length !== undefined + ? length * (value.BYTES_PER_ELEMENT || 1) + : value.byteLength - offset * (value.BYTES_PER_ELEMENT || 1) + : 0; + if (owner === 'head') headBytes += bytes; + else bodyBytes += bytes; + } + return original.apply(this, args); + }; + } + const pre = scene.preRender.addEventListener(() => { + headBytes = bodyBytes = 0; + const trail = parts.trails.diagnostics(); + wrap(trail?.body, 'body'); + wrap(trail?.headPrimitive || trail?.head?._polylineCollection, 'head'); + }); + const post = scene.postRender.addEventListener(() => { + frames++; + headUploadMax = Math.max(headUploadMax, headBytes); + bodyUploadMax = Math.max(bodyUploadMax, bodyBytes); + }); + try { + // Include a full history upload, even if the first selection already rendered. + entry.pathRevision = -1; + parts.trails.prepareEntry(entry); + scene.requestRender(); + await new Promise((resolve) => setTimeout(resolve, 20000)); + headCpu.sort((a, b) => a - b); + return { + frames, + headUploadMax, + bodyUploadMax, + headCpuP95Ms: headCpu[Math.floor(headCpu.length * 0.95)] ?? null, + headCpuMaxMs: headCpu.at(-1) ?? null, + }; + } finally { + pre(); + post(); + for (const [name, original] of originals) gl[name] = original; + for (const [primitive, update] of wrapped) + if (!primitive.isDestroyed()) primitive.update = update; + } + }, setup.key); + const result = await page.evaluate(async (setup) => { + const app = window.__godsEyeView, + scene = app.viewer.scene; + const layer = app.dataManager.layers.get('transit').module; + const state = layer._transitStateForTest(), + parts = layer._transitPartsForTest(); + const entry = state._vehicles.get(setup.key); + const trail = parts.trails.diagnostics(); + if (!entry || !trail) + return { ...setup, present: 0, reason: 'selection disappeared' }; + const { setRate } = await import('/src/data/contactPlayback.js'); + setRate(entry.track, 0, { + wallNowMs: Date.now(), + monoNowMs: performance.now(), + }); + parts.rendering.sampleIdle(entry); + parts.rendering.schedulePlayback(entry); + parts.rendering.syncRenderHold(); + const ready = () => + app.tileset?.show !== false && + app.tileset?.tilesLoaded === true && + (scene.globe?.show === false || scene.globe?.tilesLoaded === true) && + trail.body?.ready === true && + (!trail.headPrimitive || trail.headPrimitive.ready === true); + // Require sustained readiness after freezing playback, before projecting + // onto the refined surface. Repeated captures still check pixel stability. + const tilesReady = await new Promise((resolve) => { + const started = performance.now(); + let readySince = null; + const timer = setInterval(() => { + const now = performance.now(); + readySince = ready() ? (readySince ?? now) : null; + scene.requestRender(); + if ( + (readySince !== null && now - readySince >= 500) || + now - started >= 30000 + ) { + clearInterval(timer); + resolve(readySince !== null && now - readySince >= 500); + } + }, 80); + }); + if (!tilesReady) + return { + ...setup, + present: 0, + reason: 'tiles or trail geometry did not become ready', + }; + const canvas = scene.canvas, + gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); + const scale = canvas.width / canvas.clientWidth; + const points = entry.trailSegments + .filter((s) => s.toT <= entry.sample.displayT) + .flatMap((s) => s.positions); + if (trail.head?.show) points.push(...trail.head.positions); + if (points.length < 2) + return { ...setup, present: 0, reason: 'no completed path' }; + const C = scene.camera.positionCartographic.constructor; + const V = entry.marker.position.constructor; + const lengths = [0]; + for (let i = 1; i < points.length; i++) + lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i])); + const mul = (m, v) => + [0, 1, 2, 3].map( + (i) => + m[i] * v[0] + m[i + 4] * v[1] + m[i + 8] * v[2] + m[i + 12] * v[3], + ); + const samples = Array.from({ length: 8 }, (_, i) => { + const d = (lengths.at(-1) * (i + 1)) / 9; + let j = 1; + while (j < lengths.length - 1 && lengths[j] < d) j++; + const p = V.lerp( + points[j - 1], + points[j], + (d - lengths[j - 1]) / Math.max(0.001, lengths[j] - lengths[j - 1]), + new V(), + ); + const cart = C.fromCartesian(p); + const height = scene.sampleHeight( + cart, + [entry.marker, trail.body, trail.headPrimitive].filter(Boolean), + ); + if (Number.isFinite(height)) cart.height = height; + const surface = C.toCartesian(cart); + const q = mul( + scene.camera.frustum.projectionMatrix, + mul(scene.camera.viewMatrix, [surface.x, surface.y, surface.z, 1]), + ); + return { + x: ((q[0] / q[3]) * 0.5 + 0.5) * canvas.clientWidth, + y: (0.5 - (q[1] / q[3]) * 0.5) * canvas.clientHeight, + inFront: q[3] > 0, + radius: (Math.SQRT2 * Math.ceil(3 * scale)) / scale, + }; + }); + const marker = entry.marker.position; + const markerClip = mul( + scene.camera.frustum.projectionMatrix, + mul(scene.camera.viewMatrix, [marker.x, marker.y, marker.z, 1]), + ); + const sprite = { + x: ((markerClip[0] / markerClip[3]) * 0.5 + 0.5) * canvas.clientWidth, + y: (0.5 - (markerClip[1] / markerClip[3]) * 0.5) * canvas.clientHeight, + // Include the padded, rotated sprite and its selection bracket. + radius: Math.hypot(entry.marker.width, entry.marker.height) / 2 + 4, + }; + const read = () => + samples.map(({ x, y, inFront }) => { + const r = Math.ceil(3 * scale), + px = Math.round(x * scale), + py = Math.round(canvas.height - 1 - y * scale); + if ( + !inFront || + px < r || + py < r || + px + r >= canvas.width || + py + r >= canvas.height + ) + return null; + const data = new Uint8Array((2 * r + 1) ** 2 * 4); + gl.readPixels( + px - r, + py - r, + 2 * r + 1, + 2 * r + 1, + gl.RGBA, + gl.UNSIGNED_BYTE, + data, + ); + return data; + }); + const frame = () => + new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + remove(); + reject(new Error('Trail framebuffer did not render within 5 s')); + }, 5000); + const remove = scene.postRender.addEventListener(() => { + remove(); + clearTimeout(timeout); + resolve(read()); + }); + scene.requestRender(); + }); + const bodyShow = trail.body?.show, + headShow = trail.head?.show, + headPrimitiveShow = trail.headPrimitive?.show, + backingShow = trail.backing?.show; + let showTrail = false; + const applyVisibility = () => { + if (trail.body) trail.body.show = showTrail && bodyShow; + if (trail.headPrimitive) + trail.headPrimitive.show = showTrail && headPrimitiveShow; + if (trail.head) trail.head.show = showTrail && headShow; + if (trail.backing) trail.backing.show = showTrail && backingShow; + }; + // Override the layer's frame update in both states; selection stays fixed. + const removeVisibility = scene.preRender.addEventListener(applyVisibility); + const captures = []; + let captureReady = true; + try { + for (const visible of [false, true, false, true]) { + showTrail = visible; + // Let post-processing settle before each measured framebuffer. + for (let i = 0; i < 3; i++) await frame(); + captures.push(await frame()); + const current = parts.trails.diagnostics(); + captureReady &&= + ready() && + current?.body === trail.body && + current?.headPrimitive === trail.headPrimitive; + } + } finally { + removeVisibility(); + if (trail.body) trail.body.show = bodyShow; + if (trail.headPrimitive) trail.headPrimitive.show = headPrimitiveShow; + if (trail.head) trail.head.show = headShow; + if (trail.backing) trail.backing.show = backingShow; + await frame(); + } + const [off, on, offAgain, onAgain] = captures.map((patches) => + patches.map((p) => (p ? Array.from(p) : null)), + ); + return { + ...setup, + fixes: entry.track.count, + selected: state._selectedKey === setup.key, + pathMetres: lengths.at(-1), + samples, + on, + off, + onAgain, + offAgain, + sprite, + tilesReady: captureReady, + elapsedDisplayMs: entry.sample.displayT - setup.startDisplayT, + movedMetres: Math.abs(entry.sample.lat - setup.startLat) * 111320, + body: trail.body?.constructor.name, + headStrategy: trail.headStrategy || 'PolylineCollection', + headRebuilds: trail.headRebuilds ?? null, + }; + }, setup); + const pixels = reduceTrailPixels(result.on, result.off, result); + result.cost = cost; + result.altitude = altitude; + result.pitch = pitch; + result.present = pixels.present; + result.total = pixels.total; + result.samples = result.samples?.map((s, i) => ({ + ...s, + hit: pixels.hits[i], + stable: pixels.stable[i], + eligible: pixels.eligible[i], + })); + await page.screenshot({ + path: `${shots}/${tag}-${altitude}m-trail-visible.jpg`, + type: 'jpeg', + quality: 95, + }); + await writeFile( + `${shots}/${tag}-${altitude}m-trail-visible.json`, + JSON.stringify(result, null, 2), + ); + for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key]; + check( + name, + result.google && + result.selected && + result.fixes >= 5 && + result.pathMetres > 15 && + result.elapsedDisplayMs >= 19000 && + result.movedMetres >= 5 && + pixels.pass, + JSON.stringify(result), + ); + check( + 'selected trail stays within 2 MiB rebuild and 2 KiB/frame head uploads', + cost.frames > 30 && + cost.bodyUploadMax > 0 && + cost.bodyUploadMax <= 2 * 1024 * 1024 && + cost.headUploadMax > 0 && + cost.headUploadMax <= 2048, + JSON.stringify({ altitude, ...cost }), + ); + await page.evaluate(() => + window.__godsEyeView.dataManager.layers + .get('transit') + .module._transitPartsForTest() + .selection.clearSelection(), + ); +} diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index de09a6222..be39546ea 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -16,6 +16,7 @@ import { runFleetBudgets, runBostonMatrix, runBostonLive, + runTrailVisibility, } from './qa-transit-scenes.mjs'; /** * Browser proof for the Transit layer against a running app. @@ -1774,6 +1775,13 @@ try { selectStyle, sampleRendered, }; + if (runs('trail-visible')) + for (const [altitude, pitch] of [ + [300, 55], + [600, 45], + [900, 45], + ]) + await runTrailVisibility({ ...sceneQA, altitude, pitch }); if (runs('budgets')) await runFleetBudgets(sceneQA); if (runs('matrix')) await runBostonMatrix(sceneQA); if (runs('boston-live')) await runBostonLive(sceneQA); diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 47ffe5658..f22ce2cd4 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -117,9 +117,9 @@ export function createApplicationCatalog({ createApplicationTraffic({ source: sources.traffic }), createApplicationCctv({ surface, source: sources.cctv }), createApplicationRadio({ surface, source: sources.radio }), + createApplicationTransit({ surface, source: sources.transit }), createApplicationBikeshare({ source: sources.bikeshare }), createApplicationDirections(), - createApplicationTransit({ surface, source: sources.transit }), vessels, installations, createApplicationAwareness({ diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index 9de176605..6d1c0224f 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -41,6 +41,11 @@ test('catalogs construct distinct layers and classification from their supplied }); assert.equal(first.layers.length, 21); assert.ok(first.get('transit')); + const order = first.layers.map(({ id }) => id); + assert.deepEqual( + order.slice(order.indexOf('traffic'), order.indexOf('directions') + 1), + ['traffic', 'cctv', 'radio', 'transit', 'bikeshare', 'directions'], + ); assert.ok(first.get('bhote-koshi-2026')); assert.ok(first.get('bhote-koshi-locator')); const lifecycle = new LayerLifecycle({}); diff --git a/src/data/contactTrailRenderer.js b/src/data/contactTrailRenderer.js index ae8712f9d..1f82f3b9c 100644 --- a/src/data/contactTrailRenderer.js +++ b/src/data/contactTrailRenderer.js @@ -21,23 +21,70 @@ export function trailAlpha(ageMs) { /** One static history batch and one frequently updated head. Never entities. */ export function createContactTrailRenderer(scene) { - const heads = scene.primitives.add(new Cesium.PolylineCollection()); - const backing = heads.add({ - positions: [], - width: 4, - show: false, - material: Cesium.Material.fromType('Color', { - color: new Cesium.Color(0.02, 0.03, 0.05, 0.8), - }), - }); - const head = heads.add({ - positions: [], - width: 2, - show: false, - material: Cesium.Material.fromType('Color', { - color: Cesium.Color.WHITE.clone(), - }), - }); + const ground = + !!scene.frameState?.context && + Cesium.GroundPolylinePrimitive.isSupported(scene); + const collection = ground ? scene.groundPrimitives : scene.primitives; + // One 25 m corridor is clipped in material coordinates. Only subdivision + // crossings build geometry; frame updates write a scalar, never vertex buffers. + const headMaterial = (alpha) => + new Cesium.Material({ + fabric: { + type: 'TransitTrailHead', + uniforms: { + color: new Cesium.Color(0.37, 0.94, 0.54, alpha), + fraction: 0, + backing: true, + }, + source: `czm_material czm_getMaterial(czm_materialInput materialInput) { + if (materialInput.st.s > fraction) discard; + czm_material m = czm_getDefaultMaterial(materialInput); + bool edge = abs(materialInput.st.t - 0.5) > 0.3; + m.diffuse = czm_gammaCorrect(vec4(edge && backing ? vec3(0.02, 0.03, 0.05) : color.rgb, 1.0)).rgb; + m.alpha = color.a; + return m; + }`, + }, + translucent: true, + }); + const material = headMaterial(0.8), + depthMaterial = headMaterial(0.55); + depthMaterial.uniforms.backing = false; + const head = { positions: [], show: false, width: 3, material }; + const backing = { positions: [], show: false, width: 5 }; + let headPrimitive = null, + headRebuilds = 0; + function geometry(positions, width, material = false) { + return ground + ? new Cesium.GroundPolylineGeometry({ positions, width, granularity: 0 }) + : new Cesium.PolylineGeometry({ + positions, + width, + arcType: Cesium.ArcType.NONE, + vertexFormat: material + ? Cesium.PolylineMaterialAppearance.VERTEX_FORMAT + : Cesium.PolylineColorAppearance.VERTEX_FORMAT, + }); + } + function primitive(instances, appearance, depthFailAppearance) { + return ground + ? new Cesium.GroundPolylinePrimitive({ + geometryInstances: instances, + appearance, + classificationType: Cesium.ClassificationType.BOTH, + allowPicking: false, + asynchronous: true, + show: visible, + }) + : new Cesium.Primitive({ + geometryInstances: instances, + appearance, + depthFailAppearance, + allowPicking: false, + asynchronous: false, + show: visible, + }); + } let body = null, segments = [], revision = -1, @@ -68,7 +115,10 @@ export function createContactTrailRenderer(scene) { completed = -1; lastSecond = -1; active = null; - if (body) scene.primitives.remove(body); + if (headPrimitive) collection.remove(headPrimitive); + headPrimitive = null; + head.show = backing.show = false; + if (body) collection.remove(body); body = null; const instances = []; attributeIds.length = 0; @@ -80,18 +130,13 @@ export function createContactTrailRenderer(scene) { instances.push( new Cesium.GeometryInstance({ id: `${i}:${lane}`, - geometry: new Cesium.PolylineGeometry({ - positions: segment.positions, - width: lane ? 2 : 4, - arcType: Cesium.ArcType.NONE, - vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT, - }), + geometry: geometry(segment.positions, lane ? 3 : 5), attributes: { color: Cesium.ColorGeometryInstanceAttribute.fromColor( tint(0.8, !lane), ), depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor( - tint(0.8, !lane), + tint(0.55), ), show: new Cesium.ShowGeometryInstanceAttribute(false), }, @@ -100,20 +145,12 @@ export function createContactTrailRenderer(scene) { } } if (instances.length) { - const depthFailAppearance = new Cesium.PolylineColorAppearance({ - translucent: true, - fragmentShaderSource: - 'in vec4 v_color; void main() { out_FragColor = czm_gammaCorrect(vec4(v_color.rgb, v_color.a * 0.2)); }', - }); - body = scene.primitives.add( - new Cesium.Primitive({ - geometryInstances: instances, - appearance: new Cesium.PolylineColorAppearance({ translucent: true }), - depthFailAppearance, - allowPicking: false, - asynchronous: false, - show: visible, - }), + body = collection.add( + primitive( + instances, + new Cesium.PolylineColorAppearance({ translucent: true }), + new Cesium.PolylineColorAppearance({ translucent: true }), + ), ); rebuilds++; } @@ -122,6 +159,7 @@ export function createContactTrailRenderer(scene) { if (!sample || !renderedPosition) { head.show = false; backing.show = false; + if (headPrimitive) headPrimitive.show = false; return; } const second = Math.floor(sample.displayT / 1000); @@ -152,8 +190,10 @@ export function createContactTrailRenderer(scene) { attributeIds[i][lane], ); if (!attributes) continue; - attributes.show[0] = i <= done ? 1 : 0; - attributes.show = attributes.show; + // Cesium getters return copies. Write back the array we changed. + const show = attributes.show; + show[0] = i <= done ? 1 : 0; + attributes.show = show; } } completed = done; @@ -166,16 +206,20 @@ export function createContactTrailRenderer(scene) { attributeIds[i][lane], ); if (!attributes) continue; + const rgba = attributes.color; Cesium.ColorGeometryInstanceAttribute.toValue( tint(alpha, !lane), - attributes.color, + rgba, ); - attributes.color = attributes.color; - Cesium.ColorGeometryInstanceAttribute.toValue( - tint(alpha, !lane), - attributes.depthFailColor, - ); - attributes.depthFailColor = attributes.depthFailColor; + attributes.color = rgba; + if (!ground) { + const depthRgba = attributes.depthFailColor; + Cesium.ColorGeometryInstanceAttribute.toValue( + tint(0.55), + depthRgba, + ); + attributes.depthFailColor = depthRgba; + } } } lastSecond = second; @@ -183,16 +227,43 @@ export function createContactTrailRenderer(scene) { const show = visible && !!segment && sample.fraction < 1; head.show = show; backing.show = show; + if (headPrimitive) headPrimitive.show = show; if (!show) return; if (active !== segment) { active = segment; + if (headPrimitive) collection.remove(headPrimitive); + headPrimitive = collection.add( + primitive( + new Cesium.GeometryInstance({ + geometry: geometry(segment.positions, 5, true), + }), + new Cesium.PolylineMaterialAppearance({ + material, + translucent: true, + }), + new Cesium.PolylineMaterialAppearance({ + material: depthMaterial, + translucent: true, + }), + ), + ); + headRebuilds++; headPositions[0] = segment.positions[0]; headPositions[1] = endpoint; } Cesium.Cartesian3.clone(renderedPosition, endpoint); head.positions = headPositions; backing.positions = headPositions; - Cesium.Color.clone(tint(0.8), head.material.uniforms.color); + material.uniforms.fraction = Math.max( + 0, + Math.min( + 1, + (sample.displayT - segment.fromT) / (segment.toT - segment.fromT), + ), + ); + depthMaterial.uniforms.fraction = material.uniforms.fraction; + Cesium.Color.clone(tint(0.8), material.uniforms.color); + Cesium.Color.clone(tint(0.55), depthMaterial.uniforms.color); } return { replaceHistory, @@ -205,12 +276,15 @@ export function createContactTrailRenderer(scene) { }, setVisible(next) { visible = next; - heads.show = next; + if (headPrimitive) headPrimitive.show = next && head.show; if (body) body.show = next; }, destroy() { - if (body) scene.primitives.remove(body); - scene.primitives.remove(heads); + if (body) collection.remove(body); + if (headPrimitive) collection.remove(headPrimitive); + headPrimitive = null; + material.destroy(); + depthMaterial.destroy(); body = null; segments = []; }, @@ -222,6 +296,12 @@ export function createContactTrailRenderer(scene) { body, head, backing, + headPrimitive, + headStrategy: ground + ? 'draped corridor with material clip' + : 'depth-fail corridor with material clip', + ground, + headRebuilds, entitiesAdded: 0, }; }, diff --git a/src/data/contactTrailRenderer.test.mjs b/src/data/contactTrailRenderer.test.mjs index 04c7cc617..1021cd7a5 100644 --- a/src/data/contactTrailRenderer.test.mjs +++ b/src/data/contactTrailRenderer.test.mjs @@ -1,12 +1,14 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; +import { readFileSync } from 'node:fs'; +import { TRAIL_VERTEX_LIMIT } from '../layers/transit/trails.js'; import { createContactTrailRenderer, trailAlpha, } from './contactTrailRenderer.js'; -function fixture(t) { +function fixture(t, ground = false) { for (const key of [ 'HTMLCanvasElement', 'HTMLImageElement', @@ -20,9 +22,21 @@ function fixture(t) { else globalThis[key] = prior; }); } + const width = Cesium.ContextLimits._maximumAliasedLineWidth; + Cesium.ContextLimits._maximumAliasedLineWidth = 1; + t.after(() => { + Cesium.ContextLimits._maximumAliasedLineWidth = width; + }); const primitives = new Cesium.PrimitiveCollection(); t.after(() => primitives.destroy()); - return { primitives }; + const groundPrimitives = new Cesium.PrimitiveCollection(); + t.after(() => groundPrimitives.destroy()); + return { + primitives, + groundPrimitives, + frameState: { context: { depthTexture: ground } }, + globe: { show: false }, + }; } test('body clips future subdivisions, head ends at exact marker, and frames retain geometry', (t) => { const scene = fixture(t), @@ -62,7 +76,9 @@ test('body clips future subdivisions, head ends at exact marker, and frames reta for (const instance of body.geometryInstances) { assert.deepEqual( instance.attributes.depthFailColor.value, - instance.attributes.color.value, + Cesium.ColorGeometryInstanceAttribute.toValue( + Cesium.Color.fromCssColorString('#5EF08A').withAlpha(0.55), + ), ); } body._batchTableAttributeIndices = { color: 0, depthFailColor: 1 }; @@ -81,7 +97,18 @@ test('body clips future subdivisions, head ends at exact marker, and frames reta ); // Simulate Cesium's completed upload; exercise public attribute writes. body._ready = true; - body.getGeometryInstanceAttributes = (id) => attributes.get(id); + body.getGeometryInstanceAttributes = (id) => { + const stored = attributes.get(id), + accessor = {}; + for (const name of ['show', 'color', 'depthFailColor']) + Object.defineProperty(accessor, name, { + get: () => stored[name].slice(), + set: (value) => { + stored[name] = value.slice(); + }, + }); + return accessor; + }; const marker = Cesium.Cartesian3.lerp( positions[1], positions[2], @@ -95,10 +122,19 @@ test('body clips future subdivisions, head ends at exact marker, and frames reta [1, 1, 0, 0], ); for (const a of attributes.values()) - assert.deepEqual(a.depthFailColor, a.color); + assert.deepEqual( + a.depthFailColor, + Cesium.ColorGeometryInstanceAttribute.toValue( + Cesium.Color.fromCssColorString('#5EF08A').withAlpha(0.55), + ), + ); const result = renderer.diagnostics(); assert.equal(result.body, body); assert.equal(result.rebuilds, 1); + assert.equal(result.headRebuilds, 1, '120 frames retain head geometry'); + assert.equal(result.head.material.uniforms.fraction, 0.5); + assert.equal(result.head.width, 3); + assert.equal(result.backing.width, 5); assert.ok(Cesium.Cartesian3.equals(result.head.positions.at(-1), marker)); assert.equal(result.head.positions.length, 2); renderer.setDisplaySample( @@ -120,24 +156,130 @@ test('trail age curve uses the specified four alpha anchors', () => { assert.ok(Math.abs(trailAlpha(60000) - 0.625) < 1e-10); }); -test('the maximum prepared body stays below the 2 MiB geometry budget', () => { - const geometry = Cesium.PolylineGeometry.createGeometry( - new Cesium.PolylineGeometry({ - positions: [ - Cesium.Cartesian3.fromDegrees(-71, 42, 11.5), - Cesium.Cartesian3.fromDegrees(-71, 42.0002, 11.5), - ], - width: 2, - arcType: Cesium.ArcType.NONE, - vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT, - }), +test('ground body and clipped head stay inside rebuild and frame geometry budgets', async () => { + Cesium.ApproximateTerrainHeights._terrainHeights = JSON.parse( + readFileSync( + new URL( + '../../node_modules/@cesium/engine/Source/Assets/approximateTerrainHeights.json', + import.meta.url, + ), + ), ); - const bytes = - Object.values(geometry.attributes).reduce( - (total, attribute) => total + (attribute?.values?.byteLength || 0), + const positions = [ + Cesium.Cartesian3.fromDegrees(-97.7431, 30.267), + Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672), + ]; + const start = performance.now(); + const instances = async (count) => + Promise.all( + Array.from( + { length: count }, + async (_, i) => + new Cesium.GeometryInstance({ + id: i, + geometry: await Cesium.GroundPolylineGeometry.createGeometry( + new Cesium.GroundPolylineGeometry({ + positions, + width: i % 2 ? 3 : 5, + granularity: 0, + }), + ), + }), + ), + ); + const pipelineBytes = async (count) => { + // Match GroundPolylinePrimitive's internal Primitive options and the + // application's morph-capable scene, including batch IDs and encoding. + const result = Cesium.PrimitivePipeline.combineGeometry({ + instances: await instances(count), + projection: new Cesium.GeographicProjection(), + ellipsoid: Cesium.Ellipsoid.WGS84, + modelMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY), + elementIndexUintSupported: true, + scene3DOnly: false, + vertexCacheOptimize: false, + compressVertices: false, + createPickOffsets: false, + }); + return result.geometries.reduce( + (sum, geometry) => + sum + + Object.values(geometry.attributes).reduce( + (n, a) => n + (a?.values?.byteLength || 0), + 0, + ) + + geometry.indices.byteLength, 0, - ) + geometry.indices.byteLength; - assert.ok(bytes * 2 * 2047 < 2 * 1024 * 1024); + ); + }; + const bytes = await pipelineBytes(1); + const bodyInstances = 2 * (TRAIL_VERTEX_LIMIT - 1); + const bodyBytes = await pipelineBytes(bodyInstances); + assert.equal(bytes, 1576, 'measure final encoded and batched geometry'); + assert.equal(bodyBytes, 2014128); + const instanceReserve = 64 * bodyInstances; + assert.ok(bodyBytes + instanceReserve <= 2 * 1024 * 1024); + assert.ok( + bytes <= 2048, + 'subdivision crossings stay below 2 KiB of head geometry', + ); + console.log( + `trail final pipeline: ${bytes} bytes/head crossing, 0 bytes/ordinary head frame, ${bodyBytes} bytes/body (${bodyInstances} instances), ${instanceReserve} bytes instance reserve; geometry CPU ${(performance.now() - start).toFixed(3)} ms`, + ); +}); + +test('supported scenes drape body and clipped head on 3D tiles and retain head geometry', (t) => { + const scene = fixture(t, true), + renderer = createContactTrailRenderer(scene); + const positions = [ + Cesium.Cartesian3.fromDegrees(-97.7431, 30.267), + Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672), + ]; + renderer.replaceHistory({ + revision: 1, + segments: [{ fromSeq: 0, toSeq: 1, fromT: 0, toT: 20000, positions }], + }); + const first = renderer.diagnostics(); + assert.ok(first.body instanceof Cesium.GroundPolylinePrimitive); + assert.equal(first.body.classificationType, Cesium.ClassificationType.BOTH); + assert.ok(scene.groundPrimitives.contains(first.body)); + for (const instance of first.body.geometryInstances) + assert.ok(instance.geometry instanceof Cesium.GroundPolylineGeometry); + for (let i = 1; i <= 120; i++) + renderer.setDisplaySample( + { displayT: i * 100, fromSeq: 0, toSeq: 1, fraction: i / 200 }, + positions[1], + ); + const result = renderer.diagnostics(); + assert.equal(result.body, first.body); + assert.equal(result.headRebuilds, 1); + assert.ok(result.headPrimitive instanceof Cesium.GroundPolylinePrimitive); + assert.equal(result.head.material.uniforms.fraction, 0.6); + assert.match(result.head.material.shaderSource, /discard/); + for (const stack of ['Google', 'OSM', 'Bing', 'Google']) { + scene.globe.show = stack !== 'Google'; + renderer.setDisplaySample( + { displayT: 12000, fromSeq: 0, toSeq: 1, fraction: 0.6 }, + positions[1], + ); + const retained = renderer.diagnostics(); + assert.equal(retained.body, result.body, `${stack}: retained history`); + assert.equal(retained.headPrimitive, result.headPrimitive); + for (const primitive of [retained.body, retained.headPrimitive]) { + assert.equal( + primitive.classificationType, + Cesium.ClassificationType.BOTH, + stack, + ); + assert.equal(primitive.show, true, stack); + } + } + renderer.setVisible(false); + assert.equal(result.body.show, false); + assert.equal(result.headPrimitive.show, false); + renderer.destroy(); + assert.equal(scene.groundPrimitives.length, 0); + assert.equal(scene.primitives.length, 0); }); test('subdivision crossings only write the changed show range and age colors once per second', (t) => { @@ -206,5 +348,10 @@ test('subdivision crossings only write the changed show range and age colors onc display(1200); assert.deepEqual(counts, { show: 0, color: 200, depthFailColor: 200 }); for (const a of attributes.values()) - assert.deepEqual(a.color, a.depthFailColor); + assert.deepEqual( + a.depthFailColor, + Cesium.ColorGeometryInstanceAttribute.toValue( + Cesium.Color.fromCssColorString('#FF4538').withAlpha(0.55), + ), + ); }); diff --git a/src/data/transitIcons.js b/src/data/transitIcons.js index 6d5d5066f..ec480530d 100644 --- a/src/data/transitIcons.js +++ b/src/data/transitIcons.js @@ -189,20 +189,21 @@ export function transitIcon(kind, px = FLEET_RASTER_PX, options = {}) { let uri = _iconCache.get(key); if (!uri) { const size = VIEW + 2 * frame.pad; - // Sensor signatures use a broad rounded envelope, without dark interior - // joints or a second outline eating into the hot core at display resolution. - const halfW = k === 'bus' || k === 'ferry' || k === 'unknown' ? 48 : 47; - const halfH = 48; - const radius = k === 'subway' ? 8 : k === 'rail' ? 12 : 14; - const capsule = ``; - const body = profile === 'mono' ? capsule : BODIES[k]; + // Reuse the exact outer path. Sensors fill its interior without panel ink. + const silhouette = BODIES[k].match(/` + : BODIES[k]; const halo = frame.units > 0 - ? body.replace( - //, - (_m, d) => - ``, - ) + ? profile === 'mono' + ? `` + : body.replace( + //, + (_m, d) => + ``, + ) : ''; const svg = `` + diff --git a/src/data/transitIcons.test.mjs b/src/data/transitIcons.test.mjs index 17cfb20c4..8f2571017 100644 --- a/src/data/transitIcons.test.mjs +++ b/src/data/transitIcons.test.mjs @@ -32,10 +32,10 @@ test('a haloed glyph draws the dark ring under the shipped white body', () => { assert.notEqual(plain, haloed); assert.match( haloed, - /stroke="#000000" stroke-opacity="1" stroke-width="19\.20"/, + /stroke="#05080C" stroke-opacity="1" stroke-width="14\.77"/, ); assert.ok( - haloed.indexOf('#000000') < haloed.indexOf('fill="white"'), + haloed.indexOf('#05080C') < haloed.indexOf('fill="white"'), 'ring first, body on top', ); assert.match(haloed, /viewBox="-\d+ -\d+ \d+ \d+"/, 'padded frame'); @@ -97,40 +97,144 @@ test('all 36 variants retain the specified final halo at both display sizes', as assert.equal(transitIconCacheSize(), 36); }); -test('sensor bodies cover sixty percent of the padded display with opaque white', async () => { +test('every mono raster preserves the normal silhouette mask and aspect at CRT display size', async () => { const sharp = (await import('sharp')).default; const { presetSpriteScale, presetSpriteOutlinePx } = await import('./transitPresetStyle.js'); + const rasterBody = async (svg) => { + // Compare fills in the same frame; the separately tested external halo is excluded. + const d = + [...svg.matchAll(/`, + ), + ) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + const mask = []; + let minX = info.width, + minY = info.height, + maxX = 0, + maxY = 0; + for (let y = 0; y < info.height; y++) + for (let x = 0; x < info.width; x++) { + const alpha = data[(y * info.width + x) * 4 + 3]; + mask.push(alpha); + if (alpha > 127) { + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + } + } + return { mask, aspect: (maxX - minX + 1) / (maxY - minY + 1) }; + }; for (const selected of [false, true]) + for (const kind of TRANSIT_ICON_KINDS) { + const normal = await rasterBody( + decode(transitIcon(kind, selected ? 96 : 48, { style: 'normal' })), + ); + for (const style of ['surveillance', 'thermal', 'noir', 'nvg']) { + const svg = decode(transitIcon(kind, selected ? 96 : 48, { style })); + const mono = await rasterBody(svg); + assert.deepEqual( + mono.mask, + normal.mask, + `${kind}/${style}: no envelope`, + ); + assert.ok( + Math.abs(mono.aspect / normal.aspect - 1) <= 0.05, + `${kind}/${style}: aspect`, + ); + const display = + (selected ? 30 : 20) * presetSpriteScale(style, selected); + const crt = (selected ? 30 : 20) * presetSpriteScale('retro', selected); + assert.equal(display, crt); + const frame = haloFrame( + presetSpriteOutlinePx(style, selected), + display, + ); + const crtFrame = haloFrame( + presetSpriteOutlinePx('retro', selected), + crt, + ); + assert.equal( + (display * frame.ratio) / frame.ratio, + (crt * crtFrame.ratio) / crtFrame.ratio, + 'unpadded silhouette matches CRT; only external halo padding grows', + ); + assert.equal( + (svg.match(//); + } + } +}); + +test('sensor raster halo stays dark across its screen-space band on a white roof', async () => { + const sharp = (await import('sharp')).default; + const { presetSpriteScale, presetSpriteOutlinePx } = + await import('./transitPresetStyle.js'); + const { reduceSensorContrast } = + await import('../layers/transit/qaMetrics.js'); + const sides = { + bus: 17, + tram: 10, + subway: 11.5, + rail: 10.5, + ferry: 12.5, + unknown: 10, + }; + for (const selected of [false, true]) { + const pixels = []; for (const kind of TRANSIT_ICON_KINDS) { const display = (selected ? 30 : 20) * presetSpriteScale('thermal', selected); - assert.equal(display, selected ? 36 : 30); - assert.equal(presetSpriteOutlinePx('thermal', selected), 3); + const frame = haloFrame( + presetSpriteOutlinePx('thermal', selected), + display, + ); + const size = Math.round(display * frame.ratio * 8); const svg = decode( transitIcon(kind, selected ? 96 : 48, { style: 'thermal' }), ); - const frame = haloFrame(3, display); - const width = Math.round(display * frame.ratio); const { data, info } = await sharp(Buffer.from(svg)) - .resize(width, width) - .ensureAlpha() + .resize(size, size) + .flatten({ background: '#ffffff' }) .raw() .toBuffer({ resolveWithObject: true }); - let white = 0; - for (let i = 0; i < data.length; i += 4) - if ( - data[i] >= 240 && - data[i + 1] >= 240 && - data[i + 2] >= 240 && - data[i + 3] >= 240 - ) - white++; - assert.ok( - white / (info.width * info.height) >= 0.6, - `${kind}/${display}: white ${white / (info.width * info.height)}`, - ); - const c = (Math.floor(width / 2) * width + Math.floor(width / 2)) * 4; - assert.deepEqual([...data.subarray(c, c + 4)], [255, 255, 255, 255]); + const sample = (offsetPx) => { + const x = Math.floor( + size / 2 + (offsetPx * size) / (display * frame.ratio), + ); + const i = (Math.floor(size / 2) * info.width + x) * info.channels; + return ( + (0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]) / 255 + ); + }; + // The body stays at CRT size; the ring must cover this fixed external band. + const ringMin = sample((sides[kind] * display) / 96 + 1.25); + pixels.push({ + key: kind, + verified: true, + centre: sample(0), + ringMin, + ringMax: 1 - ringMin, + background: 1, + }); } + const white = reduceSensorContrast(pixels, 'white', 'thermal'); + assert.ok(white.pass, JSON.stringify({ selected, pixels, white })); + const black = reduceSensorContrast( + pixels.map((p) => ({ ...p, centre: 1 - p.centre })), + 'black', + 'thermal', + ); + assert.ok(black.pass, JSON.stringify({ selected, black })); + } }); diff --git a/src/data/transitPresetStyle.js b/src/data/transitPresetStyle.js index 4848924f4..8c79d7c05 100644 --- a/src/data/transitPresetStyle.js +++ b/src/data/transitPresetStyle.js @@ -12,7 +12,7 @@ * Encoding per profile, the traffic-dot precedent applied to a glyph: * - `mono` (surveillance/NVG, thermal/FLIR, noir, cockpit nvg/thermal): the * sprite is WHITE — the hottest end of grayscale FLIR and of the Ironbow - * ramp, and the brightest phosphor under NVG — drawn larger, with a dark + * ramp, and the brightest phosphor under NVG — at CRT size, with a thin dark * halo sized in SCREEN pixels so it keeps local contrast on a bright road * through any luma mapping. Black-hot FLIR inverts it, deliberately: a * black sprite with a light ring is what a hot object looks like there. @@ -52,8 +52,8 @@ const PROFILE_BY_STYLE = Object.freeze({ const SPRITE_STYLE = Object.freeze({ mono: Object.freeze({ rgba: Object.freeze([255, 255, 255, 1]), - scale: 1.5, - outlinePx: 3, + scale: 1.3, + outlinePx: 2, }), crt: Object.freeze({ scale: 1.3, @@ -111,8 +111,6 @@ export function presetSpriteRgba(styleName, mode) { * @returns {number} 1 under the normal profile. */ export function presetSpriteScale(styleName, selected = false) { - if (transitStyleProfile(styleName) === 'mono') - return selected ? 36 / 30 : 30 / 20; return SPRITE_STYLE[transitStyleProfile(styleName)]?.scale ?? 1; } diff --git a/src/data/transitPresetStyle.test.mjs b/src/data/transitPresetStyle.test.mjs index db9f413a8..fc8e81323 100644 --- a/src/data/transitPresetStyle.test.mjs +++ b/src/data/transitPresetStyle.test.mjs @@ -45,13 +45,13 @@ test('mono: every mode is a white core, larger, with a dark halo in screen pixel `${style} ${mode}`, ); } - assert.ok(presetSpriteScale(style) === 1.5, `${style} is drawn larger`); - // At least a whole screen pixel and a half of ring around a five-pixel - // body: thinner than that vanishes through blur and pixelation. - assert.ok( - presetSpriteOutlinePx(style) >= 1.5, - `${style} halo is ${presetSpriteOutlinePx(style)} px`, - ); + for (const selected of [false, true]) { + assert.equal( + presetSpriteScale(style, selected), + presetSpriteScale('retro', selected), + ); + assert.equal(presetSpriteOutlinePx(style, selected), 2); + } } }); diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index 0be565b2e..74e80d550 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -2178,7 +2178,7 @@ test('under FLIR and NVG every sprite is white-hot with a dark halo, and selecti assert.notEqual(entry.marker.image, plainImage, 'a haloed raster'); assert.match( Buffer.from(entry.marker.image.split(',')[1], 'base64').toString(), - /#000000/, + /#05080C/, 'with the dark ring', ); assert.ok(entry.marker.width > plainWidth * 1.2, 'and larger'); @@ -2547,7 +2547,7 @@ test('selected trail and marker share revised start floors, with unknown history assert.ok( Cesium.Cartographic.fromCartesian(entry.marker.position).height > 30, ); - assert.ok(entry.trailVertices <= 2048); + assert.ok(entry.trailVertices <= 640); parts.selection.clearSelection(); assert.equal(parts.trails.diagnostics(), null); }); @@ -2659,7 +2659,7 @@ test('a long selected history shortens its oldest geometry before sacrificing th parts.rendering.sampleIdle(entry); parts.selection.selectVehicle(entry.key); assert.equal(entry.trailTruncated, true); - assert.ok(entry.trailVertices <= 2048); + assert.ok(entry.trailVertices <= 640); assert.ok( entry.displayPaths.has(entry.sample.fromSeq), 'active path is prepared first', @@ -2671,6 +2671,63 @@ test('a long selected history shortens its oldest geometry before sacrificing th ); }); +test('sparse rail reports preserve a bounded marker corridor with and without selection', async (t) => { + const app = harness(t); + const parts = app.layer._transitPartsForTest(); + const { seek } = await import('../../data/contactPlayback.js'); + let latitude = 42.36 - 16000 / 111320; + let timestamp = reported(); + app.serve('mbta', () => ({ + status: 200, + body: snapshot( + 'mbta', + 'MBTA', + [vehicle('sparse', latitude, -71.06, timestamp, { routeId: 'CR-1' })], + { fetchedAt: Date.now() }, + ), + })); + app.layer.enable(app.viewer); + await app.layer.update(); + app.settle(); + app.advance(180000); + latitude = 42.36; + timestamp += 180; + await app.layer.update(); + app.settle(); + const entry = app.vehicles()[0]; + assert.equal(entry.mode, 'rail'); + assert.equal(entry.track.count, 2, 'both 88.9 m/s reports pass admission'); + assert.equal(entry.fixes[1].t - entry.fixes[0].t, 180000); + for (let i = 0; i < 1500; i++) + app.ground._warm.set(app.ground._key(42.2 + i * 0.0002, -71.06), 12); + seek(entry.track, entry.fixes[0].t + 90000, { + wallNowMs: Date.now(), + monoNowMs: performance.now(), + }); + parts.rendering.sampleIdle(entry); + for (const selected of [false, true, false]) { + if (selected) parts.selection.selectVehicle(entry.key); + else parts.selection.clearSelection(); + parts.trails.prepareEntry(entry); + const path = entry.displayPaths.get(entry.sample.fromSeq); + assert.ok(path, 'active corridor survives the body cap'); + assert.ok(path.positions.length <= 640); + const position = parts.trails.samplePosition( + entry, + entry.sample, + new Cesium.Cartesian3(), + true, + ); + assert.ok( + position && [position.x, position.y, position.z].every(Number.isFinite), + ); + parts.rendering.placeSample(entry, true); + assert.equal(entry.marker.show, true); + assert.ok(Cesium.Cartesian3.equals(entry.marker.position, position)); + assert.ok(entry.trailVertices <= 640); + } +}); + test('missing rectangle still rejects the far side, and frustum rejection wins', async (t) => { const app = harness(t, { altitude: 100_000 }); app.viewer.camera.computeViewRectangle = () => undefined; @@ -2783,13 +2840,13 @@ test('every styling path retains the mode palette and exact unpadded size', asyn parts.rendering.paintMode(entry, 'subway'); check('#FF4538', 30); app.style('thermal'); - check('#FFFFFF', 36); + check('#FFFFFF', 39); parts.selection.clearSelection(); - check('#FFFFFF', 30); + check('#FFFFFF', 26); app.vision('nvg', true); - check('#FFFFFF', 30); + check('#FFFFFF', 26); parts.selection.selectVehicle(entry.key); - check('#FFFFFF', 36); + check('#FFFFFF', 39); app.vision('normal', false); check('#FF4538', 30); parts.selection.clearSelection(); @@ -3673,3 +3730,131 @@ test('transit polls the supplied source and cancels it when disabled', async (t) await app.layer.update(); assert.equal(calls.length, before); }); + +test('selected trail QA fixture survives repeated live CapMetro snapshots', async (t) => { + const { TRAIL_VISIBILITY_FEED } = + await import('../../../scripts/qa-transit-scenes.mjs'); + const { getRegisteredTransitFeed } = + await import('../../data/transitFeeds.js'); + const app = harness(t, { at: { lat: 30.267, lon: -97.7431 } }); + app.layer.enable(app.viewer); + const { ingestion, selection } = app.layer._transitPartsForTest(); + const now = Date.now(); + ingestion.applySnapshot( + TRAIL_VISIBILITY_FEED, + { + fetchedAt: now, + vehicles: [vehicle('trail-visible', 30.267, -97.7431, now / 1000)], + }, + { stale: false }, + ); + const key = `${TRAIL_VISIBILITY_FEED.id}:trail-visible`; + selection.selectVehicle(key); + assert.equal(app.state()._selectedKey, key); + for (let poll = 0; poll <= MISSED_POLLS_TO_DROP; poll++) { + ingestion.applySnapshot( + getRegisteredTransitFeed('capmetro-austin'), + { + fetchedAt: now + poll, + vehicles: [vehicle('live-bus', 30.268, -97.7431, now / 1000)], + }, + { stale: false }, + ); + } + assert.ok( + app.state()._vehicles.has(key), + 'live polls must not evict the selected fixture', + ); + assert.equal(app.state()._selectedKey, key); + assert.ok( + app.state()._vehicles.has('capmetro-austin:live-bus'), + 'live ingestion still runs', + ); +}); + +for (const jump of ['setView', 'flyTo']) { + test(`${jump} recovers a cold hidden vehicle within one visibility interval`, async (t) => { + const app = harness(t, { floorAt: () => undefined }); + let floor; + app.viewer.scene.globe.show = true; + app.viewer.scene.globe.getHeight = () => floor; + app.serve('mbta', () => ({ + status: 200, + body: snapshot('mbta', 'MBTA', [ + vehicle('cold', 42.36, -71.06, reported()), + ]), + })); + app.layer.enable(app.viewer); + await app.layer.update(); + const entry = app.vehicles()[0]; + assert.equal(entry.marker.show, false); + assert.equal(entry.heightPending, true); + // Surface tiles are now loaded at the camera destination. No poll, floor + // timer or preRender is allowed to bootstrap the visibility recovery. + clearTimeout(app.state()._floorTimer); + app.state()._floorTimer = null; + app.viewer.camera[jump] = () => { + floor = 12; + app.viewer.camera.changed.raiseEvent(); + }; + app.viewer.camera[jump](); + app.advance(250); + assert.equal(entry.marker.show, true); + assert.equal(entry.heightPending, false); + assert.equal(entry.surfaceReady, true); + assert.equal(entry.visibility.heightPending, false); + assert.equal(entry.visibility.surfaceReady, true); + }); +} + +test('selected history is prepared while its head is hidden and remains shown outside the head frustum', async (t) => { + const app = harness(t); + app.layer.enable(app.viewer); + app.layer._loadTransitFleetForTest(1, BOSTON); + const entry = app.vehicles()[0], + parts = app.layer._transitPartsForTest(); + entry.marker.show = false; + app.state()._visible.delete(entry); + app.state()._moving.delete(entry); + parts.selection.selectVehicle(entry.key); + const trail = parts.trails.diagnostics(); + assert.ok(trail.segments > 0); + assert.ok(trail.body); + assert.equal(trail.body.show, true); + parts.trails.update(); + assert.equal(trail.body.show, true); + app.state()._vehicles.delete(entry.key); + parts.trails.update(); + assert.equal(trail.body.show, false); + app.state()._vehicles.set(entry.key, entry); +}); + +test('selected draped history prepares geometry before surface heights resolve', async (t) => { + const app = harness(t, { floorAt: () => undefined }); + app.layer.enable(app.viewer); + app.layer._loadTransitFleetForTest(1, BOSTON); + const entry = app.vehicles()[0], + parts = app.layer._transitPartsForTest(); + delete entry.qaFloorM; + entry.marker.show = false; + entry.heightPending = true; + // Exercise the real GroundPolylinePrimitive branch, without a WebGL context. + const width = Cesium.ContextLimits._maximumAliasedLineWidth; + Cesium.ContextLimits._maximumAliasedLineWidth = 1; + t.after(() => { + Cesium.ContextLimits._maximumAliasedLineWidth = width; + }); + app.viewer.scene.frameState = { context: { depthTexture: true } }; + app.viewer.scene.groundPrimitives = app.viewer.scene.primitives; + parts.selection.selectVehicle(entry.key); + const trail = parts.trails.diagnostics(); + assert.equal(trail.ground, true); + assert.ok(trail.body instanceof Cesium.GroundPolylinePrimitive); + assert.ok(trail.segments > 0); + assert.equal(trail.body.show, true); + assert.equal( + entry.displayPaths.size, + 0, + 'unresolved marker corridors remain unavailable', + ); +}); diff --git a/src/layers/transit/qaMetrics.js b/src/layers/transit/qaMetrics.js index 908aaa168..8870e9137 100644 --- a/src/layers/transit/qaMetrics.js +++ b/src/layers/transit/qaMetrics.js @@ -329,3 +329,75 @@ export function reduceTrailHead(anchor, target) { selection: anchor?.selection ?? null, }; } + +/** Repeated off/on patches isolate stable, trail-correlated framebuffer changes. */ +export function reduceTrailPixels(on, off, evidence = {}) { + const { onAgain, offAgain, samples = [], sprite, tilesReady } = evidence; + const finitePoint = (p) => + p && [p.x, p.y, p.radius].every(Number.isFinite) && p.radius > 0; + const stable = [], + eligible = []; + const hits = Array.from({ length: 8 }, (_, i) => { + const point = samples[i]; + eligible[i] = !!( + tilesReady === true && + finitePoint(point) && + point.inFront === true && + finitePoint(sprite) && + Math.hypot(point.x - sprite.x, point.y - sprite.y) > + point.radius + sprite.radius && + samples + .slice(0, i) + .every( + (prior) => + !finitePoint(prior) || + Math.hypot(point.x - prior.x, point.y - prior.y) > + point.radius + prior.radius, + ) + ); + const a = on?.[i], + b = off?.[i], + c = onAgain?.[i], + d = offAgain?.[i]; + stable[i] = false; + if ( + !eligible[i] || + !a?.length || + a.length % 4 || + [b, c, d].some((p) => !p || p.length !== a.length) + ) + return false; + let matches = 0; + const contrast = (lit, baseline, k) => { + const delta = Math.hypot( + lit[k] - baseline[k], + lit[k + 1] - baseline[k + 1], + lit[k + 2] - baseline[k + 2], + ); + const green = lit[k + 1] > lit[k] + 30 && lit[k + 1] > lit[k + 2] + 18; + const dark = + lit[k] + lit[k + 1] + lit[k + 2] < + 0.6 * (baseline[k] + baseline[k + 1] + baseline[k + 2]); + return delta >= 40 && (green || dark); + }; + for (let k = 0; k < a.length; k += 4) { + for (let channel = 0; channel < 3; channel++) { + const j = k + channel; + if ( + ![a[j], b[j], c[j], d[j]].every( + (v) => Number.isFinite(v) && v >= 0 && v <= 255, + ) || + Math.abs(b[j] - d[j]) > 8 || + Math.abs(a[j] - c[j]) > 8 || + Math.abs(a[j] - b[j] - (c[j] - d[j])) > 8 + ) + return false; + } + if (contrast(a, b, k) && contrast(c, d, k)) matches++; + } + stable[i] = true; + return matches >= 2; + }); + const present = hits.filter(Boolean).length; + return { hits, stable, eligible, present, total: 8, pass: present >= 6 }; +} diff --git a/src/layers/transit/qaMetrics.test.mjs b/src/layers/transit/qaMetrics.test.mjs index 47a8a1be3..11734e5c0 100644 --- a/src/layers/transit/qaMetrics.test.mjs +++ b/src/layers/transit/qaMetrics.test.mjs @@ -328,3 +328,106 @@ test('trail-head acceptance names every missing and failed condition', async () false, ); }); + +const trailPatch = (rgb) => + Array.from({ length: 49 }, () => [...rgb, 255]).flat(); +const trailEvidence = (on, off) => ({ + onAgain: on, + offAgain: off, + tilesReady: true, + samples: Array.from({ length: 8 }, (_, i) => ({ + x: i * 20 + 20, + y: 20, + radius: 3, + inFront: true, + })), + sprite: { x: 300, y: 20, radius: 24 }, +}); + +test('trail pixels require repeatable contrast at six distinct samples outside the sprite', async () => { + const { reduceTrailPixels } = await import('./qaMetrics.js'); + const off = Array.from({ length: 8 }, () => trailPatch([150, 150, 150])); + const on = off.map((p, i) => (i < 6 ? trailPatch([94, 240, 138]) : p)); + const evidence = trailEvidence(on, off); + assert.equal(reduceTrailPixels(on, off, evidence).pass, true); + const five = on.map((p, i) => (i === 5 ? off[i] : p)); + assert.equal( + reduceTrailPixels(five, off, trailEvidence(five, off)).pass, + false, + ); + for (const rgb of [ + [5, 8, 12], + [126, 126, 126], + ]) { + const patches = off.map(() => trailPatch(rgb)); + assert.equal( + reduceTrailPixels(patches, off, trailEvidence(patches, off)).present, + rgb[0] === 5 ? 8 : 0, + ); + assert.equal( + reduceTrailPixels(patches, patches, trailEvidence(patches, patches)) + .present, + 0, + ); + } + assert.equal( + reduceTrailPixels(on, off).pass, + false, + 'two reads are insufficient', + ); + assert.equal( + reduceTrailPixels(on, off, { ...evidence, tilesReady: false }).pass, + false, + ); + assert.equal( + reduceTrailPixels(on, off, { + ...evidence, + samples: Array(8).fill(evidence.samples[0]), + }).pass, + false, + ); + assert.equal( + reduceTrailPixels(on, off, { + ...evidence, + sprite: { x: 80, y: 20, radius: 200 }, + }).pass, + false, + ); + assert.equal(reduceTrailPixels([], [], trailEvidence([], [])).pass, false); +}); + +test('background refinement without a trail cannot pass repeated off/on acceptance', async () => { + const { reduceTrailPixels } = await import('./qaMetrics.js'); + const grey = (n) => Array.from({ length: 8 }, () => trailPatch([n, n, n])); + const off = grey(100), + on = grey(30); + const evidence = trailEvidence(on, off); + assert.equal( + reduceTrailPixels(on, off, { ...evidence, offAgain: grey(30) }).pass, + false, + 'one background transition is not a trail', + ); + assert.equal( + reduceTrailPixels(on, off, { + ...evidence, + offAgain: grey(170), + onAgain: grey(100), + }).pass, + false, + 'equal deltas on a drifting baseline are not a trail', + ); + assert.equal( + reduceTrailPixels(on, off, { ...evidence, onAgain: off }).pass, + false, + 'contrast must repeat', + ); + const first = off.map((p) => p.map((v, i) => (i < 8 ? on[0][i] : v))); + const second = off.map((p) => + p.map((v, i) => (i >= 8 && i < 16 ? on[0][i] : v)), + ); + assert.equal( + reduceTrailPixels(first, off, { ...evidence, onAgain: second }).pass, + false, + 'the same pixels must change twice', + ); +}); diff --git a/src/layers/transit/rendering.js b/src/layers/transit/rendering.js index 8fb7a954d..0bb1f0ff6 100644 --- a/src/layers/transit/rendering.js +++ b/src/layers/transit/rendering.js @@ -97,6 +97,7 @@ export function createRendering({ state, services, parts }) { nearGround, ); entry.surfaceReady = !!known; + entry.heightPending = !known; entry.marker.show = !!known && vehicleInView(entry); if (known) { entry.marker.position = state._scratchCartesian; @@ -523,6 +524,10 @@ export function createRendering({ state, services, parts }) { else state._visible.delete(entry); if (visible && entry.track?.count) { if (!wasVisible && !entry.qaFixture) syncPlayback(entry, now); + // Re-entry can advance into an unprepared corridor; loaded tiles can + // also resolve a cold path without a poll or a render-loop wakeup. + if (!wasVisible || entry.surfaceReady === false || entry.heightPending) + parts.trails.prepareEntry(entry); // Surface completion must recover while requestRenderMode is idle. // Otherwise surfaceReady=false prevents the very frame that clears it. if (!state._moving.has(entry)) { @@ -532,6 +537,8 @@ export function createRendering({ state, services, parts }) { } entry.marker.show = visible && !entry.heightPending && entry.surfaceReady !== false; + visibility.heightPending = !!entry.heightPending; + visibility.surfaceReady = entry.surfaceReady ?? null; schedulePlayback(entry); changed ||= visible !== wasVisible || wasShown !== entry.marker.show; } diff --git a/src/layers/transit/trails.js b/src/layers/transit/trails.js index 66a12b136..47973c1a2 100644 --- a/src/layers/transit/trails.js +++ b/src/layers/transit/trails.js @@ -18,7 +18,9 @@ const MODE_COLOR = { ferry: '#5FD6FF', unknown: '#D8DDE5', }; -export const TRAIL_VERTEX_LIMIT = 2048; +// Final Cesium batching uses 3,152 bytes per two-lane edge. +// The 1,278-instance body plus 64 bytes/instance reserve stays below 2 MiB. +export const TRAIL_VERTEX_LIMIT = 640; const STEP_M = 25; /** Shared prepared paths. Floors are aligned to work with Google 3D tiles. */ @@ -63,6 +65,7 @@ export function createTrails({ state, services, parts, source }) { entry.trailSegments = []; entry.trailVertices = 0; } + const draped = entry === selected && ensureRenderer()?.diagnostics().ground; const paths = new Map(), all = [], a = {}, @@ -113,16 +116,28 @@ export function createTrails({ state, services, parts, source }) { if (entry === selected) for (let i = activeIndex - 1; i >= 0; i--) order.push(i); entry.trailTruncated = false; + let markerVertices = 0; + let markerIntervals = Math.max(0, entry.track.count - 1 - activeIndex); for (const i of order) { readFix(entry.track, i, a); readFix(entry.track, i + 1, b); + const markerCorridor = i >= activeIndex; + if (markerCorridor) markerIntervals--; if (a.epoch !== b.epoch || b.flags & FIX_FLAGS.BREAK) continue; if (entry !== selected && b.t < entry.sample.displayT) continue; - const n = Math.max(1, Math.ceil(fixDistanceM(a, b) / STEP_M)); - if (vertices + n + 1 > TRAIL_VERTEX_LIMIT) { - entry.trailTruncated = true; - continue; - } + const desired = Math.max(1, Math.ceil(fixDistanceM(a, b) / STEP_M)); + // Reserve endpoints for every future marker interval. Sparse reports + // coarsen the active path instead of losing the vehicle to the body cap. + const n = markerCorridor + ? Math.min( + desired, + TRAIL_VERTEX_LIMIT - markerVertices - 2 * markerIntervals - 1, + ) + : desired; + const retainBody = vertices + n + 1 <= TRAIL_VERTEX_LIMIT; + if (!retainBody || n < desired) entry.trailTruncated = true; + if (!markerCorridor && !retainBody) continue; + if (markerCorridor) markerVertices += n + 1; const positions = [], heights = []; let valid = true; @@ -136,17 +151,21 @@ export function createTrails({ state, services, parts, source }) { heights.push(h); if (h === null) { valid = false; - positions.push(null); + // Ground polylines consume longitude/latitude and drape themselves. + // Unknown floors still disqualify the marker corridor. + positions.push( + draped ? Cesium.Cartesian3.fromDegrees(point.lon, point.lat) : null, + ); } else positions.push( Cesium.Cartesian3.fromDegrees(point.lon, point.lat, h), ); } signature += `${a.seq}/${b.seq}:${heights.join(',')};`; - vertices += n + 1; - if (!valid) continue; - paths.set(a.seq, { positions, toSeq: b.seq }); - if (entry === selected && fixDistanceM(a, b) > 0) { + if (retainBody) vertices += n + 1; + if (valid) paths.set(a.seq, { positions, toSeq: b.seq }); + if (!valid && !draped) continue; + if (entry === selected && retainBody && fixDistanceM(a, b) > 0) { for (let k = 0; k < n; k++) all.push({ fromSeq: a.seq, @@ -216,7 +235,9 @@ export function createTrails({ state, services, parts, source }) { } function update() { if (!selected || !renderer) return; - renderer.setVisible(state._enabled && selected.marker?.show !== false); + renderer.setVisible( + state._enabled && state._vehicles.get(selected.key) === selected, + ); renderer.setDisplaySample(selected.sample, selected.marker?.position); } function select(entry) { diff --git a/src/tooling/transitQa.test.mjs b/src/tooling/transitQa.test.mjs index 0c5fd106d..85afbe0c8 100644 --- a/src/tooling/transitQa.test.mjs +++ b/src/tooling/transitQa.test.mjs @@ -328,6 +328,7 @@ test('sensor sampler waits for postRender and reads framebuffer pixels; stopped globalThis.Image = previousImage; }); const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + const readRows = []; let listener, reads = 0, render = true; @@ -336,6 +337,7 @@ test('sensor sampler waits for postRender and reads framebuffer pixels; stopped UNSIGNED_BYTE: 2, readPixels(x, y, w, h, format, type, buffer) { reads++; + readRows.push(y); buffer.set([255, 255, 255, 255]); }, }; @@ -408,13 +410,70 @@ test('sensor sampler waits for postRender and reads framebuffer pixels; stopped }, }; const page = { evaluate: async (fn, ...args) => fn(...args) }; + const sampledLayer = + window.__godsEyeView.dataManager.layers.get('transit').module; + const originalState = sampledLayer._transitStateForTest; + sampledLayer._transitStateForTest = () => { + const state = originalState(); + state._vehicles.get('bus').marker.position.y = 0.0025; // Screen y = 199.5. + return state; + }; const pixels = await sampleTransitPixels(page, {}); + sampledLayer._transitStateForTest = originalState; + assert.deepEqual( + readRows.slice(0, 9), + [201, 200, 199, 201, 200, 199, 201, 200, 199], + 'fractional CSS coordinates must invert the floored framebuffer row', + ); assert.ok( reads >= 13, 'centre, rings and background come from WebGL readPixels', ); assert.ok(Math.abs(pixels[0].centre - 1) < 1e-6); assert.equal(listener, null); + // An opaque window/panel is not a sensor core. Choose another solid patch, + // and reject a raster with no white interior even though alpha/picking pass. + for (let i = 0; i < raster.length; i += 4) raster.fill(0, i, i + 3); + assert.equal((await sampleTransitPixels(page, {})).length, 0); + raster.fill(255); + for (let y = 6; y <= 9; y++) + for (let x = 0; x < 16; x++) { + const i = (y * 16 + x) * 4; + raster.fill(0, i, i + 3); + } + assert.equal( + (await sampleTransitPixels(page, {})).length, + 1, + 'a body patch away from the central panel remains eligible', + ); + // A narrow centre can be opaque yet put the framebuffer sample on its + // antialiased side. Prefer the wider solid band above it, before reading luma. + raster.fill(0); + for (let y = 0; y < 16; y++) + for (let x = 0; x < 16; x++) { + const wide = y >= 3 && y <= 5; + if ((wide && x >= 3 && x <= 12) || (x >= 7 && x <= 9)) + raster.fill(255, (y * 16 + x) * 4, (y * 16 + x + 1) * 4); + } + const readStart = readRows.length; + assert.equal((await sampleTransitPixels(page, {})).length, 1); + assert.ok( + readRows[readStart] > 204, + 'centre samples use the widest solid band', + ); + raster.fill(255); + sampledLayer._transitStateForTest = () => { + const state = originalState(); + state._vehicles.get('bus').mode = 'tram'; + return state; + }; + const tramStart = readRows.length; + assert.equal((await sampleTransitPixels(page, {})).length, 1); + assert.ok( + readRows[tramStart] > 204, + 'equal-width tram bands prefer the upper solid body', + ); + sampledLayer._transitStateForTest = originalState; scene.pick = () => ({ id: 'overlapping-label' }); assert.equal( (await sampleTransitPixels(page, {})).length, diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index d213cdc87..fff6b9a33 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -21,6 +21,7 @@ const PANEL_GROUPS = [ 'military', 'ais-live-vessels', 'traffic', + 'transit', 'bikeshare', ], }, diff --git a/src/ui/layerPanel.test.mjs b/src/ui/layerPanel.test.mjs new file mode 100644 index 000000000..03f44cfd5 --- /dev/null +++ b/src/ui/layerPanel.test.mjs @@ -0,0 +1,31 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; + +test('panel presentation places Transit between Street Traffic and Bike Share in Movement', () => { + const source = readFileSync( + new URL('./layerPanel.js', import.meta.url), + 'utf8', + ); + const declarations = source.slice( + source.indexOf('const PANEL_GROUPS ='), + source.indexOf('const PANEL_POSITIONS ='), + ); + const order = JSON.parse( + runInNewContext(`${declarations}\nJSON.stringify(PANEL_ORDER)`), + ); + assert.deepEqual( + order.filter(({ label }) => label === 'Movement').map(({ id }) => id), + [ + 'satellites', + 'flights', + 'military', + 'ais-live-vessels', + 'traffic', + 'transit', + 'bikeshare', + ], + ); + assert.equal(order.filter(({ id }) => id === 'transit').length, 1); +});