diff --git a/CHANGELOG.md b/CHANGELOG.md index 2136de58..b449c8a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,14 +8,21 @@ All notable changes to TowerCab 3D will be documented in this file. - New **Show Moon** setting under Settings → Graphics lets you hide the moon from the sky. - vNAS can join your CRC session on a YAAT training server: YAAT1, or YAAT Local when a local YAAT server is running. - 3D buildings now show rows of windows on their walls, some of them lit at night. +- New **Weather quality** setting (Low, Medium, High) under Settings → Graphics; iPads and phones start lower, and small insets step down. ### Changed - 3D buildings and aircraft no longer have an olive-green cast on their walls and fuselages; their reflected ground light is now a neutral grey. - Remote access accepts only devices on your local network, unless you turn on **Allow devices outside the local network** in Settings → Configuration. - FSLTL and AIG model conversions now run at most two at a time and stop after five minutes; a browser connected to a busy desktop app waits and retries. +- Clouds and fog are now drawn with the scene's depth, so aircraft, buildings and terrain in front of a cloud deck or fog bank hide it correctly. They need the logarithmic depth setting on; with it off, no clouds or weather fog are drawn. +- Fog Intensity sets how thick the depth-drawn fog looks, and terrain in low visibility is no longer fogged twice. +- Weather fog now fades in as visibility drops from 10 to 8 miles, and eases over six seconds when visibility changes, instead of popping. +- Tower mod cabs now darken at night with the rest of the view and cast a shadow on the ground. +- Tower mod `rotation` now turns the cab clockwise, as documented; a cab with a rotation other than 0 may now face a different way. ### Fixed - FSLTL and AIG aircraft now use their own livery's textures instead of another livery's, and AIG models with many liveries convert again. +- Tower mod cabs no longer float about 30 m above their tower when the airport loads before the terrain. - The remote access server no longer serves or writes files outside its own folders. - Remote browsers no longer receive your stored keys and sign-ins, such as the RealTraffic key and vNAS login. - Remote browsers no longer receive the host's folder locations (MSFS Community, model cache, VMR, FSLTL, mods, diagnostics) from its settings, lists or error messages. @@ -34,6 +41,7 @@ All notable changes to TowerCab 3D will be documented in this file. - A model conversion cut short no longer leaves a broken model that never converts again. - FSLTL and AIG liveries whose names contain dashes, slashes or other symbols now convert; every converted model converts again once after updating. - vNAS no longer disconnects when it receives aircraft data it cannot read that contains non-English characters. +- An overcast now reaches the horizon and meets the distant haze in one grey, and broken layers beneath it no longer show bright horizon streaks. ### Removed - The experimental VR mode and its VR button in the controls bar. diff --git a/CLAUDE.md b/CLAUDE.md index 283e09fc..078c3664 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,9 @@ In either, `WeatherDebugPanel` (dev builds only) forces rain, snow and cloud cov - `camera.get()` returns the active viewport's `{ heading, pitch, fov, followMode, followingCallsign }`; `camera.set({ heading, pitch, fov })` sets any of them. - `lookAt(lat, lon, altitudeFt)` aims the active viewport at a position. - `project(lat, lon, heightM)` returns the page CSS-pixel point for `browser_click` and friends, or `null` when the point is behind the camera. -- `readPixels(x, y, w, h)` returns the Babylon canvas's RGBA bytes for a page rectangle (bottom-left row order). Rain is drawn additively with alpha 0, so screenshots and canvas `drawImage` miss it; read pixels instead. +- `readPixels(x, y, w, h)` returns the Babylon canvas's RGBA bytes for a page rectangle (bottom-left row order). Rain is drawn additively with alpha 0, so screenshots and canvas `drawImage` miss it; read pixels instead. It cannot see the Cesium weather stage's clouds and fog; use screenshots for those. +- `weatherStage` drives the cloud and fog stage: `set({ camera?, testAircraft? })` (null clears an aid) and `info()` returning `{ stageInScene, stageEnabled, stageNames, numberOfFrustums, logDepth }`. +- `towerModel` returns `{ loaded, modelMatrix, nightFactor }` for the tower cab, read live: whether a cab is in the scene, its column-major model matrix (`number[]`, or null without a cab) and the light factor (1 by day, lower at night). The main view only: insets draw no cab. - `perf()` returns `{ current, window }`: `current` is the `performanceMonitor.getMetrics()` snapshot, `window` the median and p95 (`{ median, p95 }`, or null) over the last 300 frames of `frameInterval`, `totalFrame`, `cesiumUpdate`, `cesiumDraw` and `babylonRender`, plus `gpuCesiumMs` / `gpuBabylonMs` over the last 300 GPU timer results (null until the timer yields a real result). Rendering spikes sample it over 300 frames and report median and p95. Driving notes: @@ -79,6 +81,7 @@ Driving notes: - Headless Edge over raw CDP renders at 1 fps until `page.bringToFront()` and needs `--disable-extensions`; do not restart Vite while its page is open (see `docs/DRIVING_CONVENTIONS.md`, rule headless-edge-quirks). - `pnpm run dev -- ` writes the log relative to `src-tauri/`, not the repo root. - The weather debug panel's Apply replaces all weather, clouds included. +- `viewer.scene.render()` with no argument renders at the wall clock, not the app's fixed clock; pass `viewer.clock.currentTime`. - CDP `Emulation.*` overrides last only for the `browser_run_code_unsafe` call that sets them. - An airport switch stalls page timers for about 10 s; wait before reading state. @@ -354,10 +357,11 @@ With no MSFS match, `getModelInfo` goes on to custom VMR mods, the built-in mapp ## Build Configuration - **Tauri 2**: Native desktop wrapper with Rust backend -- **Vite 7**: Frontend build tool +- **Vite 8**: Frontend build tool - **TypeScript**: Strict mode with path aliases - **React 19**: Latest React with concurrent features - **vite-plugin-static-copy**: Cesium assets copied to output +- **Chunk cycle check**: every production build (`pnpm run vite:build`, so CI and `tauri build` too) fails when its emitted chunks statically import each other in a cycle (`scripts/dev/chunkCycles.ts`). A module that reads an imported binding while it loads (a store initializer reading `DEFAULT_SETTINGS`) gets `undefined` across such a cycle and the app boots to a black screen; the dev server never shows it. Break a new cycle with a `codeSplitting` group in `vite.config.ts` (the `zustand` and `settings-core` groups are examples); the plugin's known-cycle list holds only cycles a shipped build already has, each with its issue. ## Scripts Organization @@ -431,10 +435,19 @@ if (version < 7) { ### Modifying Aircraft Rendering 1. Interpolation logic (60 Hz smooth motion): `useAircraftInterpolation.ts` -2. 3D model rendering: `CesiumViewer.tsx` (Cesium entities) +2. 3D model rendering: `CesiumViewer.tsx` (Cesium entities); model heading goes through `utils/modelOrientation.ts` (`aircraftModelHeading`) 3. Datablock labels and leader lines: `useBabylonOverlay.ts` (Babylon.js GUI) 4. Regression suite: `tests/motion/` drives the interpolation hook, the timeline store, the phase detector and the gear controller with synthetic VATSIM (15 s) and vNAS (1 Hz) flights and compares each frame with truth (`pnpm vitest run tests/motion`; `MOTION_REPORT=1` writes `.tmp/motion/report.txt`). A motion fix lands with a red-first assertion there, turning its finding's `it.todo` into an `it`. +### Modifying the Tower Cab + +1. The cab is a Cesium model (`useTowerModel.ts`), not Babylon, so it hides the weather stage's clouds and fog behind it and casts a Cesium shadow; its console lines start `[useTowerModel]` +2. Placement: `ModService.getTowerPlacement` (manifest `modelPosition`, then tower-positions); ground height from `resolveGroundHeight` in `utils/towerHeight.ts` (field elevation until terrain loads); live wizard edits from `towerPositioningStore.ts` +3. Axis convention: `utils/modelOrientation.ts` (`compassToModelHeading`, `aircraftModelHeading`) serves aircraft and cab alike; `compassToModelHeading` points the glTF +X axis along a compass bearing; aircraft add 180 so their nose is on -X, and the cab adds 90 (`towerModelMatrix`) so its +X faces east at manifest `rotation` 0 and bearing `rotation + 90`, the convention KOAK's mod is tuned to +4. Night darkening: `cabNightFactor` over the shared curve in `utils/nightBrightness.ts`, the same one `useCesiumNightDarkening.ts` uses for the imagery +5. Inset windows never load mods (only the main window calls `modService.loadMods()`), so insets mount the hook but draw no cab +6. `cab-environment.env` stays on disk but nothing loads it + ### Modifying Camera Behavior 1. Camera math: `useCesiumCamera.ts` @@ -445,9 +458,9 @@ if (version < 7) { ### Modifying Weather Effects 1. METAR fetching/parsing: `services/MetarService.ts`, `weatherStore.ts` -2. Fog/cloud rendering: `useBabylonOverlay.ts`, `useBabylonWeather.ts`, `useCesiumWeather.ts` -3. Smoothing: `useSmoothedWeather.ts` -4. Settings: `settingsStore.ts` (fog/cloud toggles, intensity) +2. Fog/cloud rendering: the Cesium post-process stage in `useCesiumWeatherStages.ts` (shader in `hooks/weather/cloudStage.glsl.ts`, pure helpers in `utils/weatherStage.ts`, the deck's curved-earth geometry in `utils/cloudDeckGeometry.ts`); the stage stays hidden in a view with no logarithmic depth buffer, which then shows only the globe's distance fog; label weather culling (`isVisibleByWeather`, over `utils/weatherVisibility.ts`) in `useBabylonOverlay.ts`; scene fog in `useCesiumWeather.ts` +3. Smoothing: the fog's fade-in (`stageFogFade`) and its 6 s ease on a visibility step (`nextFogEase`, `easedFogDensity`) are in `utils/weatherStage.ts`, stepped from the stage's `preRender` in `useCesiumWeatherStages.ts` +4. Settings: `settingsStore.ts` (fog/cloud toggles, intensity, `weather.stageQuality`, the Low/Medium/High quality preset). The knobs of each preset are `WEATHER_STAGE_PRESETS` in `constants/weather.ts`; the main view reads the setting, an inset takes `insetStageQuality` from its size against the main window; the device presets (`SETTINGS_PRESETS`, via `deviceSettingsPreset()`) set it to High on desktop, Medium on iPad, Low on a phone ### Modifying Viewport System diff --git a/CREDITS.md b/CREDITS.md index d46d3072..12281563 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -4,7 +4,7 @@ ### `cab-environment.env` -Prefiltered environment cubemap used as the Babylon.js scene `environmentTexture` for image-based lighting (IBL) on the modded tower cab interior. +Prefiltered environment cubemap kept for image-based lighting (IBL) of the modded tower cab interior. Nothing loads it today; it stays on disk until the Babylon removal decides whether the Cesium cab needs it. - **File:** `src/renderer/public/cab-environment.env` - **Source:** [BabylonJS/Assets — `environments/studio.env`](https://github.com/BabylonJS/Assets/blob/master/environments/studio.env) diff --git a/MODDING.md b/MODDING.md index b1663c27..b7efba9f 100644 --- a/MODDING.md +++ b/MODDING.md @@ -171,7 +171,7 @@ One mod can match multiple aircraft types by listing them in `aircraftTypes`. ### Model Guidelines -1. **Orientation**: Aircraft should point along the +Y axis (forward) +1. **Orientation**: Nose along -X and top up (in Blender: nose toward -X, Z up; export with +Y Up). A model with its nose along +X sets `rotationOffset.y` to 180 2. **Origin**: Place the origin at the aircraft's center 3. **Scale**: Model should be in meters (1 unit = 1 meter) 4. **File Size**: Keep models under 5MB for best performance @@ -241,7 +241,7 @@ Tower mods can specify an explicit model position using absolute lat/lon coordin | `lat` | number | Yes | Latitude of 3D model position (double precision) | | `lon` | number | Yes | Longitude of 3D model position (double precision) | | `height` | number | No | Height offset in meters above terrain (default 0) | -| `rotation` | number | No | Model rotation in degrees (0=north, 90=east, default 0) | +| `rotation` | number | No | Clockwise turn of the model in degrees. At 0 the model's +X axis faces east, and 90 turns it to face south (default 0) | **Note:** The `modelPosition` field controls where the 3D tower model renders. The `cameraPosition` field (separate) controls where the camera viewpoint is located. These can be different positions. @@ -271,7 +271,7 @@ When these are set, they become the default camera position for that airport (us ### Model Guidelines -1. **Orientation**: Tower should be upright with entrance facing +Y +1. **Orientation**: Upright, with the side that should face east at `rotation` 0 along +X (in Blender: that side toward +X, Z up; export with +Y Up) 2. **Origin**: Place at ground level, center of the tower base 3. **Scale**: Model should be in meters 4. **Detail**: Include cab windows and basic structure @@ -515,7 +515,7 @@ For best results, convert SketchUp models to GLB: - **Scale**: SketchUp uses inches by default. Set units to Meters (Window → Model Info → Units) - **Origin**: Position your model at the origin (0,0,0) for proper placement -- **Orientation**: Tower entrance should face the green axis (+Y) +- **Orientation**: The side that should face east at `rotation` 0 faces the red axis (+X), with blue (+Z) up - **Textures**: Use simple, low-resolution textures for better performance - **Components**: Flatten components before export (Edit → Component → Explode) diff --git a/README.md b/README.md index 6fc59c1d..a60bd883 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ A 3D tower cab view application for VATSIM air traffic controllers. View real-ti - Dynamic time-of-day lighting (real-time or fixed hour) - Aircraft rendered as 3D models with realistic type-based sizing (39 aircraft types) - Aircraft physics emulation: pitch during climbs/descents, banking in turns, landing flare -- METAR-based weather effects: fog domes and cloud layers at real ceiling altitudes +- METAR-based weather effects: visibility fog and cloud layers at real ceiling altitudes ### Multi-Viewport System - Create multiple inset viewports for simultaneous views diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 82793502..61d60a59 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -112,6 +112,7 @@ On first connection from a touch device, you'll be prompted to apply optimized g - Reduced shadow quality for smoother performance - Lower MSAA anti-aliasing (2x instead of 4x) - Smaller tile cache to conserve memory +- Lower weather quality (Medium; a low-end tablet gets the phone preset and Low) - Optimized for 60 FPS on mobile devices **Desktop Preset:** @@ -711,12 +712,14 @@ TowerCab 3D displays real-time weather based on METAR reports. **Fog Effects:** - Visibility-based fog that limits terrain draw distance -- Visual fog dome at the visibility boundary +- Visual fog at the visibility boundary, drawn behind aircraft, buildings and terrain that are closer than the fog (not drawn in top-down view) +- Fog fades in as visibility drops from 10 to 8 statute miles, and eases over 6 seconds when the visibility changes (a new METAR, or a move of the interpolated visibility); the Visibility Scale slider, the fog toggle and the top-down view apply at once - Adjustable intensity and visibility scale **Cloud Layers:** -- Cloud planes positioned at METAR-reported ceiling altitudes -- Scattered (SCT), broken (BKN), and overcast (OVC) layers shown +- Clouds positioned at METAR-reported ceiling altitudes, hidden by anything closer to the camera +- With the Logarithmic Depth Buffer graphics setting off, no clouds or METAR fog are drawn, and only the globe's distance fog shows +- Scattered (SCT), broken (BKN), and overcast (OVC) layers shown; a layer under a BKN or OVC layer is drawn grey on top, with no bright streaks along the horizon - Aircraft behind clouds may have hidden datablocks ### Weather-Based Visibility @@ -732,9 +735,10 @@ When weather effects are enabled: | Setting | Description | |---------|-------------| | Enable Fog | Toggle fog effects on/off | -| Fog Intensity | Opacity of the fog dome (0.5x-2x) | +| Fog Intensity | How thick weather fog looks (0.5x-2x) | | Visibility Scale | Multiplier for fog distance (2x = see twice as far as METAR) | | Enable Clouds | Toggle cloud layer display on/off | +| Weather Quality | Cloud and fog detail (Low, Medium, High). Lower draws clouds nearer and coarser for a faster frame; smaller inset windows automatically use a lower quality | ### Automatic Weather Updates @@ -858,6 +862,7 @@ See the [MSFS Model Support](#msfs-model-support) section for details on using F | **Fog Intensity** | Fog opacity multiplier | 1x | | **Visibility Scale** | Fog distance multiplier | 1x | | **Enable Clouds** | Cloud layers at METAR ceilings | On | +| **Weather Quality** | Cloud and fog detail: Low, Medium or High | High in the desktop app; in a browser, Medium on iPads and tablets, Low on phones and low-end tablets | | **Enable Precipitation** | Rain/snow particle effects | On | | **Enable Lightning** | Lightning flashes during storms | On | @@ -971,7 +976,7 @@ Add custom aircraft models with airline-specific liveries. Mods match by ICAO ai ### Tower Mods -Add custom control tower models for specific airports. Mods match by ICAO airport code (e.g., KJFK, EGLL). +Add custom control tower models for specific airports. Mods match by ICAO airport code (e.g., KJFK, EGLL). The tower cab appears in the main view only, hides clouds and fog behind it, casts a shadow on the ground, and darkens at night when night darkening and lighting are on. Inset windows do not show it. ### Bundled Tower Positions diff --git a/docs/DRIVING_CONVENTIONS.md b/docs/DRIVING_CONVENTIONS.md index f4265c66..5ec40ac5 100644 --- a/docs/DRIVING_CONVENTIONS.md +++ b/docs/DRIVING_CONVENTIONS.md @@ -174,7 +174,9 @@ Source: CLAUDE.md:41, CLAUDE.md:56; docs/plans/rendering-opportunities-design.md Rain in the Babylon overlay is drawn additively with alpha 0, so CDP screenshots and canvas `drawImage` both miss it. Verify such effects with the hook's `readPixels(x, y, w, h)` on the Babylon canvas (RGBA, bottom-left row order) or a video, never a still screenshot. -The converse also holds: `readPixels` covers only the Babylon canvas, so Cesium pixels are still judged from page screenshots. The plan's standing verification line says "particles and other additive effects need a video or engine.readPixels, not a screenshot". +The converse also holds: `readPixels` covers only the Babylon canvas, so Cesium pixels, including the clouds and fog of the Cesium weather stage (drawn only in a view with a logarithmic depth buffer), are still judged from page screenshots. + +A render check that calls `viewer.scene.render()` to read those pixels passes `viewer.clock.currentTime`: with no argument it renders at the wall clock, not the app's fixed clock, so sun and night lighting differ from the screenshot. The plan's standing verification line says "particles and other additive effects need a video or engine.readPixels, not a screenshot". Source: CLAUDE.md:49; src/renderer/hooks/useAgentHarness.ts:66-81; docs/plans/rendering-opportunities-design.md:109, 214; commit 8d672dc (MAIN.md "Verification, every wave"). Seen: Wave 1 run. ### CDP emulation overrides last only for the call that sets them diff --git a/docs/architecture.md b/docs/architecture.md index fad0c1c0..f92dfad3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -75,7 +75,7 @@ useAircraftInterpolation() [60 Hz singleton] └──→ useBabylonOverlay (60Hz rendering) ├─ Datablock labels in screen space ├─ Leader lines from aircraft to labels - └─ Weather effects (fog dome, cloud layers) + └─ Rain, snow and lightning (clouds and METAR fog are the Cesium weather stage's) Note: AircraftPanel uses useAircraftFiltering for 1Hz UI updates ``` @@ -93,12 +93,14 @@ weatherStore.setWeather() └─ ceiling (feet AGL) ↓ ├──→ useCesiumWeather - │ └─ Applies fog density to Cesium scene + │ └─ Applies fog density to Cesium scene (tuned from METAR visibility only while the stage draws no fog; otherwise held at Cesium's clear-weather values so terrain is not fogged twice) + │ + ├──→ useCesiumWeatherStages + │ └─ Post-process stage drawing clouds and fog against scene depth at the Low/Medium/High quality preset (also in insets, which pick a preset by size; nothing in top-down, nor in a view with no logarithmic depth buffer, which shows only the globe's distance fog) │ └──→ useBabylonOverlay - ├─ Fog dome mesh at visibility boundary - ├─ Cloud layer plane meshes - └─ Datablock weather visibility culling + ├─ Rain, snow and lightning + └─ Datablock weather visibility culling (always on) ``` ### Settings Persistence Flow @@ -292,9 +294,27 @@ CesiumViewer Component Mount │ ├─ Sets up shadow maps (size, distance, darkness) │ └─ Updates runtime when settings change │ - ├─ 4. useCesiumWeather(viewer, showWeatherEffects, showCesiumFog, fogDensity) - │ ├─ Applies fog density from METAR visibility - │ └─ Adjusts visual density scalar and screen space error + ├─ 4. useCesiumWeatherStages(viewer, { airportIcao, refLat, refLon, viewMode, isInset }), returning its gates + │ ├─ Adds the cloud and fog post-process stage (GLSL in hooks/weather/cloudStage.glsl.ts; pure helpers in utils/weatherStage.ts), kept after the silhouette stage so aircraft in cloud get no outline + │ ├─ Knobs come from a quality preset (WEATHER_STAGE_PRESETS in constants/weather.ts, resolved by weatherStageQuality): the `weather.stageQuality` setting in the main view, `insetStageQuality` in an inset (its area against the main window's: at least 50% the main preset, at least 15% one step down, else Low; re-read 300 ms after a resize stops). A preset change rebuilds the stage only when resolutionScale changes, since Cesium reads textureScale at creation; steps, radius and the look knobs are read live + │ ├─ At High the cloud deck reaches 100 km (the preset's radius; Medium 60 km, Low 40 km) and curves down with the earth (d²/2R); layers are marched by base height relative to the camera on that curved datum, not by ray slope; undersides use the fog grey so the deck meets the distant haze; tops stay flat; a layer under a BKN or OVC layer (`layerSunShade`, `u_layerShade`) gets no direct sun on its tops, which light to the underside grey; CLOUD_STAGE_MAX_STEPS caps the shader's MAX_STEPS and the u_steps clamp; utils/cloudDeckGeometry.ts mirrors the shader geometry for tests + │ ├─ Fog density fades in linearly between 10 and 8 SM of visibility (`stageFogFade`); a change of the visibility input, METAR or interpolated, eases the drawn density over 6 s with smoothstep (`nextFogEase`, `easedFogDensity`, stepped in the stage's `preRender`), while a visibilityScale, fog toggle or top-down/3-D change applies at once + │ ├─ Split in one file into useWeatherAnchor, useWeatherStageFrame and useWeatherStageComposite + │ ├─ Reads showWeatherEffects, showClouds, showBabylonFog, stageQuality, visibilityScale, fogIntensity (scales the fog's optical depth), weatherStore.useInterpolation / interpolatedWeather.visibility; disabled when there is nothing to draw (no layers, no fog, no anchor frame) + │ ├─ Reads the live scene.logarithmicDepthBuffer, not graphics.enableLogDepth: without it the stage is hidden, one warning is logged and the view shows only the globe's distance fog, with no clouds or METAR fog + │ ├─ Cloud frame anchored on the airport (re-anchors past 100 m within it), or on the followed aircraft every 5 km in orbit-follow; terrain is resampled when the provider changes and retried each second on failure; ground changes between samples slide over 5 s; cloud noise is world-stable (origin resets past 50 km) + │ └─ useWeatherStageDriveAids runs after useCesiumCamera (dev camera and test-aircraft aids) + │ + ├─ 4b. useCesiumWeather(viewer, showWeatherEffects, showCesiumFog, fogDensity, stageDrawsFog) + │ ├─ Runs after the stage hook and takes its stageDrawsFog (its `gates.fog` with an anchor frame); scene.fog.enabled follows showWeatherEffects && showCesiumFog + │ ├─ Stage draws fog: scene.fog held at Cesium's clear-weather values (density 0.0006, visualDensityScalar 0.15, screenSpaceErrorFactor 2.0, minimumBrightness 0.03) + │ └─ Stage draws no fog (Babylon overlay draws the weather, or no log depth): applies fog density from METAR visibility, with the visual density scalar and screen space error adjusted (pure helper sceneFogValues in utils/weatherStage.ts) + │ + ├─ 4c. useTowerModel(viewer, { sunElevation, nightDarkeningEnabled, nightDarkeningIntensity }), returning a getter for the dev harness' `towerModel` + │ ├─ Draws the tower mod's cab as a Cesium model, so it writes scene depth (the weather stage's clouds and fog stop at it) and casts and receives Cesium shadows; insets mount it but draw no cab, since inset windows never load mods + │ ├─ Placement from modService.getTowerPlacement; base on resolveGroundHeight (utils/towerHeight.ts: terrain sample, globe tile, else field elevation), re-placed on terrainProviderChanged; orientation through utils/modelOrientation.ts (shared with aircraft) + │ ├─ Darkens at night with the imagery: light colour and image-based lighting scale by cabNightFactor from the curve in utils/nightBrightness.ts, shared with useCesiumNightDarkening + │ └─ While the positioning wizard's model step is open for the airport, its offsets rewrite the model matrix without reloading the glTF │ ├─ 5. useAircraftModels(viewer, modelPoolRefs, interpolatedAircraft, ...) │ ├─ Assigns aircraft to model pool slots @@ -536,7 +556,7 @@ requestAnimationFrame ├─ useBabylonOverlay.render() │ ├─ Renders datablock labels in screen space │ ├─ Renders leader lines from aircraft to labels - │ ├─ Renders weather effects (fog dome, clouds) + │ ├─ Renders weather effects (rain, snow, lightning) │ └─ Renders measuring tool lines │ └─ performanceMonitor.endFrame() @@ -544,7 +564,7 @@ requestAnimationFrame ### Environment-Map Lighting -Cesium lights OSM buildings and aircraft models from a dynamic environment map whose ground hemisphere defaults to an olive. TowerCab 3D passes a neutral grey instead, as `environmentMapOptions` at creation (setting `groundColor` afterwards does not rebuild a computed map). The colour is `ENVIRONMENT_MAP_GROUND_COLOR` in `constants/lighting.ts`, wrapped by `neutralEnvironmentMapOptions()` in `utils/environmentMap.ts`. Creation sites: `createOsmBuildingsAsync` in `CesiumViewer.tsx`, `Model.fromGltfAsync` in `useCesiumViewer.ts` and `useAircraftModels.ts`, and `ModelPreviewModal.tsx`. A new tileset or model creation site must pass the same options. +Cesium lights OSM buildings and aircraft models from a dynamic environment map whose ground hemisphere defaults to an olive. TowerCab 3D passes a neutral grey instead, as `environmentMapOptions` at creation (setting `groundColor` afterwards does not rebuild a computed map). The colour is `ENVIRONMENT_MAP_GROUND_COLOR` in `constants/lighting.ts`, wrapped by `neutralEnvironmentMapOptions()` in `utils/environmentMap.ts`. Creation sites: `createOsmBuildingsAsync` in `CesiumViewer.tsx`, `Model.fromGltfAsync` in `useCesiumViewer.ts`, `useAircraftModels.ts` and `useTowerModel.ts`, and `ModelPreviewModal.tsx`. A new tileset or model creation site must pass the same options. ### Coordinate Transform Pipeline @@ -729,12 +749,8 @@ useBabylonOverlay (Orchestrator - 265 LOC) │ ├─ Adds hemispheric and directional lighting │ └─ Handles canvas resizing and resource disposal │ - ├─ 2. useBabylonWeather({ scene }) - │ ├─ Creates fog dome mesh (hemisphere, BACKSIDE rendering) - │ ├─ Creates cloud layer mesh pool (4 planes, 50km diameter) - │ ├─ Updates fog scale/opacity based on METAR visibility - │ ├─ Updates cloud positions based on METAR ceiling data - │ └─ Provides isVisibleByWeather() for label culling, delegating to isVisibleThroughWeather() in utils/weatherVisibility.ts (shared with useAircraftFiltering) + ├─ 2. isVisibleByWeather() (a callback inside useBabylonOverlay, no hook of its own) + │ └─ Label culling, delegating to isVisibleThroughWeather() in utils/weatherVisibility.ts (shared with useAircraftFiltering) │ ├─ 3. useBabylonLabels({ guiTexture }) │ ├─ Creates aircraft datablock labels (Rectangle + TextBlock) @@ -751,12 +767,11 @@ useBabylonOverlay (Orchestrator - 265 LOC) │ ├─ Provides getters for matrices and terrain offset │ └─ Disposes root node on unmount or airport change │ - └─ 5. useBabylonCameraSync({ cesiumViewer, camera, fogDome }) + └─ 5. useBabylonCameraSync({ cesiumViewer, camera, getFixedToEnu }) ├─ Reads Cesium camera position/rotation/FOV ├─ Converts ECEF → ENU using fixedToEnu matrix ├─ Extracts Euler angles from direction/up vectors ├─ Updates Babylon camera position/rotation/FOV - ├─ Positions fog dome at camera location └─ Handles both 3D perspective and top-down modes ``` @@ -765,13 +780,12 @@ useBabylonOverlay (Orchestrator - 265 LOC) ```typescript // Correct order - each hook depends on previous ones const { engine, scene, camera, guiTexture, sceneReady } = useBabylonScene({ canvas }) -const { fogDome, isVisibleByWeather } = useBabylonWeather({ scene }) const { updateLabel, updateLabelPosition, removeLabel } = useBabylonLabels({ guiTexture }) const { setupRootNode } = useBabylonRootNode({ scene, cesiumViewer }) -const { syncCamera } = useBabylonCameraSync({ cesiumViewer, camera, fogDome }) +const { syncCamera } = useBabylonCameraSync({ cesiumViewer, camera, getFixedToEnu }) // WRONG - camera sync before scene initialization -const { syncCamera } = useBabylonCameraSync({ cesiumViewer, camera, fogDome }) // ❌ camera is null! +const { syncCamera } = useBabylonCameraSync({ cesiumViewer, camera, getFixedToEnu }) // ❌ camera is null! const { camera } = useBabylonScene({ canvas }) ``` @@ -780,7 +794,7 @@ const { camera } = useBabylonScene({ canvas }) **For LLM Agents:** - 70% reduction in file size (889 → 265 LOC for orchestrator) - Clear hook boundaries with single responsibilities -- Easier to locate specific functionality (search for "fog" → useBabylonWeather) +- Easier to locate specific functionality (search for "label" → useBabylonLabels) - Better code search results (specialized files vs monolith) **For Developers:** @@ -793,7 +807,6 @@ const { camera } = useBabylonScene({ canvas }) **Memory Management:** Each hook manages its own resource disposal: - useBabylonScene → engine, scene, camera, GUI -- useBabylonWeather → fog dome, cloud meshes - useBabylonLabels → labels, leader lines - useBabylonRootNode → root transform node - useBabylonCameraSync → no resources (stateless calculations) @@ -913,6 +926,7 @@ src/renderer/ │ ├─ vatsimStore.ts │ ├─ viewportStore.ts (PRIMARY CAMERA STORE) │ ├─ vnasStore.ts (vNAS connection state) +│ ├─ weatherStageStore.ts (dev-only drive aids of the cloud and fog stage, not persisted) │ └─ weatherStore.ts │ ├─ services/ diff --git a/docs/coordinate-systems.md b/docs/coordinate-systems.md index 8ec05322..fedd61e9 100644 --- a/docs/coordinate-systems.md +++ b/docs/coordinate-systems.md @@ -63,7 +63,7 @@ const { latitude, longitude, height } = cartographic ## 3. ENU (East-North-Up) -**Used by**: Babylon.js overlay for weather effects (fog dome, cloud layers), measuring tool +**Used by**: Babylon.js overlay for weather effects (rain, snow, lightning), measuring tool **Format**: - Local tangent plane at tower location @@ -94,7 +94,7 @@ const enuPosition = transformPositionToENU( ``` **When to use**: -- Weather effect positioning (fog dome, cloud layers) +- Weather effect positioning (rain, snow, lightning) - Measuring tool line placement - Local distance calculations @@ -266,7 +266,7 @@ const distance = calculateDistanceNM( - **Cause**: Geoid offset not applied - **Fix**: Calculate `geoidOffset = terrainHeight - airportElevationMSL` -**Problem**: Weather effects (fog dome, clouds) don't align with Cesium terrain +**Problem**: Weather effects (rain, snow, lightning) don't align with Cesium terrain - **Cause**: Root node not set up at correct location - **Fix**: Call `babylonOverlay.setupRootNode(lat, lon, elevation)` after airport change diff --git a/docs/plans/babylon-to-cesium.md b/docs/plans/babylon-to-cesium.md index 185e1842..fd3794ba 100644 --- a/docs/plans/babylon-to-cesium.md +++ b/docs/plans/babylon-to-cesium.md @@ -28,7 +28,7 @@ Read by: `useCesiumLabels` (everything above), `CesiumViewer.tsx` click path (la ## 1b. Clouds | File | Lines | Role | |---|---|---| -| `src/renderer/hooks/useBabylonWeather.ts` | 1415 | fog dome + 4-slot cloud pool (planes, domes, above-planes), METAR smoothing reads, rotation animation (:1220-1359), label culling `isVisibleByWeather` (:1370-1401), per-type materials, night emissive | +| `src/renderer/hooks/useBabylonWeather.ts` | 1415 | fog dome + 4-slot cloud pool (planes, domes, above-planes), METAR smoothing reads, rotation animation (:1220-1359), label culling `isVisibleByWeather` (now in `useBabylonOverlay.ts:258-272`), per-type materials, night emissive | | `src/renderer/hooks/weather/cloudDomeMesh.ts` | 118 | dome mesh builder (Babylon import) | | `src/renderer/hooks/weather/cloudTextureGenerator.ts` | 323 | procedural cloud textures (Babylon import) | | `src/renderer/hooks/weather/cloudNoiseGenerator.ts` | 58 | noise (no Babylon import, serves the textures) | @@ -43,7 +43,7 @@ Read by: `useCesiumLabels` through `isVisibleByWeather` (label culling), `useBab ## 1c. Fog Same files as clouds (`useBabylonWeather.ts` fog dome effect :864-926, scale = `visibilityMeters * visibilityScale`) plus Cesium's own fog `useCesiumWeather.ts` (89 lines; `scene.fog` density from `fogDensity`, gated on `showWeatherEffects && showCesiumFog`) and `weatherStore.visibilityToFogDensity` (the spike exports it). Replaces it: fog in the same post-process composite, `exp(-distance/visibility)` with a height falloff (spike: 400 m scale, none at 10 SM+), replacing both the dome and the `scene.fog` tuning; also the base TC3D-70 needs. -Read by (second copy of the culling rule): `useAircraftFiltering.ts:390-410` (Nearby Aircraft panel "weather visibility" filter reads `showCesiumFog`, `showClouds`, `visibilityScale` and repeats the visibility and cloud arithmetic). `useBabylonWeather.ts:1377` gates label culling on `showCesiumFog`, not `showBabylonFog` (TC3D-137). +Read by (second copy of the culling rule): `useAircraftFiltering.ts:382-395` (Nearby Aircraft panel "weather visibility" filter reads `showCesiumFog`, `showClouds`, `visibilityScale` and calls the shared `isVisibleThroughWeather`). `useBabylonOverlay.ts:263` gates label culling on `showCesiumFog`, not `showBabylonFog` (TC3D-137). ## 1d. Precipitation (rain, snow, lightning) | File | Lines | Role | @@ -62,7 +62,7 @@ Reads: `weatherStore` (`precipitation`, `wind`, `cameraPosition`), `airportStore | `src/renderer/hooks/useBabylonScene.ts` | (above) | IBL environment `IBL_ENV_URL` = `src/renderer/public/cab-environment.env` (Babylon `studio.env`, `CREDITS.md:7-11`), `ShadowGenerator` 2048 | | `src/renderer/hooks/useBabylonRootNode.ts` | 289 | ENU `TransformNode`, geoid/terrain sampling for the cab and weather placement | -Replaces it: Cesium `Model` as before commit 05d0611; the spike's `useTowerModel.ts` (311 lines, branch 715e42a) already does it behind a flag (the Cesium hook that existed in history). +Replaces it: Cesium `Model` as before commit 05d0611; `hooks/useTowerModel.ts` now draws the cab in the main view (shadow, night darkening, one axis convention in `utils/modelOrientation.ts`), and `useBabylonTowerModel.ts`, `useBabylonSunDirection.ts` and the Babylon shadow generator and IBL load are deleted. Read by: `SettingsModsTab.tsx:137` (comment only: the positioning store starts at 0 and `useBabylonTowerModel` caches the terrain), `towerPositioningStore` wizard, TC3D-126 (cab unlit at night), TC3D-138 (axis convention: the Babylon path adds +90 degrees, the Cesium path would use `gltfForwardAxis = X`). Important coupling: the cab only lives in Babylon so Babylon clouds depth-test against it. The cab and the Babylon weather must leave Babylon together (see M2). @@ -102,7 +102,7 @@ Docs/skills that mention the handle: `CLAUDE.md` ("E2E testing" section), `docs/ Replaces it: `readPixels` repoints at the Cesium canvas or is deleted, since rain and clouds become part of the Cesium frame and show in screenshots (open decision below); TC3D-55 (`perf()` on the harness) is the measurement base. ## 1i. Babylon-only settings and config -- `types/settings.ts:742-743` `showBabylonFog` (default `:1967`); `settingsStore.ts:695` (migration repair copy; store `version: 38` at :446); `SettingsGraphicsWeatherTab.tsx:89, 860-863, 904, 921` (the checkbox, and it disables the intensity/scale sliders); `SettingsTreeBuilder.ts:113` (`fog` group lists it). +- `types/settings.ts:742-743` `showBabylonFog` (default `:1967`); `settingsStore.ts:695` (migration repair copy; store `version` at :446); `SettingsGraphicsWeatherTab.tsx:89, 860-863, 904, 921` (the checkbox, and it disables the intensity/scale sliders); `SettingsTreeBuilder.ts:113` (`fog` group lists it). - Babylon-semantics settings that survive with new meaning: `cloudOpacity` (:748, "cloud plane opacity", plane meshes only), `fogIntensity` (:755, dome opacity multiplier), `visibilityScale` (:761, dome radius; also read by `useAircraftFiltering.ts:263`), `showClouds`, `showPrecipitation`, `precipitationIntensity`, `showLightning`, `showWeatherEffects`, `showCesiumFog` (also gates label culling), `enableWeatherInterpolation`. `aircraft.datablockFontSize` is read by `useBabylonOverlay.ts:185`. - `package.json:71-73` `@babylonjs/core`, `gui`, `loaders` (^9.28.0); no `@babylonjs/addons` yet (TC3D-61 would add it). - `vite.config.ts:90-95` (`optimizeDeps.exclude: ['@babylonjs/core']`), `:127-133` (`babylon` manual chunk; audit D1 baseline 8,108 kB / 1,791 kB gzip), `.github/dependabot.yml:12-14` (`babylonjs` group). @@ -123,7 +123,7 @@ Hard ordering fact: moving the cab to Cesium before Babylon clouds are gone undo BRANCH: no (each issue is one-site or additive; nothing changes what the user sees). - Land Cesium 1.145 (TC3D-9, project "Cesium 1.145 and shadow darkness", `feat/cesium-1.145`) before M2: audit A1 says 1.144 fixes a one-frame black flash from the stale framebuffer cache "post-process stages + shadow maps trigger it" and a post-process crash on large selected-feature lists. Dependency, not a new issue. - "Expose frame timings on the harness and fix the GPU timer" (TC3D-55 widened): `useAgentHarness.ts`, `types/harness.ts`, `performanceMonitor.ts`. Proving check: `__tc3d.perf()` returns median and p95 over 300 frames; GPU ms nonzero on the owner's desktop (spike: timer read 0 on this backend, GPU cost unmeasured, iPad unmeasured). Gate for M2's exit. -- "Extract label weather culling into one pure module" (imperative: *Move `isVisibleByWeather` and the `useAircraftFiltering` copy into one weather-visibility function*): `useBabylonWeather.ts:1370-1401`, `useAircraftFiltering.ts:390-410`, new `utils/weatherVisibility.ts`. Proving: unit test under `tests/`; app check that labels cull the same as before. Hotspot: none. Unblocks M2 and M4 to run in parallel and absorbs TC3D-137. +- "Extract label weather culling into one pure module" (imperative: *Move `isVisibleByWeather` and the `useAircraftFiltering` copy into one weather-visibility function*): `isVisibleByWeather` (now `useBabylonOverlay.ts:258-272`), `useAircraftFiltering.ts:382-395`, new `utils/weatherVisibility.ts`. Proving: unit test under `tests/`; app check that labels cull the same as before. Hotspot: none. Unblocks M2 and M4 to run in parallel and absorbs TC3D-137. Parallel with: M1 (VR). Hotspots: none (harness only overlaps TC3D-92). ## M1 Remove VR (main) @@ -143,12 +143,12 @@ Issues (order): 1. Port the spike's stage into production: `useCesiumWeather.ts` (or a new `useCesiumWeatherStages.ts`), `hooks/weather/cloudStage.glsl.ts` (from `spikeWeatherShaders.ts`), reads `weatherStore` + settings instead of `spikeWeatherStore`; mounts once, main viewport and insets decided (open decision). Proving: `__tc3d` camera poses of the spike's five cases (BKN025 10SM from the cab; OVC008 1/2SM; OVC015 from 5000 ft with an aircraft at 3000/1000 ft; BKN012 toward hills) match the spike's occlusion results; frame cost via `perf()`. -2. Curve the deck with the earth, widen to ~100 km, add top relief; fold TC3D-125 (clear-sky band under OVC; dense-fog colour). Proving: EGLL case from TC3D-125 (camera ~80 m, heading 274, pitch -3, FOV 45; BKN900 + OVC2000; `fogDensity` 0.0083) shows grey horizon and grey haze. -3. Fog in the composite replacing `scene.fog` tuning; map `fogIntensity` and `visibilityScale`; hide Cesium sky/sun/moon hack (`CesiumViewer.tsx:318-343`, `SkyBox.show` ts-expect-error from audit A1.2) only if overcast pixels now cover the sky. +2. Curve the deck with the earth, widen to ~100 km, add top relief; fold TC3D-125 (clear-sky band under OVC; dense-fog colour). Proving: EGLL case from TC3D-125 (camera ~80 m, heading 274, pitch -3, FOV 45; BKN900 + OVC2000; `fogDensity` 0.0083) shows grey horizon and grey haze. Done (TC3D-125) for the deck and the fog colour: the deck reaches 100 km and drops with the earth (`utils/cloudDeckGeometry.ts`), undersides use the fog grey, and the render check shows no band at 10 SM and one grey for deck and fog at 5.96 SM. Tops are still flat, so top relief stays open here. +3. Fog in the composite replacing `scene.fog` tuning; map `fogIntensity` and `visibilityScale`; hide Cesium sky/sun/moon hack (`src/renderer/components/CesiumViewer/CesiumViewer.tsx` ~:322-346, a plain `skyBox.show` assignment that hides sky box, sun and moon under an OVC layer) only if overcast pixels now cover the sky. 4. Restore the cab as a Cesium model: bring back `useTowerModel.ts` from the spike, delete `useBabylonTowerModel.ts`; light it with Cesium (fold the "cab stays lit at night" half of TC3D-126); resolve TC3D-138's axis convention in this step (one Cesium convention for aircraft and cab, no +90 degrees special case). Proving: KOAK mod loaded, wizard live edit still moves the cab, cab hides clouds and is hidden by aircraft/terrain correctly, shadow on the tarmac. 5. Night: already done by the weather stage, which dims cloud and fog by sun elevation itself (`daylight()` in `hooks/weather/cloudStage.glsl.ts`); `useBabylonNightLighting` never touched cloud or fog colours. Its deletion, with the dead `BABYLON_NIGHT_*` constants, goes with TC3D-149; one sun curve for every layer (the stage, the imagery, the cab) is TC3D-185's design. -6. Quality presets and resolution: spike knobs (`steps`, `densityScale`, `radius`, `resolutionScale`, `fogExtinction`, `fogHeightScale`) become presets; ties to TC3D-12 C2 (`viewer.resolutionScale`). -7. Delete Babylon fog dome and cloud meshes and `hooks/weather/*` (this empties `useBabylonWeather.ts` apart from `isVisibleByWeather`, which M0 already moved) and recentering in `useBabylonCameraSync.ts`. +6. Quality presets and resolution: spike knobs (`steps`, `densityScale`, `radius`, `resolutionScale`, `fogExtinction`, `fogHeightScale`) become presets; ties to TC3D-12 C2 (`viewer.resolutionScale`). Done (TC3D-148): `WEATHER_STAGE_PRESETS` (Low, Medium, High) replaces `WEATHER_STAGE_QUALITY`; High is steps 120, resolutionScale 0.5, radius 100 km, Medium 72, 0.375, 60 km, Low 48, 0.25, 40 km, with equal look knobs. The per-browser `weather.stageQuality` (default High; settings `version` 39 migrates an iPad to Medium and a phone to Low) is a "Weather quality" select in Graphics & Weather, and the device prompt sets it. An inset picks its preset from its area against the main window (at least 50% the main preset, at least 15% one step down, else Low). The preset's `resolutionScale` is the cloud pass's own, independent of TC3D-12's canvas `viewer.resolutionScale`. Desktop GPU cost per preset is in `docs/plans/canvases/cesium-weather-stage/README.md`; the iPad figure is TC3D-150. +7. Delete Babylon fog dome and cloud meshes and `hooks/weather/*` (this empties `useBabylonWeather.ts` apart from `isVisibleByWeather`, which M0 already moved) and recentering in `useBabylonCameraSync.ts`. Done (TC3D-149): `hooks/weather/cloudStage.glsl.ts` stays, `isVisibleByWeather` lives in `useBabylonOverlay.ts`, `useBabylonNightLighting.ts` and `BABYLON_NIGHT_*` went with it, and with the logarithmic depth buffer off the Cesium stage stays hidden (no clouds or METAR fog, only the globe's distance fog). 8. iPad exit measurement (remote browser) at FEW/BKN/OVC and 1/4, 3, 10 SM; owner look sign-off. Hotspots: `CesiumViewer.tsx`, `useBabylonOverlay.ts`, `useBabylonCameraSync.ts`, `useCesiumWeather.ts`, `weatherStore.ts`; settings files only if a semantic changes (defer the key changes to M6). Depends on: M0 (1.145, perf harness, culling module). TC3D-141 (re-shoot "before" images) should land before the sign-off. Parallel with: M1 and M4, not with M3. @@ -174,7 +174,7 @@ BRANCH: no. Evidence: the milestone is deletion plus a settings migration, no ne Issues: - Delete `useBabylon*.ts` (overlay, scene, root node, camera sync, night lighting, sun direction), `types/babylon.ts`, `constants/babylon.ts`, `BABYLON_NIGHT_*` in `constants/lighting.ts:62-87`, Babylon half of `utils/enuTransforms.ts`, `babylon-overlay-canvas` CSS and canvas creation (`CesiumViewer.tsx:515-542`), `CesiumViewer.tsx` Babylon GPU timer and counters (:808-899, :960-1020), and the `babylon` rows in `PerformanceHUD.tsx` and `performanceMonitor.ts`. - Remove deps and config: `package.json:71-73`, `vite.config.ts:90-95,127-133`, `.github/dependabot.yml:12-14`, `src/renderer/public/cab-environment.env` and `CREDITS.md:5-11`; re-measure `pnpm run vite:build` (baseline babylon chunk 8,108 kB / 1,791 kB gzip). -- Settings migration: remove `showBabylonFog`, decide `cloudOpacity`/`fogIntensity`/`visibilityScale` mapping (bump `settingsStore.ts` version 38 to 39; follow the "Adding a New Setting" migration rules in `CLAUDE.md`); update `SettingsGraphicsWeatherTab.tsx` (or the registry entries if TC3D-86 landed), `SettingsTreeBuilder.ts:113`. +- Settings migration: remove `showBabylonFog`, decide `cloudOpacity`/`fogIntensity`/`visibilityScale` mapping (bump `settingsStore.ts` `version` by one; follow the "Adding a New Setting" migration rules in `CLAUDE.md`); update `SettingsGraphicsWeatherTab.tsx` (or the registry entries if TC3D-86 landed), `SettingsTreeBuilder.ts:113`. - Harness: drop `__tc3d.babylon`, repoint or remove `readPixels` (`useAgentHarness.ts`, `types/harness.ts`), update `CLAUDE.md` and `docs/DRIVING_CONVENTIONS.md`. Proving: `rg -i "babylon" src package.json vite.config.ts` returns nothing; `pnpm run check`; full app check; build size delta. Depends on: M1-M4 and the settings-drawer decisions. @@ -192,7 +192,7 @@ Parallelism summary: wave 1 = M0 then {M2 on `feat/cesium-weather` || M4 on main | TC3D-12 | Render resolution slider and Cesium quick wins (C1-C6, D7) | keep but rewrite | C1-C6 are Cesium-only and stay; drop D7 (hardware scaling for the Babylon canvas) and its sentence "drives resolutionScale and Babylon hardware scaling". C2 `viewer.resolutionScale` should land with or before M2 step 6 because the cloud stage's `textureScale` and the slider interact; C1 silhouettes use a `PostProcessStageComposite`, so check stage order with the weather stages. Hotspots `CesiumViewer.tsx`, `useCesiumViewer.ts`. | | TC3D-14 | Rendering docs H1-H6 | cancel: superseded by migration (fold H1 into M6) | H2-H5 describe Babylon hooks that M5 deletes; H6 documents the two-canvas depth split that M2 removes. H1 (measuring tool is Cesium `Entity`, not Babylon; `architecture.md:321,548`, `coordinate-systems.md:66`) is subsumed when M6 rewrites those sections. | | TC3D-15 | Reshape docs/architecture.md to the entry-point template | keep but rewrite | Still wanted (no Task Index, no README), but reshape once after M5 or reshape now with a stub for rendering; its Task Index row "Babylon overlay DPR or label screen size" becomes "datablock labels" (2D layer files). It overlaps TC3D-123, TC3D-122, TC3D-16, TC3D-109 and TC3D-77; sequence them as one docs pass. | -| TC3D-125 | Overcast clear-sky band; dense fog dark teal | fold into milestone M2 (step 2) | Both symptoms come from the Babylon dome plus the Cesium sky hack (`CesiumViewer.tsx:318-343`, `useBabylonWeather.ts`, `useCesiumWeather.ts`). The spike notes the same band exists with a flat 40 km deck and that curving/widening to ~100 km fixes it. Use the EGLL repro as the acceptance test. | +| TC3D-125 | Overcast clear-sky band; dense fog dark teal | fold into milestone M2 (step 2) | The band was the flat 40 km cloud deck of the weather stage; the dark teal fog was not reproduced on the Cesium stage. Curving the deck and widening it to ~100 km fixed the band (done, M2 step 2; measured in the render check). Use the EGLL repro as the acceptance test. | | TC3D-126 | KOAK mod: camera 220 m outside cab; cab lit at night | keep but rewrite (split) | Half 1 (manifest `cameraPosition` vs `modelPosition`, or `getCameraPositionFromManifest` / `airportStore.selectAirport` at `airportStore.ts:88-125`) is engine-independent: keep as a single. Half 2 (cab stays lit at night, `useBabylonTowerModel.ts`) folds into M2 step 4/5 (Cesium model lighting). | | TC3D-137 | Label fog culling follows the Cesium fog toggle | fold into milestone M4 (via M0 module) | The issue says it folds into the single-engine work. With one fog, `showBabylonFog` disappears and the toggle question is moot; the shared pure function must also replace the `useAircraftFiltering.ts:390-410` copy. | | TC3D-138 | MODDING.md axes | keep as is | Pure doc-vs-code question, engine-independent, but its trace is simpler after M2 step 4 (one Cesium convention, no +90 degrees Babylon path). Do it with or just before M2 step 4; related to TC3D-95 (body frame). | @@ -234,7 +234,7 @@ Other open issues that name a Babylon file or feature (from `.tmp/linear-list.js 1. 2D label layer technology: DOM elements (native hit-testing, CSS transforms, easy outline text; risk: 100+ moving nodes on iPad, missed by video capture TC3D-72) against one 2D `` (single draw, DPR control, hit-testing via the existing `labelBounds` map, composited in recordings) against Cesium `LabelCollection` (depth-aware, but the assessment says labels need no depth and hides them badly). Neither docs nor code settle it; the existing `labelBounds` + `findLabelAtPosition` contract favours a canvas. -2. Babylon-semantics settings: drop or remap `showBabylonFog` (dropping merges into `showCesiumFog`; users who had Cesium fog off and Babylon on get different looks), `cloudOpacity` (plane opacity; remap to cloud density or drop), `fogIntensity`, `visibilityScale` (also drives label/panel culling). Migration at `settingsStore.ts` version 38, interplay with the registry. Not settled by docs. +2. Babylon-semantics settings: drop or remap `showBabylonFog` (dropping merges into `showCesiumFog`; users who had Cesium fog off and Babylon on get different looks), `cloudOpacity` (plane opacity; remap to cloud density or drop), `fogIntensity`, `visibilityScale` (also drives label/panel culling). Migration at the next `settingsStore.ts` `version`, interplay with the registry. Not settled by docs. 3. Inset and remote-browser clients: the spike disabled the stage for insets (`isInset ? null : viewer`). Each inset is its own iframe viewer (`docs/inset-architecture.md:7`) already receiving `fogDensity`/`cloudLayers`/`visib` (`useInsetStoreSync.ts:116-126`). Do insets get the post-process (second stage cost) or reduced/no weather? iPad/remote browsers run Cesium in the browser: need the exit measurement, a quality preset per device, and the existing `DeviceOptimizationPrompt` to pick it. WebGL2 availability on older Safari decides whether rain has a fallback. @@ -280,9 +280,9 @@ Other open issues that name a Babylon file or feature (from `.tmp/linear-list.js # TASK INDEX GAP The answer took ~20 doc reads, ~25 searches and the `git show` of the spike branch beyond a Task Index row (there is no Task Index). Rows that should exist: -- "Change how weather is drawn (clouds, fog, rain)": `hooks/useBabylonWeather.ts` + `hooks/weather/*` + `useBabylonPrecipitation.ts` (today); `useCesiumWeather.ts`, the weather stage hooks (after M2); `stores/weatherStore.ts`, `useSmoothedWeather.ts`, `types/settings.ts` `WeatherSettings` (:735). +- "Change how weather is drawn (clouds, fog, rain)": `hooks/useBabylonWeather.ts` + `hooks/weather/*` + `useBabylonPrecipitation.ts` (today); `useCesiumWeather.ts`, the weather stage hooks (after M2); `stores/weatherStore.ts`, `utils/weatherStage.ts` (fog fade and ease), `types/settings.ts` `WeatherSettings` (:735). - "Change datablock labels or leader lines": `useCesiumLabels.ts` (layout, culling) -> `useBabylonOverlay.ts` adapter -> `useBabylonLabels.ts` -> `stores/datablockPositionStore.ts` -> `CesiumViewer.tsx:1145` (click hit-test). -- "Change the tower cab model": `useBabylonTowerModel.ts` -> `useBabylonRootNode.ts` -> `towerPositioningStore.ts` -> `airportStore.ts:88-125`. +- "Change the tower cab model": `hooks/useTowerModel.ts` -> `services/ModService.ts` (`getTowerPlacement`) -> `stores/towerPositioningStore.ts` -> `utils/modelOrientation.ts` -> `hooks/useAircraftModels.ts` -> `utils/nightBrightness.ts` -> `hooks/useCesiumLighting.ts` -> `MODDING.md` -> `stores/airportStore.ts` (camera default). - "Add or change an `__tc3d` harness call": `useAgentHarness.ts`, `types/harness.ts`, `CLAUDE.md` harness list, `docs/DRIVING_CONVENTIONS.md` (TC3D-123 notes the harness is in no doc). # MISSING ENTRY POINT diff --git a/docs/plans/canvases/cesium-weather-stage/README.md b/docs/plans/canvases/cesium-weather-stage/README.md new file mode 100644 index 00000000..826e1536 --- /dev/null +++ b/docs/plans/canvases/cesium-weather-stage/README.md @@ -0,0 +1,49 @@ +# Cesium cloud and fog stage: app captures + +The spike's cases, driven in the dev desktop app at KOAK with `window.__tc3d`. Each case was shot twice. `-cesium.png` shows the Cesium post-process stage (`weatherStage.renderer = 'cesium'`, the branch default). `-babylon.png` shows the same weather and pose with the switch on `'babylon'` (Babylon fog dome and cloud meshes, stage out of the scene). + +Weather was set with `stores.weather`: `setDebugOverriding(true)`, `setCloudLayers`, `currentMetar.visib` and a hand-computed `fogDensity`, then `triggerInstantUpdate()`. Time was fixed at 13:00 local (`cesium.timeMode = 'fixed'`). Tower camera at the KOAK tower, 75.9 m above the ground; FOV 60. The ENU anchor ground sampled at -28.49 m ellipsoid height. Heights below are above that ground (AGL) unless marked ellipsoid. + +| Files | Weather | Pose | What it shows | +|---|---|---|---| +| `case1-bkn025-10sm-*` | BKN025, 10 SM | Tower camera, heading 300, pitch +6 | Broken deck at 2500 ft with blue gaps; terrain and San Francisco skyline unfogged. A light cyan clear-sky band shows between the deck edge and the horizon (the flat 40 km deck; TC3D-125's band). | +| `case2-ovc008-halfsm-*` | OVC008, 1/2 SM (`fogDensity` 0.0122) | Tower camera, heading 240, pitch -2; test B738 on the ground 400 m out, heading 150 | Grey fog: the aircraft at 400 m is visible but hazed, everything farther fades into fog colour; the overcast base is lost in the fog. | +| `case3a-ovc015-5000ft-acft3000-*` | OVC015, 10 SM | Camera override 3 km south of the tower at 5000 ft AGL, heading 0, pitch -11.5; B738 over the tower at 3000 ft AGL | The camera is above the deck, so the stage shows a white cloud top. The aircraft is hidden: at 3000 ft (914 m) it sits inside the OVC slab (base 457 m, 600 m thick, top 1057 m), not above it. | +| `case3c-ovc015-5000ft-acft4000-cesium.png` | OVC015, 10 SM | Camera override 1 km south at 5000 ft AGL, pitch -16; B738 over the tower at 4000 ft AGL (above the slab top) | Aircraft drawn on top of the cloud top: depth occlusion keeps an aircraft above the deck visible. Added because 3a's aircraft is inside the slab. | +| `case3b-ovc015-5000ft-acft1000-*` | OVC015, 10 SM | Camera override 3 km south at 5000 ft AGL, pitch -22.1; B738 over the tower at 1000 ft AGL | Cesium: the deck hides the airport and the aircraft below it. Babylon: the ground shows through a pale wash. | +| `case4-bkn012-hills-*` | BKN012, 10 SM | Tower camera, heading 55, pitch +4 (toward the Oakland hills) | Deck base at 1200 ft over the hills. From this pose the ridge stays below the base, so the hills-cut-the-base effect the spike saw is not visible here. | +| `inset-bkn025-10sm-cesium.png` | BKN025, 10 SM | Main tower camera heading 300 pitch +6, one inset opened with `addViewport()` | The inset iframe draws the same stage. Its `__tc3d.weatherStage.info()` returned `stageInScene: true`, `logDepth: true`, one frustum. | + +## Review round 1 checks + +| Files | Setup | What it shows | +|---|---|---| +| `reanchor-before.png`, `reanchor-after.png` | BKN025 10 SM. Camera override at the tower, 20 m ellipsoid height, heading 300, pitch 25. Between the shots the custom tower position moves 0.003° north (about 330 m), which re-anchors the deck within KOAK (log: `Anchored on airport:KOAK at 37.72246…`). | The cloud field stays in place across the re-anchor; only the slow wind drift moves it. The noise now follows a fixed world origin instead of the anchor. | +| `silhouette-fog400m-cesium.png`, `silhouette-clear400m-control.png` | Silhouettes on (viewer reloaded). Test B738 at 400 m, selected for edge detection, camera aimed with `lookAt` at FOV 30. Fog is OVC008 1/2 SM; the control is 10 SM with no clouds. | `info().stageNames` is `["czm_silhouette", "tc3dWeatherStage"]`: the weather stage runs after the silhouette stage, so the aircraft in fog is fogged with no outline drawn over it. The 0.5 px outline is faint even in the clear control. | +| `logdepth-off-babylon-fallback.png` | `enableLogDepth` off, viewer reloaded. BKN025 with 1 SM. | `info()`: `logDepth: false`, `stageInScene: false`; the Babylon `fog_dome` is visible, so Babylon draws the weather. The console shows the warning once. | + +## Frame cost + +`__tc3d.perf().window` was read after 9 s per shot, over 300 frames. Values are median / p95 in ms. "Stage off" is the Babylon switch, so `cesiumDraw` is Cesium without the stage. RTX 4090 desktop, 1600x900 CSS window. The app was otherwise idle, but the machine ran other builds through the gate. + +| Case | cesiumDraw on | cesiumDraw off | frameInterval on | frameInterval off | +|---|---|---|---|---| +| 1 BKN025 10SM | 16.6 / 19.4 | 16.8 / 19.5 | 20.9 / 24.5 | 21.3 / 25.0 | +| 2 OVC008 1/2SM | 9.6 / 10.9 | 9.6 / 11.7 | 16.6 / 18.6 | 16.6 / 19.3 | +| 3a OVC015 5000 ft, acft 3000 ft | 19.6 / 22.9 | 18.2 / 21.4 | 24.1 / 28.3 | 22.5 / 26.5 | +| 3b OVC015 5000 ft, acft 1000 ft | 17.4 / 20.1 | 16.9 / 18.8 | 22.1 / 27.3 | 21.3 / 24.3 | +| 4 BKN012 hills | 18.0 / 20.8 | 18.2 / 21.2 | 22.3 / 26.1 | 22.9 / 26.8 | + +The stage adds at most 1.4 ms median CPU `cesiumDraw` in these runs, against the spike's +9.7 ms at half resolution. `cesiumDraw` is CPU time; the GPU cost of the raymarch is not in it, and `gpuCesiumMs` was not read. + +## Preset GPU cost + +The quality presets (`WEATHER_STAGE_PRESETS`: High, Medium, Low) were compared in the TC3D-148 mock (`docs/plans/canvases/tc3d-148-presets/`, on the main checkout) on the RTX desktop through ANGLE D3D11 in headless Edge, reading `gpuCesiumMs` with the stage on against off. Stage cost, median over the mock's shots: + +| Preset | steps / resolutionScale / radius | GPU cost of the stage | +|---|---|---| +| High | 120 / 0.5 / 100 km | +1.2 to +2.7 ms | +| Medium | 72 / 0.375 / 60 km | +0.9 to +1.5 ms | +| Low | 48 / 0.25 / 40 km | +0.7 to +0.9 ms | + +`frameInterval` sits on the 60 Hz cap in every case, so the desktop shows no frame-rate difference between presets. The iPad cost is unmeasured here (TC3D-150). diff --git a/docs/plans/canvases/cesium-weather-stage/case1-bkn025-10sm-babylon.png b/docs/plans/canvases/cesium-weather-stage/case1-bkn025-10sm-babylon.png new file mode 100644 index 00000000..cfc34d3d Binary files /dev/null and b/docs/plans/canvases/cesium-weather-stage/case1-bkn025-10sm-babylon.png differ diff --git a/docs/plans/canvases/cesium-weather-stage/case1-bkn025-10sm-cesium.png b/docs/plans/canvases/cesium-weather-stage/case1-bkn025-10sm-cesium.png new file mode 100644 index 00000000..ba367364 Binary files /dev/null and b/docs/plans/canvases/cesium-weather-stage/case1-bkn025-10sm-cesium.png differ diff --git a/docs/plans/canvases/cesium-weather-stage/case2-ovc008-halfsm-babylon.png b/docs/plans/canvases/cesium-weather-stage/case2-ovc008-halfsm-babylon.png new file mode 100644 index 00000000..4a1683be Binary files /dev/null and b/docs/plans/canvases/cesium-weather-stage/case2-ovc008-halfsm-babylon.png differ diff --git a/docs/plans/canvases/cesium-weather-stage/case2-ovc008-halfsm-cesium.png b/docs/plans/canvases/cesium-weather-stage/case2-ovc008-halfsm-cesium.png new file mode 100644 index 00000000..3c536b5b Binary files /dev/null and b/docs/plans/canvases/cesium-weather-stage/case2-ovc008-halfsm-cesium.png differ diff --git a/docs/plans/canvases/cesium-weather-stage/case3a-ovc015-5000ft-acft3000-babylon.png b/docs/plans/canvases/cesium-weather-stage/case3a-ovc015-5000ft-acft3000-babylon.png new file mode 100644 index 00000000..36f12f37 Binary files /dev/null and b/docs/plans/canvases/cesium-weather-stage/case3a-ovc015-5000ft-acft3000-babylon.png differ diff --git a/docs/plans/canvases/cesium-weather-stage/case3a-ovc015-5000ft-acft3000-cesium.png b/docs/plans/canvases/cesium-weather-stage/case3a-ovc015-5000ft-acft3000-cesium.png new file mode 100644 index 00000000..a74fd5b3 Binary files /dev/null and b/docs/plans/canvases/cesium-weather-stage/case3a-ovc015-5000ft-acft3000-cesium.png differ diff --git a/docs/plans/canvases/cesium-weather-stage/case3b-ovc015-5000ft-acft1000-babylon.png b/docs/plans/canvases/cesium-weather-stage/case3b-ovc015-5000ft-acft1000-babylon.png new file mode 100644 index 00000000..3df9dc8c Binary files /dev/null and b/docs/plans/canvases/cesium-weather-stage/case3b-ovc015-5000ft-acft1000-babylon.png differ diff --git a/docs/plans/canvases/cesium-weather-stage/case3b-ovc015-5000ft-acft1000-cesium.png b/docs/plans/canvases/cesium-weather-stage/case3b-ovc015-5000ft-acft1000-cesium.png new file mode 100644 index 00000000..baf58c5d Binary files /dev/null and b/docs/plans/canvases/cesium-weather-stage/case3b-ovc015-5000ft-acft1000-cesium.png differ diff --git a/docs/plans/canvases/cesium-weather-stage/case3c-ovc015-5000ft-acft4000-cesium.png b/docs/plans/canvases/cesium-weather-stage/case3c-ovc015-5000ft-acft4000-cesium.png new file mode 100644 index 00000000..2e855321 Binary files /dev/null and b/docs/plans/canvases/cesium-weather-stage/case3c-ovc015-5000ft-acft4000-cesium.png differ diff --git a/docs/plans/canvases/cesium-weather-stage/case4-bkn012-hills-babylon.png b/docs/plans/canvases/cesium-weather-stage/case4-bkn012-hills-babylon.png new file mode 100644 index 00000000..e7160da6 Binary files /dev/null and b/docs/plans/canvases/cesium-weather-stage/case4-bkn012-hills-babylon.png differ diff --git a/docs/plans/canvases/cesium-weather-stage/case4-bkn012-hills-cesium.png b/docs/plans/canvases/cesium-weather-stage/case4-bkn012-hills-cesium.png new file mode 100644 index 00000000..48002976 Binary files /dev/null and b/docs/plans/canvases/cesium-weather-stage/case4-bkn012-hills-cesium.png differ diff --git a/docs/plans/canvases/cesium-weather-stage/inset-bkn025-10sm-cesium.png b/docs/plans/canvases/cesium-weather-stage/inset-bkn025-10sm-cesium.png new file mode 100644 index 00000000..18625bf4 Binary files /dev/null and b/docs/plans/canvases/cesium-weather-stage/inset-bkn025-10sm-cesium.png differ diff --git a/docs/plans/canvases/cesium-weather-stage/logdepth-off-babylon-fallback.png b/docs/plans/canvases/cesium-weather-stage/logdepth-off-babylon-fallback.png new file mode 100644 index 00000000..e7ec94e5 Binary files /dev/null and b/docs/plans/canvases/cesium-weather-stage/logdepth-off-babylon-fallback.png differ diff --git a/docs/plans/canvases/cesium-weather-stage/reanchor-after.png b/docs/plans/canvases/cesium-weather-stage/reanchor-after.png new file mode 100644 index 00000000..4c0c3c6e Binary files /dev/null and b/docs/plans/canvases/cesium-weather-stage/reanchor-after.png differ diff --git a/docs/plans/canvases/cesium-weather-stage/reanchor-before.png b/docs/plans/canvases/cesium-weather-stage/reanchor-before.png new file mode 100644 index 00000000..ed8b49cb Binary files /dev/null and b/docs/plans/canvases/cesium-weather-stage/reanchor-before.png differ diff --git a/docs/plans/canvases/cesium-weather-stage/silhouette-clear400m-control.png b/docs/plans/canvases/cesium-weather-stage/silhouette-clear400m-control.png new file mode 100644 index 00000000..f72b5b1b Binary files /dev/null and b/docs/plans/canvases/cesium-weather-stage/silhouette-clear400m-control.png differ diff --git a/docs/plans/canvases/cesium-weather-stage/silhouette-fog400m-cesium.png b/docs/plans/canvases/cesium-weather-stage/silhouette-fog400m-cesium.png new file mode 100644 index 00000000..b1c8ecb1 Binary files /dev/null and b/docs/plans/canvases/cesium-weather-stage/silhouette-fog400m-cesium.png differ diff --git a/docs/plans/canvases/cesium-weather-stage/silhouette-near150m-cesium.png b/docs/plans/canvases/cesium-weather-stage/silhouette-near150m-cesium.png new file mode 100644 index 00000000..d3f07856 Binary files /dev/null and b/docs/plans/canvases/cesium-weather-stage/silhouette-near150m-cesium.png differ diff --git a/docs/plans/canvases/tc3d-125-weather-options/RECIPE.md b/docs/plans/canvases/tc3d-125-weather-options/RECIPE.md new file mode 100644 index 00000000..74eca09a --- /dev/null +++ b/docs/plans/canvases/tc3d-125-weather-options/RECIPE.md @@ -0,0 +1,529 @@ +# TC3D-125 weather options: rebuild recipe + +`index.html` here links 22 JPEG shots and 23 PNG crops by relative path (`a1-10.jpg`, `crops/a1-10-horizon.png`, …). They total about 7 MB, so they are not committed. They live in the scratch tree's `.tmp/mocks/tc3d-125-weather-options/`, beside a copy of this page. This file rebuilds them. + +## Source + +- Tree: `feat/cesium-weather` at 3ac96df (`ref: delete the Babylon fog dome, cloud meshes and night lighting`), checked out in a scratch worktree with `pnpm install --frozen-lockfile`. +- The prototype diff below applied on top. It adds runtime switches read every frame from `window.__tc3dMock`; with the object empty every uniform keeps today's value, and A1/C1/D1 measured the same as the unedited tree. +- An ion token in the desktop dev settings (`/api/dev-health` answers `ionToken: true`). + +| `__tc3dMock` field | Meaning | Today | +|---|---|---| +| `radius` | deck radius, m (`u_radius`) | 40000 | +| `steps` | march steps per layer (`u_steps`, clamp 4–256; `MAX_STEPS` raised to 256) | 48 | +| `curve` | 1 = slab follows the earth down by d²/2R, R = 6,371 km, exact ray entry/exit | 0 | +| `relief` | top rises by up to this share of the slab thickness (2D value noise, 5 km + 2 km cells) | 0 | +| `dark` | 1 = cloud underside `dark` becomes the fog grey | 0 | +| `fogDarken` | fog grey (and the retuned `dark`) darkened by this × the highest layer coverage | 0 | +| `haze` | visibility, m, of a haze on sky pixels fading out by ~4° above level (composite only) | 0 (off) | + +## Steps + +1. `pnpm --dir run vite:headless` in the background; check `http://localhost:5174/api/dev-health`. +2. With the `playwright-headless` MCP server, `browser_navigate` to `http://localhost:5174/`. +3. Set up (`browser_run_code_unsafe`): wait for `window.__tc3d.viewer`; `(await import('/stores/airportStore.ts')).useAirportStore.getState().selectAirport('EGLL')`; `__tc3d.stores.settings.getState().updateCesiumSettings({ timeMode: 'fixed', fixedTimeHour: 13 })` (the hour is UTC); wait 12 s. +4. For each batch: `browser_evaluate` sets `window.__jobs` (lists below), then `browser_run_code_unsafe` with `filename` pointing at the shoot script below (the MCP server reads scripts only under the main checkout, so it was copied to `X:\dev\towercab-3d\.playwright-mcp\tc3d125-shoot.js`). The shoot script injects the helpers, sets weather, mock and pose per job, waits 6 s, hides the app panels by opacity, screenshots the Cesium canvas and reads the metrics. +5. Run every tower-pose batch before any above-deck job. Clearing the camera override leaves the camera where the override put it; reload the page (step 2–3) to get the tower camera back. +6. Frame cost: `browser_evaluate` with the perf snippet below at each pose. +7. Convert and crop (from the shots folder): every `*.png` saved as `*.jpg` at quality 90; tower shots cropped to columns 400–1200, rows 200–330 and scaled ×2 into `crops/-horizon.png`; the pitch −6 B shots cropped to columns 300–1300, rows 246–496 into `crops/-tops.png`. Pillow, `uv run --no-project --with pillow python`. + +Layers in every job: `[{ type: 'BKN', altitude: 274.3, coverage: 0.7 }, { type: 'OVC', altitude: 609.6, coverage: 1 }]`. Tower pose: the app's EGLL tower camera (88.8 m above the anchor ground, 158.6 m ellipsoid), `camera.set({ heading: 274, pitch: -3, fov: 45 })`. The baseline `base-*` shots were taken before the diff with the same weather and pose. + +### Job lists + +```js +const a2 = { curve: 1, radius: 100000, steps: 120 } +const a3 = { curve: 1, radius: 121800, steps: 146 } // horizon radius for 88.8 m and a 609.6 m base +// Batch 1 (tower) +window.__jobs = [ + { name: 'a1-596', vis: 5.96 }, { name: 'a2-596', vis: 5.96, mock: a2 }, { name: 'a3-596', vis: 5.96, mock: a3 }, + { name: 'a1-10', vis: 10 }, { name: 'a2-10', vis: 10, mock: a2 }, { name: 'a3-10', vis: 10, mock: a3 }, + { name: 'a3c2-596', vis: 5.96, mock: { ...a3, dark: 1 } }, + { name: 'c1-13', vis: 1.3 }, { name: 'c2-13', vis: 1.3, mock: { dark: 1 } }, + { name: 'c3-13', vis: 1.3, mock: { dark: 1, fogDarken: 0.15 } }, { name: 'c4-13', vis: 1.3, mock: { dark: 1 }, sceneFog: false }, + { name: 'd1-10', vis: 10 }, { name: 'd2-10', vis: 10, mock: { haze: 16093 } }, { name: 'd2a3-10', vis: 10, mock: { ...a3, haze: 16093 } }, +] +// Batch 2 (above deck): camera 1 km east of the tower at 1,524 m AGL; test B738 over the tower at 1,219 m AGL +const lat = 51.47060012819999, lon = -0.46194100379999997, camLon = -0.4475199424157619, ground = 69.8 +__tc3d.weatherStage.set({ testAircraft: { lat, lon, heightM: ground + 1219.2, heading: 0 } }) +const pose = (pitch) => ({ lat, lon: camLon, heightM: ground + 1524, heading: 274, pitch }) +window.__jobs = [['b1', 0], ['b2', 0.2], ['b3', 0.4]].flatMap(([n, relief]) => [ + { name: n + '-p16', vis: 10, mock: { ...a3, relief }, pose: pose(-16) }, + { name: n + '-p6', vis: 10, mock: { ...a3, relief }, pose: pose(-6) }, +]) +// Batch 3: same as batch 2's -p6 jobs, names suffixed '-lowsun', after updateCesiumSettings({ fixedTimeHour: 16.5 }) +``` + +Metric gotcha: `scene.render()` without a time renders at the wall clock, not the app's fixed clock; the helpers pass `viewer.clock.currentTime`. + +## Prototype diff + +```diff +diff --git a/src/renderer/hooks/useCesiumWeatherStages.ts b/src/renderer/hooks/useCesiumWeatherStages.ts +index a004938..d92d7d3 100644 +--- a/src/renderer/hooks/useCesiumWeatherStages.ts ++++ b/src/renderer/hooks/useCesiumWeatherStages.ts +@@ -142,7 +142,14 @@ function createStage(viewer: Cesium.Viewer, refs: StageRefs, startMs: number): C + return frameRef.current + } + ++ // MOCK (TC3D-125): lane switches read every frame from window.__tc3dMock; absent fields keep today's values. ++ const mock = (): Record => (window as unknown as { __tc3dMock?: Record }).__tc3dMock ?? {} ++ const maxCoverage = () => Math.max(0, ...inputsRef.current.layers.map((l) => (l.w > 0 ? l.z : 0))) ++ + const shared = { ++ u_mockDark: () => mock().dark ?? 0, ++ u_mockFogDarken: () => (mock().fogDarken ?? 0) * maxCoverage(), ++ u_mockHaze: () => mock().haze ?? 0, + // The deck shift is added to the camera, which moves the whole field (noise included) relative to it. + u_camPosEnu: () => { + const current = frame() +@@ -167,9 +174,11 @@ function createStage(viewer: Cesium.Viewer, refs: StageRefs, startMs: number): C + u_layer2: layerUniform(2), + u_layer3: layerUniform(3), + u_layerCount: () => inputsRef.current.layerCount, +- u_steps: () => Cesium.Math.clamp(Math.round(quality.steps), 4, 128), ++ u_steps: () => Cesium.Math.clamp(Math.round(mock().steps ?? quality.steps), 4, 256), + u_densityScale: () => quality.densityScale, +- u_radius: () => quality.radius, ++ u_radius: () => mock().radius ?? quality.radius, ++ u_mockCurve: () => mock().curve ?? 0, ++ u_mockRelief: () => mock().relief ?? 0, + u_time: () => (performance.now() - startMs) / 1000, + u_noiseOffset: () => frame().noiseOffset, + }, +diff --git a/src/renderer/hooks/weather/cloudStage.glsl.ts b/src/renderer/hooks/weather/cloudStage.glsl.ts +index e07a82f..9ead20b 100644 +--- a/src/renderer/hooks/weather/cloudStage.glsl.ts ++++ b/src/renderer/hooks/weather/cloudStage.glsl.ts +@@ -51,8 +51,14 @@ float daylight() { + return smoothstep(-0.1, 0.25, sunEnu().z); + } + ++uniform float u_mockDark; ++uniform float u_mockFogDarken; ++uniform float u_mockHaze; ++ ++const vec3 FOG_GREY = vec3(0.62, 0.66, 0.70); ++ + vec3 fogColor() { +- return vec3(0.62, 0.66, 0.70) * (0.08 + 0.92 * daylight()); ++ return FOG_GREY * (1.0 - u_mockFogDarken) * (0.08 + 0.92 * daylight()); + } + + // Length of the segment from ro over distance d along rd that lies below the anchor ground (z < 0). +@@ -72,14 +78,14 @@ float fogBelowGroundLength(vec3 ro, vec3 rd, float d) { + // Density is (1 / visibility) * exp(-max(height, 0) / u_fogHeightScale): never denser than at + // the anchor ground, so terrain below it is not drowned. Below the ground the density is + // constant; above it the integral is analytic, starting at the camera or at the ground. +-float fogTransmittance(vec3 ro, vec3 rd, float d) { +- if (u_visibility <= 0.0) { ++float fogTransmittanceVis(vec3 ro, vec3 rd, float d, float visibility) { ++ if (visibility <= 0.0) { + return 1.0; + } + float below = fogBelowGroundLength(ro, rd, d); + float above = d - below; +- float integral = below / u_visibility; +- float base = exp(-max(ro.z, 0.0) / u_fogHeightScale) / u_visibility; ++ float integral = below / visibility; ++ float base = exp(-max(ro.z, 0.0) / u_fogHeightScale) / visibility; + float k = rd.z / u_fogHeightScale; + if (abs(k * above) < 1.0e-4) { + integral += base * above; +@@ -88,6 +94,10 @@ float fogTransmittance(vec3 ro, vec3 rd, float d) { + } + return exp(-u_fogExtinction * integral); + } ++ ++float fogTransmittance(vec3 ro, vec3 rd, float d) { ++ return fogTransmittanceVis(ro, rd, d, u_visibility); ++} + ` + + export const CLOUD_STAGE_FS = /* glsl */ ` +@@ -104,8 +114,11 @@ uniform float u_time; + // Horizontal position of this frame's origin from a fixed noise origin, in this frame's axes, + // so the noise field stays put horizontally when the anchor moves; vertically it follows the deck. + uniform vec3 u_noiseOffset; ++uniform float u_mockCurve; ++uniform float u_mockRelief; + +-const int MAX_STEPS = 128; ++const int MAX_STEPS = 256; ++const float EARTH_R = 6371000.0; + + float hash13(vec3 p3) { + p3 = fract(p3 * 0.1031); +@@ -143,9 +156,26 @@ float fbm(vec3 p) { + return sum / 0.9375; + } + ++// Height above the curved deck datum: the earth falls away by d^2/2R at horizontal distance d. ++float deckZ(vec3 p) { ++ return p.z + u_mockCurve * dot(p.xy, p.xy) / (2.0 * EARTH_R); ++} ++ ++// Local slab top: the flat top plus low-frequency billows rising up to u_mockRelief of the thickness. ++float localTop(vec3 p, vec4 layer) { ++ float top = layer.x + layer.y; ++ if (u_mockRelief <= 0.0) { ++ return top; ++ } ++ vec2 w = p.xy + u_noiseOffset.xy + vec2(u_time * 7.2, u_time * 3.6); ++ float n = 0.65 * valueNoise(vec3(w / 5000.0, 7.0)) + 0.35 * valueNoise(vec3(w / 2000.0, 13.0)); ++ return top + u_mockRelief * layer.y * clamp((n - 0.25) / 0.5, 0.0, 1.0); ++} ++ + // Layer: x = base (m above airport), y = thickness (m), z = coverage (0..1), w = active. + float cloudDensity(vec3 p, vec4 layer) { +- float hf = (p.z - layer.x) / layer.y; ++ float z = deckZ(p); ++ float hf = (z - layer.x) / (localTop(p, layer) - layer.x); + if (hf < 0.0 || hf > 1.0) { + return 0.0; + } +@@ -154,7 +184,7 @@ float cloudDensity(vec3 p, vec4 layer) { + return 0.0; + } + float shape = smoothstep(0.0, 0.12, hf) * (1.0 - smoothstep(0.5, 1.0, hf)); +- vec3 world = p + u_noiseOffset; ++ vec3 world = vec3(p.xy, z) + u_noiseOffset; + vec3 q = vec3(world.xy / 1800.0, world.z / 600.0) + vec3(u_time * 0.004, u_time * 0.002, 0.0); + // fbm of value noise clusters around 0.5; stretch it so the coverage threshold + // leaves roughly the reported fraction of sky covered. +@@ -168,8 +198,8 @@ vec3 cloudLight(vec3 p, vec4 layer, vec3 sun) { + float lightStep = layer.y * 0.3; + float occluder = cloudDensity(p + sun * lightStep, layer); + float lightT = exp(-occluder * u_densityScale * lightStep * 0.5); +- float hf = clamp((p.z - layer.x) / layer.y, 0.0, 1.0); +- vec3 dark = vec3(0.40, 0.43, 0.50); ++ float hf = clamp((deckZ(p) - layer.x) / (localTop(p, layer) - layer.x), 0.0, 1.0); ++ vec3 dark = mix(vec3(0.40, 0.43, 0.50), FOG_GREY * (1.0 - u_mockFogDarken), u_mockDark); + vec3 bright = vec3(1.0, 0.98, 0.95); + return mix(dark, bright, clamp(0.35 * hf + 0.65 * lightT, 0.0, 1.0)) * (0.08 + 0.92 * daylight()); + } +@@ -198,24 +228,44 @@ float cylinderExit(vec3 ro, vec3 rd) { + return (-b + sqrt(disc)) / a; + } + ++// Interval of t where deckZ(ro + rd * t) <= h, as (lo, hi); lo > hi when empty. ++// deckZ along the ray is a + b t + c t^2 with c >= 0, so the set is one interval. ++vec2 belowLevel(vec3 ro, vec3 rd, float h) { ++ const float BIG = 1.0e9; ++ float qa = u_mockCurve * dot(rd.xy, rd.xy) / (2.0 * EARTH_R); ++ float qb = rd.z + u_mockCurve * dot(ro.xy, rd.xy) / EARTH_R; ++ float qc = deckZ(ro) - h; ++ if (qa < 1.0e-14) { ++ if (abs(qb) < 1.0e-7) { ++ return qc <= 0.0 ? vec2(-BIG, BIG) : vec2(1.0, -1.0); ++ } ++ float r = -qc / qb; ++ return qb > 0.0 ? vec2(-BIG, r) : vec2(r, BIG); ++ } ++ float disc = qb * qb - 4.0 * qa * qc; ++ if (disc < 0.0) { ++ return vec2(1.0, -1.0); ++ } ++ float s = sqrt(disc); ++ float q = -0.5 * (qb + (qb >= 0.0 ? s : -s)); ++ float r0 = q / qa; ++ float r1 = qc / q; ++ return vec2(min(r0, r1), max(r0, r1)); ++} ++ + void marchLayer(vec3 ro, vec3 rd, float tScene, vec4 layer, float jitter, vec3 sun, + inout float transmittance, inout vec3 color, inout float weightedT, inout float weightSum) { +- float top = layer.x + layer.y; +- float t0; +- float t1; +- if (abs(rd.z) < 1.0e-5) { +- if (ro.z < layer.x || ro.z > top) { +- return; ++ vec2 inTop = belowLevel(ro, rd, layer.x + layer.y * (1.0 + u_mockRelief)); ++ float t0 = max(inTop.x, 0.0); ++ float t1 = min(inTop.y, min(tScene, cylinderExit(ro, rd))); ++ vec2 under = belowLevel(ro, rd, layer.x); ++ if (under.x <= under.y) { ++ if (under.x <= t0 && under.y > t0) { ++ t0 = under.y; ++ } else if (under.x > t0 && under.x < t1) { ++ t1 = under.x; + } +- t0 = 0.0; +- t1 = SKY_DISTANCE; +- } else { +- float ta = (layer.x - ro.z) / rd.z; +- float tb = (top - ro.z) / rd.z; +- t0 = max(min(ta, tb), 0.0); +- t1 = max(ta, tb); + } +- t1 = min(t1, min(tScene, cylinderExit(ro, rd))); + if (t1 <= t0) { + return; + } +@@ -278,8 +328,16 @@ void main() { + vec2 uv = v_textureCoordinates; + vec4 scene = texture(colorTexture, uv); + vec4 cloud = texture(cloudTexture, uv); +- float fogT = fogTransmittance(u_camPosEnu, viewRayEnu(uv), sceneDistance(uv)); ++ vec3 rd = viewRayEnu(uv); ++ float dist = sceneDistance(uv); ++ float fogT = fogTransmittance(u_camPosEnu, rd, dist); + vec3 fogged = mix(fogColor(), scene.rgb, fogT); ++ // Horizon haze on sky pixels only, at u_mockHaze visibility, gone by about 4 deg above level. ++ if (u_mockHaze > 0.0 && dist >= SKY_DISTANCE) { ++ float hazeT = fogTransmittanceVis(u_camPosEnu, rd, dist, u_mockHaze); ++ hazeT = mix(hazeT, 1.0, smoothstep(0.0, 0.07, rd.z)); ++ fogged = mix(fogColor(), fogged, hazeT); ++ } + out_FragColor = vec4(fogged * (1.0 - cloud.a) + cloud.rgb, scene.a); + } + ` +``` + +## Helpers (`mock-helpers.js`, injected with `page.addScriptTag`) + +```js +// Page-side helpers for the TC3D-125 mock captures; injected with page.evaluate. +window.__m = (() => { + const R = 6371000 + const deg = Math.PI / 180 + const t = () => window.__tc3d + + async function weather({ vis, layers }) { + const ws = t().stores.weather + ws.getState().setDebugOverriding(true) + ws.getState().setCloudLayers(layers) + const metar = ws.getState().currentMetar + // fogDensity mirrors visibilityToFogDensity's inverse figures from the map (scene.fog only). + const fogBySM = { 10: 0, 5.96: 0.0021, 1.3: 0.0083 } + ws.setState({ useInterpolation: false, currentMetar: { ...metar, visib: vis }, fogDensity: fogBySM[vis] ?? 0 }) + ws.getState().triggerInstantUpdate?.() + } + + function groundUnderCamera() { + const v = t().viewer + const c = v.camera.positionCartographic + const g = v.scene.globe.getHeight(c) + return { camH: c.height, ground: g, agl: g === undefined ? null : c.height - g } + } + + function grab() { + const v = t().viewer + // scene.render() without a time renders at the wall clock; pass the app's (fixed) clock time. + v.scene.render(v.clock.currentTime) + const c = v.canvas + const g = document.createElement('canvas') + g.width = c.width + g.height = c.height + const x = g.getContext('2d', { willReadFrequently: true }) + x.drawImage(c, 0, 0) + return x.getImageData(0, 0, c.width, c.height) + } + + function horizonRow(img) { + const v = t().viewer + const { agl } = groundUnderCamera() + const dip = Math.sqrt((2 * Math.max(agl ?? 0, 0)) / R) + const pitch = v.camera.pitch + const fovy = v.camera.frustum.fovy + const H = img.height + const rowAt = (elev) => H / 2 - (Math.tan(elev - pitch) / Math.tan(fovy / 2)) * (H / 2) + return { hr: Math.round(rowAt(-dip)), rowAt, dipDeg: dip / deg } + } + + function stats(img, r0, r1, c0, c1) { + let n = 0, chroma = 0, br = 0, lum = 0, rs = 0, gs = 0, bs = 0 + for (let y = Math.max(0, r0); y <= Math.min(img.height - 1, r1); y++) { + for (let xx = c0; xx <= c1; xx++) { + const i = (y * img.width + xx) * 4 + const r = img.data[i], gg = img.data[i + 1], b = img.data[i + 2] + chroma += Math.max(r, gg, b) - Math.min(r, gg, b) + br += b - r + lum += 0.2126 * r + 0.7152 * gg + 0.0722 * b + rs += r; gs += gg; bs += b + n++ + } + } + const f = (v) => +(v / n).toFixed(1) + return { chroma: f(chroma), bMinusR: f(br), lum: f(lum), rgb: [f(rs), f(gs), f(bs)] } + } + + function rowLum(img, y, c0, c1) { + let s = 0 + for (let xx = c0; xx <= c1; xx++) { + const i = (y * img.width + xx) * 4 + s += 0.2126 * img.data[i] + 0.7152 * img.data[i + 1] + 0.0722 * img.data[i + 2] + } + return s / (c1 - c0 + 1) + } + + async function nextFrame() { + await new Promise((r) => requestAnimationFrame(() => r())) + } + + // Metrics averaged over three frames (the deck drifts and the march jitters). + async function metrics() { + const acc = [] + for (let k = 0; k < 3; k++) { + await nextFrame() + const img = grab() + const { hr, rowAt, dipDeg } = horizonRow(img) + const cx = Math.round(img.width / 2) + const c0 = cx - 100, c1 = cx + 99 + const band = stats(img, hr - 20, hr + 20, c0, c1) + const top = Math.round(rowAt(-dipDeg * deg + 2 * deg)) + let maxStep = 0, maxStepRow = null + let prev = rowLum(img, top, c0, c1) + // Stops 8 rows above the computed horizon: the terrain's own edge sits about 6 rows above it. + for (let y = top + 1; y <= hr - 8; y++) { + const l = rowLum(img, y, c0, c1) + if (Math.abs(l - prev) > maxStep) { maxStep = Math.abs(l - prev); maxStepRow = y } + prev = l + } + const haze = stats(img, hr + 10, hr + 109, cx - 50, cx + 49) + const sky = stats(img, Math.max(0, top - 60), Math.max(0, top - 20), c0, c1) + const strip = stats(img, hr - 20, hr - 8, c0, c1) + acc.push({ hr, top, band, strip, maxStep, maxStepRowsAboveHorizon: maxStepRow === null ? null : hr - maxStepRow, haze, sky }) + } + const avg = (fn) => +(acc.reduce((s, a) => s + fn(a), 0) / acc.length).toFixed(1) + return { + horizonRow: acc[0].hr, + twoDegRow: acc[0].top, + band: { chroma: avg((a) => a.band.chroma), bMinusR: avg((a) => a.band.bMinusR), lum: avg((a) => a.band.lum), rgb: acc[0].band.rgb }, + strip: { chroma: avg((a) => a.strip.chroma), bMinusR: avg((a) => a.strip.bMinusR), lum: avg((a) => a.strip.lum), rgb: acc[0].strip.rgb }, + maxRowStep: avg((a) => a.maxStep), + maxStepRowsAboveHorizon: acc.map((a) => a.maxStepRowsAboveHorizon), + haze: { chroma: avg((a) => a.haze.chroma), lum: avg((a) => a.haze.lum), rgb: acc[0].haze.rgb }, + skyAbove: { chroma: avg((a) => a.sky.chroma), bMinusR: avg((a) => a.sky.bMinusR), lum: avg((a) => a.sky.lum) }, + } + } + + function perf() { + const w = t().perf().window + const pick = (k) => (w && w[k] ? { median: +w[k].median.toFixed(2), p95: +w[k].p95.toFixed(2) } : null) + return { frameInterval: pick('frameInterval'), cesiumDraw: pick('cesiumDraw'), gpuCesiumMs: pick('gpuCesiumMs') } + } + + // Wall time of one full scene.render() including the GPU (a 1-pixel readPixels waits for it), in ms. + async function renderCost(n = 90) { + const v = t().viewer + const gl = v.scene.context._gl + const px = new Uint8Array(4) + const samples = [] + for (let i = 0; i < n; i++) { + await nextFrame() + const t0 = performance.now() + v.scene.render(v.clock.currentTime) + gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px) + samples.push(performance.now() - t0) + } + samples.sort((a, b) => a - b) + const q = (f) => +samples[Math.min(samples.length - 1, Math.floor(f * samples.length))].toFixed(2) + return { median: q(0.5), p95: q(0.95) } + } + + return { weather, groundUnderCamera, metrics, perf, grab, renderCost } +})() +``` + +## Shoot script (`browser_run_code_unsafe` `filename`) + +```js +async (page) => { + const dir = 'X:/dev/towercab-3d.wt/tc3d-125-mock/towercab-3d/.tmp/mocks/tc3d-125-weather-options/' + const defaultLayers = [ + { type: 'BKN', altitude: 274.3, coverage: 0.7 }, + { type: 'OVC', altitude: 609.6, coverage: 1 }, + ] + const hideUi = (hide) => + page.evaluate((hide) => { + const canvas = document.querySelector('.cesium-widget canvas') + if (hide) { + window.__hidden = [] + let el = canvas + while (el && el !== document.body) { + for (const sib of el.parentElement.children) { + if (sib !== el) { + window.__hidden.push([sib, sib.style.opacity]) + sib.style.setProperty('opacity', '0', 'important') + } + } + el = el.parentElement + } + } else { + for (const [e, o] of window.__hidden) e.style.opacity = o + } + }, hide) + await page.addScriptTag({ path: 'X:/dev/towercab-3d.wt/tc3d-125-mock/towercab-3d/.tmp/mock-helpers.js' }) + const jobs = await page.evaluate(() => window.__jobs) + const out = [] + for (const job of jobs) { + const { name, vis, mock = {}, pose = null, wait = 6000, perf = false, sceneFog = true } = job + const layers = job.layers ?? defaultLayers + await page.evaluate( + async ({ vis, layers, mock, pose, sceneFog }) => { + window.__tc3dMock = mock + await window.__m.weather({ vis, layers }) + const t = window.__tc3d + if (pose) { + t.weatherStage.set({ camera: pose }) + t.camera.set({ fov: 45 }) + } else { + t.weatherStage.set({ camera: null }) + t.camera.set({ heading: 274, pitch: -3, fov: 45 }) + } + t.viewer.scene.fog.enabled = sceneFog + }, + { vis, layers, mock, pose, sceneFog }, + ) + await page.waitForTimeout(perf ? Math.max(wait, 11000) : wait) + await page.evaluate((sceneFog) => { + window.__tc3d.viewer.scene.fog.enabled = sceneFog + }, sceneFog) + const p = perf + ? await page.evaluate(async () => ({ ...window.__m.perf(), renderCost: await window.__m.renderCost() })) + : null + await hideUi(true) + const box = await page.locator('.cesium-widget canvas').first().boundingBox() + await page.screenshot({ path: dir + name + '.png', clip: box }) + await hideUi(false) + const m = await page.evaluate(() => window.__m.metrics()) + out.push({ name, m, perf: p }) + } + return out +} +``` + +## Frame cost snippet (`browser_evaluate`) + +```js +// browser_evaluate body: interleaved render cost per lane at the current pose. +// Tower pose: lanes { noMarch: { radius: 1 }, a1: {}, a2, a3, a3relief40: { ...a3, relief: 0.4 } } after weather({ vis: 5.96 }). +// Above-deck pose: weatherStage.set({ camera: { lat: 51.4706, lon: -0.44752, heightM: 69.8 + 1524, heading: 274, pitch: -6 } }), +// weather({ vis: 10 }), lanes { b1: { ...a3, relief: 0 }, b2: { ...a3, relief: 0.2 }, b3: { ...a3, relief: 0.4 }, a1flat: {} }. +async () => { + const t = window.__tc3d; const v = t.viewer; const gl = v.scene.context._gl; const px = new Uint8Array(4) + const a3 = { curve: 1, radius: 121800, steps: 146 } + const lanes = { noMarch: { radius: 1 }, a1: {}, a2: { curve: 1, radius: 100000, steps: 120 }, a3, a3relief40: { ...a3, relief: 0.4 } } + const s = Object.fromEntries(Object.keys(lanes).map((k) => [k, []])) + for (let round = 0; round < 8; round++) { + for (const [k, mock] of Object.entries(lanes)) { + window.__tc3dMock = mock + for (let w = 0; w < 3; w++) { v.scene.render(v.clock.currentTime); gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px) } + for (let i = 0; i < 20; i++) { + const t0 = performance.now() + v.scene.render(v.clock.currentTime) + gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px) + s[k].push(performance.now() - t0) + } + await new Promise((r) => setTimeout(r, 50)) + } + } + window.__tc3dMock = {} + const q = (a, f) => { const b = [...a].sort((x, y) => x - y); return +b[Math.floor(f * (b.length - 1))].toFixed(2) } + return Object.fromEntries(Object.entries(s).map(([k, a]) => [k, { median: q(a, 0.5), p95: q(a, 0.95) }])) +} +``` diff --git a/docs/plans/canvases/tc3d-125-weather-options/index.html b/docs/plans/canvases/tc3d-125-weather-options/index.html new file mode 100644 index 00000000..9d1919c6 --- /dev/null +++ b/docs/plans/canvases/tc3d-125-weather-options/index.html @@ -0,0 +1,252 @@ + + + + + +TC3D-125 Weather Options + + + +
+

TC3D-125: weather stage options

+

Rendered mocks for decisions 1, 3/4, 6 and 7 of the TC3D-125 map (overcast leaves a clear-sky band at the horizon; dense fog renders dark teal). Every lane is a throwaway prototype on the Cesium stage of feat/cesium-weather at 3ac96df; the rebuild steps and the prototype diff are in RECIPE.md beside this page.

+ +
+

Baseline. EGLL, BKN 274.3 m (coverage 0.7) + OVC 609.6 m (coverage 1). Clock fixed at 13:00 UTC (sun high, daylight() = 1). Tower camera 88.8 m above the anchor ground (not 80 m), heading 274, pitch −3, FOV 45; canvas 1596×800, horizon on row 309. Headless Edge with a GPU, pnpm run vite:headless.

+

How to read the numbers. Strip: mean RGB of the rows 0.24–0.6° above the horizon (centre 200 columns), where the band sits. Row step: the largest luminance jump between two adjacent rows from 2° above the horizon down to 0.24° above it (0–255; the band's edge). Haze: 100×100 patch just below the horizon. Overhead: rows 2.6–3.8° above the horizon. Each is a mean over three frames.

+

Frame cost. perf().window is no use here: frameInterval sits at 16.7 ms (60 Hz cap) in every lane, gpuCesiumMs returned the same 5.63/8.2 ms in every lane (stale), and cesiumDraw wandered 2.4–4.8 ms with no lane pattern. The cost column is instead the wall time of one scene.render() plus a 1-pixel readPixels (which waits for the GPU), lanes interleaved over 8 rounds of 20 frames. Other builds were running on the machine, so compare lanes with each other, not with any frame budget.

+
+ +
+
Baseline at 5.96 SM
Baseline 5.96 SM (pre-edit capture; app panels visible). Fog-grey strip between the terrain and the darker deck.
+
Baseline at 1.3 SM
Baseline 1.3 SM. Grey fog, not dark teal.
+
Baseline at 10 SM
Baseline 10 SM. The mint band: Cesium's sky between the 40 km deck edge and the terrain.
+
+ + + + + +
BaselineStrip RGBRow stepHaze (lum, chroma)Render ms (median / p95)
5.96 SM158,168,17810–13 at 1.1–1.2° up123, 85.9 / 8.0 (no march 4.4 / 7.5)
1.3 SM158,168,1780.8152, 13—
10 SM205,236,21757–72 at 0.6° up105, 10—
+

The baseline images were captured before the prototype edit. The baseline numbers come from lanes A1, C1 and D1 (the prototype with every switch at today's value), whose metrics matched the pre-edit capture to 0.1.

+ +

A. Deck radius decision 1

+

How far should the overcast reach: today's flat 40 km, a curved deck at a fixed 100 km, or a curved deck sized to the horizon?

+

Curved means the slab follows the earth down by d²/2R (R = 6,371 km), with exact entry and exit along the ray. Steps scale with the radius (48 per 40 km). The edge fade stays at 75–100% of the radius in every lane. Horizon-derived radius for an 88.8 m camera and a 609.6 m OVC base: 121.8 km.

+
+
+

A1 · flat 40 km, 48 steps (today)

+

10 SM, horizon ×2

A1 horizon at 10 SM +

5.96 SM, horizon ×2

A1 horizon at 5.96 SM +

10 SM full frame

A1 at 10 SM + + + + +
Strip RGBRow step
10 SM205,236,21757–72
5.96 SM158,168,17810–13
+

Render 5.9 / 8.0 ms (+1.5 ms over no march)

+

Worst case: at 10 SM a mint band about 0.6° tall (17–22 px) right above the terrain.

+
+
+

A2 · curved, 100 km, 120 steps

+

10 SM, horizon ×2

A2 horizon at 10 SM +

5.96 SM, horizon ×2

A2 horizon at 5.96 SM +

10 SM full frame

A2 at 10 SM + + + + +
Strip RGBRow step
10 SM128,134,14730
5.96 SM156,166,1779–11
+

Render 6.2 / 9.1 ms (+0.3 ms over A1)

+

Worst case: no residual sliver at this pose; the 10 SM step is now the white BKN streaks along the horizon (in every lane, A1 included). The computed 0.14° gap is hidden here by terrain standing about 6 rows above the geometric horizon; a sea horizon or a higher camera would show it (not shot).

+
+
+

A3 · curved, 121.8 km (horizon), 146 steps

+

10 SM, horizon ×2

A3 horizon at 10 SM +

5.96 SM, horizon ×2

A3 horizon at 5.96 SM +

10 SM full frame

A3 at 10 SM + + + + +
Strip RGBRow step
10 SM125,130,14530
5.96 SM156,166,1779–10
+

Render 6.3 / 9.7 ms (+0.4 ms over A1)

+

Worst case: looks the same as A2 from this tower; the same BKN streaks. Costs most of the three and the radius changes with camera height.

+
+
+

A3 + C2 · horizon radius, underside retuned to the fog grey

+

5.96 SM, horizon ×2

A3 with C2 at 5.96 SM +

5.96 SM full frame

A3 with C2 at 5.96 SM + + + +
Strip RGBRow stepOverhead lum
5.96 SM159,169,1793.7172 (A3: 130)
+

Worst case: the band at 5.96 SM goes, because the deck and the fog are now one grey; the whole overcast is lighter (overhead 172 against 130). Added because C changes how the 5.96 SM band reads: the radius alone does not touch it.

+
+
+ +

B. Top relief decisions 3 and 4

+

Seen from above the overcast, should the top stay flat or rise in billows, and how much?

+

Camera 1 km east of the tower at 1,524 m (5,000 ft) above the ground, heading 274; pitch −16 (as the spike's case 3c) and −6 (across the deck). 10 SM. All B lanes use the A3 curved 121.8 km deck so a deck edge does not distract. The base stays flat; a two-octave 2D value noise (5 km and 2 km cells) raises the top by up to the lane's share of the slab thickness (600 m for OVC, 450 m for BKN). Test B738 over the tower at 1,219 m (4,000 ft), 10 m above the nominal OVC top, as in case 3c. The last row is the same pose at 16:30 UTC (sun about 2.5° up) because at 13:00 the relief barely shows.

+
+
+

B1 · no relief (today)

+

Pitch −16

B1 pitch -16 +

Pitch −6, deck crop

B1 pitch -6 crop +

Pitch −6 at 16:30 UTC, deck crop

B1 low sun crop +
Lum std (deck patch)−16: 8.1 · −6: 1.4 · low sun: 3.4
+

Render at pitch −6: 6.9 / 10.1 ms (flat 40 km deck at the same pose: 5.5 / 7.0)

+

Worst case: a featureless white plane; the aircraft above it reads clearly.

+
+
+

B2 · moderate, up to 20% (120 m on OVC)

+

Pitch −16

B2 pitch -16 +

Pitch −6, deck crop

B2 pitch -6 crop +

Pitch −6 at 16:30 UTC, deck crop

B2 low sun crop +
Lum std (deck patch)−16: 9.1 · −6: 2.3 · low sun: 3.9
+

Render at pitch −6: 7.5 / 9.2 ms

+

Worst case: at noon hardly different from B1; the billows show only as faint shading near the horizon under a low sun.

+
+
+

B3 · strong, up to 40% (240 m on OVC)

+

Pitch −16

B3 pitch -16 +

Pitch −6, deck crop

B3 pitch -6 crop +

Pitch −6 at 16:30 UTC, deck crop

B3 low sun crop +
Lum std (deck patch)−16: 5.6 · −6: 4.1 · low sun: 5.2
+

Render at pitch −6: 7.4 / 8.3 ms

+

Worst case: the case 3c aircraft at 4,000 ft is washed out: billows reach 1,449 m and swallow it. Even here the relief reads as soft shading, not as distinct cloud tops.

+
+
+

Relief changes nothing from the tower: A3 with 40% relief rendered in 6.1 / 8.2 ms against A3's 6.3 / 9.7 ms at the tower pose. The top shading (cloudLight) has no relief cue of its own, which is why the billows are faint; making them read would need lighting work beyond the top shape.

+ +

C. Fog colour at 1.3 SM decision 6

+

Which grey should dense fog and the overcast underside share, and should Cesium's own scene fog stay on under the stage?

+

daylight() is unchanged in every lane (TC3D-185 owns dusk). Fog grey is (0.62, 0.66, 0.70); today's underside dark is (0.40, 0.43, 0.50).

+
+
+

C1 · today

+ C1 at 1.3 SM +

Horizon ×2

C1 horizon + + + +
Haze146,153,159 · lum 152 · chroma 13
Overheadlum 154 · chroma 21
+

Worst case: the overhead deck is a little bluer than the fog. Not dark teal: the haze passes the map's grey test (chroma ≤ 20, lum ≥ 115).

+
+
+

C2 · underside dark = fog grey

+ C2 at 1.3 SM +

Horizon ×2

C2 horizon + + + +
Haze146,153,159 · lum 152 · chroma 13
Overheadlum 168 · chroma 20
+

Worst case: at 1.3 SM only the overhead deck changes (lighter by 15); the fogged far field is identical. The overcast loses its darker underside.

+
+
+

C3 · C2, fog grey darkened 15% at full overcast

+ C3 at 1.3 SM +

Horizon ×2

C3 horizon + + + +
Haze130,136,141 · lum 135 · chroma 11
Overheadlum 144 · chroma 17
+

Worst case: the whole scene is a duller, heavier grey; still well above the "not dark" line (lum 135 against 115). Darkening uses the highest layer coverage, so a BKN-only sky darkens by 0.7 × 15%.

+
+
+

C4 · C2 with scene.fog off

+ C4 at 1.3 SM +

Horizon ×2

C4 horizon + + + +
Haze147,154,160 · lum 153 · chroma 13
Overheadlum 169 · chroma 20
+

Worst case: no visible change from C2 (haze +1.4 lum); Cesium then renders 83 terrain tiles instead of 68 (+22%, read at the B pose), because its fog also culls distant tiles.

+
+
+ +

D. Haze above the horizon at 10 SM decision 7

+

With visibility at 10 SM or more, should the sky right above the horizon still get a thin haze?

+

D2 fogs sky pixels with the stage's height-fog model at a 10 SM (16,093 m) visibility, fading out by about 4° above level. Today's flat 40 km deck in D1 and D2; the third lane adds A3.

+
+
+

D1 · none (today)

+ D1 at 10 SM +

Horizon ×2

D1 horizon +
Strip205,236,217 · step 72
+

Worst case: the mint band.

+
+
+

D2 · thin horizon haze

+ D2 at 10 SM +

Horizon ×2

D2 horizon +
Strip158,169,179 · step 22
+

Worst case: the band turns fog grey but stays a lighter strip under the darker deck. It also hazes terrain up to about 1° below the horizon, which the composite reads as sky distance (see findings).

+
+
+

D2 + A3 · haze with the horizon-radius deck

+ D2 with A3 at 10 SM +

Horizon ×2

D2 with A3 horizon +
Strip123,129,143 · step 21
+

Worst case: no visible change from A3 alone: the deck already covers the sky there, so D2 only matters without a deck (BKN gaps, FEW/SCT, clear sky).

+
+
+

D2's frame cost was not measured on its own; it adds one fog integral on sky pixels in the composite.

+ +

Findings the map did not predict

+
    +
  • At 5.96 SM the band is not sky: it is a fog-grey strip (158,168,178) under a darker, bluer deck. The radius does not change it (A1–A3 step 9–13); retuning the underside does (A3 + C2 step 3.7).
  • +
  • Dense fog at 1.3 SM is not dark teal on the Cesium path at 13:00 UTC: haze lum 152, chroma 13. scene.fog barely affects it (C4).
  • +
  • The flat-deck band at 10 SM measured about 0.6° (17–22 px), under the computed 1.05–1.3°: terrain covers its lowest part.
  • +
  • A 100 km curved deck leaves no visible sliver from the EGLL tower at 88.8 m; the computed 0.14° residual sits under the terrain.
  • +
  • The composite treats terrain within about 1° below the horizon as sky (D2's haze reaches it), so sceneDistance returns SKY_DISTANCE for those pixels. Cause unverified.
  • +
  • The white streaks of the BKN layer near the horizon (undersampled noise at grazing angles) appear in every A lane at 10 SM and are now the largest row step there.
  • +
+
+ + diff --git a/scripts/dev/chunkCycles.ts b/scripts/dev/chunkCycles.ts new file mode 100644 index 00000000..cb676dee --- /dev/null +++ b/scripts/dev/chunkCycles.ts @@ -0,0 +1,160 @@ +import type { Plugin } from 'vite' + +/** The static import edges of one emitted chunk, as the bundle reports them. */ +export interface ChunkImports { + readonly fileName: string + readonly imports: readonly string[] +} + +/** One build's chunk cycles, as chunk-name lists, split by whether a known entry covers them. */ +export interface CycleSplit { + /** The cycles an entry in `known` matched. */ + known: string[][] + /** The cycles no entry covers; these fail the build. */ + unknown: string[][] + /** The entries of `known` that no cycle in the build matched. */ + gone: string[][] +} + +/** + * Every elementary cycle among `chunks`' static imports. A cycle is the list of file names in import + * order, starting at its lexicographically smallest file name; each cycle appears once and the list + * is sorted. An import naming a file outside `chunks` (an asset, an external) and a self import are + * not cycles between chunks. + */ +export function findChunkCycles(chunks: ReadonlyArray): string[][] { + const edges = new Map() + for (const chunk of chunks) { + edges.set(chunk.fileName, chunk.imports) + } + const cycles: string[][] = [] + const path: string[] = [] + const onPath = new Set() + + // A depth-first search started at each file name, restricted to names at or after the start, finds + // each cycle exactly once: from its smallest name, whose position in the cycle is unique. + const walk = (start: string, node: string): void => { + path.push(node) + onPath.add(node) + for (const next of edges.get(node) ?? []) { + if (!edges.has(next)) { + continue + } + if (next === start) { + if (path.length > 1) { + cycles.push([...path]) + } + continue + } + if (next < start || onPath.has(next)) { + continue + } + walk(start, next) + } + onPath.delete(node) + path.pop() + } + + for (const start of [...edges.keys()].sort()) { + walk(start, start) + } + return cycles.sort(compareCycles) +} + +/** + * Splits `cycles` (file-name lists) into those a known cycle covers and those it does not, naming + * chunks with `nameOf`. A known entry matches a cycle whatever rotation either is written in; the + * entries of `known` that match nothing come back as `gone`. Every returned list starts at its + * lexicographically smallest name, except `gone`, which keeps each entry as it was written. + */ +export function splitKnownCycles( + cycles: string[][], + nameOf: (fileName: string) => string, + known: ReadonlyArray +): CycleSplit { + const entries = known.map((entry) => ({ entry, key: cycleKey(rotateToSmallest(entry)) })) + const matched = new Set() + const knownCycles: string[][] = [] + const unknown: string[][] = [] + for (const cycle of cycles) { + const names = rotateToSmallest(cycle.map(nameOf)) + const key = cycleKey(names) + if (entries.some((candidate) => candidate.key === key)) { + matched.add(key) + knownCycles.push(names) + } else { + unknown.push(names) + } + } + const gone = entries.filter((candidate) => !matched.has(candidate.key)).map((candidate) => [...candidate.entry]) + return { known: knownCycles, unknown, gone } +} + +function rotateToSmallest(names: readonly string[]): string[] { + let smallest = 0 + for (let index = 1; index < names.length; index++) { + if (names[index] < names[smallest]) { + smallest = index + } + } + return [...names.slice(smallest), ...names.slice(0, smallest)] +} + +function cycleKey(names: readonly string[]): string { + return names.join('\n') +} + +function compareCycles(left: readonly string[], right: readonly string[]): number { + const shared = Math.min(left.length, right.length) + for (let index = 0; index < shared; index++) { + if (left[index] !== right[index]) { + return left[index] < right[index] ? -1 : 1 + } + } + return left.length - right.length +} + +/** + * Fails a production build whose emitted chunks statically import each other in a cycle: a module that + * reads an imported binding while it loads gets `undefined` when the other chunk loads first. Dynamic + * imports are left out, since a dynamically imported chunk evaluates after its importer. A cycle whose + * chunk names match an entry of `knownCycles` is warned about instead of failing. + */ +export function chunkCyclePlugin(knownCycles: ReadonlyArray): Plugin { + return { + name: 'tc3d-chunk-cycles', + apply: 'build', + generateBundle(_options, bundle) { + const chunks: ChunkImports[] = [] + const chunkNames = new Map() + for (const entry of Object.values(bundle)) { + if (entry.type === 'chunk') { + chunks.push({ fileName: entry.fileName, imports: entry.imports }) + chunkNames.set(entry.fileName, entry.name) + } + } + const nameOf = (fileName: string): string => { + const name = chunkNames.get(fileName) + if (name === undefined) { + throw new Error(`chunk ${fileName} has no name in the emitted bundle`) + } + return name + } + const split = splitKnownCycles(findChunkCycles(chunks), nameOf, knownCycles) + for (const cycle of split.known) { + this.warn(`known chunk cycle (TC3D-351): ${cycle.join(' -> ')} -> ${cycle[0]}`) + } + for (const entry of split.gone) { + this.warn(`known chunk cycle no longer occurs; remove it from vite.config.ts: ${entry.join(' -> ')}`) + } + if (split.unknown.length === 0) { + return + } + const report = split.unknown.map((cycle) => ` ${cycle.join(' -> ')} -> ${cycle[0]}`).join('\n') + this.error( + `Production chunks import each other in a cycle; a module-level read across it is undefined at boot ` + + `(see the zustand and settings-core groups in vite.config.ts):\n${report}` + ) + } + } +} diff --git a/src/renderer/components/CesiumViewer/CesiumViewer.tsx b/src/renderer/components/CesiumViewer/CesiumViewer.tsx index 56ae3c76..bd40a1b2 100644 --- a/src/renderer/components/CesiumViewer/CesiumViewer.tsx +++ b/src/renderer/components/CesiumViewer/CesiumViewer.tsx @@ -5,21 +5,21 @@ import { useAgentHarness } from '../../hooks/useAgentHarness' import { setInterpolationTerrainData, useAircraftInterpolation } from '../../hooks/useAircraftInterpolation' import { useAircraftModels } from '../../hooks/useAircraftModels' import { useAutoAirportSwitch } from '../../hooks/useAutoAirportSwitch' -import { useBabylonNightLighting } from '../../hooks/useBabylonNightLighting' import { getMemoryCounters, useBabylonOverlay } from '../../hooks/useBabylonOverlay' -import { useBabylonSunDirection } from '../../hooks/useBabylonSunDirection' import { useCesiumCamera } from '../../hooks/useCesiumCamera' import { useCesiumLabels } from '../../hooks/useCesiumLabels' import { useCesiumLighting } from '../../hooks/useCesiumLighting' import { useCesiumNightDarkening } from '../../hooks/useCesiumNightDarkening' import { useCesiumViewer } from '../../hooks/useCesiumViewer' import { useCesiumWeather } from '../../hooks/useCesiumWeather' +import { useCesiumWeatherStages, useWeatherStageDriveAids } from '../../hooks/useCesiumWeatherStages' import { useGroundAircraftTerrain } from '../../hooks/useGroundAircraftTerrain' import { useImageryProvider } from '../../hooks/useImageryProvider' import { insetLog } from '../../hooks/useSharedWorkerConsumer' import { useSunElevation } from '../../hooks/useSunElevation' import { useTerrainFlattening } from '../../hooks/useTerrainFlattening' import { useTerrainQuality } from '../../hooks/useTerrainQuality' +import { useTowerModel } from '../../hooks/useTowerModel' import { useAircraftFilterStore } from '../../stores/aircraftFilterStore' import { useAirportStore } from '../../stores/airportStore' import { useDatablockPositionStore } from '../../stores/datablockPositionStore' @@ -35,7 +35,7 @@ import { neutralEnvironmentMapOptions } from '../../utils/environmentMap' import { GpuFrameTimer, getRendererInfo, NO_GPU_RESULTS } from '../../utils/gpuTimer' import { performanceMonitor } from '../../utils/performanceMonitor' import { getServiceWorkerCacheStats } from '../../utils/serviceWorkerRegistration' -import { getTowerPosition } from '../../utils/towerHeight' +import { feetToMeters, getTowerPosition, metersToFeet } from '../../utils/towerHeight' import { hasViewingContext, isOrbitFollowing, isOrbitWithoutAirport } from '../../utils/viewingContext' import DebugCoordinateOverlay from '../UI/DebugCoordinateOverlay' import './CesiumViewer.css' @@ -67,7 +67,8 @@ interface CesiumViewerProps { * 1. useCesiumViewer - core viewer initialization * 2. useTerrainQuality - terrain setup * 3. useCesiumLighting - lighting/shadows - * 4. useCesiumWeather - weather effects + * 4. useCesiumWeatherStages - cloud and fog post-process stage (returns whether it draws fog), then + * useCesiumWeather - Cesium scene fog, told whether the stage is drawing the fog * 5. useAircraftModels - aircraft model pool * 6. useCesiumLabels - datablock labels * 7. useBabylonOverlay - screen-space labels, leader lines, weather effects (requires viewer fully initialized) @@ -217,10 +218,10 @@ function CesiumViewer({ viewportId = 'main', isInset = false, isActivated = true const towerPos = getTowerPosition(currentAirport, towerHeight, customTowerPosition ?? undefined) refLat = towerPos.latitude refLon = towerPos.longitude - groundElevationMeters = currentAirport.elevation ? currentAirport.elevation * 0.3048 : 0 + groundElevationMeters = feetToMeters(currentAirport.elevation || 0) airportElevationFeet = currentAirport.elevation || 0 // Tower altitude = ground elevation + tower height (convert tower height from meters to feet) - refAltitudeFeet = (currentAirport.elevation || 0) + towerHeight / 0.3048 + refAltitudeFeet = (currentAirport.elevation || 0) + metersToFeet(towerHeight) } // ========================================================================= @@ -382,7 +383,26 @@ function CesiumViewer({ viewportId = 'main', isInset = false, isActivated = true // ========================================================================= // 4. Weather Effects (Fog) // ========================================================================= - useCesiumWeather(viewer, showWeatherEffects, showCesiumFog, fogDensity) + const stageDrawsFog = useCesiumWeatherStages(viewer, { + airportIcao: currentAirport?.icao ?? null, + refLat, + refLon, + // Without an airport groundElevationMeters is the followed aircraft's altitude, not an airport elevation. + airportElevationM: currentAirport ? groundElevationMeters : null, + followedCallsign: isOrbitWithoutAirport(currentAirport, followMode, followingCallsign) ? followingCallsign : null, + interpolatedAircraft, + viewMode, + isInset, + }) + useCesiumWeather(viewer, showWeatherEffects, showCesiumFog, fogDensity, stageDrawsFog) + + // Tower cab model; darkens at night with the imagery. Insets mount it too but draw nothing + // until inset windows load tower mods (only the main window calls modService.loadMods()). + const getTowerModel = useTowerModel(viewer, { + sunElevation, + nightDarkeningEnabled: enableNightDarkening && enableLighting, + nightDarkeningIntensity, + }) // ========================================================================= // 5. Ground Aircraft Terrain Sampling (10x per second) @@ -415,9 +435,6 @@ function CesiumViewer({ viewportId = 'main', isInset = false, isActivated = true // ========================================================================= // 7. Datablock Label Rendering // ========================================================================= - // Tower cab models are loaded inside the Babylon overlay (see useBabylonTowerModel - // composed into useBabylonOverlay below) so weather meshes can depth-test against - // the cab geometry. The previous Cesium-side useTowerModel hook has been removed. // (Reference position calculated above before hook initialization) // ========================================================================= @@ -429,23 +446,7 @@ function CesiumViewer({ viewportId = 'main', isInset = false, isActivated = true }) // Dev-only agent test harness (window.__tc3d) for MCP-driven tests - useAgentHarness(viewer, { engine: babylonOverlay.engine ?? null, scene: babylonOverlay.scene ?? null }) - - // Adjust Babylon.js lighting based on sun position - useBabylonNightLighting(babylonOverlay?.scene ?? null, sunElevation, { - enabled: enableNightDarkening && enableLighting, - }) - - // Sync Babylon directional light's direction to the actual sun position so the - // tower cab lights and self-shadows realistically as the day progresses. - useBabylonSunDirection({ - scene: babylonOverlay?.scene ?? null, - cesiumViewer: viewer, - getFixedToEnu: babylonOverlay.getFixedToEnu, - enabled: enableLighting, - timeMode, - fixedTimeHour, - }) + useAgentHarness(viewer, { engine: babylonOverlay.engine ?? null, scene: babylonOverlay.scene ?? null }, getTowerModel) useCesiumLabels({ viewer, @@ -475,6 +476,8 @@ function CesiumViewer({ viewportId = 'main', isInset = false, isActivated = true // 9. Camera Controls // ========================================================================= useCesiumCamera(viewer, viewportId, interpolatedAircraft, isActivated) + // After useCesiumCamera so the dev camera override's preRender listener runs last. + useWeatherStageDriveAids(isInset ? null : viewer) // ========================================================================= // 10. Auto-Airport Switching (only on main viewport) diff --git a/src/renderer/components/UI/DeviceOptimizationPrompt.tsx b/src/renderer/components/UI/DeviceOptimizationPrompt.tsx index 25d3048c..83b293f9 100644 --- a/src/renderer/components/UI/DeviceOptimizationPrompt.tsx +++ b/src/renderer/components/UI/DeviceOptimizationPrompt.tsx @@ -6,8 +6,9 @@ */ import { useEffect, useState } from 'react' -import { type SettingsPreset, useSettingsStore } from '../../stores/settingsStore' -import { getDevicePerformanceTier, isIPad, isMobileDevice, isTouchDevice } from '../../utils/deviceDetection' +import type { SettingsPreset } from '@/types' +import { useSettingsStore } from '../../stores/settingsStore' +import { deviceSettingsPreset, isTouchDevice } from '../../utils/deviceDetection' import './DeviceOptimizationPrompt.css' function DeviceOptimizationPrompt() { @@ -23,15 +24,7 @@ function DeviceOptimizationPrompt() { if (deviceOptimizationPromptDismissed) return if (!isTouchDevice()) return - // Determine suggested preset based on device type - const tier = getDevicePerformanceTier() - if (tier === 'low' || isMobileDevice()) { - setSuggestedPreset('mobile') - } else if (isIPad()) { - setSuggestedPreset('ipad') - } else { - setSuggestedPreset('ipad') // Default to iPad for unknown touch devices - } + setSuggestedPreset(deviceSettingsPreset()) // Small delay to avoid flash on load const timer = setTimeout(() => { @@ -95,6 +88,7 @@ function DeviceOptimizationPrompt() {
  • Anti-aliasing: None
  • Terrain: Low quality
  • Tile cache: 200 tiles
  • +
  • Weather quality: Low
  • ) : ( <> @@ -102,6 +96,7 @@ function DeviceOptimizationPrompt() {
  • Anti-aliasing: 2x MSAA
  • Terrain: Medium quality
  • Tile cache: 500 tiles
  • +
  • Weather quality: Medium
  • )} diff --git a/src/renderer/components/UI/SettingsGraphicsWeatherTab.tsx b/src/renderer/components/UI/SettingsGraphicsWeatherTab.tsx index bec6de76..7b8753a0 100644 --- a/src/renderer/components/UI/SettingsGraphicsWeatherTab.tsx +++ b/src/renderer/components/UI/SettingsGraphicsWeatherTab.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import type { WeatherQualityPreset } from '@/types' import { useGlobalSettingsStore } from '../../stores/globalSettingsStore' import { useSettingsStore } from '../../stores/settingsStore' import { useWeatherStore } from '../../stores/weatherStore' @@ -90,6 +91,7 @@ function SettingsGraphicsWeatherTab() { const showClouds = useSettingsStore((state) => state.weather.showClouds) const cloudOpacity = useSettingsStore((state) => state.weather.cloudOpacity) const fogIntensity = useSettingsStore((state) => state.weather.fogIntensity) + const stageQuality = useSettingsStore((state) => state.weather.stageQuality) const visibilityScale = useSettingsStore((state) => state.weather.visibilityScale) const showPrecipitation = useSettingsStore((state) => state.weather.showPrecipitation ?? true) const precipitationIntensity = useSettingsStore((state) => state.weather.precipitationIntensity ?? 1.0) @@ -876,6 +878,24 @@ function SettingsGraphicsWeatherTab() { +
    + + +

    + Cloud and fog detail. Lower draws clouds nearer and coarser for a faster frame; smaller insets use a + lower quality. +

    +
    +
    Cloud Opacity
    diff --git a/src/renderer/components/UI/SettingsModsTab.tsx b/src/renderer/components/UI/SettingsModsTab.tsx index 037d3342..822469ed 100644 --- a/src/renderer/components/UI/SettingsModsTab.tsx +++ b/src/renderer/components/UI/SettingsModsTab.tsx @@ -134,7 +134,7 @@ function SettingsModsTab({ onRequestClose }: SettingsModsTabProps) { onRequestClose?.() // Sample terrain height at tower position (for accurate model placement). - // The positioning store starts at 0; useBabylonTowerModel caches the real terrain + // The positioning store starts at 0; useTowerModel caches the real terrain // height when it loads the cab and uses that internally for live-edit math. const terrainHeight = 0 diff --git a/src/renderer/components/UI/WeatherDebugPanel.tsx b/src/renderer/components/UI/WeatherDebugPanel.tsx index 681235f2..9443a68e 100644 --- a/src/renderer/components/UI/WeatherDebugPanel.tsx +++ b/src/renderer/components/UI/WeatherDebugPanel.tsx @@ -44,7 +44,6 @@ export function WeatherDebugPanel() { const setWind = useWeatherStore((state) => state.setWind) const setCloudLayers = useWeatherStore((state) => state.setCloudLayers) const setDebugOverriding = useWeatherStore((state) => state.setDebugOverriding) - const triggerInstantUpdate = useWeatherStore((state) => state.triggerInstantUpdate) const isDebugOverriding = useWeatherStore((state) => state.isDebugOverriding) const currentCloudLayers = useWeatherStore((state) => state.cloudLayers) @@ -93,9 +92,6 @@ export function WeatherDebugPanel() { // Enable debug override mode to prevent METAR updates from overwriting setDebugOverriding(true) - - // Trigger instant update to bypass smoothing - triggerInstantUpdate() } const clearOverride = () => { diff --git a/src/renderer/constants/babylon.ts b/src/renderer/constants/babylon.ts index 41939962..7646192b 100644 --- a/src/renderer/constants/babylon.ts +++ b/src/renderer/constants/babylon.ts @@ -1,6 +1,6 @@ /** * Babylon.js rendering configuration constants - * Used by: useBabylonOverlay, useBabylonScene, useBabylonWeather, useBabylonLabels + * Used by: useBabylonScene, utils/weatherVisibility, utils/weatherStage */ // ============================================================================ @@ -19,169 +19,6 @@ export const CAMERA_MIN_Z = 1 */ export const CAMERA_MAX_Z = 1e10 -// ============================================================================ -// Cloud Layer Configuration -// ============================================================================ - -/** - * Maximum number of cloud layers that can be rendered simultaneously. - * Matches max METAR cloud layers (typically 3-4). - */ -export const CLOUD_POOL_SIZE = 4 - -/** - * Diameter of cloud plane meshes in meters (50km). - * Large enough to cover horizon from typical ATC tower heights. - */ -export const CLOUD_PLANE_DIAMETER = 50000 - -/** - * Cloud plane mesh rotation to horizontal (radians). - * Planes are created in XY, rotated to XZ for horizontal clouds. - */ -export const CLOUD_PLANE_ROTATION_X = Math.PI / 2 - -/** - * Cloud material diffuse color (RGB). - * Slightly blue-tinted white for realistic cloud appearance. - */ -export const CLOUD_DIFFUSE_COLOR = [0.95, 0.95, 0.98] as const - -/** - * Cloud material emissive color (RGB). - * Subtle self-illumination for visibility. - */ -export const CLOUD_EMISSIVE_COLOR = [0.4, 0.4, 0.45] as const - -/** - * Base alpha transparency for cloud materials (0-1). - * Multiplied by cloud coverage (0.125-1.0) and user opacity setting. - */ -export const CLOUD_BASE_ALPHA = 0.5 - -// ============================================================================ -// Fog Dome Configuration -// ============================================================================ - -/** - * Base diameter of fog dome mesh before scaling. - * Actual size is determined by METAR visibility distance. - */ -export const FOG_DOME_BASE_DIAMETER = 2 - -/** - * Number of segments for fog dome sphere mesh. - * Higher values = smoother sphere, but more polygons. - */ -export const FOG_DOME_SEGMENTS = 32 - -/** - * Fog dome material diffuse color (RGB). - * Light gray-blue for realistic fog appearance. - */ -export const FOG_DIFFUSE_COLOR = [0.8, 0.8, 0.82] as const - -/** - * Fog dome material emissive color (RGB). - * Subtle self-illumination for visibility in low light. - */ -export const FOG_EMISSIVE_COLOR = [0.6, 0.6, 0.65] as const - -/** - * Fog dome material specular color (RGB). - * Set to black (no specular highlights). - */ -export const FOG_SPECULAR_COLOR = [0, 0, 0] as const - -/** - * Base alpha transparency for fog dome material (0-1). - * Adjusted dynamically based on visibility conditions. - */ -export const FOG_BASE_ALPHA = 0.3 - -/** - * Fresnel effect bias for fog dome opacity (0-1). - * Controls transparency at center vs edges. - * Lower = more transparent center. - */ -export const FOG_FRESNEL_BIAS = 0.1 - -/** - * Fresnel effect power for fog dome opacity. - * Controls sharpness of edge falloff. - * Higher = sharper transition. - */ -export const FOG_FRESNEL_POWER = 3 - -// ============================================================================ -// Visibility-Based Fog Adjustment -// ============================================================================ - -/** - * Visibility threshold for extremely low visibility conditions (statute miles). - * Visibility <= 1/4 SM triggers heavy fog rendering. - */ -export const VISIBILITY_THRESHOLD_EXTREMELY_LOW = 0.25 - -/** - * Visibility threshold for low visibility conditions (statute miles). - * Visibility <= 1 SM triggers moderate to heavy fog. - */ -export const VISIBILITY_THRESHOLD_LOW = 1.0 - -/** - * Visibility threshold for moderate visibility conditions (statute miles). - * Visibility <= 3 SM triggers light fog. - */ -export const VISIBILITY_THRESHOLD_MODERATE = 3.0 - -/** - * Visibility threshold for decent visibility conditions (statute miles). - * Visibility <= 6 SM triggers very light haze. - */ -export const VISIBILITY_THRESHOLD_DECENT = 6.0 - -/** - * Fog alpha at extremely low visibility (<= 1/4 SM). - */ -export const FOG_ALPHA_EXTREMELY_LOW = 0.5 - -/** - * Fog alpha at low visibility (1/4 to 1 SM) - minimum value at 1 SM. - */ -export const FOG_ALPHA_LOW_MIN = 0.25 - -/** - * Fog alpha at moderate visibility (1 to 3 SM) - minimum value at 3 SM. - */ -export const FOG_ALPHA_MODERATE_MIN = 0.1 - -/** - * Fog alpha at decent visibility (3 to 6 SM) - minimum value at 6 SM. - */ -export const FOG_ALPHA_DECENT_MIN = 0.03 - -/** - * Fog alpha at good visibility (> 6 SM) - barely visible hint. - */ -export const FOG_ALPHA_GOOD = 0.03 - -/** - * Fresnel bias at extremely low visibility (<= 1/4 SM). - * Higher bias = fog visible even at center of view. - */ -export const FOG_FRESNEL_BIAS_EXTREMELY_LOW = 0.3 - -/** - * Fresnel bias at low visibility (1/4 to 1 SM) - minimum value at 1 SM. - */ -export const FOG_FRESNEL_BIAS_LOW_MIN = 0.15 - -/** - * Fresnel bias at moderate/decent/good visibility (>= 1 SM). - */ -export const FOG_FRESNEL_BIAS_MODERATE = 0.1 - // ============================================================================ // Cloud Ceiling Visibility Culling // ============================================================================ @@ -224,203 +61,3 @@ export const DIRECTIONAL_LIGHT_INTENSITY = 0.6 * Used for METAR visibility conversions. */ export const STATUTE_MILES_TO_METERS = 1609.34 - -// ============================================================================ -// Cloud Patchiness Configuration (Noise-Based) -// ============================================================================ - -/** - * Size of the noise texture for cloud patchiness (pixels). - * Higher values = finer detail but more memory. - */ -export const CLOUD_NOISE_TEXTURE_SIZE = 512 - -/** - * Number of octaves for fractal Brownian motion (fBm) noise. - * More octaves = more detail but slightly slower generation. - */ -export const CLOUD_NOISE_OCTAVES = 4 - -/** - * Noise scale factor - controls "zoom level" of cloud patterns. - * Higher values = more smaller patches, lower = fewer larger patches. - */ -export const CLOUD_NOISE_SCALE = 10.0 - -/** - * Base persistence for fBm - how much each octave contributes. - */ -export const CLOUD_NOISE_PERSISTENCE = 0.5 - -/** - * Threshold offset for cloud coverage calculation. - * Set to 0 so noise threshold directly matches coverage percentage. - */ -export const CLOUD_COVERAGE_THRESHOLD_OFFSET = 0 - -/** - * Edge softness for cloud transparency (0-1). - * Lower values = sharper edges, clearer gaps between clouds. - */ -export const CLOUD_EDGE_SOFTNESS = 0.05 - -/** - * Radial fade start point (0-1 from center). - * Where the radial edge fade begins. - */ -export const CLOUD_RADIAL_FADE_START = 0.5 - -/** - * Radial fade end point (0-1 from center). - * Where the radial edge fade reaches full transparency. - */ -export const CLOUD_RADIAL_FADE_END = 1.0 - -/** - * Cloud rotation base speed in radians per second. - * Very slow rotation to simulate cloud drift without being distracting. - * At 0.002 rad/s, a full rotation takes ~52 minutes. - */ -export const CLOUD_ROTATION_SPEED = 0.002 - -/** - * Cloud rotation speed variance (multiplier range around base speed). - * Speed will vary from baseSpeed * (1 - variance) to baseSpeed * (1 + variance). - * At 0.5, speed ranges from 50% to 150% of base speed. - */ -export const CLOUD_ROTATION_SPEED_VARIANCE = 0.5 - -/** - * How often to change cloud rotation speed (in seconds). - * Lower values = more frequent speed changes. - */ -export const CLOUD_ROTATION_CHANGE_INTERVAL = 300 - -/** - * How long it takes to transition to a new rotation speed (in seconds). - * Creates smooth acceleration/deceleration between speeds. - */ -export const CLOUD_ROTATION_TRANSITION_TIME = 5 - -// ============================================================================ -// Cloud Layer Transition Configuration -// ============================================================================ - -/** - * Maximum altitude difference (meters) for matching old layer to new layer. - * Layers within this range are considered the "same" layer and will transition. - * Layers outside this range will crossfade (old fades out, new fades in). - */ -export const CLOUD_LAYER_MATCH_ALTITUDE_THRESHOLD = 500 - -/** - * Maximum coverage difference for matching old layer to new layer. - * E.g., FEW (0.1875) to BKN (0.6875) = 0.5 difference, which exceeds 0.4 threshold. - */ -export const CLOUD_LAYER_MATCH_COVERAGE_THRESHOLD = 0.4 - -/** - * How fast cloud layer altitude transitions (meters per second). - * At 50 m/s, a 1000m altitude change takes 20 seconds. - */ -export const CLOUD_LAYER_ALTITUDE_TRANSITION_SPEED = 50 - -/** - * How fast cloud layer coverage transitions (coverage units per second). - * At 0.05/s, FEW to SCT (0.25 change) takes 5 seconds. - */ -export const CLOUD_LAYER_COVERAGE_TRANSITION_SPEED = 0.05 - -/** - * How fast cloud layers fade in/out (alpha per second). - * At 0.2/s, full fade takes 5 seconds. - */ -export const CLOUD_LAYER_FADE_SPEED = 0.2 - -/** - * Minimum coverage change before regenerating texture. - * Small changes don't need texture regeneration. - */ -export const CLOUD_LAYER_COVERAGE_REGEN_THRESHOLD = 0.05 - -// ============================================================================ -// Overcast Dome Configuration -// ============================================================================ - -/** - * Coverage threshold above which to use dome geometry instead of flat plane. - * OVC (overcast) = 1.0, BKN (broken) = 0.75 - * Using 0.9 to catch OVC but not BKN. - */ -export const CLOUD_DOME_COVERAGE_THRESHOLD = 0.9 - -/** - * Radius of the overcast dome in meters. - * Should match or exceed CLOUD_PLANE_DIAMETER / 2 for consistency. - */ -export const CLOUD_DOME_RADIUS = 25000 - -/** - * Number of segments for dome mesh (affects smoothness). - * Higher = smoother but more polygons. - */ -export const CLOUD_DOME_SEGMENTS = 48 - -/** - * How much the dome curves down at the horizon (0-1). - * 0 = flat plane, 1 = full hemisphere. - * Very small values (0.01-0.03) give subtle curvature without blocking view. - */ -export const CLOUD_DOME_CURVATURE = 0.02 - -/** - * Horizon darkening factor for overcast dome (0-1). - * Higher = darker at horizon edges. - */ -export const CLOUD_DOME_HORIZON_DARKENING = 0.12 - -/** - * Fresnel bias for cloud dome material. - * Controls base opacity at center vs edges. - */ -export const CLOUD_DOME_FRESNEL_BIAS = 0.6 - -/** - * Fresnel power for cloud dome material. - * Controls sharpness of edge opacity falloff. - */ -export const CLOUD_DOME_FRESNEL_POWER = 1.5 - -/** - * Altitude buffer for switching between dome/plane and above-cloud flat plane (meters). - * Camera must be this far above/below the cloud layer to fully switch views. - * The transition zone is 2x this value centered on the cloud altitude. - */ -export const CLOUD_ABOVE_TRANSITION_BUFFER = 200 - -/** - * Fresnel bias for cloud dome when viewed from above (transition zone). - * Lower value = more opaque, better visibility from above. - */ -export const CLOUD_DOME_FRESNEL_BIAS_ABOVE = 0.15 - -/** - * Fresnel power for cloud dome when viewed from above. - * Lower value = gentler opacity falloff. - */ -export const CLOUD_DOME_FRESNEL_POWER_ABOVE = 1.0 - -/** - * Diffuse color for above-cloud plane (clouds from above appear whiter). - */ -export const CLOUD_ABOVE_DIFFUSE_COLOR: [number, number, number] = [0.95, 0.95, 0.97] - -/** - * Emissive color for above-cloud plane (self-illumination for uniform look). - */ -export const CLOUD_ABOVE_EMISSIVE_COLOR: [number, number, number] = [0.85, 0.85, 0.88] - -/** - * Base alpha for above-cloud plane. - */ -export const CLOUD_ABOVE_BASE_ALPHA = 0.9 diff --git a/src/renderer/constants/index.ts b/src/renderer/constants/index.ts index 5a923362..d56f90e6 100644 --- a/src/renderer/constants/index.ts +++ b/src/renderer/constants/index.ts @@ -115,77 +115,14 @@ export { CAMERA_MAX_Z, // Scene and camera CAMERA_MIN_Z, - CLOUD_ABOVE_BASE_ALPHA, - CLOUD_ABOVE_DIFFUSE_COLOR, - CLOUD_ABOVE_EMISSIVE_COLOR, - // Above-cloud view - CLOUD_ABOVE_TRANSITION_BUFFER, - CLOUD_BASE_ALPHA, // Cloud ceiling visibility CLOUD_CEILING_COVERAGE_THRESHOLD, - CLOUD_COVERAGE_THRESHOLD_OFFSET, - CLOUD_DIFFUSE_COLOR, - // Overcast dome - CLOUD_DOME_COVERAGE_THRESHOLD, - CLOUD_DOME_CURVATURE, - CLOUD_DOME_FRESNEL_BIAS, - CLOUD_DOME_FRESNEL_BIAS_ABOVE, - CLOUD_DOME_FRESNEL_POWER, - CLOUD_DOME_FRESNEL_POWER_ABOVE, - CLOUD_DOME_HORIZON_DARKENING, - CLOUD_DOME_RADIUS, - CLOUD_DOME_SEGMENTS, - CLOUD_EDGE_SOFTNESS, - CLOUD_EMISSIVE_COLOR, - CLOUD_LAYER_ALTITUDE_TRANSITION_SPEED, - CLOUD_LAYER_COVERAGE_REGEN_THRESHOLD, - CLOUD_LAYER_COVERAGE_TRANSITION_SPEED, - CLOUD_LAYER_FADE_SPEED, - CLOUD_LAYER_MATCH_ALTITUDE_THRESHOLD, - CLOUD_LAYER_MATCH_COVERAGE_THRESHOLD, - CLOUD_NOISE_OCTAVES, - CLOUD_NOISE_PERSISTENCE, - CLOUD_NOISE_SCALE, - // Cloud patchiness (noise-based) - CLOUD_NOISE_TEXTURE_SIZE, - CLOUD_PLANE_DIAMETER, - CLOUD_PLANE_ROTATION_X, - // Cloud layers - CLOUD_POOL_SIZE, - CLOUD_RADIAL_FADE_END, - CLOUD_RADIAL_FADE_START, - CLOUD_ROTATION_CHANGE_INTERVAL, - CLOUD_ROTATION_SPEED, - CLOUD_ROTATION_SPEED_VARIANCE, - CLOUD_ROTATION_TRANSITION_TIME, DIRECTIONAL_LIGHT_INTENSITY, - FOG_ALPHA_DECENT_MIN, - FOG_ALPHA_EXTREMELY_LOW, - FOG_ALPHA_GOOD, - FOG_ALPHA_LOW_MIN, - FOG_ALPHA_MODERATE_MIN, - FOG_BASE_ALPHA, - FOG_DIFFUSE_COLOR, - // Fog dome - FOG_DOME_BASE_DIAMETER, - FOG_DOME_SEGMENTS, - FOG_EMISSIVE_COLOR, - FOG_FRESNEL_BIAS, - FOG_FRESNEL_BIAS_EXTREMELY_LOW, - FOG_FRESNEL_BIAS_LOW_MIN, - FOG_FRESNEL_BIAS_MODERATE, - FOG_FRESNEL_POWER, - FOG_SPECULAR_COLOR, HEMISPHERIC_LIGHT_GROUND_COLOR, // Lighting HEMISPHERIC_LIGHT_INTENSITY, // Unit conversions STATUTE_MILES_TO_METERS, - VISIBILITY_THRESHOLD_DECENT, - // Visibility-based fog adjustment - VISIBILITY_THRESHOLD_EXTREMELY_LOW, - VISIBILITY_THRESHOLD_LOW, - VISIBILITY_THRESHOLD_MODERATE, } from './babylon' // ============================================================================ @@ -292,7 +229,9 @@ export { AUTO_SWITCH_HYSTERESIS_NM, AUTO_SWITCH_MIN_DISTANCE_NM, CLOUD_ALTITUDE_BAND_FEET, - FOG_DENSITY_TRANSITION_TIME, + // Cesium cloud and fog stage + CLOUD_THICKNESS_BY_TYPE_M, + CLOUD_THICKNESS_DEFAULT_M, INTERPOLATION_DISTANCE_POWER, INTERPOLATION_MIN_WEIGHT, INTERPOLATION_POSITION_THRESHOLD_DEG, @@ -300,18 +239,28 @@ export { // Interpolation settings INTERPOLATION_STATION_COUNT, INTERPOLATION_UPDATE_THROTTLE_MS, + // Weather hysteresis PRECIPITATION_CESSATION_DELAY, PRECIPITATION_FADE_TIME, PRECIPITATION_ONSET_DELAY, THUNDERSTORM_CESSATION_DELAY, THUNDERSTORM_ONSET_DELAY, - VISIBILITY_CHANGE_THRESHOLD, - // Weather smoothing / hysteresis - VISIBILITY_TRANSITION_TIME, - WIND_DIRECTION_CHANGE_THRESHOLD, - WIND_DIRECTION_TRANSITION_TIME, - WIND_SPEED_CHANGE_THRESHOLD, - WIND_SPEED_TRANSITION_TIME, + WEATHER_STAGE_AIRPORT_REANCHOR_DISTANCE_M, + WEATHER_STAGE_FOG_EASE_MS, + WEATHER_STAGE_FULL_FOG_VISIBILITY_SM, + WEATHER_STAGE_GROUND_BLEND_MS, + WEATHER_STAGE_INSET_FULL_AREA_RATIO, + WEATHER_STAGE_INSET_RESIZE_SETTLE_MS, + WEATHER_STAGE_INSET_STEP_DOWN_AREA_RATIO, + WEATHER_STAGE_MAX_LAYERS, + WEATHER_STAGE_MIN_VISIBILITY_SM, + WEATHER_STAGE_NO_FOG_VISIBILITY_SM, + WEATHER_STAGE_NOISE_ORIGIN_RESET_M, + WEATHER_STAGE_PRESETS, + WEATHER_STAGE_REANCHOR_CHECK_MS, + WEATHER_STAGE_REANCHOR_DISTANCE_M, + WEATHER_STAGE_SUN_SHADE_COVERAGE, + type WeatherStageQuality, } from './weather' // ============================================================================ @@ -319,11 +268,6 @@ export { // ============================================================================ export { - BABYLON_NIGHT_CLOUD_EMISSIVE_MULT, - BABYLON_NIGHT_DIRECTIONAL_MULT, - BABYLON_NIGHT_FOG_EMISSIVE_MULT, - // Babylon.js night lighting multipliers - BABYLON_NIGHT_HEMISPHERIC_MULT, // Cesium environment map ENVIRONMENT_MAP_GROUND_COLOR, NIGHT_BRIGHTNESS_CIVIL, diff --git a/src/renderer/constants/lighting.ts b/src/renderer/constants/lighting.ts index 7af1903e..1235a5ea 100644 --- a/src/renderer/constants/lighting.ts +++ b/src/renderer/constants/lighting.ts @@ -58,34 +58,6 @@ export const NIGHT_BRIGHTNESS_CIVIL = 0.6 */ export const NIGHT_GAMMA_BOOST = 1.15 -// ============================================================================= -// Babylon.js Night Lighting Multipliers -// ============================================================================= - -/** - * Hemispheric light intensity multiplier at night (0.0-1.0) - * Applied to ambient lighting for weather effects and 3D models - */ -export const BABYLON_NIGHT_HEMISPHERIC_MULT = 0.3 - -/** - * Directional light intensity multiplier at night (0.0-1.0) - * Applied to sun-like directional lighting - */ -export const BABYLON_NIGHT_DIRECTIONAL_MULT = 0.15 - -/** - * Fog emissive color multiplier at night (0.0-1.0) - * Reduces fog glow for darker nighttime appearance - */ -export const BABYLON_NIGHT_FOG_EMISSIVE_MULT = 0.3 - -/** - * Cloud emissive color multiplier at night (0.0-1.0) - * Slightly brighter than fog to simulate moonlight reflection - */ -export const BABYLON_NIGHT_CLOUD_EMISSIVE_MULT = 0.4 - // ============================================================================= // Performance Constants // ============================================================================= diff --git a/src/renderer/constants/weather.ts b/src/renderer/constants/weather.ts index 6f404936..2d795bca 100644 --- a/src/renderer/constants/weather.ts +++ b/src/renderer/constants/weather.ts @@ -4,6 +4,8 @@ * Configuration for METAR weather interpolation and auto-airport switching features. */ +import type { WeatherQualityPreset } from '@/types' + // ============================================================================ // WEATHER INTERPOLATION // ============================================================================ @@ -75,32 +77,9 @@ export const AUTO_SWITCH_HYSTERESIS_NM = 5 export const AUTO_SWITCH_MIN_DISTANCE_NM = 2 // ============================================================================ -// WEATHER SMOOTHING / HYSTERESIS +// WEATHER HYSTERESIS // ============================================================================ -/** - * Transition time for visibility changes (seconds) - * Controls how fast fog opacity/scale responds to METAR updates - * Higher = smoother but slower to respond to real weather changes - */ -export const VISIBILITY_TRANSITION_TIME = 8.0 - -/** - * Transition time for fog density changes (seconds) - */ -export const FOG_DENSITY_TRANSITION_TIME = 8.0 - -/** - * Transition time for wind direction changes (seconds) - * Uses circular interpolation to handle wrap-around at 360° - */ -export const WIND_DIRECTION_TRANSITION_TIME = 5.0 - -/** - * Transition time for wind speed changes (seconds) - */ -export const WIND_SPEED_TRANSITION_TIME = 3.0 - /** * Transition time for precipitation intensity fade in/out (seconds) * Controls how fast rain/snow particles ramp up or down @@ -130,18 +109,135 @@ export const THUNDERSTORM_ONSET_DELAY = 1.0 */ export const THUNDERSTORM_CESSATION_DELAY = 8.0 +// ============================================================================ +// CESIUM CLOUD AND FOG STAGE +// ============================================================================ + +/** + * Knobs of the Cesium cloud and fog post-process stage at one quality preset. + * + * - `steps`: raymarch steps per cloud layer crossing (shader maximum 256) + * - `resolutionScale`: cloud pass resolution relative to the canvas (0.25..1); read when + * the stage is created, independent of the canvas's own resolution + * - `densityScale`: cloud extinction per meter at full density + * - `fogExtinction`: k in the fog transmittance exp(-k * distance / visibility) + * - `fogHeightScale`: height in meters over which fog density falls by 1/e + * - `radius`: radius in meters around the anchor that cloud layers cover + */ +export interface WeatherStageQuality { + readonly steps: number + readonly resolutionScale: number + readonly densityScale: number + readonly fogExtinction: number + readonly fogHeightScale: number + readonly radius: number +} + +/** + * Knobs of the stage per quality preset. Only the cost knobs (steps, resolutionScale, + * radius) differ; the look knobs (densityScale, fogExtinction, fogHeightScale) are equal. + * + * High: the deck curves down with the earth, so from a tower cab an overcast base at + * 2,000 ft ends about 0.15 deg above the horizon at 100 km (a flat 40 km deck leaves a + * degree of clear sky). About 120 km would reach the horizon, but would need 144 steps at + * the same step length; 100 km keeps the sliver small for 120 steps, which keep the step + * length of 48 steps over 40 km. Medium and Low trade a wider band of clear sky at the + * horizon and a coarser cloud pass for frame time. + */ +export const WEATHER_STAGE_PRESETS: Readonly> = { + low: { + steps: 48, + resolutionScale: 0.25, + densityScale: 0.04, + fogExtinction: 1.0, + fogHeightScale: 400, + radius: 40000, + }, + medium: { + steps: 72, + resolutionScale: 0.375, + densityScale: 0.04, + fogExtinction: 1.0, + fogHeightScale: 400, + radius: 60000, + }, + high: { + steps: 120, + resolutionScale: 0.5, + densityScale: 0.04, + fogExtinction: 1.0, + fogHeightScale: 400, + radius: 100000, + }, +} + +/** Inset area, as a fraction of the main viewport's, from which an inset uses the main view's preset */ +export const WEATHER_STAGE_INSET_FULL_AREA_RATIO = 0.5 + +/** Inset area fraction from which an inset uses one preset below the main view's; under it, Low */ +export const WEATHER_STAGE_INSET_STEP_DOWN_AREA_RATIO = 0.15 + +/** Quiet time after an inset resize before a changed preset rebuilds the stage, in ms */ +export const WEATHER_STAGE_INSET_RESIZE_SETTLE_MS = 300 + +/** Cloud slab thickness in meters by METAR cover code */ +export const CLOUD_THICKNESS_BY_TYPE_M: Readonly> = { + FEW: 250, + SCT: 350, + BKN: 450, + OVC: 600, +} + +/** Cloud slab thickness in meters for a cover code missing from CLOUD_THICKNESS_BY_TYPE_M */ +export const CLOUD_THICKNESS_DEFAULT_M = 400 + +/** Cloud layers the stage shader takes (u_layer0..u_layer3) */ +export const WEATHER_STAGE_MAX_LAYERS = 4 + /** - * Minimum visibility change (statute miles) to trigger transition - * Changes smaller than this are applied instantly + * Coverage from which a cloud layer shades the layers under it from direct sun: + * 5 oktas, the lower bound of BKN */ -export const VISIBILITY_CHANGE_THRESHOLD = 0.1 +export const WEATHER_STAGE_SUN_SHADE_COVERAGE = 5 / 8 + +/** Visibility (statute miles) at and above which the stage draws no fog */ +export const WEATHER_STAGE_NO_FOG_VISIBILITY_SM = 10 /** - * Minimum wind direction change (degrees) to trigger transition + * Visibility (statute miles) at and below which the stage draws the METAR's full fog; + * from here up to WEATHER_STAGE_NO_FOG_VISIBILITY_SM its density fades out linearly */ -export const WIND_DIRECTION_CHANGE_THRESHOLD = 5 +export const WEATHER_STAGE_FULL_FOG_VISIBILITY_SM = 8 + +/** Time (ms) over which the stage's fog density eases (smoothstep) to a new METAR's */ +export const WEATHER_STAGE_FOG_EASE_MS = 6000 + +/** Lowest visibility (statute miles) the stage's fog uses, so 0 SM stays finite */ +export const WEATHER_STAGE_MIN_VISIBILITY_SM = 0.05 /** - * Minimum wind speed change (knots) to trigger transition + * Distance in meters a followed aircraft (orbit mode without an airport) moves + * before the cloud anchor follows it; small against the cloud radius, large + * enough that a terrain sample is not taken every frame */ -export const WIND_SPEED_CHANGE_THRESHOLD = 2 +export const WEATHER_STAGE_REANCHOR_DISTANCE_M = 5000 + +/** + * Distance in meters the reference moves within the same airport (a custom + * tower position, a tower switch) before the cloud anchor follows it + */ +export const WEATHER_STAGE_AIRPORT_REANCHOR_DISTANCE_M = 100 + +/** + * Distance in meters between the anchor and the cloud noise origin past which + * the origin moves to the anchor: one deliberate noise pop per this distance, + * keeping the horizontal drift from rotated re-anchor axes under about 40 m per + * WEATHER_STAGE_REANCHOR_DISTANCE_M step before it + */ +export const WEATHER_STAGE_NOISE_ORIGIN_RESET_M = 50000 + +/** How often (ms) the stage checks whether its anchor should move, and retries a failed terrain sample */ +export const WEATHER_STAGE_REANCHOR_CHECK_MS = 1000 + +/** Time (ms) over which a change in the anchor's ground height blends into the cloud deck */ +export const WEATHER_STAGE_GROUND_BLEND_MS = 5000 diff --git a/src/renderer/hooks/useAgentHarness.ts b/src/renderer/hooks/useAgentHarness.ts index 50445958..d73154b3 100644 --- a/src/renderer/hooks/useAgentHarness.ts +++ b/src/renderer/hooks/useAgentHarness.ts @@ -1,7 +1,9 @@ import type * as BABYLON from '@babylonjs/core' import * as Cesium from 'cesium' import { useEffect } from 'react' -import type { AgentHarness } from '@/types' +import { WEATHER_STAGE_NAME } from '@/hooks/useCesiumWeatherStages' +import { useWeatherStageStore } from '@/stores/weatherStageStore' +import type { AgentHarness, TowerModelHandle } from '@/types' import { performanceMonitor } from '@/utils/performanceMonitor' import { useSettingsStore } from '../stores/settingsStore' import { useViewportStore } from '../stores/viewportStore' @@ -12,7 +14,7 @@ import { useWeatherStore } from '../stores/weatherStore' * * The global exposes the Cesium viewer, the Babylon overlay engine and scene, * and the viewport/weather/settings store hooks, plus `camera`, `lookAt`, - * `project`, `readPixels` and `perf` helpers. Agent-driven tests use it to + * `project`, `readPixels` and `perf` helpers and the `weatherStage` and `towerModel` handles. Agent-driven tests use it to * inspect and drive the running app through the `tauri-app` and `playwright` * MCP servers. * @@ -21,10 +23,12 @@ import { useWeatherStore } from '../stores/weatherStore' * * @param viewer - Cesium viewer of the main viewport, or null before it exists * @param babylon - Babylon overlay engine and scene (either may be null) + * @param getTowerModel - Getter for the tower cab's state, from `useTowerModel` */ export function useAgentHarness( viewer: Cesium.Viewer | null, babylon: { engine: BABYLON.Engine | null; scene: BABYLON.Scene | null }, + getTowerModel: () => TowerModelHandle, ): void { const { engine, scene } = babylon @@ -85,6 +89,36 @@ export function useAgentHarness( current: performanceMonitor.getMetrics(), window: performanceMonitor.getFrameWindow(), }), + weatherStage: { + set: ({ camera, testAircraft }) => { + const state = useWeatherStageStore.getState() + useWeatherStageStore.setState({ + cameraOverride: camera === undefined ? state.cameraOverride : camera, + testAircraft: testAircraft === undefined ? state.testAircraft : testAircraft, + }) + }, + info: () => { + const stages = viewer.scene.postProcessStages + const stageNames: string[] = [] + let stage: { enabled: boolean } | null = null + for (let i = 0; i < stages.length; i++) { + const current = stages.get(i) + stageNames.push(current.name) + if (current.name === WEATHER_STAGE_NAME) stage = current + } + return { + stageInScene: stage !== null, + stageEnabled: stage?.enabled ?? false, + stageNames, + // Scene.numberOfFrustums is a getter Cesium leaves out of its type declarations. + numberOfFrustums: (viewer.scene as unknown as { numberOfFrustums: number }).numberOfFrustums, + logDepth: viewer.scene.logarithmicDepthBuffer, + } + }, + }, + get towerModel() { + return getTowerModel() + }, } window.__tc3d = harness @@ -93,5 +127,5 @@ export function useAgentHarness( if (window.__tc3d === harness) delete window.__tc3d } } - }, [viewer, engine, scene]) + }, [viewer, engine, scene, getTowerModel]) } diff --git a/src/renderer/hooks/useAircraftFiltering.ts b/src/renderer/hooks/useAircraftFiltering.ts index 5b039755..3ee119b4 100644 --- a/src/renderer/hooks/useAircraftFiltering.ts +++ b/src/renderer/hooks/useAircraftFiltering.ts @@ -152,7 +152,7 @@ interface UseAircraftFilteringOptions { * ## Weather Visibility Culling * * The weather filter delegates to `isVisibleThroughWeather` in - * `utils/weatherVisibility.ts`, which `useBabylonWeather` shares: aircraft beyond + * `utils/weatherVisibility.ts`, which `useBabylonOverlay` shares: aircraft beyond * the scaled METAR visibility, or behind a BKN/OVC layer between camera and * aircraft, are hidden. Cloud culling is skipped in top-down view. * diff --git a/src/renderer/hooks/useAircraftModels.ts b/src/renderer/hooks/useAircraftModels.ts index 87f7f71a..8b192b04 100644 --- a/src/renderer/hooks/useAircraftModels.ts +++ b/src/renderer/hooks/useAircraftModels.ts @@ -25,6 +25,7 @@ import { updateGearAnimation, } from '../utils/gearAnimationController' import { getModelGroundData, parseGroundDataFromUrl } from '../utils/gltfAnimationParser' +import { aircraftModelHeading } from '../utils/modelOrientation' import { performanceMonitor } from '../utils/performanceMonitor' import type { ModelPoolRefs, SilhouetteRefs } from './useCesiumViewer' import { isInsetContext } from './useInsetStoreSync' @@ -443,14 +444,12 @@ export function useAircraftModels( prevModelPositionsRef.current.set(aircraft.callsign, Cesium.Cartesian3.clone(position)) } - // Model heading: Cesium models typically face +X, so heading=0 means east - // Subtract 90 to convert from compass heading (north=0) to model heading - // Add 180° to flip models that face backwards - // Add rotationOffset for models that face different directions (e.g., FSLTL = 180°) + // Heading through the shared model convention (utils/modelOrientation.ts), with the + // aircraft's +180 flip and the model's own rotationOffset (e.g. FSLTL = 180°). // Pitch/Roll: Use values directly - positive pitch = nose up, positive roll = right bank const rotationOffset = modelInfo.rotationOffset ?? 0 const hpr = new Cesium.HeadingPitchRoll( - Cesium.Math.toRadians(aircraft.interpolatedHeading - 90 + 180 + rotationOffset), + aircraftModelHeading(aircraft.interpolatedHeading, rotationOffset), Cesium.Math.toRadians(aircraft.interpolatedPitch), Cesium.Math.toRadians(aircraft.interpolatedRoll), ) diff --git a/src/renderer/hooks/useBabylonCameraSync.ts b/src/renderer/hooks/useBabylonCameraSync.ts index 5a6254fc..01a06b74 100644 --- a/src/renderer/hooks/useBabylonCameraSync.ts +++ b/src/renderer/hooks/useBabylonCameraSync.ts @@ -9,26 +9,9 @@ interface Camera2DState { heading: number } -// Distance threshold for repositioning cloud layers (meters) -// Clouds are repositioned when camera moves this far from the last cloud center -// This prevents the "following" visual artifact while ensuring continuous coverage -const CLOUD_REPOSITION_THRESHOLD = 10000 // 10km - -interface CloudMeshData { - plane: BABYLON.Mesh - dome: BABYLON.Mesh - abovePlane: BABYLON.Mesh - material: BABYLON.StandardMaterial - domeMaterial: BABYLON.StandardMaterial - aboveMaterial: BABYLON.StandardMaterial -} - interface UseBabylonCameraSyncOptions { cesiumViewer: Cesium.Viewer | null camera: BABYLON.FreeCamera | null - fogDome: BABYLON.Mesh | null - /** Getter function for cloud meshes - called each frame to get current meshes */ - getCloudMeshes: () => CloudMeshData[] /** Getter for fixed-to-ENU transformation matrix from useBabylonRootNode */ getFixedToEnu: () => Cesium.Matrix4 | null } @@ -58,12 +41,11 @@ interface UseBabylonCameraSyncResult { * - Handles both 3D perspective view and 2D top-down orthographic view * - Synchronizes camera position, rotation (Euler angles), and FOV each frame * - Manages terrain offset calculations for accurate altitude positioning - * - Positions fog dome at camera location for weather effects * * ## Dependencies * - Requires: Cesium viewer and Babylon FreeCamera instances (must be initialized) * - Reads: Cesium camera's position, orientation, and FOV - * - Writes: Babylon camera's position, rotation, FOV, and fog dome position + * - Writes: Babylon camera's position, rotation, and FOV * - Uses: ENU transform data from `setupEnuTransforms()` utility * * ## Call Order @@ -72,13 +54,12 @@ interface UseBabylonCameraSyncResult { * function BabylonOverlay() { * const cesiumViewer = useRef(null) * const babylonCamera = useRef(null) - * const fogDome = useRef(null) * * // Setup camera sync * const { setupBasePosition, syncCamera } = useBabylonCameraSync({ * cesiumViewer: cesiumViewer.current, * camera: babylonCamera.current, - * fogDome: fogDome.current + * getFixedToEnu * }) * * // Initialize base position when airport changes @@ -161,13 +142,6 @@ interface UseBabylonCameraSyncResult { * * The `get2DState()` function provides camera's current lat/lon/heading for this calculation. * - * ## Fog Dome Positioning - * - * The fog dome (weather visibility sphere) is positioned at the camera location: - * - Follows camera movement in both 3D and top-down modes - * - Uses `fogDome.position.copyFrom(camera.position)` for direct copy - * - Updated every frame during `syncCamera()` - * * ## Performance Considerations * * - **Matrix multiplication**: One 4×4 matrix transform per frame (~60Hz) @@ -188,7 +162,7 @@ interface UseBabylonCameraSyncResult { * @param options - Configuration options * @param options.cesiumViewer - The Cesium viewer instance (must not be destroyed) * @param options.camera - The Babylon FreeCamera to synchronize (must not be disposed) - * @param options.fogDome - Optional fog dome mesh to position at camera (can be null) + * @param options.getFixedToEnu - Getter for the fixed-to-ENU matrix from useBabylonRootNode * @returns Camera sync controls and state accessors * * @example @@ -196,7 +170,7 @@ interface UseBabylonCameraSyncResult { * const { setupBasePosition, syncCamera } = useBabylonCameraSync({ * cesiumViewer: viewer, * camera: babylonCamera, - * fogDome: fogDomeMesh + * getFixedToEnu * }) * * // Setup ENU origin at tower @@ -212,7 +186,7 @@ interface UseBabylonCameraSyncResult { * const { get2DState, isTopDownMode } = useBabylonCameraSync({ * cesiumViewer: viewer, * camera: babylonCamera, - * fogDome: null + * getFixedToEnu * }) * * if (isTopDownMode()) { @@ -225,7 +199,7 @@ interface UseBabylonCameraSyncResult { * const { getTerrainOffset } = useBabylonCameraSync({ * cesiumViewer: viewer, * camera: babylonCamera, - * fogDome: null + * getFixedToEnu * }) * * const aircraftHeightAboveTerrain = aircraftAltitudeMSL - getTerrainOffset() @@ -237,8 +211,6 @@ interface UseBabylonCameraSyncResult { export function useBabylonCameraSync({ cesiumViewer, camera, - fogDome, - getCloudMeshes, getFixedToEnu, }: UseBabylonCameraSyncOptions): UseBabylonCameraSyncResult { // ENU transformation data @@ -251,9 +223,6 @@ export function useBabylonCameraSync({ // Terrain offset: difference between MSL elevation and actual Cesium terrain height const terrainOffsetRef = useRef(0) - // Last position where clouds were centered (for threshold-based repositioning) - const cloudCenterRef = useRef<{ x: number; z: number }>({ x: 0, z: 0 }) - // Setup ENU transforms for a given base position const setupBasePosition = useCallback( (lat: number, lon: number, height: number) => { @@ -307,18 +276,7 @@ export function useBabylonCameraSync({ // Look straight down with no rotation camera.rotation.set(Math.PI / 2, 0, 0) - - // Position fog dome at camera position - if (fogDome) { - fogDome.position.copyFrom(camera.position) - } - - // Hide cloud planes in top-down mode (they would obscure the view) - const cloudMeshes = getCloudMeshes() - for (const meshData of cloudMeshes) { - meshData.plane.isVisible = false - } - }, [cesiumViewer, camera, fogDome, getCloudMeshes]) + }, [cesiumViewer, camera]) // Sync Babylon camera for 3D view const syncCamera3D = useCallback((): boolean => { @@ -338,47 +296,8 @@ export function useBabylonCameraSync({ camera.rotation.set(syncData.rotation.rotationX, syncData.rotation.rotationY, syncData.rotation.rotationZ) camera.fov = syncData.fov - // Position fog dome at camera position - if (fogDome) { - fogDome.position.copyFrom(camera.position) - } - - // Cloud layer positioning: reposition only when camera moves significantly - // This prevents the "following clouds" visual artifact while ensuring continuous coverage - const cameraX = camera.position.x - const cameraZ = camera.position.z - const cloudCenter = cloudCenterRef.current - - // Calculate distance from camera to current cloud center - const dx = cameraX - cloudCenter.x - const dz = cameraZ - cloudCenter.z - const distanceFromCloudCenter = Math.sqrt(dx * dx + dz * dz) - - // Reposition clouds if camera has moved beyond threshold - const shouldRepositionClouds = distanceFromCloudCenter > CLOUD_REPOSITION_THRESHOLD - - // Reposition all cloud meshes when threshold exceeded - // View-dependent visibility is handled by useBabylonWeather - if (shouldRepositionClouds) { - const cloudMeshes = getCloudMeshes() - for (const meshData of cloudMeshes) { - // Reposition all cloud geometries (plane, dome, abovePlane) - meshData.plane.position.x = cameraX - meshData.plane.position.z = cameraZ - meshData.dome.position.x = cameraX - meshData.dome.position.z = cameraZ - meshData.abovePlane.position.x = cameraX - meshData.abovePlane.position.z = cameraZ - } - } - - // Update cloud center reference if we repositioned - if (shouldRepositionClouds) { - cloudCenterRef.current = { x: cameraX, z: cameraZ } - } - return true - }, [cesiumViewer, camera, fogDome, getCloudMeshes, getFixedToEnu]) + }, [cesiumViewer, camera, getFixedToEnu]) // Main sync camera function - dispatches to 2D or 3D based on view const syncCamera = useCallback(() => { diff --git a/src/renderer/hooks/useBabylonNightLighting.ts b/src/renderer/hooks/useBabylonNightLighting.ts deleted file mode 100644 index 00b2f67c..00000000 --- a/src/renderer/hooks/useBabylonNightLighting.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type * as BABYLON from '@babylonjs/core' -import { useEffect, useRef } from 'react' -import { - BABYLON_NIGHT_DIRECTIONAL_MULT, - BABYLON_NIGHT_HEMISPHERIC_MULT, - DIRECTIONAL_LIGHT_INTENSITY, - HEMISPHERIC_LIGHT_INTENSITY, - SUN_ELEVATION_DAY, - SUN_ELEVATION_NIGHT, -} from '@/constants' - -/** - * Smoothstep function for smooth interpolation - */ -function smoothstep(edge0: number, edge1: number, x: number): number { - const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0))) - return t * t * (3 - 2 * t) -} - -/** - * Linear interpolation between two values - */ -function lerp(a: number, b: number, t: number): number { - return a + (b - a) * t -} - -/** - * Calculate light intensity multiplier based on sun elevation - * - * @param sunElevation - Sun elevation angle in degrees - * @param nightMultiplier - Target multiplier at full night - * @returns Intensity multiplier (nightMultiplier to 1.0) - */ -function calculateLightMultiplier(sunElevation: number, nightMultiplier: number): number { - // Day: full intensity - if (sunElevation >= SUN_ELEVATION_DAY) { - return 1.0 - } - - // Night: minimum intensity - if (sunElevation <= SUN_ELEVATION_NIGHT) { - return nightMultiplier - } - - // Twilight: smooth transition - const t = smoothstep(SUN_ELEVATION_NIGHT, SUN_ELEVATION_DAY, sunElevation) - return lerp(nightMultiplier, 1.0, t) -} - -export interface BabylonNightLightingSettings { - /** Whether night lighting adjustment is enabled */ - enabled: boolean -} - -/** - * Adjusts Babylon.js scene lighting based on sun elevation for night-time effects - * - * ## How It Works - * This hook finds the hemispheric and directional lights in the Babylon.js scene - * and adjusts their intensities based on the sun's elevation angle. This ensures - * that weather effects (fog, clouds) and any 3D overlays are appropriately dimmed - * at night. - * - * ## Light Adjustments - * - **Hemispheric light**: Reduced to 30% at night (ambient lighting) - * - **Directional light**: Reduced to 15% at night (sun-like lighting) - * - * ## Important Note - * This does NOT affect the GUI layer (aircraft labels) since they use - * emissive colors that are independent of scene lighting. - * - * @param scene - Babylon.js Scene instance - * @param sunElevation - Current sun elevation in degrees (from useSunElevation) - * @param settings - Night lighting configuration - * - * @example - * ```tsx - * const sunElevation = useSunElevation(cesiumViewer) - * useBabylonNightLighting(babylonScene, sunElevation, { enabled: true }) - * ``` - */ -export function useBabylonNightLighting( - scene: BABYLON.Scene | null, - sunElevation: number | null, - settings: BabylonNightLightingSettings, -): void { - const { enabled } = settings - - // Store current values for smooth interpolation - const currentHemisphericIntensityRef = useRef(HEMISPHERIC_LIGHT_INTENSITY) - const currentDirectionalIntensityRef = useRef(DIRECTIONAL_LIGHT_INTENSITY) - - useEffect(() => { - if (!scene || scene.isDisposed) return - - // Find lights by name - const hemisphericLight = scene.getLightByName('light') as BABYLON.HemisphericLight | null - const directionalLight = scene.getLightByName('dirLight') as BABYLON.DirectionalLight | null - - if (!enabled || sunElevation === null) { - // Reset to default intensities when disabled - if (hemisphericLight) { - hemisphericLight.intensity = HEMISPHERIC_LIGHT_INTENSITY - } - if (directionalLight) { - directionalLight.intensity = DIRECTIONAL_LIGHT_INTENSITY - } - currentHemisphericIntensityRef.current = HEMISPHERIC_LIGHT_INTENSITY - currentDirectionalIntensityRef.current = DIRECTIONAL_LIGHT_INTENSITY - return - } - - // Calculate target intensities based on sun elevation - const hemisphericMultiplier = calculateLightMultiplier(sunElevation, BABYLON_NIGHT_HEMISPHERIC_MULT) - const directionalMultiplier = calculateLightMultiplier(sunElevation, BABYLON_NIGHT_DIRECTIONAL_MULT) - - const targetHemispheric = HEMISPHERIC_LIGHT_INTENSITY * hemisphericMultiplier - const targetDirectional = DIRECTIONAL_LIGHT_INTENSITY * directionalMultiplier - - // Apply intensities directly (smoothstep in calculateLightMultiplier provides smooth transitions) - if (hemisphericLight) { - hemisphericLight.intensity = targetHemispheric - } - if (directionalLight) { - directionalLight.intensity = targetDirectional - } - - currentHemisphericIntensityRef.current = targetHemispheric - currentDirectionalIntensityRef.current = targetDirectional - }, [scene, sunElevation, enabled]) - - // Cleanup: reset intensities when unmounting - useEffect(() => { - return () => { - if (scene && !scene.isDisposed) { - const hemisphericLight = scene.getLightByName('light') as BABYLON.HemisphericLight | null - const directionalLight = scene.getLightByName('dirLight') as BABYLON.DirectionalLight | null - - if (hemisphericLight) { - hemisphericLight.intensity = HEMISPHERIC_LIGHT_INTENSITY - } - if (directionalLight) { - directionalLight.intensity = DIRECTIONAL_LIGHT_INTENSITY - } - } - } - }, [scene]) -} diff --git a/src/renderer/hooks/useBabylonOverlay.ts b/src/renderer/hooks/useBabylonOverlay.ts index c63cfe69..777e89d5 100644 --- a/src/renderer/hooks/useBabylonOverlay.ts +++ b/src/renderer/hooks/useBabylonOverlay.ts @@ -1,20 +1,20 @@ import { useCallback } from 'react' -import type { BabylonOverlayOptions } from '@/types' +import type { BabylonOverlayOptions, WeatherVisibilityParams } from '@/types' +import { isVisibleThroughWeather } from '@/utils/weatherVisibility' import { useSettingsStore } from '../stores/settingsStore' import { useViewportStore } from '../stores/viewportStore' +import { useWeatherStore } from '../stores/weatherStore' import { useBabylonCameraSync } from './useBabylonCameraSync' import { useBabylonLabels } from './useBabylonLabels' import { useBabylonPrecipitation } from './useBabylonPrecipitation' import { useBabylonRootNode } from './useBabylonRootNode' import { useBabylonScene } from './useBabylonScene' -import { useBabylonTowerModel } from './useBabylonTowerModel' -import { useBabylonWeather } from './useBabylonWeather' /** * Orchestrator hook for Babylon.js overlay rendering synchronized with Cesium. * * ## Responsibilities - * - Initialize and coordinate all Babylon.js subsystems (scene, weather, labels, transforms) + * - Initialize and coordinate all Babylon.js subsystems (scene, precipitation, labels, transforms) * - Provide unified API for CesiumViewer to manage the overlay * - Delegate to specialized hooks for specific functionality * - Maintain backward-compatible interface for existing code @@ -24,7 +24,7 @@ import { useBabylonWeather } from './useBabylonWeather' * This hook is a **thin orchestrator** that composes specialized hooks: * * 1. **useBabylonScene**: Engine, scene, camera, GUI initialization - * 2. **useBabylonWeather**: Fog dome and cloud layers from METAR data + * 2. **useBabylonPrecipitation**: Rain, snow and lightning from METAR data * 3. **useBabylonLabels**: Aircraft datablock labels and leader lines * 4. **useBabylonRootNode**: ENU coordinate system and transformation matrices * 5. **useBabylonCameraSync**: Camera synchronization with Cesium @@ -35,8 +35,8 @@ import { useBabylonWeather } from './useBabylonWeather' * ## Dependencies * - Requires: Initialized Cesium.Viewer (from useCesiumViewer) * - Requires: HTMLCanvasElement for Babylon rendering (overlaid on Cesium) - * - Reads: weatherStore (via useBabylonWeather for METAR data) - * - Reads: settingsStore (via useBabylonWeather for weather toggles) + * - Reads: weatherStore (METAR visibility and cloud layers for label weather culling) + * - Reads: settingsStore (weather toggles for label weather culling, datablock font size) * * ## Call Order * This hook must be initialized AFTER Cesium viewer is ready: @@ -53,7 +53,7 @@ import { useBabylonWeather } from './useBabylonWeather' * * ### Initialization (happens in specialized hooks) * 1. **Scene**: Create Babylon engine with transparent clear color, MSAA 4x - * 2. **Weather**: Create fog dome and cloud layer meshes + * 2. **Precipitation**: Create rain, snow and lightning effects * 3. **Labels**: Initialize GUI texture for 2D overlays * 4. **Root Node**: Setup ENU coordinate system at tower location * 5. **Camera Sync**: Prepare camera transform calculations @@ -63,7 +63,7 @@ import { useBabylonWeather } from './useBabylonWeather' * 2. Update aircraft labels via `updateAircraftLabel()` + `updateLeaderLine()` * 3. Call `render()` to render the Babylon scene * - * Weather effects (fog dome, cloud layers) update automatically via React effects + * Precipitation effects (rain, snow, lightning) update automatically via React effects * when METAR data or settings change. * * ## Coordinate System @@ -105,7 +105,7 @@ import { useBabylonWeather } from './useBabylonWeather' * * All specialized hooks handle their own resource disposal: * - **useBabylonScene**: Disposes engine, scene, camera, GUI on unmount - * - **useBabylonWeather**: Disposes fog dome and cloud meshes + * - **useBabylonPrecipitation**: Disposes particle systems and their textures * - **useBabylonLabels**: Disposes labels and leader lines * - **useBabylonRootNode**: Disposes root node * - **useBabylonCameraSync**: No resources (stateless calculations) @@ -113,7 +113,6 @@ import { useBabylonWeather } from './useBabylonWeather' * Memory counters available via: * - `getMemoryCounters()` from useBabylonOverlay (total) * - `getLabelMemoryCounters()` from useBabylonLabels - * - `getWeatherMemoryCounters()` from useBabylonWeather * * @param options - Configuration options * @param options.cesiumViewer - Initialized Cesium.Viewer instance @@ -169,7 +168,8 @@ import { useBabylonWeather } from './useBabylonWeather' * } * * @see useBabylonScene - Scene initialization hook - * @see useBabylonWeather - Weather effects hook + * @see useBabylonPrecipitation - Rain, snow and lightning hook + * @see utils/weatherVisibility.ts - Label weather culling rules * @see useBabylonLabels - Label management hook * @see useBabylonRootNode - ENU coordinate system hook * @see useBabylonCameraSync - Camera synchronization hook @@ -184,18 +184,20 @@ export function useBabylonOverlay({ cesiumViewer, canvas }: BabylonOverlayOption // Get datablock font size setting const datablockFontSize = useSettingsStore((state) => state.aircraft.datablockFontSize) - // 1. Initialize scene (engine, scene, camera, GUI, lighting, shadow generator) - const { engine, scene, camera, guiTexture, shadowGenerator, sceneReady } = useBabylonScene({ + // 1. Initialize scene (engine, scene, camera, GUI, lighting) + const { engine, scene, camera, guiTexture, sceneReady } = useBabylonScene({ canvas: canvas!, antialias: true, transparent: true, }) - // 2. Initialize weather effects (fog dome, cloud layers) - const { fogDome, getCloudMeshes, isVisibleByWeather } = useBabylonWeather({ - scene, - isTopDownView, - }) + // 2. Weather state for label culling (clouds and fog themselves are drawn by the Cesium weather stage) + const cloudLayers = useWeatherStore((state) => state.cloudLayers) + const currentMetar = useWeatherStore((state) => state.currentMetar) + const showWeatherEffects = useSettingsStore((state) => state.weather.showWeatherEffects) + const showCesiumFog = useSettingsStore((state) => state.weather.showCesiumFog) + const showClouds = useSettingsStore((state) => state.weather.showClouds) + const visibilityScale = useSettingsStore((state) => state.weather.visibilityScale) // 3. Initialize precipitation effects (rain, snow, lightning) useBabylonPrecipitation({ @@ -216,21 +218,10 @@ export function useBabylonOverlay({ cesiumViewer, canvas }: BabylonOverlayOption scene, }) - // 6. Tower cab model (loads modded glTF into Babylon scene so weather meshes - // depth-test against cab geometry — replaces the previous Cesium-side cab). - useBabylonTowerModel({ - scene, - cesiumViewer, - getFixedToEnu, - shadowGenerator, - }) - - // 7. Initialize camera synchronization + // 6. Initialize camera synchronization (the tower cab is a Cesium model, see useTowerModel) const { syncCamera: syncCameraInternal } = useBabylonCameraSync({ cesiumViewer, camera, - fogDome, - getCloudMeshes, getFixedToEnu, }) @@ -263,6 +254,24 @@ export function useBabylonOverlay({ cesiumViewer, canvas }: BabylonOverlayOption [removeLabel], ) + // Weather-based visibility culling: beyond METAR visibility or behind a BKN/OVC ceiling + const isVisibleByWeather = useCallback( + (params: WeatherVisibilityParams): boolean => + isVisibleThroughWeather( + { + showWeatherEffects, + cullBeyondVisibility: showCesiumFog, + showClouds, + isTopDownView, + visibilityStatuteMiles: currentMetar ? currentMetar.visib : null, + visibilityScale, + cloudLayers, + }, + params, + ), + [showWeatherEffects, showCesiumFog, showClouds, currentMetar, cloudLayers, visibilityScale, isTopDownView], + ) + const isDatablockVisibleByWeatherAdapter = useCallback( (cameraAltitudeAGL: number, aircraftAltitudeAGL: number, distanceMeters: number) => { return isVisibleByWeather({ @@ -305,7 +314,6 @@ export function useBabylonOverlay({ cesiumViewer, canvas }: BabylonOverlayOption export function getMemoryCounters() { // This is now managed by individual hooks: // - getLabelMemoryCounters() from useBabylonLabels - // - getWeatherMemoryCounters() from useBabylonWeather // Return empty for backward compatibility return { materialsCreated: 0, diff --git a/src/renderer/hooks/useBabylonRootNode.ts b/src/renderer/hooks/useBabylonRootNode.ts index 8060cab3..b1f7d4c8 100644 --- a/src/renderer/hooks/useBabylonRootNode.ts +++ b/src/renderer/hooks/useBabylonRootNode.ts @@ -35,8 +35,8 @@ interface UseBabylonRootNodeOptions { * // 2. Setup root node (needs scene) * const rootNode = useBabylonRootNode({ scene }) * - * // 3. Create weather effects, labels, etc. (can use rootNode transforms) - * const weather = useBabylonWeather({ scene }) + * // 3. Create precipitation, labels, etc. (can use rootNode transforms) + * const precipitation = useBabylonPrecipitation({ scene, camera, isTopDownView }) * const labels = useBabylonLabels({ scene, guiTexture }) * } * ``` diff --git a/src/renderer/hooks/useBabylonScene.ts b/src/renderer/hooks/useBabylonScene.ts index 3d9792b4..6cf55672 100644 --- a/src/renderer/hooks/useBabylonScene.ts +++ b/src/renderer/hooks/useBabylonScene.ts @@ -1,7 +1,4 @@ import * as BABYLON from '@babylonjs/core' -// Side-effect import: registers the glTF SceneLoader plugin so LoadAssetContainerAsync -// can load .glb / .gltf models for tower cabs and any future Babylon-side mod content. -import '@babylonjs/loaders/glTF' import * as GUI from '@babylonjs/gui' import { useEffect, useRef, useState } from 'react' import { @@ -13,19 +10,6 @@ import { } from '@/constants' import type { BabylonSceneOptions, UseBabylonSceneResult } from '@/types' -// Static path served from src/renderer/public/. If the file is absent the texture -// load fails silently and the scene falls back to hemispheric+directional lighting only. -const IBL_ENV_URL = '/cab-environment.env' - -// Shadow map size in texels. 2048 gives crisp self-shadows on cab interior fixtures -// without measurable frame-time impact at airport scale. -const SHADOW_MAP_SIZE = 2048 - -// Tight near/far range for the directional light's shadow camera. Cab interiors are -// a few tens of meters across; the shadow camera doesn't need globe-scale distances. -const SHADOW_LIGHT_MIN_Z = 1 -const SHADOW_LIGHT_MAX_Z = 5000 - /** * Initializes Babylon.js engine, scene, camera, and lighting for transparent overlay rendering. * @@ -51,10 +35,10 @@ const SHADOW_LIGHT_MAX_Z = 5000 * const { engine, scene, camera, guiTexture, sceneReady } = useBabylonScene({ canvas }) * * // 2. Setup camera synchronization (needs camera from scene) - * const cameraSync = useBabylonCameraSync({ cesiumViewer, camera }) + * const cameraSync = useBabylonCameraSync({ cesiumViewer, camera, getFixedToEnu }) * - * // 3. Create weather effects (needs scene for meshes) - * const weather = useBabylonWeather({ scene }) + * // 3. Create precipitation effects (needs scene and camera) + * const precipitation = useBabylonPrecipitation({ scene, camera, isTopDownView }) * * // 4. Setup labels (needs guiTexture and scene) * const labels = useBabylonLabels({ guiTexture, scene }) @@ -103,7 +87,9 @@ const SHADOW_LIGHT_MAX_Z = 5000 * ### Directional Light * - **Direction**: (-1, -2, -1) pointing down and southwest * - **Intensity**: 0.6 (configurable via DIRECTIONAL_LIGHT_INTENSITY) - * - Simulates sunlight for shadows and highlights + * + * Both lights keep a constant intensity day and night; nothing the overlay draws + * (labels, rain, snow, lightning) is lit by them. * * ## GUI Texture * @@ -133,7 +119,7 @@ const SHADOW_LIGHT_MAX_Z = 5000 * 5. Clear all refs * * **Note:** Scene disposal automatically handles child resources (lights, cameras), - * but external hooks (useBabylonWeather, useBabylonLabels) are responsible for + * but external hooks (useBabylonPrecipitation, useBabylonLabels) are responsible for * disposing their own resources before scene disposal. * * ## Scene Ready State @@ -200,14 +186,14 @@ const SHADOW_LIGHT_MAX_Z = 5000 * camera: camera * }) * - * // Weather effects (requires scene) - * const weather = useBabylonWeather({ scene }) + * // Precipitation effects (requires scene and camera) + * const precipitation = useBabylonPrecipitation({ scene, camera, isTopDownView }) * * // Labels (requires guiTexture and scene) * const labels = useBabylonLabels({ guiTexture, scene }) * * @see useBabylonCameraSync - For camera synchronization with Cesium - * @see useBabylonWeather - For fog and cloud effects + * @see useBabylonPrecipitation - For rain, snow and lightning effects * @see useBabylonLabels - For aircraft datablock labels */ export function useBabylonScene(options: BabylonSceneOptions): UseBabylonSceneResult { @@ -217,8 +203,6 @@ export function useBabylonScene(options: BabylonSceneOptions): UseBabylonSceneRe const sceneRef = useRef(null) const cameraRef = useRef(null) const guiTextureRef = useRef(null) - const shadowGeneratorRef = useRef(null) - const environmentTextureRef = useRef(null) const [sceneReady, setSceneReady] = useState(false) // Track when canvas has valid dimensions (needed for freshly created canvases) const [canvasReady, setCanvasReady] = useState(false) @@ -311,36 +295,6 @@ export function useBabylonScene(options: BabylonSceneOptions): UseBabylonSceneRe // Add directional light const dirLight = new BABYLON.DirectionalLight('dirLight', new BABYLON.Vector3(-1, -2, -1), scene) dirLight.intensity = DIRECTIONAL_LIGHT_INTENSITY - dirLight.shadowMinZ = SHADOW_LIGHT_MIN_Z - dirLight.shadowMaxZ = SHADOW_LIGHT_MAX_Z - - // Shadow generator on the directional light. Tower cab meshes (added by useBabylonTowerModel) - // register themselves as casters and set receiveShadows = true so cab geometry self-shadows - // (e.g. desks shadow the floor). useExponentialShadowMap is cheap and produces soft edges - // without acne, which matters since the cab is the only Babylon-side opaque geometry today. - const shadowGenerator = new BABYLON.ShadowGenerator(SHADOW_MAP_SIZE, dirLight) - shadowGenerator.useExponentialShadowMap = true - shadowGeneratorRef.current = shadowGenerator - - // Best-effort IBL load. If the .env file isn't bundled (e.g. dev workspace without the asset), - // CubeTexture's onError fires and we leave environmentTexture unset — PBR materials in the cab - // will look flatter but everything still renders. StandardMaterial (fog/clouds) ignores - // environmentTexture so weather effects are unaffected either way. - const envTexture = new BABYLON.CubeTexture( - IBL_ENV_URL, - scene, - undefined, - undefined, - undefined, - () => { - scene.environmentTexture = envTexture - }, - () => { - envTexture.dispose() - environmentTextureRef.current = null - }, - ) - environmentTextureRef.current = envTexture // Handle resize - engine.resize() re-reads the canvas's client size and the device // ratio, then re-sizes the backing store and the viewport accordingly. @@ -365,10 +319,6 @@ export function useBabylonScene(options: BabylonSceneOptions): UseBabylonSceneRe resizeObserver.disconnect() window.removeEventListener('resize', handleResize) - shadowGeneratorRef.current?.dispose() - shadowGeneratorRef.current = null - environmentTextureRef.current?.dispose() - environmentTextureRef.current = null guiTexture.dispose() scene.dispose() engine.dispose() @@ -385,7 +335,6 @@ export function useBabylonScene(options: BabylonSceneOptions): UseBabylonSceneRe scene: sceneRef.current, camera: cameraRef.current, guiTexture: guiTextureRef.current, - shadowGenerator: shadowGeneratorRef.current, sceneReady, } } diff --git a/src/renderer/hooks/useBabylonSunDirection.ts b/src/renderer/hooks/useBabylonSunDirection.ts deleted file mode 100644 index 92260fa0..00000000 --- a/src/renderer/hooks/useBabylonSunDirection.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * useBabylonSunDirection Hook - * - * Keeps the Babylon directional light (named `dirLight`) pointing in the same - * direction as the real sun, so that opaque Babylon-side geometry (the modded - * tower cab) is lit and self-shadows realistically as the day progresses. - * - * The sun position is computed in ECEF using Cesium's planetary-position model, - * transformed into the current ENU root frame, swapped into Babylon's Y-up - * coordinate system, and negated to give the light's "from sun toward ground" - * direction vector. - * - * Throttled to ~1 Hz (sun moves <0.005°/sec; per-frame is wasted work) but with - * an immediate re-run when the ENU root origin changes (airport switch). - * - * Note: weather meshes (fog dome, cloud planes) use StandardMaterial without - * directional lighting — only the cab cares about dirLight direction today. - */ - -import type * as BABYLON from '@babylonjs/core' -import * as Cesium from 'cesium' -import { useEffect, useRef } from 'react' -import type { TimeMode } from '@/types/settings' -import { ecefToEnuDirection } from '../utils/enuTransforms' - -interface UseBabylonSunDirectionOptions { - scene: BABYLON.Scene | null - cesiumViewer: Cesium.Viewer | null - getFixedToEnu: () => Cesium.Matrix4 | null - enabled?: boolean - timeMode?: TimeMode - fixedTimeHour?: number -} - -const SUN_DIRECTION_UPDATE_INTERVAL_MS = 1000 - -/** - * Compute the unit vector pointing from Earth's center toward the sun, in ECEF. - * Uses the same Simon-1994 model as useSunElevation but returns a direction - * (not a scalar elevation). - */ -function computeSunDirectionEcef(time: Cesium.JulianDate): Cesium.Cartesian3 | null { - const sunEci = Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame(time) - - const icrfToFixed = Cesium.Transforms.computeIcrfToFixedMatrix(time) - if (icrfToFixed) { - Cesium.Matrix3.multiplyByVector(icrfToFixed, sunEci, sunEci) - } else { - const temeToFixed = Cesium.Transforms.computeTemeToPseudoFixedMatrix(time) - if (!temeToFixed) return null - Cesium.Matrix3.multiplyByVector(temeToFixed, sunEci, sunEci) - } - - return Cesium.Cartesian3.normalize(sunEci, new Cesium.Cartesian3()) -} - -/** - * Compute the JulianDate to evaluate the sun position at. Mirrors useSunElevation's - * fixed-time-mode logic so day/night appearance stays consistent across hooks. - */ -function resolveTime( - cesiumViewer: Cesium.Viewer, - timeMode: TimeMode | undefined, - fixedTimeHour: number | undefined, -): Cesium.JulianDate { - if (timeMode === 'fixed' && fixedTimeHour !== undefined) { - const cartographic = Cesium.Cartographic.fromCartesian(cesiumViewer.camera.positionWC) - const longitudeDegrees = Cesium.Math.toDegrees(cartographic.longitude) - const longitudeOffsetHours = longitudeDegrees / 15 - - const now = new Date() - const targetTime = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0)) - const utcHour = fixedTimeHour - longitudeOffsetHours - targetTime.setTime(targetTime.getTime() + utcHour * 60 * 60 * 1000) - return Cesium.JulianDate.fromDate(targetTime) - } - return Cesium.JulianDate.now() -} - -export function useBabylonSunDirection(options: UseBabylonSunDirectionOptions): void { - const { scene, cesiumViewer, getFixedToEnu, enabled = true, timeMode, fixedTimeHour } = options - - const lastUpdateMsRef = useRef(0) - const lastFixedToEnuRef = useRef(null) - - useEffect(() => { - if (!enabled || !scene || scene.isDisposed) return - if (!cesiumViewer || cesiumViewer.isDestroyed()) return - - const dirLight = scene.getLightByName('dirLight') as BABYLON.DirectionalLight | null - if (!dirLight) return - - const updateDirection = (force: boolean): void => { - const fixedToEnu = getFixedToEnu() - if (!fixedToEnu) return - - const now = Date.now() - const enuChanged = fixedToEnu !== lastFixedToEnuRef.current - if (!force && !enuChanged && now - lastUpdateMsRef.current < SUN_DIRECTION_UPDATE_INTERVAL_MS) { - return - } - - const sunEcef = computeSunDirectionEcef(resolveTime(cesiumViewer, timeMode, fixedTimeHour)) - if (!sunEcef) return - - const sunEnu = ecefToEnuDirection(sunEcef, fixedToEnu) - // ENU x=East, y=North, z=Up; Babylon x=East, y=Up, z=North. - // Light direction points FROM sun TO ground, so negate the toward-sun vector. - dirLight.direction.set(-sunEnu.x, -sunEnu.z, -sunEnu.y) - - lastUpdateMsRef.current = now - lastFixedToEnuRef.current = fixedToEnu - } - - // Initial compute. - updateDirection(true) - - // Throttled re-compute on every Cesium post-render. - const removeListener = cesiumViewer.scene.postRender.addEventListener(() => { - updateDirection(false) - }) - - return () => { - removeListener() - } - }, [scene, cesiumViewer, getFixedToEnu, enabled, timeMode, fixedTimeHour]) -} - -export default useBabylonSunDirection diff --git a/src/renderer/hooks/useBabylonTowerModel.ts b/src/renderer/hooks/useBabylonTowerModel.ts deleted file mode 100644 index 269f712b..00000000 --- a/src/renderer/hooks/useBabylonTowerModel.ts +++ /dev/null @@ -1,351 +0,0 @@ -/** - * useBabylonTowerModel Hook - * - * Loads modded tower cab glTF models into the Babylon.js overlay scene so that - * weather meshes (fog dome, cloud planes/domes) — which already live in Babylon — - * depth-test correctly against the cab geometry. Replaces the previous Cesium-side - * `useTowerModel` hook, which couldn't share a depth buffer with Babylon and let - * weather paint over the cab interior. - * - * Lifecycle, position math, and wizard live-edit contract mirror the previous - * Cesium implementation exactly (`useTowerModel.ts:1-310` in git history). Behaviors - * preserved: - * - One cab per airport, swapped on airport change - * - Async terrain sampling with fallback to placement-only height on failure - * - `loadingIcaoRef` race guard against airport changes mid-load - * - Manifest fallback chain (modelPosition → tower-positions → skip) - * - Wizard Step 1 live edit via `towerPositioningStore` subscription - * - * Known limitations carried over from the migration: - * - Aircraft 3D models still render in Cesium; an aircraft passing in front of a - * cab pillar from camera POV would still draw over the pillar. Practical impact - * is low (cab pillars rarely intersect aircraft positions). - * - Aircraft GUI labels are screen-space and don't depth-test against cab geometry. - * - Cesium terrain can't receive shadows from the Babylon directional light, so the - * cab no longer casts a shadow on the tarmac (it self-shadows internally though). - */ - -import * as BABYLON from '@babylonjs/core' -import * as Cesium from 'cesium' -import { useEffect, useRef } from 'react' -import { modService } from '../services/ModService' -import { useAirportStore } from '../stores/airportStore' -import { useTowerPositioningStore } from '../stores/towerPositioningStore' -import { ecefToEnu, enuToBabylonPosition } from '../utils/enuTransforms' -import { convertToAssetUrlSync } from '../utils/tauriApi' - -interface UseBabylonTowerModelOptions { - /** Babylon scene to load the cab into */ - scene: BABYLON.Scene | null - /** Cesium viewer used for terrain height sampling (terrain provider lives in Cesium) */ - cesiumViewer: Cesium.Viewer | null - /** Getter for the ECEF→ENU transform anchored at the current root-node origin */ - getFixedToEnu: () => Cesium.Matrix4 | null - /** Shadow generator on the Babylon directional light (optional but recommended) */ - shadowGenerator: BABYLON.ShadowGenerator | null - /** Whether the hook is enabled */ - enabled?: boolean -} - -const toRadians = (degrees: number): number => (degrees * Math.PI) / 180 - -/** - * Apply position/rotation/scale to the glTF __root__ mesh, composing our heading - * rotation and uniform scale on top of the handedness conversion the glTF loader - * baked in. Centralizes the math so initial-load and wizard-live-edit paths stay in sync. - * - * The glTF loader produces __root__ with rotationQuaternion=(0,1,0,0) and scaling=(1,1,-1) - * to convert from glTF's RH coords to Babylon's LH coords. We must preserve these baseline - * transforms or the cab geometry mirrors / offsets incorrectly. - */ -function applyRootTransform( - root: BABYLON.AbstractMesh, - baseRotationQuat: BABYLON.Quaternion, - baseScaling: BABYLON.Vector3, - ecefPos: Cesium.Cartesian3, - fixedToEnu: Cesium.Matrix4, - rotationDegrees: number, - scale: number, -): void { - const enuPos = ecefToEnu(ecefPos, fixedToEnu) - const babPos = enuToBabylonPosition(enuPos) - root.position.copyFrom(babPos) - - // Compose: handedness flip first, then heading rotation in Babylon space. - // Cesium HPR heading is clockwise from north around Up; Babylon Y-axis rotation is - // counter-clockwise viewed from above, so we negate to match real-world heading. - const headingQuat = BABYLON.Quaternion.RotationAxis(BABYLON.Vector3.Up(), -toRadians(rotationDegrees)) - root.rotationQuaternion = headingQuat.multiply(baseRotationQuat) - // Clear euler rotation so it doesn't fight the quaternion path. - root.rotation.set(0, 0, 0) - - // Multiply uniform scale into the handedness scaling vector to preserve the Z-flip. - root.scaling.set(baseScaling.x * scale, baseScaling.y * scale, baseScaling.z * scale) -} - -/** - * Hook that loads and manages tower cab models inside the Babylon overlay scene. - * - * @param options.scene - Babylon scene - * @param options.cesiumViewer - Cesium viewer (for terrain sampling only) - * @param options.getFixedToEnu - Provides the ECEF→ENU matrix from useBabylonRootNode - * @param options.shadowGenerator - Directional-light shadow generator - * @param options.enabled - Toggle the hook on/off (default true) - */ -export function useBabylonTowerModel(options: UseBabylonTowerModelOptions): void { - const { scene, cesiumViewer, getFixedToEnu, shadowGenerator, enabled = true } = options - - const currentAirport = useAirportStore((state) => state.currentAirport) - - // The asset container owns meshes, materials, textures, and animations from the glTF. - // Disposing it cleans up all child resources atomically. - const assetContainerRef = useRef(null) - // The glTF loader's __root__ node, which we transform directly. We don't introduce - // a wrapper because the loader sets rotationQuaternion=(0,1,0,0) and scaling=(1,1,-1) - // on __root__ to convert glTF's right-handed coords to Babylon's left-handed; wrapping - // it shifts cab geometry that isn't perfectly origin-centered. - const rootMeshRef = useRef(null) - // The handedness quat the glTF loader baked into __root__; we compose our heading on top. - const baseRotationQuatRef = useRef(null) - // The handedness scaling vector (typically (1,1,-1)); we multiply our uniform scale into it. - const baseScalingRef = useRef(null) - const currentIcaoRef = useRef(null) - const loadingIcaoRef = useRef(null) - - // Cached terrain height under the cab (used by wizard live-edit to recompute height - // without re-sampling terrain on every keypress). - const terrainHeightRef = useRef(0) - const scaleRef = useRef(1.0) - - useEffect(() => { - if (!enabled || !scene || scene.isDisposed) { - return - } - if (!cesiumViewer || cesiumViewer.isDestroyed()) { - return - } - - const icao = currentAirport?.icao?.toUpperCase() ?? null - - if (!icao || icao === currentIcaoRef.current) { - return - } - - loadingIcaoRef.current = icao - // Tear down the previous airport's cab before swapping. AssetContainer.dispose() - // releases meshes, materials, and textures atomically. - assetContainerRef.current?.dispose() - assetContainerRef.current = null - rootMeshRef.current = null - baseRotationQuatRef.current = null - baseScalingRef.current = null - currentIcaoRef.current = null - - const towerMod = modService.getTowerModel(icao) - if (!towerMod) { - return - } - - const placement = modService.getTowerPlacement(icao) - if (!placement) { - console.warn(`[useBabylonTowerModel] No position available for tower mod at ${icao}`) - return - } - - const modelUrl = convertToAssetUrlSync(towerMod.modelUrl) - const scale = towerMod.manifest.scale ?? 1.0 - scaleRef.current = scale - - console.log(`[useBabylonTowerModel] Loading tower model for ${icao}:`, { - modelUrl, - position: { lat: placement.lat, lon: placement.lon }, - height: placement.height, - rotation: placement.rotation, - source: placement.source, - scale, - }) - - const loadAtHeight = (finalHeight: number): void => { - // Capture references at call time. Anything in this closure that reads `scene`, - // `getFixedToEnu`, `shadowGenerator`, `icao` is stable for the duration of one - // load attempt; staleness checks below guard against airport-change races. - void BABYLON.LoadAssetContainerAsync(modelUrl, scene) - .then((container) => { - if (loadingIcaoRef.current !== icao || scene.isDisposed) { - container.dispose() - return - } - - const fixedToEnu = getFixedToEnu() - if (!fixedToEnu) { - console.warn( - `[useBabylonTowerModel] ENU transform not ready for ${icao}; root node setup may have raced. Disposing model.`, - ) - container.dispose() - return - } - - container.addAllToScene() - - // Find the glTF loader's __root__ — top-level mesh with no parent. The loader - // names it "__root__" but we look up by parent instead to be robust to renames. - const root = container.meshes.find((m) => !m.parent) - if (!root) { - console.warn(`[useBabylonTowerModel] No root mesh found in container for ${icao}`) - container.dispose() - return - } - - // Capture the loader's handedness-conversion transform, then compose an extra - // +90° rotation around Y on top to match Cesium's glTF axis interpretation. - // - // Cesium's `Cesium.Model.fromGltfAsync` defaults `gltfForwardAxis = X` (model's - // +X is "forward" = North under heading=0), overriding the glTF spec's +Z-forward. - // Babylon's loader respects the spec and orients +Z forward. Mod authors who - // tuned against Cesium therefore author with glTF +X = North; loading the same - // glTF in Babylon places the cab rotated 90° relative to where Cesium had it, - // which — combined with off-center authored geometry — produces a visible position - // offset (empirically: ~6.7m east + ~2m north for OAK with AABB center at - // glTF (4.32, 15.64, 2.37)). - // - // Composing rotY(+90°) on top of the loader's rotY(π) × scaleZ(-1) maps a glTF - // point (a, b, c) to Babylon (c, b, a) — i.e. glTF +X → +North, +Y → +Up, - // +Z → +East — matching Cesium's `gltfForwardAxis = X` convention. Determinant - // stays at -1, so the loader's sideOrientation = ClockWise winding compensation - // remains valid. - const loaderQuat = - root.rotationQuaternion?.clone() ?? - BABYLON.Quaternion.FromEulerAngles(root.rotation.x, root.rotation.y, root.rotation.z) - const cesiumForwardAxisRotation = BABYLON.Quaternion.RotationAxis(BABYLON.Vector3.Up(), Math.PI / 2) - const baseRotationQuat = cesiumForwardAxisRotation.multiply(loaderQuat) - const baseScaling = root.scaling.clone() - - const ecefPos = Cesium.Cartesian3.fromDegrees(placement.lon, placement.lat, finalHeight) - applyRootTransform(root, baseRotationQuat, baseScaling, ecefPos, fixedToEnu, placement.rotation, scale) - - if (shadowGenerator) { - for (const mesh of container.meshes) { - shadowGenerator.addShadowCaster(mesh, true) - mesh.receiveShadows = true - } - } - - assetContainerRef.current = container - rootMeshRef.current = root - baseRotationQuatRef.current = baseRotationQuat - baseScalingRef.current = baseScaling - currentIcaoRef.current = icao - - console.log(`[useBabylonTowerModel] Tower model loaded for ${icao}`) - }) - .catch((error) => { - console.error(`[useBabylonTowerModel] Failed to load tower model for ${icao}:`, error) - }) - } - - // Sample terrain, then load. Falls back to placement-only height on terrain-sample - // failure so the cab still renders (matches Cesium-side behaviour). - const terrainPosition = Cesium.Cartographic.fromDegrees(placement.lon, placement.lat) - Cesium.sampleTerrainMostDetailed(cesiumViewer.terrainProvider, [terrainPosition]) - .then((sampledPositions) => { - if (loadingIcaoRef.current !== icao || scene.isDisposed) { - return - } - const terrainHeight = sampledPositions[0].height ?? 0 - terrainHeightRef.current = terrainHeight - const finalHeight = terrainHeight + placement.height - console.log(`[useBabylonTowerModel] Terrain sampled for ${icao}:`, { - terrainHeight, - placementHeight: placement.height, - finalHeight, - }) - loadAtHeight(finalHeight) - }) - .catch((error) => { - console.warn(`[useBabylonTowerModel] Failed to sample terrain for ${icao}:`, error) - if (loadingIcaoRef.current !== icao || scene.isDisposed) { - return - } - terrainHeightRef.current = 0 - loadAtHeight(placement.height) - }) - - return () => { - assetContainerRef.current?.dispose() - assetContainerRef.current = null - rootMeshRef.current = null - baseRotationQuatRef.current = null - baseScalingRef.current = null - currentIcaoRef.current = null - } - }, [scene, cesiumViewer, currentAirport?.icao, enabled, getFixedToEnu, shadowGenerator]) - - // Reset refs on full unmount. - useEffect(() => { - return () => { - assetContainerRef.current = null - rootMeshRef.current = null - baseRotationQuatRef.current = null - baseScalingRef.current = null - currentIcaoRef.current = null - loadingIcaoRef.current = null - } - }, []) - - // Wizard live-edit subscription. Mirrors the Cesium hook's behaviour: - // when the user is in Step 1 ('model') of the positioning wizard for the current - // airport, recompute the wrapper transform on every offset/rotation change - // without reloading the glTF. - useEffect(() => { - if (!scene || scene.isDisposed) { - return - } - - let prevOffset = { north: 0, east: 0, up: 0 } - let prevRotation = 0 - - const unsubscribe = useTowerPositioningStore.subscribe((state) => { - const root = rootMeshRef.current - const baseRotationQuat = baseRotationQuatRef.current - const baseScaling = baseScalingRef.current - if (!root || !baseRotationQuat || !baseScaling) { - return - } - if (!state.isActive || state.step !== 'model') { - return - } - if (state.targetIcao?.toUpperCase() !== currentIcaoRef.current) { - return - } - if ( - state.modelOffset.north === prevOffset.north && - state.modelOffset.east === prevOffset.east && - state.modelOffset.up === prevOffset.up && - state.modelRotation === prevRotation - ) { - return - } - - prevOffset = { ...state.modelOffset } - prevRotation = state.modelRotation - - const newPos = state.getAbsoluteModelPosition() - if (!newPos) { - return - } - - const fixedToEnu = getFixedToEnu() - if (!fixedToEnu) { - return - } - - const finalHeight = terrainHeightRef.current + newPos.height - const ecefPos = Cesium.Cartesian3.fromDegrees(newPos.lon, newPos.lat, finalHeight) - applyRootTransform(root, baseRotationQuat, baseScaling, ecefPos, fixedToEnu, newPos.rotation, scaleRef.current) - }) - - return unsubscribe - }, [scene, getFixedToEnu]) -} - -export default useBabylonTowerModel diff --git a/src/renderer/hooks/useBabylonWeather.ts b/src/renderer/hooks/useBabylonWeather.ts deleted file mode 100644 index 109f4afd..00000000 --- a/src/renderer/hooks/useBabylonWeather.ts +++ /dev/null @@ -1,1401 +0,0 @@ -import * as BABYLON from '@babylonjs/core' -import { useCallback, useEffect, useRef } from 'react' -import { - CLOUD_ABOVE_BASE_ALPHA, - CLOUD_ABOVE_DIFFUSE_COLOR, - CLOUD_ABOVE_EMISSIVE_COLOR, - CLOUD_ABOVE_TRANSITION_BUFFER, - CLOUD_BASE_ALPHA, - CLOUD_DIFFUSE_COLOR, - CLOUD_DOME_COVERAGE_THRESHOLD, - CLOUD_DOME_FRESNEL_BIAS, - CLOUD_DOME_FRESNEL_BIAS_ABOVE, - CLOUD_DOME_FRESNEL_POWER, - CLOUD_EMISSIVE_COLOR, - CLOUD_LAYER_ALTITUDE_TRANSITION_SPEED, - CLOUD_LAYER_COVERAGE_REGEN_THRESHOLD, - CLOUD_LAYER_COVERAGE_TRANSITION_SPEED, - CLOUD_LAYER_FADE_SPEED, - CLOUD_LAYER_MATCH_ALTITUDE_THRESHOLD, - CLOUD_LAYER_MATCH_COVERAGE_THRESHOLD, - CLOUD_NOISE_TEXTURE_SIZE, - CLOUD_PLANE_DIAMETER, - CLOUD_PLANE_ROTATION_X, - CLOUD_POOL_SIZE, - CLOUD_ROTATION_CHANGE_INTERVAL, - CLOUD_ROTATION_SPEED, - CLOUD_ROTATION_SPEED_VARIANCE, - CLOUD_ROTATION_TRANSITION_TIME, - FOG_ALPHA_DECENT_MIN, - FOG_ALPHA_EXTREMELY_LOW, - FOG_ALPHA_GOOD, - FOG_ALPHA_LOW_MIN, - FOG_ALPHA_MODERATE_MIN, - FOG_BASE_ALPHA, - FOG_DIFFUSE_COLOR, - FOG_DOME_BASE_DIAMETER, - FOG_DOME_SEGMENTS, - FOG_EMISSIVE_COLOR, - FOG_FRESNEL_BIAS, - FOG_FRESNEL_BIAS_EXTREMELY_LOW, - FOG_FRESNEL_BIAS_LOW_MIN, - FOG_FRESNEL_BIAS_MODERATE, - FOG_FRESNEL_POWER, - FOG_SPECULAR_COLOR, - STATUTE_MILES_TO_METERS, - VISIBILITY_THRESHOLD_DECENT, - VISIBILITY_THRESHOLD_EXTREMELY_LOW, - VISIBILITY_THRESHOLD_LOW, - VISIBILITY_THRESHOLD_MODERATE, - VISIBILITY_TRANSITION_TIME, -} from '@/constants' -import type { CloudLayer, CloudMeshData, UseBabylonWeatherResult, WeatherVisibilityParams } from '@/types' -import { isVisibleThroughWeather } from '@/utils/weatherVisibility' -import { useAirportStore } from '../stores/airportStore' -import { useSettingsStore } from '../stores/settingsStore' -import { useWeatherStore } from '../stores/weatherStore' - -// Import extracted weather utilities -import { - createAboveCloudTexture, - createCloudDomeMesh, - createOvercastDomeTexture, - createPatchyCloudTexture, -} from './weather' - -interface UseBabylonWeatherOptions { - scene: BABYLON.Scene | null - isTopDownView?: boolean -} - -/** - * Persistent state for a cloud layer that enables smooth transitions. - * When METAR updates, layers are matched by altitude proximity and - * transitions are animated rather than instantly regenerated. - */ -interface CloudLayerState { - /** Noise seed for texture generation - persists when layer matches to keep pattern */ - noiseSeed: number - /** Current coverage being displayed (animates toward target) */ - currentCoverage: number - /** Target coverage from METAR data */ - targetCoverage: number - /** Current altitude in meters (animates toward target) */ - currentAltitude: number - /** Target altitude from METAR data */ - targetAltitude: number - /** Current alpha/opacity (for fade in/out transitions) */ - currentAlpha: number - /** Target alpha (1 = visible, 0 = fading out) */ - targetAlpha: number - /** Coverage value last used to generate texture (avoids unnecessary regeneration) */ - lastRenderedCoverage: number - /** Whether this layer is actively in use */ - active: boolean - /** Rotation state for this layer */ - rotation: { - currentSpeed: number - targetSpeed: number - } - /** Whether this layer should use dome geometry (for OVC coverage) */ - useDome: boolean - /** Whether dome texture has been generated for current state */ - domeTextureGenerated: boolean - /** Whether above-cloud texture has been generated for current state */ - aboveTextureGenerated: boolean -} - -/** - * Manages METAR-based weather effects in Babylon.js (fog dome and cloud layers). - * - * ## Responsibilities - * - Create fog dome mesh with fresnel material for visibility simulation - * - Create cloud layer mesh pool (up to 4 layers) from METAR data - * - Update fog dome scale and opacity based on visibility conditions - * - Update cloud layer positions and visibility based on METAR ceiling data - * - Provide weather-based visibility culling function for aircraft labels - * - Dispose all weather meshes and materials on unmount - * - * ## Dependencies - * - Requires: Initialized Babylon.js scene (from useBabylonScene) - * - Reads: weatherStore (METAR data, cloud layers, fog density) - * - Reads: settingsStore (weather effect toggles, intensities) - * - Writes: Creates and manages fog dome mesh and cloud layer meshes - * - * ## Call Order - * This hook should be called after useBabylonScene but before useBabylonLabels: - * ```typescript - * // 1. Initialize scene first - * const { scene, sceneReady } = useBabylonScene({ canvas }) - * - * // 2. Create weather effects (needs scene) - * const { fogDome, cloudLayers, isVisibleByWeather } = useBabylonWeather({ - * scene, - * isTopDownView: false - * }) - * - * // 3. Setup labels (needs visibility culling function) - * const labels = useBabylonLabels({ scene, isVisibleByWeather }) - * ``` - * - * ## Fog Dome Implementation - * - * The fog dome is a hemisphere mesh that surrounds the camera at the METAR - * visibility distance, creating a "fog wall" effect. - * - * ### Geometry - * - **Shape**: Sphere with BACKSIDE orientation (renders inside faces only) - * - **Base diameter**: 2 meters (scaled dynamically based on visibility) - * - **Segments**: 32 (smooth sphere, balanced polygon count) - * - * ### Material - * - **Diffuse color**: Light gray-blue [0.8, 0.8, 0.82] - * - **Emissive color**: Self-illuminated [0.6, 0.6, 0.65] - * - **Specular**: Black (no highlights) - * - **Alpha**: Base 0.3, adjusted based on visibility severity - * - * ### Fresnel Effect - * The fog uses opacity fresnel to create realistic fog appearance: - * - **Center**: Almost fully transparent (looking through fog) - * - **Edges**: More opaque (looking at fog wall) - * - **Bias**: Adjusted based on visibility (0.1-0.3) - * - **Power**: 3 (sharp edge falloff) - * - * This makes fog barely visible in good visibility (6+ SM) but prominent - * in low visibility (<1 SM). - * - * ### Dynamic Scaling - * The fog dome is scaled to match METAR visibility distance: - * ```typescript - * // METAR visibility: 1 SM = 1609.34 meters - * const domeScale = visibilityMeters * visibilityScale - * fogDome.scaling.setAll(domeScale) - * ``` - * - * The `visibilityScale` setting allows users to see farther (2.0 = double distance). - * - * ### Visibility-Based Opacity - * Fog opacity is adjusted based on METAR visibility thresholds: - * - * | Visibility | Alpha | Fresnel Bias | Effect | - * |------------|-------|--------------|--------| - * | ≤ 0.25 SM | 0.5 | 0.3 | Heavy fog (extremely low vis) | - * | 0.25-1 SM | 0.5→0.25 | 0.3→0.15 | Moderate to heavy fog (low vis) | - * | 1-3 SM | 0.25→0.1 | 0.15→0.1 | Light fog (moderate vis) | - * | 3-6 SM | 0.1→0.03 | 0.1 | Very light haze (decent vis) | - * | > 6 SM | 0.03 | 0.1 | Barely visible hint (good vis) | - * - * Interpolation is linear within each range for smooth transitions. - * - * ### Intensity Multiplier - * The `fogIntensity` setting multiplies the base alpha: - * ```typescript - * finalAlpha = Math.min(1.0, baseAlpha * fogIntensity) - * ``` - * - * This allows users to customize fog density (0.5 = half, 1.0 = default, 2.0 = double). - * - * ## Cloud Layer Implementation - * - * Cloud layers are represented as large horizontal plane meshes positioned at - * METAR-reported ceiling altitudes. - * - * ### Mesh Pool - * - **Pool size**: 4 layers (matches max METAR cloud layers) - * - **Diameter**: 50km (covers horizon from typical tower heights) - * - **Rotation**: 90° around X-axis (XY plane → XZ horizontal plane) - * - * ### Material - * - **Diffuse color**: Slightly blue-tinted white [0.95, 0.95, 0.98] - * - **Emissive color**: Subtle self-illumination [0.4, 0.4, 0.45] - * - **Base alpha**: 0.5 - * - **Back face culling**: false (visible from above and below) - * - **Lighting**: enabled for realistic shading - * - * ### Dynamic Opacity - * Cloud opacity is calculated from METAR coverage and user setting: - * ```typescript - * cloudAlpha = cloudLayer.coverage * cloudOpacity - * ``` - * - * Coverage values: - * - FEW: 0.125 (very transparent) - * - SCT: 0.375 (partially transparent) - * - BKN: 0.75 (mostly opaque) - * - OVC: 1.0 (fully opaque) - * - * ### Top-Down Mode Behavior - * Clouds are **hidden** in top-down view because looking straight down through - * clouds would obscure the entire map. This is controlled by the `isTopDownView` prop. - * - * ## Weather Visibility Culling - * - * The `isVisibleByWeather` function determines if aircraft labels should be - * visible based on METAR weather conditions. It delegates to - * `isVisibleThroughWeather` in `utils/weatherVisibility.ts`, which - * `useAircraftFiltering` shares. - * - * ### Surface Visibility Culling - * Aircraft beyond METAR visibility range are hidden: - * ```typescript - * visibilityMeters = metar.visibility * 1609.34 * visibilityScale - * if (horizontalDistance > visibilityMeters) return false - * ``` - * - * ### Cloud Ceiling Culling - * Aircraft obscured by BKN/OVC cloud layers are hidden: - * ```typescript - * if (layer.coverage >= 0.75 && - * layer.altitude between [cameraAlt, aircraftAlt]) { - * return false // Cloud blocks line-of-sight - * } - * ``` - * - * **Note:** Cloud culling is skipped in top-down view since clouds don't - * visually obscure in that mode. - * - * ### Culling Conditions - * The function returns `false` (hide aircraft) if: - * 1. Aircraft is beyond surface visibility range (fog) - * 2. A BKN/OVC cloud layer is between camera and aircraft - * - * Otherwise returns `true` (show aircraft). - * - * ## Memory Management - * - * All weather resources are properly disposed on unmount: - * 1. Cloud plane materials disposed - * 2. Cloud plane meshes disposed - * 3. Fog dome material disposed - * 4. Fog dome mesh disposed - * 5. All refs cleared - * - * **Important:** This hook manages its own resources but relies on the scene - * being available. If the scene is disposed before this hook unmounts, resources - * may already be cleaned up by scene disposal. - * - * ## Performance Considerations - * - * - **Fog dome**: Single mesh, ~2000 polygons (32 segments), minimal impact - * - **Cloud layers**: 4 large planes (50km each), ~8 polygons total, negligible - * - **Material updates**: Only when METAR data changes (5-minute interval) - * - **Visibility culling**: O(n) where n = number of cloud layers (max 4) - * - * @param options - Weather configuration options - * @param options.scene - Initialized Babylon.js scene (required) - * @param options.isTopDownView - Whether camera is in top-down mode (default: false) - * @returns Weather effect meshes and visibility culling function - * - * @example - * // Basic weather effects setup - * const { fogDome, cloudLayers, isVisibleByWeather } = useBabylonWeather({ - * scene: babylonScene, - * isTopDownView: false - * }) - * - * // Check if aircraft label should be visible - * const visible = isVisibleByWeather({ - * cameraAltitudeMeters: 10, // Tower at 10m AGL - * aircraftAltitudeMeters: 1500, // Aircraft at 1500m AGL - * horizontalDistanceMeters: 8000 // 8km away - * }) - * - * @example - * // Using with top-down view - * const [isTopDown, setIsTopDown] = useState(false) - * - * const weather = useBabylonWeather({ - * scene, - * isTopDownView: isTopDown // Clouds will be hidden when true - * }) - * - * @example - * // Integrating with label management - * const { isVisibleByWeather } = useBabylonWeather({ scene }) - * - * // In label update logic - * filteredAircraft.forEach(aircraft => { - * const visible = isVisibleByWeather({ - * cameraAltitudeMeters: towerHeight, - * aircraftAltitudeMeters: aircraft.altitudeAGL, - * horizontalDistanceMeters: aircraft.distance * 1852 // NM to meters - * }) - * - * if (visible) { - * updateLabel(aircraft.callsign, aircraft.position, aircraft.text) - * } else { - * removeLabel(aircraft.callsign) - * } - * }) - * - * @see useBabylonScene - For scene initialization - * @see useBabylonLabels - For label management with visibility culling - * @see weatherStore - For METAR data and cloud layers - * @see settingsStore - For weather effect toggles and intensities - */ -export function useBabylonWeather(options: UseBabylonWeatherOptions): UseBabylonWeatherResult { - const { scene, isTopDownView = false } = options - - const fogDomeRef = useRef(null) - const fogDomeMaterialRef = useRef(null) - const cloudMeshPoolRef = useRef([]) - const cloudLayerStatesRef = useRef([]) - const cloudOpacityRef = useRef(1.0) - const prevIsTopDownViewRef = useRef(undefined) - // Track if we've ever had active cloud layers (for instant display on initial load) - const hadActiveCloudsRef = useRef(false) - - // Fog smoothing state - smooth transitions when interpolated weather changes - const fogSmoothingRef = useRef({ - // Current smoothed values (what's actually rendered) - currentVisibility: 10, - currentAlpha: 0, - currentFresnelBias: 0.1, - currentScale: 10000, - // Target values (from METAR data) - targetVisibility: 10, - targetAlpha: 0, - targetFresnelBias: 0.1, - targetScale: 10000, - // Whether smoothing is initialized - initialized: false, - }) - - // Cloud layer smoothing - smooth the cloud layer data before matching - const cloudSmoothingRef = useRef<{ - layers: Array<{ - currentAltitude: number - currentCoverage: number - targetAltitude: number - targetCoverage: number - type: string - }> - initialized: boolean - }>({ layers: [], initialized: false }) - - // Track current airport to detect airport switches (reset smoothing on switch) - const currentAirportIcao = useAirportStore((state) => state.currentAirport?.icao ?? null) - const prevAirportIcaoRef = useRef(null) - - // Track camera position to detect position jumps (reset smoothing on teleport) - const cameraPosition = useWeatherStore((state) => state.cameraPosition) - const prevCameraPositionRef = useRef<{ lat: number; lon: number } | null>(null) - - // Weather store subscriptions - const cloudLayers = useWeatherStore((state) => state.cloudLayers) - const currentMetar = useWeatherStore((state) => state.currentMetar) - const fogDensity = useWeatherStore((state) => state.fogDensity) - const consumeInstantUpdate = useWeatherStore((state) => state.consumeInstantUpdate) - const showWeatherEffects = useSettingsStore((state) => state.weather.showWeatherEffects) - const showCesiumFog = useSettingsStore((state) => state.weather.showCesiumFog) - const showBabylonFog = useSettingsStore((state) => state.weather.showBabylonFog) - const showClouds = useSettingsStore((state) => state.weather.showClouds) - const cloudOpacity = useSettingsStore((state) => state.weather.cloudOpacity) - const fogIntensity = useSettingsStore((state) => state.weather.fogIntensity) - const visibilityScale = useSettingsStore((state) => state.weather.visibilityScale) - - // Keep cloudOpacity ref in sync (avoids recreating animation effect when opacity changes) - useEffect(() => { - cloudOpacityRef.current = cloudOpacity - }, [cloudOpacity]) - - // Detect airport changes and reset smoothing (instant weather changes when switching airports) - useEffect(() => { - if (currentAirportIcao !== prevAirportIcaoRef.current) { - prevAirportIcaoRef.current = currentAirportIcao - - // Reset fog smoothing - snap to target values - const fogState = fogSmoothingRef.current - fogState.currentVisibility = fogState.targetVisibility - fogState.currentAlpha = fogState.targetAlpha - fogState.currentFresnelBias = fogState.targetFresnelBias - fogState.currentScale = fogState.targetScale - - // Reset cloud smoothing - snap to target values - const cloudState = cloudSmoothingRef.current - cloudState.initialized = false // Force re-initialization for new airport - for (const layer of cloudState.layers) { - layer.currentAltitude = layer.targetAltitude - layer.currentCoverage = layer.targetCoverage - } - - // Also reset the visual cloud layer states to force immediate update - for (const state of cloudLayerStatesRef.current) { - state.currentAlpha = state.targetAlpha - state.currentAltitude = state.targetAltitude - state.currentCoverage = state.targetCoverage - } - - // Reset initial load flag so clouds appear instantly at new airport - hadActiveCloudsRef.current = false - } - }, [currentAirportIcao]) - - // Detect camera position jumps and reset smoothing (instant weather when teleporting) - // Threshold: ~5 nautical miles (0.083 degrees latitude ≈ 5nm) - const POSITION_JUMP_THRESHOLD_DEG = 0.083 - useEffect(() => { - if (!cameraPosition) return - - const prev = prevCameraPositionRef.current - if (prev) { - // Calculate approximate distance using simple lat/lon difference - // This is fast and accurate enough for detecting large jumps - const latDiff = Math.abs(cameraPosition.lat - prev.lat) - const lonDiff = Math.abs(cameraPosition.lon - prev.lon) * Math.cos((cameraPosition.lat * Math.PI) / 180) - const distance = Math.sqrt(latDiff * latDiff + lonDiff * lonDiff) - - if (distance > POSITION_JUMP_THRESHOLD_DEG) { - // Position jumped significantly - reset all smoothing - const fogState = fogSmoothingRef.current - fogState.currentVisibility = fogState.targetVisibility - fogState.currentAlpha = fogState.targetAlpha - fogState.currentFresnelBias = fogState.targetFresnelBias - fogState.currentScale = fogState.targetScale - - const cloudState = cloudSmoothingRef.current - for (const layer of cloudState.layers) { - layer.currentAltitude = layer.targetAltitude - layer.currentCoverage = layer.targetCoverage - } - - for (const state of cloudLayerStatesRef.current) { - state.currentAlpha = state.targetAlpha - state.currentAltitude = state.targetAltitude - state.currentCoverage = state.targetCoverage - } - } - } - - prevCameraPositionRef.current = { ...cameraPosition } - }, [cameraPosition]) - - // Create cloud plane mesh pool and fog dome - useEffect(() => { - if (!scene) return - - // Create cloud plane mesh pool (opacity textures are set dynamically based on coverage) - for (let i = 0; i < CLOUD_POOL_SIZE; i++) { - // Create flat plane for FEW/SCT/BKN coverage - const plane = BABYLON.MeshBuilder.CreatePlane( - `cloud_layer_${i}`, - { - size: CLOUD_PLANE_DIAMETER, - }, - scene, - ) - - // Rotate to horizontal (XZ plane) - plane.rotation.x = CLOUD_PLANE_ROTATION_X - plane.isVisible = false - - // Create semi-transparent cloud material (diffuseTexture with alpha set in update effect) - const material = new BABYLON.StandardMaterial(`cloud_mat_${i}`, scene) - material.diffuseColor = new BABYLON.Color3(...CLOUD_DIFFUSE_COLOR) - material.emissiveColor = new BABYLON.Color3(...CLOUD_EMISSIVE_COLOR) - material.alpha = CLOUD_BASE_ALPHA - material.backFaceCulling = false - material.disableLighting = false - material.useAlphaFromDiffuseTexture = true // Use alpha channel from diffuseTexture - plane.material = material - - // Create dome mesh for OVC (overcast) coverage - const dome = createCloudDomeMesh(`cloud_dome_${i}`, scene) - dome.isVisible = false - - // Create dome material with fresnel effect for more realistic overcast appearance - const domeMaterial = new BABYLON.StandardMaterial(`cloud_dome_mat_${i}`, scene) - domeMaterial.diffuseColor = new BABYLON.Color3(0.75, 0.75, 0.78) // Slightly darker gray - domeMaterial.emissiveColor = new BABYLON.Color3(0.35, 0.35, 0.38) - domeMaterial.specularColor = new BABYLON.Color3(0, 0, 0) - domeMaterial.alpha = 1.0 - domeMaterial.backFaceCulling = false - domeMaterial.disableLighting = true // Use emissive for uniform lighting - domeMaterial.useAlphaFromDiffuseTexture = true - - // Fresnel effect: more opaque at glancing angles (horizon), more transparent overhead - domeMaterial.opacityFresnelParameters = new BABYLON.FresnelParameters() - domeMaterial.opacityFresnelParameters.bias = CLOUD_DOME_FRESNEL_BIAS - domeMaterial.opacityFresnelParameters.power = CLOUD_DOME_FRESNEL_POWER - domeMaterial.opacityFresnelParameters.leftColor = new BABYLON.Color3(1, 1, 1) // Opaque at edges - domeMaterial.opacityFresnelParameters.rightColor = new BABYLON.Color3(0.7, 0.7, 0.7) // Slightly transparent center - - dome.material = domeMaterial - - // Create above-cloud plane (for viewing clouds from above) - const abovePlane = BABYLON.MeshBuilder.CreatePlane( - `cloud_above_${i}`, - { - size: CLOUD_PLANE_DIAMETER, - }, - scene, - ) - abovePlane.rotation.x = CLOUD_PLANE_ROTATION_X - abovePlane.isVisible = false - - // Above-cloud material: lighter colors simulating cloud tops - const aboveMaterial = new BABYLON.StandardMaterial(`cloud_above_mat_${i}`, scene) - aboveMaterial.diffuseColor = new BABYLON.Color3(...CLOUD_ABOVE_DIFFUSE_COLOR) - aboveMaterial.emissiveColor = new BABYLON.Color3(...CLOUD_ABOVE_EMISSIVE_COLOR) - aboveMaterial.specularColor = new BABYLON.Color3(0, 0, 0) - aboveMaterial.alpha = CLOUD_ABOVE_BASE_ALPHA - aboveMaterial.backFaceCulling = false - aboveMaterial.disableLighting = true // Use emissive for uniform look - aboveMaterial.useAlphaFromDiffuseTexture = true - abovePlane.material = aboveMaterial - - cloudMeshPoolRef.current.push({ - plane, - dome, - abovePlane, - material, - domeMaterial, - aboveMaterial, - usingDome: false, - }) - } - - // Create fog dome - const fogDome = BABYLON.MeshBuilder.CreateSphere( - 'fog_dome', - { - diameter: FOG_DOME_BASE_DIAMETER, - segments: FOG_DOME_SEGMENTS, - sideOrientation: BABYLON.Mesh.BACKSIDE, - }, - scene, - ) - fogDome.isVisible = false - - const fogDomeMaterial = new BABYLON.StandardMaterial('fog_dome_mat', scene) - fogDomeMaterial.diffuseColor = new BABYLON.Color3(...FOG_DIFFUSE_COLOR) - fogDomeMaterial.emissiveColor = new BABYLON.Color3(...FOG_EMISSIVE_COLOR) - fogDomeMaterial.specularColor = new BABYLON.Color3(...FOG_SPECULAR_COLOR) - fogDomeMaterial.alpha = FOG_BASE_ALPHA - fogDomeMaterial.backFaceCulling = true - fogDomeMaterial.disableLighting = true - - // Fresnel effect for fog - fogDomeMaterial.opacityFresnelParameters = new BABYLON.FresnelParameters() - fogDomeMaterial.opacityFresnelParameters.bias = FOG_FRESNEL_BIAS - fogDomeMaterial.opacityFresnelParameters.power = FOG_FRESNEL_POWER - fogDomeMaterial.opacityFresnelParameters.leftColor = new BABYLON.Color3(1, 1, 1) - fogDomeMaterial.opacityFresnelParameters.rightColor = new BABYLON.Color3(0, 0, 0) - - fogDome.material = fogDomeMaterial - fogDomeRef.current = fogDome - fogDomeMaterialRef.current = fogDomeMaterial - - return () => { - // Dispose cloud planes, domes, above-planes, and their individual diffuse textures - for (const cloudData of cloudMeshPoolRef.current) { - cloudData.material.diffuseTexture?.dispose() - cloudData.material?.dispose() - cloudData.plane.dispose() - cloudData.domeMaterial.diffuseTexture?.dispose() - cloudData.domeMaterial?.dispose() - cloudData.dome.dispose() - cloudData.aboveMaterial.diffuseTexture?.dispose() - cloudData.aboveMaterial?.dispose() - cloudData.abovePlane.dispose() - } - cloudMeshPoolRef.current = [] - - // Dispose fog dome - fogDomeMaterialRef.current?.dispose() - fogDomeRef.current?.dispose() - fogDomeRef.current = null - fogDomeMaterialRef.current = null - } - }, [scene]) - - // Initialize cloud layer states when mesh pool is created. - // `scene` dep is load-bearing: the body reads cloudMeshPoolRef which is populated by the [scene]-deps - // effect above. Without this dep the effect runs once at mount with an empty pool and never re-runs. - // biome-ignore lint/correctness/useExhaustiveDependencies: see comment above - useEffect(() => { - if (cloudMeshPoolRef.current.length === 0) return - - // Initialize layer states if not already done - if (cloudLayerStatesRef.current.length === 0) { - for (let i = 0; i < CLOUD_POOL_SIZE; i++) { - const initialSpeed = CLOUD_ROTATION_SPEED * (1 + (Math.random() * 2 - 1) * CLOUD_ROTATION_SPEED_VARIANCE) - cloudLayerStatesRef.current.push({ - noiseSeed: Math.random() * 10000, - currentCoverage: 0, - targetCoverage: 0, - currentAltitude: 0, - targetAltitude: 0, - currentAlpha: 0, - targetAlpha: 0, - lastRenderedCoverage: -1, - active: false, - rotation: { - currentSpeed: initialSpeed, - targetSpeed: initialSpeed, - }, - useDome: false, - domeTextureGenerated: false, - aboveTextureGenerated: false, - }) - } - } - }, [scene]) - - // Smooth cloud layer data before matching to prevent flickering from rapid METAR changes - // Updates target values in cloudSmoothingRef, actual smoothing happens in animation loop - useEffect(() => { - const smoothing = cloudSmoothingRef.current - - // Check if instant update was requested (e.g., from Weather Debug panel) - const isInstantUpdate = consumeInstantUpdate() - - // Resize smoothing array to match incoming cloud layers - while (smoothing.layers.length < cloudLayers.length) { - smoothing.layers.push({ - currentAltitude: 0, - currentCoverage: 0, - targetAltitude: 0, - targetCoverage: 0, - type: '', - }) - } - - // Update targets from raw cloud layer data - for (let i = 0; i < cloudLayers.length; i++) { - const layer = cloudLayers[i] - const smooth = smoothing.layers[i] - smooth.targetAltitude = layer.altitude - smooth.targetCoverage = layer.coverage - smooth.type = layer.type - - // Initialize on first update OR instant update - snap to target (no transition) - if (!smoothing.initialized || isInstantUpdate) { - smooth.currentAltitude = layer.altitude - smooth.currentCoverage = layer.coverage - } - } - - // Mark layers beyond the current count as having zero coverage target - for (let i = cloudLayers.length; i < smoothing.layers.length; i++) { - smoothing.layers[i].targetCoverage = 0 - // Also snap current to zero on instant update - if (isInstantUpdate) { - smoothing.layers[i].currentCoverage = 0 - } - } - - // If instant update, also reset the visual cloud layer states and force texture regeneration - if (isInstantUpdate) { - for (const state of cloudLayerStatesRef.current) { - state.currentAlpha = state.targetAlpha - state.currentAltitude = state.targetAltitude - state.currentCoverage = state.targetCoverage - // Force texture regeneration - state.aboveTextureGenerated = false - state.domeTextureGenerated = false - state.lastRenderedCoverage = -1 - } - } - - // Only mark as initialized once we have actual cloud data - // This ensures first METAR with clouds snaps to values instead of smoothing from 0 - if (cloudLayers.length > 0) { - smoothing.initialized = true - } - }, [cloudLayers, consumeInstantUpdate]) - - // Match cloud layers to existing visual states and update targets. - // Uses smoothed cloud layer values to prevent matching failures from rapid METAR changes. - // This enables smooth transitions instead of instant regeneration. - // `cloudLayers` dep is load-bearing: the body reads cloudSmoothingRef which is populated by the - // [cloudLayers]-deps effect above. Without this dep new METAR layers never get assigned to mesh slots. - // biome-ignore lint/correctness/useExhaustiveDependencies: see comment above - useEffect(() => { - // Wait for both mesh pool and layer states to be initialized - if (cloudMeshPoolRef.current.length === 0 || cloudLayerStatesRef.current.length === 0) return - - const states = cloudLayerStatesRef.current - const smoothing = cloudSmoothingRef.current - const shouldShowClouds = showWeatherEffects && showClouds && !isTopDownView - - // Get smoothed cloud layers (only those with non-zero coverage) - const smoothedLayers: CloudLayer[] = smoothing.layers - .filter((l) => l.targetCoverage > 0) - .map((l) => ({ - altitude: l.currentAltitude, // Use smoothed current value for matching - coverage: l.currentCoverage, - type: l.type, - })) - - if (!shouldShowClouds || smoothedLayers.length === 0) { - // Fade out all layers - for (const state of states) { - if (state.active) { - state.targetAlpha = 0 - } - } - return - } - - // Track which states have been matched to new layers - const matchedStates = new Set() - const matchedNewLayers = new Set() - - // First pass: match smoothed layers to existing active states by altitude proximity - // Use target values for matching (more stable than current values mid-transition) - for (let newIdx = 0; newIdx < smoothedLayers.length; newIdx++) { - const newLayer = smoothedLayers[newIdx] - let bestMatchIdx = -1 - let bestMatchScore = Infinity - - for (let stateIdx = 0; stateIdx < states.length; stateIdx++) { - const state = states[stateIdx] - if (!state.active || matchedStates.has(stateIdx)) continue - - // Match using TARGET values (more stable during transitions) - const altitudeDiff = Math.abs(state.targetAltitude - newLayer.altitude) - const coverageDiff = Math.abs(state.targetCoverage - newLayer.coverage) - - // Use more lenient thresholds for smoother transitions - // Double the thresholds to allow more matching during rapid changes - const altThreshold = CLOUD_LAYER_MATCH_ALTITUDE_THRESHOLD * 2 - const covThreshold = CLOUD_LAYER_MATCH_COVERAGE_THRESHOLD * 1.5 - - if (altitudeDiff <= altThreshold && coverageDiff <= covThreshold) { - // Score by altitude difference (prefer closer matches) - if (altitudeDiff < bestMatchScore) { - bestMatchScore = altitudeDiff - bestMatchIdx = stateIdx - } - } - } - - if (bestMatchIdx >= 0) { - // Match found - update targets from smoothed values, keep noise seed - const state = states[bestMatchIdx] - state.targetCoverage = newLayer.coverage - state.targetAltitude = newLayer.altitude - state.targetAlpha = 1 - matchedStates.add(bestMatchIdx) - matchedNewLayers.add(newIdx) - } - } - - // Second pass: fade out unmatched active states - for (let stateIdx = 0; stateIdx < states.length; stateIdx++) { - const state = states[stateIdx] - if (state.active && !matchedStates.has(stateIdx)) { - state.targetAlpha = 0 - } - } - - // Third pass: assign unmatched smoothed layers to inactive states (fade in) - // On initial load (never had active clouds), snap to visible immediately - const isInitialLoad = !hadActiveCloudsRef.current - - for (let newIdx = 0; newIdx < smoothedLayers.length; newIdx++) { - if (matchedNewLayers.has(newIdx)) continue - - const newLayer = smoothedLayers[newIdx] - - // Find an inactive state to use - for (let stateIdx = 0; stateIdx < states.length; stateIdx++) { - const state = states[stateIdx] - if (!state.active && state.targetAlpha === 0 && state.currentAlpha < 0.01) { - // Use this inactive slot - generate new seed for new pattern - state.noiseSeed = Math.random() * 10000 - state.currentCoverage = newLayer.coverage - state.targetCoverage = newLayer.coverage - state.currentAltitude = newLayer.altitude - state.targetAltitude = newLayer.altitude - // On initial load, show clouds instantly; otherwise fade in - state.currentAlpha = isInitialLoad ? 1 : 0 - state.targetAlpha = 1 - state.lastRenderedCoverage = -1 // Force texture regeneration - state.active = true - hadActiveCloudsRef.current = true // Mark that we've had active clouds - matchedNewLayers.add(newIdx) - break - } - } - } - }, [cloudLayers, showWeatherEffects, showClouds, isTopDownView]) - - // Instantly hide/show weather effects when switching between 3D and 2D view modes - // (no gradual fade - user expects immediate response to view mode toggle) - useEffect(() => { - const wasTopDown = prevIsTopDownViewRef.current - prevIsTopDownViewRef.current = isTopDownView - - // Skip on initial mount (no previous value to compare) - if (wasTopDown === undefined) return - - // Only act on actual view mode changes - if (wasTopDown === isTopDownView) return - - const pool = cloudMeshPoolRef.current - const states = cloudLayerStatesRef.current - - if (isTopDownView) { - // Switching to top-down: instantly hide all cloud meshes - for (let i = 0; i < pool.length && i < states.length; i++) { - pool[i].plane.isVisible = false - pool[i].dome.isVisible = false - // Set currentAlpha to target to prevent fade animation when switching back - states[i].currentAlpha = states[i].targetAlpha - } - } else { - // Switching to 3D view: instantly show cloud meshes that should be visible - // The animation loop will handle proper material setup on next frame - for (let i = 0; i < pool.length && i < states.length; i++) { - if (states[i].active && states[i].targetAlpha > 0) { - // Instantly set alpha to target - states[i].currentAlpha = states[i].targetAlpha - // Show appropriate mesh based on coverage - if (states[i].useDome) { - pool[i].dome.isVisible = true - } else { - pool[i].plane.isVisible = true - } - } - } - } - }, [isTopDownView]) - - // Calculate fog target values from METAR data - // Smoothing is applied in the animation loop below - useEffect(() => { - const fogState = fogSmoothingRef.current - - const shouldShowFog = showWeatherEffects && showBabylonFog && currentMetar && fogDensity > 0 && !isTopDownView - - if (shouldShowFog && currentMetar) { - const visibilityMeters = currentMetar.visib * STATUTE_MILES_TO_METERS - const domeScale = visibilityMeters * visibilityScale - - // Adjust fog opacity based on visibility severity - const visib = currentMetar.visib - let baseAlpha: number - let fresnelBias: number - - if (visib <= VISIBILITY_THRESHOLD_EXTREMELY_LOW) { - baseAlpha = FOG_ALPHA_EXTREMELY_LOW - fresnelBias = FOG_FRESNEL_BIAS_EXTREMELY_LOW - } else if (visib <= VISIBILITY_THRESHOLD_LOW) { - const t = - (visib - VISIBILITY_THRESHOLD_EXTREMELY_LOW) / (VISIBILITY_THRESHOLD_LOW - VISIBILITY_THRESHOLD_EXTREMELY_LOW) - baseAlpha = FOG_ALPHA_EXTREMELY_LOW - t * (FOG_ALPHA_EXTREMELY_LOW - FOG_ALPHA_LOW_MIN) - fresnelBias = FOG_FRESNEL_BIAS_EXTREMELY_LOW - t * (FOG_FRESNEL_BIAS_EXTREMELY_LOW - FOG_FRESNEL_BIAS_LOW_MIN) - } else if (visib <= VISIBILITY_THRESHOLD_MODERATE) { - const t = (visib - VISIBILITY_THRESHOLD_LOW) / (VISIBILITY_THRESHOLD_MODERATE - VISIBILITY_THRESHOLD_LOW) - baseAlpha = FOG_ALPHA_LOW_MIN - t * (FOG_ALPHA_LOW_MIN - FOG_ALPHA_MODERATE_MIN) - fresnelBias = FOG_FRESNEL_BIAS_LOW_MIN - t * (FOG_FRESNEL_BIAS_LOW_MIN - FOG_FRESNEL_BIAS_MODERATE) - } else if (visib <= VISIBILITY_THRESHOLD_DECENT) { - const t = - (visib - VISIBILITY_THRESHOLD_MODERATE) / (VISIBILITY_THRESHOLD_DECENT - VISIBILITY_THRESHOLD_MODERATE) - baseAlpha = FOG_ALPHA_MODERATE_MIN - t * (FOG_ALPHA_MODERATE_MIN - FOG_ALPHA_DECENT_MIN) - fresnelBias = FOG_FRESNEL_BIAS_MODERATE - } else { - baseAlpha = FOG_ALPHA_GOOD - fresnelBias = FOG_FRESNEL_BIAS_MODERATE - } - - // Set target values for smoothing - fogState.targetVisibility = currentMetar.visib - fogState.targetScale = domeScale - fogState.targetAlpha = Math.min(1.0, baseAlpha * fogIntensity) - fogState.targetFresnelBias = fresnelBias - - // Initialize on first valid METAR - snap to target (no transition) - if (!fogState.initialized) { - fogState.currentVisibility = fogState.targetVisibility - fogState.currentScale = fogState.targetScale - fogState.currentAlpha = fogState.targetAlpha - fogState.currentFresnelBias = fogState.targetFresnelBias - fogState.initialized = true - } - } else { - // Fog should be hidden - fade alpha to 0 - fogState.targetAlpha = 0 - fogState.targetScale = fogState.currentScale // Keep scale while fading - // Initialize if first update - if (!fogState.initialized) { - fogState.currentAlpha = 0 - fogState.initialized = true - } - } - }, [showWeatherEffects, showBabylonFog, currentMetar, fogDensity, fogIntensity, visibilityScale, isTopDownView]) - - // Animate cloud layers: coverage, altitude, alpha, and rotation - // Also handles texture regeneration when coverage changes enough - useEffect(() => { - if (!scene) return - - let lastTime = performance.now() - let lastSpeedChangeTime = performance.now() - - // Helper to apply material properties based on coverage - // Uses cloudOpacityRef.current to avoid effect recreation when opacity slider changes - const applyMaterialForCoverage = (material: BABYLON.StandardMaterial, coverage: number, alpha: number) => { - const opacity = cloudOpacityRef.current - if (coverage >= 0.95) { - // OVC: fully opaque - material.transparencyMode = BABYLON.Material.MATERIAL_OPAQUE - material.needDepthPrePass = true - material.disableLighting = true - material.emissiveColor = new BABYLON.Color3(0.25, 0.25, 0.28) - material.diffuseColor = new BABYLON.Color3(0, 0, 0) - material.alpha = alpha * opacity - } else if (coverage >= 0.6) { - // BKN: nearly opaque with alpha blending - material.transparencyMode = BABYLON.Material.MATERIAL_ALPHABLEND - material.needDepthPrePass = false - material.disableLighting = false - material.emissiveColor = new BABYLON.Color3(...CLOUD_EMISSIVE_COLOR) - material.diffuseColor = new BABYLON.Color3(...CLOUD_DIFFUSE_COLOR) - const baseAlpha = 0.95 - material.alpha = baseAlpha * alpha * opacity - } else { - // FEW/SCT: transparent - material.transparencyMode = BABYLON.Material.MATERIAL_ALPHABLEND - material.needDepthPrePass = false - material.disableLighting = false - material.emissiveColor = new BABYLON.Color3(...CLOUD_EMISSIVE_COLOR) - material.diffuseColor = new BABYLON.Color3(...CLOUD_DIFFUSE_COLOR) - const baseAlpha = 0.6 + coverage * 0.4 - material.alpha = baseAlpha * alpha * opacity - } - } - - // Generate new target rotation speeds for all layers, correlating by altitude - const pickNewTargetSpeeds = () => { - const states = cloudLayerStatesRef.current - if (states.length === 0) return - - // Get active layers with their altitudes - const activeStates: { index: number; altitude: number }[] = [] - for (let i = 0; i < states.length; i++) { - if (states[i].active || states[i].currentAlpha > 0.01) { - activeStates.push({ index: i, altitude: states[i].currentAltitude }) - } - } - - if (activeStates.length === 0) return - - // Sort by altitude - activeStates.sort((a, b) => a.altitude - b.altitude) - - // Pick a base speed for the lowest layer - const baseVariance = (Math.random() * 2 - 1) * CLOUD_ROTATION_SPEED_VARIANCE - const lowestLayerSpeed = CLOUD_ROTATION_SPEED * (1 + baseVariance) - states[activeStates[0].index].rotation.targetSpeed = lowestLayerSpeed - - // For each subsequent layer, vary speed based on altitude difference - for (let i = 1; i < activeStates.length; i++) { - const prevState = activeStates[i - 1] - const currState = activeStates[i] - const altitudeDiff = Math.abs(currState.altitude - prevState.altitude) - - // Normalize altitude difference: 0 at 0m, 1 at 2000m+ - const normalizedDiff = Math.min(1, altitudeDiff / 2000) - const correlation = 1 - normalizedDiff - - const independentVariance = (Math.random() * 2 - 1) * CLOUD_ROTATION_SPEED_VARIANCE - const independentSpeed = CLOUD_ROTATION_SPEED * (1 + independentVariance) - - const prevSpeed = states[prevState.index].rotation.targetSpeed - states[currState.index].rotation.targetSpeed = prevSpeed * correlation + independentSpeed * (1 - correlation) - } - } - - const observer = scene.onBeforeRenderObservable.add(() => { - const now = performance.now() - // Clamp delta time to prevent huge jumps when tab was backgrounded - const deltaSeconds = Math.min((now - lastTime) / 1000, 0.1) - lastTime = now - - // === FOG SMOOTHING === - // Smooth fog dome parameters toward targets for gradual weather transitions - const fogDome = fogDomeRef.current - const fogMaterial = fogDomeMaterialRef.current - const fogState = fogSmoothingRef.current - - if (fogDome && fogMaterial && fogState.initialized) { - // Exponential smoothing factor: 1 - e^(-dt / (tau/3)) - const fogLerpFactor = 1 - Math.exp(-deltaSeconds / (VISIBILITY_TRANSITION_TIME / 3)) - - // Smooth scale - if (Math.abs(fogState.currentScale - fogState.targetScale) > 1) { - fogState.currentScale += (fogState.targetScale - fogState.currentScale) * fogLerpFactor - } else { - fogState.currentScale = fogState.targetScale - } - - // Smooth alpha - if (Math.abs(fogState.currentAlpha - fogState.targetAlpha) > 0.001) { - fogState.currentAlpha += (fogState.targetAlpha - fogState.currentAlpha) * fogLerpFactor - } else { - fogState.currentAlpha = fogState.targetAlpha - } - - // Smooth fresnel bias - if (Math.abs(fogState.currentFresnelBias - fogState.targetFresnelBias) > 0.001) { - fogState.currentFresnelBias += (fogState.targetFresnelBias - fogState.currentFresnelBias) * fogLerpFactor - } else { - fogState.currentFresnelBias = fogState.targetFresnelBias - } - - // Apply smoothed values to fog dome - fogDome.scaling.setAll(fogState.currentScale) - fogMaterial.alpha = fogState.currentAlpha - fogMaterial.opacityFresnelParameters!.bias = fogState.currentFresnelBias - - // Show/hide based on alpha threshold - fogDome.isVisible = fogState.currentAlpha > 0.001 - } - - // === CLOUD LAYER DATA SMOOTHING === - // Smooth cloud layer altitude/coverage values before they're used in matching - const cloudSmoothing = cloudSmoothingRef.current - if (cloudSmoothing.initialized) { - // Use same lerp factor as fog for consistent weather transition speed - const cloudLerpFactor = 1 - Math.exp(-deltaSeconds / (VISIBILITY_TRANSITION_TIME / 3)) - - for (const layer of cloudSmoothing.layers) { - // Smooth altitude - if (Math.abs(layer.currentAltitude - layer.targetAltitude) > 1) { - layer.currentAltitude += (layer.targetAltitude - layer.currentAltitude) * cloudLerpFactor - } else { - layer.currentAltitude = layer.targetAltitude - } - - // Smooth coverage - if (Math.abs(layer.currentCoverage - layer.targetCoverage) > 0.001) { - layer.currentCoverage += (layer.targetCoverage - layer.currentCoverage) * cloudLerpFactor - } else { - layer.currentCoverage = layer.targetCoverage - } - } - } - - // Periodically pick new rotation speeds - const timeSinceSpeedChange = (now - lastSpeedChangeTime) / 1000 - if (timeSinceSpeedChange > CLOUD_ROTATION_CHANGE_INTERVAL) { - pickNewTargetSpeeds() - lastSpeedChangeTime = now - } - - const pool = cloudMeshPoolRef.current - const states = cloudLayerStatesRef.current - - for (let i = 0; i < pool.length && i < states.length; i++) { - const meshData = pool[i] - const state = states[i] - - // Animate alpha (fade in/out) - if (Math.abs(state.currentAlpha - state.targetAlpha) > 0.001) { - const alphaDelta = CLOUD_LAYER_FADE_SPEED * deltaSeconds - if (state.currentAlpha < state.targetAlpha) { - state.currentAlpha = Math.min(state.targetAlpha, state.currentAlpha + alphaDelta) - } else { - state.currentAlpha = Math.max(state.targetAlpha, state.currentAlpha - alphaDelta) - } - } - - // Mark as inactive when fully faded out - if (state.currentAlpha < 0.01 && state.targetAlpha === 0) { - state.active = false - meshData.plane.isVisible = false - meshData.dome.isVisible = false - // Dispose textures to free GPU memory - if (meshData.material.diffuseTexture) { - meshData.material.diffuseTexture.dispose() - meshData.material.diffuseTexture = null - } - if (meshData.domeMaterial.diffuseTexture) { - meshData.domeMaterial.diffuseTexture.dispose() - meshData.domeMaterial.diffuseTexture = null - } - state.domeTextureGenerated = false - continue - } - - // Skip if not visible at all - if (state.currentAlpha < 0.01 && !state.active) { - meshData.plane.isVisible = false - meshData.dome.isVisible = false - continue - } - - // Animate altitude - if (Math.abs(state.currentAltitude - state.targetAltitude) > 0.1) { - const altDelta = CLOUD_LAYER_ALTITUDE_TRANSITION_SPEED * deltaSeconds - if (state.currentAltitude < state.targetAltitude) { - state.currentAltitude = Math.min(state.targetAltitude, state.currentAltitude + altDelta) - } else { - state.currentAltitude = Math.max(state.targetAltitude, state.currentAltitude - altDelta) - } - } - - // Animate coverage - if (Math.abs(state.currentCoverage - state.targetCoverage) > 0.001) { - const covDelta = CLOUD_LAYER_COVERAGE_TRANSITION_SPEED * deltaSeconds - if (state.currentCoverage < state.targetCoverage) { - state.currentCoverage = Math.min(state.targetCoverage, state.currentCoverage + covDelta) - } else { - state.currentCoverage = Math.max(state.targetCoverage, state.currentCoverage - covDelta) - } - } - - // Determine if we should use dome or plane based on coverage (for below view) - const shouldUseDome = state.currentCoverage >= CLOUD_DOME_COVERAGE_THRESHOLD - - // Handle geometry switching for below-cloud view - if (shouldUseDome !== state.useDome) { - state.useDome = shouldUseDome - if (shouldUseDome) { - state.domeTextureGenerated = false - } else { - state.lastRenderedCoverage = -1 - } - } - - // === VIEW-DEPENDENT GEOMETRY SWITCHING === - // Determine camera altitude and view mode (above/below/transition) - const cameraAltitude = scene.activeCamera?.position.y ?? 0 - const cloudAltitude = state.currentAltitude - const buffer = CLOUD_ABOVE_TRANSITION_BUFFER - - type ViewMode = 'below' | 'above' | 'transition' - let viewMode: ViewMode - if (cameraAltitude > cloudAltitude + buffer) { - viewMode = 'above' - } else if (cameraAltitude < cloudAltitude - buffer) { - viewMode = 'below' - } else { - viewMode = 'transition' - } - - const opacity = cloudOpacityRef.current - - // Set all mesh positions (Y coordinate) - meshData.plane.position.y = cloudAltitude - meshData.dome.position.y = cloudAltitude - meshData.abovePlane.position.y = cloudAltitude - - if (viewMode === 'above') { - // Camera is above cloud layer - show above-plane (cloud tops) - meshData.plane.isVisible = false - meshData.dome.isVisible = false - meshData.abovePlane.isVisible = true - - // Generate above texture if needed - if (!state.aboveTextureGenerated) { - if (meshData.aboveMaterial.diffuseTexture) { - meshData.aboveMaterial.diffuseTexture.dispose() - } - const aboveTexture = createAboveCloudTexture( - scene, - CLOUD_NOISE_TEXTURE_SIZE, - state.currentCoverage, - state.noiseSeed, - ) - meshData.aboveMaterial.diffuseTexture = aboveTexture - state.aboveTextureGenerated = true - } - - // Apply above material properties - // Keep above-cloud plane semi-transparent so aircraft in Cesium show through - // (Babylon overlay is on top of Cesium canvas, so we can't use depth testing) - const isOVC = state.currentCoverage >= 0.95 - if (isOVC) { - meshData.aboveMaterial.useAlphaFromDiffuseTexture = false - meshData.aboveMaterial.alpha = 0.75 // Semi-transparent so aircraft show through - } else { - meshData.aboveMaterial.useAlphaFromDiffuseTexture = true - meshData.aboveMaterial.alpha = state.currentAlpha * opacity * 0.8 // Slightly more transparent - } - - // Animate rotation - const rotLerpFactor = Math.min(1, deltaSeconds / CLOUD_ROTATION_TRANSITION_TIME) - state.rotation.currentSpeed += (state.rotation.targetSpeed - state.rotation.currentSpeed) * rotLerpFactor - meshData.abovePlane.rotation.y += state.rotation.currentSpeed * deltaSeconds - } else if (viewMode === 'below') { - // Camera is below cloud layer - show dome or plane based on coverage - meshData.abovePlane.isVisible = false - - if (shouldUseDome) { - // Use dome for OVC (overcast) - meshData.plane.isVisible = false - meshData.dome.isVisible = true - - // Generate dome texture if needed - if (!state.domeTextureGenerated) { - if (meshData.domeMaterial.diffuseTexture) { - meshData.domeMaterial.diffuseTexture.dispose() - } - const domeTexture = createOvercastDomeTexture(scene, CLOUD_NOISE_TEXTURE_SIZE, state.noiseSeed) - meshData.domeMaterial.diffuseTexture = domeTexture - state.domeTextureGenerated = true - } - - // Reset fresnel to default for below view - meshData.domeMaterial.opacityFresnelParameters!.bias = CLOUD_DOME_FRESNEL_BIAS - meshData.domeMaterial.alpha = state.currentAlpha * opacity - - // Animate dome rotation - const rotLerpFactor = Math.min(1, deltaSeconds / CLOUD_ROTATION_TRANSITION_TIME) - state.rotation.currentSpeed += (state.rotation.targetSpeed - state.rotation.currentSpeed) * rotLerpFactor - meshData.dome.rotation.y += state.rotation.currentSpeed * deltaSeconds * 0.5 - } else { - // Use plane for FEW/SCT/BKN - meshData.dome.isVisible = false - meshData.plane.isVisible = true - - // Regenerate texture if coverage changed enough - const coverageChange = Math.abs(state.currentCoverage - state.lastRenderedCoverage) - if (coverageChange >= CLOUD_LAYER_COVERAGE_REGEN_THRESHOLD || state.lastRenderedCoverage < 0) { - if (meshData.material.diffuseTexture) { - meshData.material.diffuseTexture.dispose() - } - const patchyTexture = createPatchyCloudTexture( - scene, - CLOUD_NOISE_TEXTURE_SIZE, - state.currentCoverage, - state.noiseSeed, - ) - meshData.material.diffuseTexture = patchyTexture - state.lastRenderedCoverage = state.currentCoverage - } - - applyMaterialForCoverage(meshData.material, state.currentCoverage, state.currentAlpha) - - // Animate rotation - const rotLerpFactor = Math.min(1, deltaSeconds / CLOUD_ROTATION_TRANSITION_TIME) - state.rotation.currentSpeed += (state.rotation.targetSpeed - state.rotation.currentSpeed) * rotLerpFactor - meshData.plane.rotation.y += state.rotation.currentSpeed * deltaSeconds - } - } else { - // Transition zone - interpolate between above and below views - // Show both geometries with blended opacity - const transitionT = (cameraAltitude - (cloudAltitude - buffer)) / (2 * buffer) - const aboveAlpha = transitionT - const belowAlpha = 1 - transitionT - - // Generate above texture if needed - if (!state.aboveTextureGenerated) { - if (meshData.aboveMaterial.diffuseTexture) { - meshData.aboveMaterial.diffuseTexture.dispose() - } - const aboveTexture = createAboveCloudTexture( - scene, - CLOUD_NOISE_TEXTURE_SIZE, - state.currentCoverage, - state.noiseSeed, - ) - meshData.aboveMaterial.diffuseTexture = aboveTexture - state.aboveTextureGenerated = true - } - - // Show above plane with transition alpha - // Keep semi-transparent so aircraft show through - meshData.abovePlane.isVisible = true - const isOVCTransition = state.currentCoverage >= 0.95 - if (isOVCTransition) { - meshData.aboveMaterial.useAlphaFromDiffuseTexture = false - meshData.aboveMaterial.alpha = 0.75 * aboveAlpha // Semi-transparent with transition - } else { - meshData.aboveMaterial.useAlphaFromDiffuseTexture = true - meshData.aboveMaterial.alpha = state.currentAlpha * opacity * aboveAlpha * 0.8 - } - - if (shouldUseDome) { - meshData.plane.isVisible = false - meshData.dome.isVisible = true - - if (!state.domeTextureGenerated) { - if (meshData.domeMaterial.diffuseTexture) { - meshData.domeMaterial.diffuseTexture.dispose() - } - const domeTexture = createOvercastDomeTexture(scene, CLOUD_NOISE_TEXTURE_SIZE, state.noiseSeed) - meshData.domeMaterial.diffuseTexture = domeTexture - state.domeTextureGenerated = true - } - - // Dynamic fresnel: interpolate bias for smoother transition - const adjustedBias = CLOUD_DOME_FRESNEL_BIAS * belowAlpha + CLOUD_DOME_FRESNEL_BIAS_ABOVE * aboveAlpha - meshData.domeMaterial.opacityFresnelParameters!.bias = adjustedBias - meshData.domeMaterial.alpha = state.currentAlpha * opacity * belowAlpha - - const rotLerpFactor = Math.min(1, deltaSeconds / CLOUD_ROTATION_TRANSITION_TIME) - state.rotation.currentSpeed += (state.rotation.targetSpeed - state.rotation.currentSpeed) * rotLerpFactor - meshData.dome.rotation.y += state.rotation.currentSpeed * deltaSeconds * 0.5 - } else { - meshData.dome.isVisible = false - meshData.plane.isVisible = true - - const coverageChange = Math.abs(state.currentCoverage - state.lastRenderedCoverage) - if (coverageChange >= CLOUD_LAYER_COVERAGE_REGEN_THRESHOLD || state.lastRenderedCoverage < 0) { - if (meshData.material.diffuseTexture) { - meshData.material.diffuseTexture.dispose() - } - const patchyTexture = createPatchyCloudTexture( - scene, - CLOUD_NOISE_TEXTURE_SIZE, - state.currentCoverage, - state.noiseSeed, - ) - meshData.material.diffuseTexture = patchyTexture - state.lastRenderedCoverage = state.currentCoverage - } - - applyMaterialForCoverage(meshData.material, state.currentCoverage, state.currentAlpha * belowAlpha) - - const rotLerpFactor = Math.min(1, deltaSeconds / CLOUD_ROTATION_TRANSITION_TIME) - state.rotation.currentSpeed += (state.rotation.targetSpeed - state.rotation.currentSpeed) * rotLerpFactor - meshData.plane.rotation.y += state.rotation.currentSpeed * deltaSeconds - } - - // Rotate above plane too - meshData.abovePlane.rotation.y += state.rotation.currentSpeed * deltaSeconds - } - } - }) - - return () => { - scene.onBeforeRenderObservable.remove(observer) - } - }, [scene]) // cloudOpacity accessed via ref to avoid effect recreation - - // Weather-based visibility culling function - const isVisibleByWeather = useCallback( - (params: WeatherVisibilityParams): boolean => - isVisibleThroughWeather( - { - showWeatherEffects, - cullBeyondVisibility: showCesiumFog, - showClouds, - isTopDownView, - visibilityStatuteMiles: currentMetar ? currentMetar.visib : null, - visibilityScale, - cloudLayers, - }, - params, - ), - [showWeatherEffects, showCesiumFog, showClouds, currentMetar, cloudLayers, visibilityScale, isTopDownView], - ) - - // Return getter function for cloud meshes to avoid stale closure issue - // The mesh creation effect runs after initial render, so returning the ref - // contents directly would capture an empty array - const getCloudMeshes = useCallback(() => cloudMeshPoolRef.current, []) - - return { - fogDome: fogDomeRef.current, - getCloudMeshes, - isVisibleByWeather, - } -} - -export default useBabylonWeather diff --git a/src/renderer/hooks/useCesiumNightDarkening.ts b/src/renderer/hooks/useCesiumNightDarkening.ts index ce5ce350..c635aa91 100644 --- a/src/renderer/hooks/useCesiumNightDarkening.ts +++ b/src/renderer/hooks/useCesiumNightDarkening.ts @@ -1,15 +1,7 @@ import type * as Cesium from 'cesium' import { useEffect, useRef } from 'react' -import { - NIGHT_BRIGHTNESS_CIVIL, - NIGHT_BRIGHTNESS_MIN, - NIGHT_BRIGHTNESS_TWILIGHT, - NIGHT_GAMMA_BOOST, - SUN_ELEVATION_CIVIL_TWILIGHT, - SUN_ELEVATION_DAY, - SUN_ELEVATION_NAUTICAL_TWILIGHT, - SUN_ELEVATION_NIGHT, -} from '@/constants' +import { NIGHT_GAMMA_BOOST, SUN_ELEVATION_CIVIL_TWILIGHT, SUN_ELEVATION_DAY } from '@/constants' +import { nightBrightness, smoothstep } from '@/utils/nightBrightness' export interface NightDarkeningSettings { /** Whether night darkening is enabled */ @@ -18,70 +10,6 @@ export interface NightDarkeningSettings { intensity: number } -/** - * Smoothstep function for smooth interpolation between values - * Returns smooth transition between 0 and 1 based on where x is between edge0 and edge1 - */ -function smoothstep(edge0: number, edge1: number, x: number): number { - const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0))) - return t * t * (3 - 2 * t) -} - -/** - * Linear interpolation between two values - */ -function lerp(a: number, b: number, t: number): number { - return a + (b - a) * t -} - -/** - * Calculate the target brightness based on sun elevation angle - * - * Uses smooth transitions through twilight phases: - * - Day (sun > 0°): Full brightness (1.0) - * - Civil twilight (0° to -6°): 1.0 -> 0.6 - * - Nautical twilight (-6° to -12°): 0.6 -> 0.3 - * - Astronomical twilight (-12° to -18°): 0.3 -> min - * - Night (sun < -18°): Minimum brightness - * - * @param sunElevation - Sun elevation angle in degrees - * @param intensity - User intensity setting (0.0-1.0) - * @returns Target brightness value (0.0-1.0) - */ -function calculateTargetBrightness(sunElevation: number, intensity: number): number { - // Minimum brightness at night depends on intensity setting - // intensity 0.0 = minBrightness 1.0 (no darkening - same as disabled) - // intensity 0.5 = minBrightness ~0.58 - // intensity 1.0 = minBrightness 0.15 (maximum darkening) - const minBrightness = NIGHT_BRIGHTNESS_MIN + (1 - intensity) * (1.0 - NIGHT_BRIGHTNESS_MIN) - - // Day: full brightness - if (sunElevation >= SUN_ELEVATION_DAY) { - return 1.0 - } - - // Civil twilight: smooth transition from 1.0 to 0.6 - if (sunElevation > SUN_ELEVATION_CIVIL_TWILIGHT) { - const t = smoothstep(SUN_ELEVATION_CIVIL_TWILIGHT, SUN_ELEVATION_DAY, sunElevation) - return lerp(NIGHT_BRIGHTNESS_CIVIL, 1.0, t) - } - - // Nautical twilight: smooth transition from 0.6 to 0.3 - if (sunElevation > SUN_ELEVATION_NAUTICAL_TWILIGHT) { - const t = smoothstep(SUN_ELEVATION_NAUTICAL_TWILIGHT, SUN_ELEVATION_CIVIL_TWILIGHT, sunElevation) - return lerp(NIGHT_BRIGHTNESS_TWILIGHT, NIGHT_BRIGHTNESS_CIVIL, t) - } - - // Astronomical twilight: smooth transition from 0.3 to minimum - if (sunElevation > SUN_ELEVATION_NIGHT) { - const t = smoothstep(SUN_ELEVATION_NIGHT, SUN_ELEVATION_NAUTICAL_TWILIGHT, sunElevation) - return lerp(minBrightness, NIGHT_BRIGHTNESS_TWILIGHT, t) - } - - // Full night: minimum brightness - return minBrightness -} - /** * Calculate target gamma based on sun elevation * Slightly boost gamma during twilight for warmer sunset/sunrise tones @@ -156,9 +84,9 @@ export function useCesiumNightDarkening( } // Calculate target values based on sun elevation - // The smoothstep functions in calculateTargetBrightness already provide + // The smoothstep functions in nightBrightness already provide // smooth transitions through twilight phases - const targetBrightness = calculateTargetBrightness(sunElevation, intensity) + const targetBrightness = nightBrightness(sunElevation, intensity) const targetGamma = calculateTargetGamma(sunElevation) // Only update if values changed significantly (avoid unnecessary updates) diff --git a/src/renderer/hooks/useCesiumWeather.ts b/src/renderer/hooks/useCesiumWeather.ts index 838ed8cc..b18bca0b 100644 --- a/src/renderer/hooks/useCesiumWeather.ts +++ b/src/renderer/hooks/useCesiumWeather.ts @@ -1,13 +1,15 @@ import type { Viewer } from 'cesium' import { useEffect } from 'react' +import { sceneFogValues } from '@/utils/weatherStage' /** - * Manages METAR-based weather effects for Cesium rendering + * Manages Cesium's own scene fog from METAR weather data * * ## Responsibilities - * - Apply fog based on METAR visibility data + * - Turn the scene's fog on and off with the weather toggles + * - Tune the fog from METAR visibility while the Cesium weather stage draws no + * fog, and hold it at Cesium's defaults while the stage fogs the scene * - Adjust terrain draw distance in low visibility conditions - * - Coordinate with Babylon fog dome for immersive weather * * ## Fog System * Cesium fog affects: @@ -16,6 +18,10 @@ import { useEffect } from 'react' * - **Performance**: Increases screen space error in fog for faster rendering * - **Brightness**: Prevents fog from being too dark * + * The weather stage (`useCesiumWeatherStages`) fogs the scene in its composite + * pass from the same METAR visibility; while it does, the scene's fog stays at + * Cesium's defaults so the terrain is not fogged twice. + * * ## Fog Density Scale * - METAR 1/4 SM (400m): fogDensity ~0.015 (very dense fog, LIFR) * - METAR 1 SM (1600m): fogDensity ~0.006 (dense fog, IFR) @@ -31,12 +37,15 @@ import { useEffect } from 'react' * ## Dependencies * - Requires: weatherStore for fog density from METAR * - Requires: settingsStore for weather effect toggles - * - Coordinates with: useBabylonOverlay for fog dome rendering + * - Takes: whether the Cesium weather stage draws the fog itself * * @param viewer - Initialized Cesium.Viewer instance * @param showWeatherEffects - Global weather effects toggle * @param showCesiumFog - Cesium-specific fog toggle * @param fogDensity - Fog density from METAR visibility (0 to ~0.015) + * @param stageDrawsFog - Whether the Cesium weather stage is drawing the fog now + * (its fog gate on and an anchor frame); while it is not, this hook keeps + * tuning the scene's fog from METAR visibility * * @example * ```tsx @@ -45,7 +54,7 @@ import { useEffect } from 'react' * const showWeatherEffects = useSettingsStore(state => state.showWeatherEffects) * const showCesiumFog = useSettingsStore(state => state.showCesiumFog) * - * useCesiumWeather(viewer, showWeatherEffects, showCesiumFog, fogDensity) + * useCesiumWeather(viewer, showWeatherEffects, showCesiumFog, fogDensity, stageDrawsFog) * ``` */ export function useCesiumWeather( @@ -53,37 +62,21 @@ export function useCesiumWeather( showWeatherEffects: boolean, showCesiumFog: boolean, fogDensity: number, + stageDrawsFog: boolean, ) { - // Update Cesium fog based on weather effects and METAR visibility - // Cesium fog primarily reduces draw distance and fades terrain/imagery + // Update Cesium's own scene fog; it mostly reduces draw distance and fades terrain/imagery useEffect(() => { if (!viewer || viewer.isDestroyed()) return - const shouldShowFog = showWeatherEffects && showCesiumFog - viewer.scene.fog.enabled = shouldShowFog - - if (shouldShowFog && fogDensity > 0) { - // Apply fog density from METAR visibility - // fogDensity ranges from ~0.015 (1/4 SM) to ~0 (10+ SM) - viewer.scene.fog.density = fogDensity - - // visualDensityScalar controls the visual appearance of fog (default 0.15) - // Scale it based on fog density for more dramatic effect in low visibility - // Range from 0.15 (light fog) to 1.0 (very dense fog) - const visualScalar = Math.min(1.0, 0.15 + (fogDensity / 0.015) * 0.85) - viewer.scene.fog.visualDensityScalar = visualScalar - - // Increase screen space error factor in fog for better performance - viewer.scene.fog.screenSpaceErrorFactor = 2.0 - - // Prevent fog from being too dark - viewer.scene.fog.minimumBrightness = 0.1 - } else { - // Reset to default fog settings when no weather effects - viewer.scene.fog.density = 0.0006 // Cesium default - viewer.scene.fog.visualDensityScalar = 0.15 // Cesium default - viewer.scene.fog.screenSpaceErrorFactor = 2.0 - viewer.scene.fog.minimumBrightness = 0.03 // Cesium default - } - }, [viewer, showWeatherEffects, showCesiumFog, fogDensity]) + const values = sceneFogValues({ + fogEnabled: showWeatherEffects && showCesiumFog, + fogDensity, + stageDrawsFog, + }) + viewer.scene.fog.enabled = values.enabled + viewer.scene.fog.density = values.density + viewer.scene.fog.visualDensityScalar = values.visualDensityScalar + viewer.scene.fog.screenSpaceErrorFactor = values.screenSpaceErrorFactor + viewer.scene.fog.minimumBrightness = values.minimumBrightness + }, [viewer, showWeatherEffects, showCesiumFog, fogDensity, stageDrawsFog]) } diff --git a/src/renderer/hooks/useCesiumWeatherStages.test.ts b/src/renderer/hooks/useCesiumWeatherStages.test.ts new file mode 100644 index 00000000..cbe78e46 --- /dev/null +++ b/src/renderer/hooks/useCesiumWeatherStages.test.ts @@ -0,0 +1,274 @@ +import * as Cesium from 'cesium' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { WeatherStagesOptions } from '@/hooks/useCesiumWeatherStages' + +/** + * Drives `useCesiumWeatherStages` without a DOM: react's hooks are replaced by a minimal + * renderer that keeps refs, state and effect deps by call order, runs effects after each + * render, and re-renders while the effects leave state changed. + */ +const hookState = vi.hoisted(() => { + interface Slot { + deps?: unknown[] + cleanup?: (() => void) | undefined + ref?: { current: unknown } + value?: unknown + hasValue?: boolean + set?: (next: unknown) => void + } + const slots: Slot[] = [] + let effects: Array<() => void> = [] + let layoutEffects: Array<() => void> = [] + let index = 0 + let dirty = false + const depsChanged = (a: unknown[] | undefined, b: unknown[] | undefined) => + !a || !b || a.length !== b.length || a.some((value, i) => !Object.is(value, b[i])) + const slot = (): Slot => { + if (!slots[index]) slots[index] = {} + return slots[index++] + } + const register = (target: Slot, fn: () => (() => void) | undefined, deps: unknown[] | undefined) => { + if (!depsChanged(target.deps, deps)) return + target.deps = deps + const run = () => { + target.cleanup?.() + target.cleanup = fn() ?? undefined + } + return run + } + return { + begin() { + index = 0 + effects = [] + layoutEffects = [] + }, + clear() { + dirty = false + }, + isDirty: () => dirty, + runLayoutEffects() { + const queued = layoutEffects.splice(0) + for (const run of queued) run() + }, + runEffects() { + const queued = effects.splice(0) + for (const run of queued) run() + }, + reset() { + slots.length = 0 + index = 0 + effects = [] + layoutEffects = [] + dirty = false + }, + unmount() { + for (const target of slots) { + target.cleanup?.() + target.cleanup = undefined + } + }, + useRef(initial: T): { current: T } { + const target = slot() + if (!target.ref) target.ref = { current: initial } + return target.ref as { current: T } + }, + useState(initial: T | (() => T)): [T, (next: T | ((previous: T) => T)) => void] { + const target = slot() + if (!target.hasValue) { + target.hasValue = true + target.value = typeof initial === 'function' ? (initial as () => T)() : initial + } + if (!target.set) { + target.set = (next: unknown) => { + const value = typeof next === 'function' ? (next as (previous: unknown) => unknown)(target.value) : next + if (Object.is(value, target.value)) return + target.value = value + dirty = true + } + } + return [target.value as T, target.set as (next: T | ((previous: T) => T)) => void] + }, + useEffect(fn: () => (() => void) | undefined, deps?: unknown[]) { + const run = register(slot(), fn, deps) + if (run) effects.push(run) + }, + useLayoutEffect(fn: () => (() => void) | undefined, deps?: unknown[]) { + const run = register(slot(), fn, deps) + if (run) layoutEffects.push(run) + }, + } +}) + +vi.mock('react', () => { + const api = { + useRef: (initial: T) => hookState.useRef(initial), + useState: (initial: T | (() => T)) => hookState.useState(initial), + useEffect: (fn: () => (() => void) | undefined, deps?: unknown[]) => hookState.useEffect(fn, deps), + useLayoutEffect: (fn: () => (() => void) | undefined, deps?: unknown[]) => hookState.useLayoutEffect(fn, deps), + } + return { ...api, default: api } +}) + +const settings = vi.hoisted(() => ({ + weather: { + showWeatherEffects: true, + showClouds: true, + showBabylonFog: true, + visibilityScale: 1, + fogIntensity: 1, + stageQuality: 'high' as string, + }, +})) +vi.mock('@/stores/settingsStore', () => ({ + useSettingsStore: (selector: (state: typeof settings) => unknown) => selector(settings), +})) + +const weather = vi.hoisted(() => ({ + cloudLayers: [] as unknown[], + currentMetar: undefined as { visib?: number } | undefined, + useInterpolation: false, + interpolatedWeather: undefined as { visibility?: number } | undefined, +})) +vi.mock('@/stores/weatherStore', () => ({ + useWeatherStore: (selector: (state: typeof weather) => unknown) => selector(weather), +})) + +vi.mock('@/stores/weatherStageStore', () => ({ + useWeatherStageStore: (selector: (state: { cameraOverride: null; testAircraft: null }) => unknown) => + selector({ cameraOverride: null, testAircraft: null }), +})) + +const cesiumFakes = vi.hoisted(() => ({ + created: [] as Array<{ name: string; textureScale?: number }>, + sampleTerrainMostDetailed: vi.fn(async () => [{ height: 0 }]), +})) + +vi.mock('cesium', async (importOriginal) => { + const actual = await importOriginal() + class FakeStage { + name: string + textureScale: number | undefined + enabled = true + constructor(options: { name: string; textureScale?: number }) { + this.name = options.name + this.textureScale = options.textureScale + cesiumFakes.created.push(this) + } + } + class FakeComposite { + name: string + enabled = true + constructor(options: { name: string }) { + this.name = options.name + cesiumFakes.created.push(this) + } + } + return { + ...actual, + PostProcessStage: FakeStage, + PostProcessStageComposite: FakeComposite, + sampleTerrainMostDetailed: cesiumFakes.sampleTerrainMostDetailed, + } +}) + +import { useCesiumWeatherStages } from '@/hooks/useCesiumWeatherStages' + +function fakeViewer() { + const stages = { + list: [] as unknown[], + add(stage: unknown) { + this.list.push(stage) + return stage + }, + remove(stage: unknown) { + const index = this.list.indexOf(stage) + if (index >= 0) this.list.splice(index, 1) + }, + get(index: number) { + return this.list[index] + }, + get length() { + return this.list.length + }, + } + return { + isDestroyed: () => false, + terrainProvider: {}, + scene: { + postProcessStages: stages, + preRender: new Cesium.Event(), + terrainProviderChanged: new Cesium.Event(), + logarithmicDepthBuffer: true, + globe: { getHeight: () => undefined }, + }, + } +} + +const OPTIONS: WeatherStagesOptions = { + airportIcao: 'KOAK', + refLat: 37.7213, + refLon: -122.2208, + airportElevationM: 9, + followedCallsign: null, + interpolatedAircraft: new Map(), + viewMode: '3d', + isInset: false, +} + +type FakeViewer = ReturnType + +/** One render of the hook, with the effects it queued, and whether they left state changed. */ +function renderOnce(viewer: FakeViewer): { result: boolean; dirty: boolean } { + hookState.begin() + hookState.clear() + // biome-ignore lint/correctness/useHookAtTopLevel: driven by the minimal renderer above, one render per call + const result = useCesiumWeatherStages(viewer as unknown as Cesium.Viewer, OPTIONS) + hookState.runLayoutEffects() + hookState.runEffects() + return { result, dirty: hookState.isDirty() } +} + +/** Re-renders while the effects leave state changed. */ +function settle(viewer: FakeViewer): boolean { + let rendered = renderOnce(viewer) + for (let pass = 0; rendered.dirty && pass < 10; pass++) rendered = renderOnce(viewer) + if (rendered.dirty) throw new Error('the weather stage hook did not settle') + return rendered.result +} + +async function flush(): Promise { + for (let i = 0; i < 10; i++) await Promise.resolve() +} + +/** Settles the effects that only run once the terrain sample resolves. */ +async function renderSettled(viewer: FakeViewer): Promise { + let result = settle(viewer) + await flush() + result = settle(viewer) + await flush() + return result +} + +/** The cloud pass is the only stage created with a texture scale */ +function cloudTextures(): number[] { + return cesiumFakes.created.flatMap((stage) => (stage.textureScale === undefined ? [] : [stage.textureScale])) +} + +beforeEach(() => { + hookState.unmount() + hookState.reset() + cesiumFakes.created.length = 0 + settings.weather.stageQuality = 'high' +}) + +describe('useCesiumWeatherStages', () => { + it('gives the main view the cloud pass resolution of its quality preset', async () => { + const viewer = fakeViewer() + await renderSettled(viewer) + expect(cloudTextures()).toEqual([0.5]) + + settings.weather.stageQuality = 'low' + await renderSettled(viewer) + expect(cloudTextures()).toEqual([0.5, 0.25]) + }) +}) diff --git a/src/renderer/hooks/useCesiumWeatherStages.ts b/src/renderer/hooks/useCesiumWeatherStages.ts new file mode 100644 index 00000000..289f0e6c --- /dev/null +++ b/src/renderer/hooks/useCesiumWeatherStages.ts @@ -0,0 +1,666 @@ +import * as Cesium from 'cesium' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { + CLOUD_THICKNESS_BY_TYPE_M, + WEATHER_STAGE_INSET_RESIZE_SETTLE_MS, + WEATHER_STAGE_MAX_LAYERS, + WEATHER_STAGE_REANCHOR_CHECK_MS, + type WeatherStageQuality, +} from '@/constants' +import { CLOUD_STAGE_FS, CLOUD_STAGE_MAX_STEPS, WEATHER_COMPOSITE_FS } from '@/hooks/weather/cloudStage.glsl' +import { useSettingsStore } from '@/stores/settingsStore' +import { useWeatherStageStore } from '@/stores/weatherStageStore' +import { useWeatherStore } from '@/stores/weatherStore' +import type { ViewMode, WeatherQualityPreset } from '@/types' +import { aircraftModelHeading } from '@/utils/modelOrientation' +import { + anchorGround, + buildCloudLayerUniforms, + easedFogDensity, + effectiveVisibilitySM, + type FogEase, + type FogEaseInput, + type FrameGround, + groundBlendShift, + groundShiftStart, + horizontalNoiseOffset, + layerSunShade, + NO_FOG_EASE, + type NoiseOriginAnchor, + nextFogEase, + nextWeatherAnchor, + noiseOriginNeedsReset, + stageDrawsFog, + stageFogDensity, + stageFogExtinction, + stagePresetForViewport, + type WeatherAnchor, + type WeatherReference, + weatherStageGates, + weatherStageHasWork, + weatherStageQuality, +} from '@/utils/weatherStage' + +/** Name of the composite the hook adds to `scene.postProcessStages` */ +export const WEATHER_STAGE_NAME = 'tc3dWeatherStage' +const CLOUD_PASS_NAME = 'tc3dWeatherClouds' +const COMPOSITE_PASS_NAME = 'tc3dWeatherComposite' + +/** The stage's east-north-up frame on the ground under its anchor */ +interface EnuFrame { + anchor: WeatherAnchor + fixedToEnu: Cesium.Matrix4 + ecefToEnuRotation: Cesium.Matrix3 + /** The ground the frame sits on (ellipsoid height) and whether it was sampled */ + ground: FrameGround + /** This frame's origin from the noise origin, horizontal only, in this frame's axes */ + noiseOffset: Cesium.Cartesian3 + /** Vertical shift of the deck when the frame arrived, blending to 0 (see groundBlendShift) */ + shiftStartM: number + shiftStartMs: number +} + +/** Uniform values derived from the weather store and settings, refreshed when they change */ +interface StageInputs { + /** Always WEATHER_STAGE_MAX_LAYERS entries; unused slots are inactive (w = 0) */ + layers: Cesium.Cartesian4[] + /** Per layer (x..w = layer 0..3), 1 when a higher layer shades it from direct sun (`layerSunShade`) */ + layerShade: Cesium.Cartesian4 + layerCount: number + /** Fog density per meter the fog eases to (`stageFogDensity`), 0 for no fog */ + fogDensity: number + /** Optical depth multiplier of the fog, from the quality knob and `fogIntensity` */ + fogExtinction: number +} + +const NO_INPUTS: StageInputs = { + layers: Array.from({ length: WEATHER_STAGE_MAX_LAYERS }, () => new Cesium.Cartesian4(0, 1, 0, 0)), + layerShade: new Cesium.Cartesian4(0, 0, 0, 0), + layerCount: 0, + fogDensity: 0, + fogExtinction: 0, +} + +/** The fog density drawn: the ease in flight (`nextFogEase`) and its value at the last frame */ +interface FogState { + ease: FogEase + /** Density per meter drawn this frame, stepped in `preRender` */ + density: number +} + +/** The fog inputs before the first run: no visibility yet, so the first input eases in from no fog */ +const INITIAL_FOG_INPUT: FogEaseInput = { visibilitySM: undefined, gateOpen: true, targetDensity: 0 } + +/** Mutable state shared by the stage's hooks and its uniform callbacks */ +interface StageRefs { + frame: { current: EnuFrame | null } + inputs: { current: StageInputs } + fog: { current: FogState } + /** Inputs of the last effect run, which decide the next ease (`nextFogEase`) */ + fogInput: { current: FogEaseInput } + stage: { current: Cesium.PostProcessStageComposite | null } + /** Knobs of this viewport's preset; the uniform callbacks read them live */ + quality: { current: WeatherStageQuality } +} + +/** Where the noise field is fixed in the world; moved per `noiseOriginNeedsReset` */ +interface NoiseOrigin extends NoiseOriginAnchor { + point: Cesium.Cartesian3 +} + +function buildEnuFrame( + anchor: WeatherAnchor, + ground: FrameGround, + previous: EnuFrame | null, + noiseOrigin: NoiseOrigin, +): EnuFrame { + const point = Cesium.Cartesian3.fromDegrees(anchor.lon, anchor.lat, ground.groundM) + const enuToFixed = Cesium.Transforms.eastNorthUpToFixedFrame(point) + const fixedToEnu = Cesium.Matrix4.inverseTransformation(enuToFixed, new Cesium.Matrix4()) + const ecefToEnuRotation = Cesium.Matrix4.getMatrix3(fixedToEnu, new Cesium.Matrix3()) + const fromOrigin = Cesium.Cartesian3.subtract(point, noiseOrigin.point, new Cesium.Cartesian3()) + const offset = horizontalNoiseOffset( + Cesium.Matrix3.multiplyByVector(ecefToEnuRotation, fromOrigin, new Cesium.Cartesian3()), + ) + const now = performance.now() + return { + anchor, + fixedToEnu, + ecefToEnuRotation, + ground, + noiseOffset: new Cesium.Cartesian3(offset.x, offset.y, offset.z), + shiftStartM: groundShiftStart(previous, ground, now), + shiftStartMs: now, + } +} + +function currentShift(frame: EnuFrame): number { + return groundBlendShift(frame.shiftStartM, performance.now() - frame.shiftStartMs) +} + +/** Enables the stage only while it has a frame and something to draw, a fog still easing out included */ +function syncStageEnabled(refs: StageRefs): void { + const stage = refs.stage.current + if (!stage) return + const { layerCount, fogDensity } = refs.inputs.current + const fog = Math.max(fogDensity, refs.fog.current.density) + stage.enabled = weatherStageHasWork(layerCount, fog, refs.frame.current !== null) +} + +/** Moves the drawn fog density along its ease for this frame */ +function stepFog(refs: StageRefs): void { + const fog = refs.fog.current + fog.density = easedFogDensity(fog.ease, refs.inputs.current.fogDensity, performance.now()) + syncStageEnabled(refs) +} + +function setFrame(refs: StageRefs, frame: EnuFrame | null): void { + refs.frame.current = frame + syncStageEnabled(refs) +} + +/** + * Builds the cloud and composite passes. Cesium reads uniforms only while the + * stage is enabled, and `syncStageEnabled` enables it only with a frame, so + * the uniform callbacks can count on one. + * + * The cloud pass resolution is fixed here; the other knobs are read live from `refs.quality`. + * + * @param startMs - Time the cloud drift counts from; kept for the viewer's lifetime so the drift does not snap back + * @param resolutionScale - Cloud pass resolution relative to the canvas, the preset's `resolutionScale` + */ +function createStage( + viewer: Cesium.Viewer, + refs: StageRefs, + startMs: number, + resolutionScale: number, +): Cesium.PostProcessStageComposite { + const { frame: frameRef, inputs: inputsRef, fog: fogRef, quality: qualityRef } = refs + const scratchCam = new Cesium.Cartesian3() + const layerUniform = (index: number) => () => inputsRef.current.layers[index] + const frame = (): EnuFrame => { + if (!frameRef.current) { + throw new Error('[WeatherStage] Stage drawn without a frame; syncStageEnabled should have disabled it') + } + return frameRef.current + } + + const shared = { + // The deck shift is added to the camera, which moves the whole field (noise included) relative to it. + u_camPosEnu: () => { + const current = frame() + Cesium.Matrix4.multiplyByPoint(current.fixedToEnu, viewer.camera.positionWC, scratchCam) + scratchCam.z += currentShift(current) + return scratchCam + }, + u_ecefToEnu: () => frame().ecefToEnuRotation, + // The shader takes the fog as a visibility (density = 1 / visibility); 0 draws no fog. + u_visibility: () => (fogRef.current.density > 0 ? 1 / fogRef.current.density : 0), + u_fogExtinction: () => inputsRef.current.fogExtinction, + u_fogHeightScale: () => qualityRef.current.fogHeightScale, + } + + const clouds = new Cesium.PostProcessStage({ + name: CLOUD_PASS_NAME, + fragmentShader: CLOUD_STAGE_FS, + textureScale: Cesium.Math.clamp(resolutionScale, 0.25, 1), + uniforms: { + ...shared, + u_layer0: layerUniform(0), + u_layer1: layerUniform(1), + u_layer2: layerUniform(2), + u_layer3: layerUniform(3), + u_layerShade: () => inputsRef.current.layerShade, + u_layerCount: () => inputsRef.current.layerCount, + u_steps: () => Cesium.Math.clamp(Math.round(qualityRef.current.steps), 4, CLOUD_STAGE_MAX_STEPS), + u_densityScale: () => qualityRef.current.densityScale, + u_radius: () => qualityRef.current.radius, + u_time: () => (performance.now() - startMs) / 1000, + u_noiseOffset: () => frame().noiseOffset, + }, + }) + + const composite = new Cesium.PostProcessStage({ + name: COMPOSITE_PASS_NAME, + fragmentShader: WEATHER_COMPOSITE_FS, + uniforms: { ...shared, cloudTexture: CLOUD_PASS_NAME }, + }) + + return new Cesium.PostProcessStageComposite({ + name: WEATHER_STAGE_NAME, + stages: [clouds, composite], + inputPreviousStageTexture: false, + }) +} + +/** What the anchor follows; read every WEATHER_STAGE_REANCHOR_CHECK_MS */ +interface ReferenceInputs { + airportIcao: string | null + refLat: number | null + refLon: number | null + airportElevationM: number | null + followedCallsign: string | null + interpolatedAircraft: ReadonlyMap +} + +function currentReference(input: ReferenceInputs): WeatherReference { + if (!input.airportIcao && input.followedCallsign) { + const aircraft = input.interpolatedAircraft.get(input.followedCallsign) + if (aircraft) { + return { airportIcao: null, lat: aircraft.interpolatedLatitude, lon: aircraft.interpolatedLongitude } + } + } + return { + airportIcao: input.airportIcao, + lat: input.refLat, + lon: input.refLon, + groundHintM: input.airportIcao ? input.airportElevationM : null, + } +} + +/** + * The stage's anchor from `nextWeatherAnchor`: re-checked when the reference + * inputs change, and every WEATHER_STAGE_REANCHOR_CHECK_MS, since a followed + * aircraft moves without re-rendering the viewer. + */ +function useWeatherAnchor(viewer: Cesium.Viewer | null, reference: ReferenceInputs): WeatherAnchor | null { + const { airportIcao, refLat, refLon, airportElevationM, followedCallsign, interpolatedAircraft } = reference + const referenceRef = useRef(reference) + referenceRef.current = reference + const [anchor, setAnchor] = useState(null) + + useEffect(() => { + const next = currentReference({ + airportIcao, + refLat, + refLon, + airportElevationM, + followedCallsign, + interpolatedAircraft, + }) + setAnchor((previous) => nextWeatherAnchor(previous, next)) + }, [airportIcao, refLat, refLon, airportElevationM, followedCallsign, interpolatedAircraft]) + + useEffect(() => { + if (!viewer || viewer.isDestroyed()) return + let lastCheckMs = 0 + return viewer.scene.preRender.addEventListener(() => { + const now = performance.now() + if (now - lastCheckMs < WEATHER_STAGE_REANCHOR_CHECK_MS) return + lastCheckMs = now + const next = currentReference(referenceRef.current) + setAnchor((previous) => nextWeatherAnchor(previous, next)) + }) + }, [viewer]) + + return anchor +} + +/** + * Keeps the stage's frame on the ground under the anchor: a placeholder ground + * (see `anchorGround`) at once, then the terrain sample, taken again when the + * terrain provider changes. A sample that fails or comes back without a + * height is retried every WEATHER_STAGE_REANCHOR_CHECK_MS until one succeeds. + * Clears the frame while the stage is off or there is no anchor. + */ +function useWeatherStageFrame( + viewer: Cesium.Viewer | null, + anchor: WeatherAnchor | null, + stageOn: boolean, + refs: StageRefs, +): void { + const noiseOriginRef = useRef(null) + const retryPendingRef = useRef(false) + const [terrainVersion, setTerrainVersion] = useState(0) + const [sampleAttempt, setSampleAttempt] = useState(0) + + useEffect(() => { + if (!viewer || viewer.isDestroyed()) return + const removeTerrainListener = viewer.scene.terrainProviderChanged.addEventListener(() => + setTerrainVersion((version) => version + 1), + ) + let lastCheckMs = 0 + const removeRetry = viewer.scene.preRender.addEventListener(() => { + const now = performance.now() + if (!retryPendingRef.current || now - lastCheckMs < WEATHER_STAGE_REANCHOR_CHECK_MS) return + lastCheckMs = now + retryPendingRef.current = false + setSampleAttempt((attempt) => attempt + 1) + }) + return () => { + removeTerrainListener() + removeRetry() + } + }, [viewer]) + + // terrainVersion re-runs the sample once the world terrain replaces the bare ellipsoid provider, + // and sampleAttempt re-runs a sample that failed. + // biome-ignore lint/correctness/useExhaustiveDependencies: see comment above + useEffect(() => { + retryPendingRef.current = false + if (!viewer || viewer.isDestroyed() || !stageOn || !anchor) { + setFrame(refs, null) + return + } + let cancelled = false + const { key, lat, lon, groundHintM } = anchor + const ellipsoidProvider = viewer.terrainProvider instanceof Cesium.EllipsoidTerrainProvider + const applyGround = (sampledM: number | undefined): FrameGround => { + const previous = refs.frame.current + const ground = anchorGround({ + key, + sampledM, + ellipsoidProvider, + hintM: groundHintM, + previous: previous?.ground ?? null, + moved: previous?.anchor !== anchor, + }) + const kept = noiseOriginRef.current + const origin = + kept && !noiseOriginNeedsReset(kept, anchor) + ? kept + : { key, lat, lon, point: Cesium.Cartesian3.fromDegrees(lon, lat, ground.groundM) } + noiseOriginRef.current = origin + setFrame(refs, buildEnuFrame(anchor, ground, previous, origin)) + return ground + } + if (refs.frame.current?.anchor !== anchor) { + applyGround(undefined) + } + if (ellipsoidProvider) { + return + } + Cesium.sampleTerrainMostDetailed(viewer.terrainProvider, [Cesium.Cartographic.fromDegrees(lon, lat)]) + .then(([sample]) => { + if (cancelled || viewer.isDestroyed()) return + // The sample can come back without a height; the loaded globe tile is the next best source. + const ground = applyGround(sample.height ?? viewer.scene.globe.getHeight(sample)) + retryPendingRef.current = !ground.sampled + const source = ground.sampled ? 'sampled' : 'placeholder, retrying' + console.log(`[WeatherStage] Anchored on ${key} at ${lat}, ${lon}, ground ${ground.groundM} m (${source})`) + }) + .catch((error) => { + if (cancelled) return + retryPendingRef.current = true + console.warn( + `[WeatherStage] Terrain sample at ${lat}, ${lon} failed; keeping ground ${refs.frame.current?.ground.groundM} m and retrying:`, + error, + ) + }) + return () => { + cancelled = true + } + }, [viewer, anchor, stageOn, terrainVersion, sampleAttempt, refs]) +} + +/** + * Adds the stage to the scene while it is on, and keeps it last among the + * scene's post-process stages: stages added later (the silhouette stage is + * added a few frames after the viewer) would draw over the clouds and fog. + * Removing destroys a stage, so a fresh one is added at the end. A new cloud + * pass resolution rebuilds the stage, since Cesium reads it at creation. The + * cloud drift start time is held for the viewer's lifetime, so neither + * re-creation nor the stage going off and on snaps the drift back. + */ +function useWeatherStageComposite( + viewer: Cesium.Viewer | null, + stageOn: boolean, + refs: StageRefs, + resolutionScale: number, +): void { + const startMsRef = useRef<{ viewer: Cesium.Viewer; startMs: number } | null>(null) + + useEffect(() => { + if (!viewer || viewer.isDestroyed() || !stageOn) return + if (startMsRef.current?.viewer !== viewer) { + startMsRef.current = { viewer, startMs: performance.now() } + } + const { startMs } = startMsRef.current + const stages = viewer.scene.postProcessStages + const addStage = () => { + refs.stage.current = stages.add( + createStage(viewer, refs, startMs, resolutionScale), + ) as Cesium.PostProcessStageComposite + syncStageEnabled(refs) + } + addStage() + const easeFog = viewer.scene.preRender.addEventListener(() => stepFog(refs)) + const keepLast = viewer.scene.preRender.addEventListener(() => { + const current = refs.stage.current + if (!current || stages.get(stages.length - 1) === current) return + stages.remove(current) + addStage() + }) + return () => { + easeFog() + keepLast() + if (refs.stage.current && !viewer.isDestroyed()) { + stages.remove(refs.stage.current) + } + refs.stage.current = null + } + }, [viewer, stageOn, refs, resolutionScale]) +} + +/** Areas of an inset and of the main window it sits in, in CSS pixels squared */ +interface InsetAreas { + insetArea: number + mainArea: number +} + +/** + * This inset window's area and its parent (main) window's. Null, with a warning, + * when the parent window cannot be read. + */ +function readInsetAreas(): InsetAreas | null { + const insetArea = window.innerWidth * window.innerHeight + try { + return { insetArea, mainArea: window.parent.innerWidth * window.parent.innerHeight } + } catch (error) { + console.warn("[WeatherStage] Cannot read the main window's size from the inset; using the main preset:", error) + return null + } +} + +/** + * The quality preset this viewport's stage uses (`stagePresetForViewport`): the + * `stageQuality` setting in the main view; in an inset, its size against the main + * window, read once on mount and again once a resize has been quiet for + * WEATHER_STAGE_INSET_RESIZE_SETTLE_MS, so a drag does not rebuild the stage on every frame. + */ +function useStagePreset(isInset: boolean): WeatherQualityPreset { + const mainPreset = useSettingsStore((state) => state.weather.stageQuality) + const [insetAreas, setInsetAreas] = useState(() => (isInset ? readInsetAreas() : null)) + + useEffect(() => { + if (!isInset) return + let timer: ReturnType | undefined + const onResize = () => { + clearTimeout(timer) + timer = setTimeout(() => setInsetAreas(readInsetAreas()), WEATHER_STAGE_INSET_RESIZE_SETTLE_MS) + } + window.addEventListener('resize', onResize) + return () => { + clearTimeout(timer) + window.removeEventListener('resize', onResize) + } + }, [isInset]) + + return stagePresetForViewport({ + stageQuality: mainPreset, + isInset, + insetArea: insetAreas?.insetArea ?? null, + mainArea: insetAreas?.mainArea ?? null, + }) +} + +/** Inputs of `useCesiumWeatherStages` from the viewer component */ +export interface WeatherStagesOptions extends ReferenceInputs { + viewMode: ViewMode + /** Whether the viewport is an inset, whose preset follows its size against the main view */ + isInset: boolean +} + +/** + * Draws METAR cloud layers and visibility fog as one Cesium post-process + * composite that stops every ray at the scene depth, so aircraft, buildings and + * terrain in front of a cloud or fog hide it. + * + * Reads `weatherStore` (cloud layers; METAR visibility, or the interpolated + * visibility while weather interpolation is on) and the weather settings: + * `showClouds` gates the clouds, `showBabylonFog` the fog and `visibilityScale` + * scales the fog visibility while `fogIntensity` scales its optical depth, under + * `showWeatherEffects`. The fog fades in between 10 and 8 SM (`stageFogFade`); a + * change of the visibility input, METAR or interpolated, eases the fog over + * WEATHER_STAGE_FOG_EASE_MS, while a settings or view change applies at once + * (`nextFogEase`, `easedFogDensity`, stepped in `preRender`). Its knobs come from the `stageQuality` preset (see + * `useStagePreset` for insets): steps, radius and the look knobs apply live, and a + * new cloud pass resolution rebuilds the stage. Draws nothing in the top-down view, and is disabled while + * there is no anchor or nothing to draw. Without a logarithmic depth buffer on the + * scene the stage stays out (no clouds or METAR fog are drawn) and one warning is logged. The stage is kept last among the scene's + * post-process stages, so the aircraft silhouette outlines are fogged and + * clouded too. + * + * The east-north-up frame sits on the ground under the anchor from + * `nextWeatherAnchor`, checked every WEATHER_STAGE_REANCHOR_CHECK_MS: keyed on + * the airport, or following an aircraft in orbit mode without one. A + * placeholder ground (the airport elevation) holds until the terrain is + * sampled (a failed sample is retried), and the terrain is resampled when its provider changes; changes + * between two sampled grounds blend over WEATHER_STAGE_GROUND_BLEND_MS, and + * the cloud noise stays fixed horizontally in the world when the anchor moves, + * re-centred once the anchor is WEATHER_STAGE_NOISE_ORIGIN_RESET_M from its origin. + * + * @param viewer - Cesium viewer of this viewport (main or inset) + * @param options - Reference position inputs and view mode of this viewport + * @returns Whether the stage actually draws the fog now (its fog gate on and an + * anchor frame to draw in), so the caller can leave Cesium's own scene fog + * tuned from METAR visibility while it does not + */ +export function useCesiumWeatherStages(viewer: Cesium.Viewer | null, options: WeatherStagesOptions): boolean { + const showWeatherEffects = useSettingsStore((state) => state.weather.showWeatherEffects) + const showClouds = useSettingsStore((state) => state.weather.showClouds) + const showBabylonFog = useSettingsStore((state) => state.weather.showBabylonFog) + const visibilityScale = useSettingsStore((state) => state.weather.visibilityScale) + const fogIntensity = useSettingsStore((state) => state.weather.fogIntensity) + const cloudLayers = useWeatherStore((state) => state.cloudLayers) + const metarSM = useWeatherStore((state) => state.currentMetar?.visib) + const useInterpolation = useWeatherStore((state) => state.useInterpolation) + const interpolatedSM = useWeatherStore((state) => state.interpolatedWeather?.visibility) + + // The scene's flag is fixed when the viewer is created; it is false without fragmentDepth too. + const logDepth = viewer && !viewer.isDestroyed() ? viewer.scene.logarithmicDepthBuffer : true + const viewMode = options.viewMode + const gates = weatherStageGates({ showWeatherEffects, showClouds, showBabylonFog, viewMode, logDepth }) + + const quality = weatherStageQuality(useStagePreset(options.isInset)) + const refs = useRef({ + frame: { current: null }, + inputs: { current: NO_INPUTS }, + fog: { current: { ease: NO_FOG_EASE, density: 0 } }, + fogInput: { current: INITIAL_FOG_INPUT }, + stage: { current: null }, + quality: { current: quality }, + }).current + useLayoutEffect(() => { + refs.quality.current = quality + }, [quality, refs]) + const warnedLogDepthRef = useRef(false) + const anchor = useWeatherAnchor(viewer, options) + + useEffect(() => { + const layers = gates.clouds ? buildCloudLayerUniforms(cloudLayers, CLOUD_THICKNESS_BY_TYPE_M) : [] + const visibSM = effectiveVisibilitySM({ metarSM, useInterpolation, interpolatedSM }) + const shade = layerSunShade(layers) + const fogDensity = gates.fog ? stageFogDensity(visibSM, visibilityScale) : 0 + // A visibility step eases over WEATHER_STAGE_FOG_EASE_MS; the scale, the gate and the view apply at once. + const fogInput: FogEaseInput = { visibilitySM: visibSM, gateOpen: gates.fog, targetDensity: fogDensity } + refs.fog.current.ease = nextFogEase(refs.fog.current.ease, refs.fogInput.current, fogInput, performance.now()) + refs.fogInput.current = fogInput + refs.inputs.current = { + layers: NO_INPUTS.layers.map((inactive, index) => { + const layer = layers[index] + return layer ? new Cesium.Cartesian4(layer.base, layer.thickness, layer.coverage, 1) : inactive + }), + layerShade: new Cesium.Cartesian4(shade[0] ?? 0, shade[1] ?? 0, shade[2] ?? 0, shade[3] ?? 0), + layerCount: layers.length, + fogDensity, + fogExtinction: stageFogExtinction(quality.fogExtinction, fogIntensity), + } + syncStageEnabled(refs) + }, [ + gates.clouds, + gates.fog, + cloudLayers, + metarSM, + useInterpolation, + interpolatedSM, + visibilityScale, + fogIntensity, + quality.fogExtinction, + refs, + ]) + + useEffect(() => { + if (!gates.missingLogDepth || warnedLogDepthRef.current) return + warnedLogDepthRef.current = true + console.warn( + '[WeatherStage] This view has no logarithmic depth buffer, so the Cesium stage draws no clouds or METAR fog; ' + + "only the globe's distance fog shows.", + ) + }, [gates.missingLogDepth]) + + useWeatherStageFrame(viewer, anchor, gates.stage, refs) + useWeatherStageComposite(viewer, gates.stage, refs, quality.resolutionScale) + return stageDrawsFog(gates.fog, anchor !== null) +} + +/** + * Dev-only drive aids for the stage: a fixed camera pose and a fixed test + * aircraft from `weatherStageStore`, so occlusion cases can be framed without + * live traffic or the app's camera modes. Set from `window.__tc3d.weatherStage`. + * + * Mount after `useCesiumCamera`, so the override's `preRender` listener runs + * after the app's and its pose wins each frame. + * + * @param viewer - Main viewport's Cesium viewer, or null to do nothing + */ +export function useWeatherStageDriveAids(viewer: Cesium.Viewer | null): void { + const cameraOverride = useWeatherStageStore((state) => state.cameraOverride) + const testAircraft = useWeatherStageStore((state) => state.testAircraft) + + useEffect(() => { + if (!import.meta.env.DEV || !viewer || viewer.isDestroyed() || !cameraOverride) return + const { lat, lon, heightM, heading, pitch } = cameraOverride + const destination = Cesium.Cartesian3.fromDegrees(lon, lat, heightM) + const orientation = { heading: Cesium.Math.toRadians(heading), pitch: Cesium.Math.toRadians(pitch), roll: 0 } + return viewer.scene.preRender.addEventListener(() => { + viewer.camera.setView({ destination, orientation }) + }) + }, [viewer, cameraOverride]) + + useEffect(() => { + if (!import.meta.env.DEV || !viewer || viewer.isDestroyed() || !testAircraft) return + let model: Cesium.Model | null = null + let cancelled = false + const position = Cesium.Cartesian3.fromDegrees(testAircraft.lon, testAircraft.lat, testAircraft.heightM) + const hpr = new Cesium.HeadingPitchRoll(aircraftModelHeading(testAircraft.heading, 0), 0, 0) + Cesium.Model.fromGltfAsync({ + url: './b738.glb', + modelMatrix: Cesium.Transforms.headingPitchRollToFixedFrame(position, hpr), + }) + .then((loaded) => { + if (cancelled || viewer.isDestroyed()) { + loaded.destroy() + return + } + model = viewer.scene.primitives.add(loaded) as Cesium.Model + }) + .catch((error) => console.error('[WeatherStage] Test aircraft failed to load:', error)) + return () => { + cancelled = true + if (model && !viewer.isDestroyed()) { + viewer.scene.primitives.remove(model) + } + } + }, [viewer, testAircraft]) +} diff --git a/src/renderer/hooks/useSmoothedWeather.ts b/src/renderer/hooks/useSmoothedWeather.ts deleted file mode 100644 index e2cec2a8..00000000 --- a/src/renderer/hooks/useSmoothedWeather.ts +++ /dev/null @@ -1,440 +0,0 @@ -/** - * Weather smoothing hook with hysteresis - * - * Provides smoothed weather values that transition gradually rather than - * instantly when METAR/interpolated weather data updates. This prevents - * jarring visual changes when flying between weather stations or when - * rapidly changing interpolated weather data. - * - * Smoothed parameters: - * - Visibility (affects fog dome scale and opacity) - * - Fog density - * - Wind direction (circular interpolation for wrap-around) - * - Wind speed and gust speed - * - Precipitation state (with onset/cessation hysteresis) - * - Thunderstorm state (with onset/cessation hysteresis) - */ - -import { useCallback, useEffect, useRef } from 'react' -import { - FOG_DENSITY_TRANSITION_TIME, - PRECIPITATION_CESSATION_DELAY, - PRECIPITATION_FADE_TIME, - PRECIPITATION_ONSET_DELAY, - THUNDERSTORM_CESSATION_DELAY, - THUNDERSTORM_ONSET_DELAY, - VISIBILITY_TRANSITION_TIME, - WIND_DIRECTION_TRANSITION_TIME, - WIND_SPEED_TRANSITION_TIME, -} from '@/constants' -import type { PrecipitationState, WindState } from '@/types' -import { useWeatherStore } from '../stores/weatherStore' - -export interface SmoothedWeatherState { - /** Smoothed visibility in statute miles */ - visibility: number - /** Smoothed fog density (0 to ~0.015) */ - fogDensity: number - /** Smoothed wind state */ - wind: WindState - /** Smoothed precipitation state with intensity factor (0-1) */ - precipitation: SmoothedPrecipitationState - /** Whether any smoothing is currently active */ - isTransitioning: boolean -} - -export interface SmoothedPrecipitationState extends PrecipitationState { - /** - * Intensity factor for precipitation particles (0-1) - * Used to fade in/out particle emit rates smoothly - */ - intensityFactor: number - /** - * Intensity factor for thunderstorm effects (0-1) - * Controls lightning flash probability/intensity - */ - thunderstormFactor: number -} - -interface SmoothingState { - // Current smoothed values - visibility: number - fogDensity: number - windDirection: number - windSpeed: number - windGustSpeed: number | null - - // Target values from weather store - targetVisibility: number - targetFogDensity: number - targetWindDirection: number - targetWindSpeed: number - targetWindGustSpeed: number | null - - // Precipitation hysteresis - precipitationActive: boolean - precipitationIntensityFactor: number - precipitationOnsetTime: number | null // When precip was first detected - precipitationCessationTime: number | null // When precip was last active - - // Thunderstorm hysteresis - thunderstormActive: boolean - thunderstormFactor: number - thunderstormOnsetTime: number | null - thunderstormCessationTime: number | null - - // Track if values are initialized - initialized: boolean -} - -/** - * Interpolates between two angles (in degrees) taking the shortest path - * Handles wrap-around at 0/360 degrees - */ -function lerpAngle(current: number, target: number, t: number): number { - // Normalize angles to 0-360 - current = ((current % 360) + 360) % 360 - target = ((target % 360) + 360) % 360 - - // Find the shortest angular distance - let diff = target - current - if (diff > 180) diff -= 360 - if (diff < -180) diff += 360 - - // Interpolate - const result = current + diff * t - // Normalize result - return ((result % 360) + 360) % 360 -} - -/** - * Linear interpolation with clamping to prevent overshoot - */ -function lerp(current: number, target: number, t: number): number { - const clamped = Math.max(0, Math.min(1, t)) - return current + (target - current) * clamped -} - -/** - * Calculate interpolation factor based on delta time and transition time - * Uses exponential smoothing formula: 1 - e^(-dt/tau) - * This provides smooth asymptotic approach to target - */ -function calcLerpFactor(deltaSeconds: number, transitionTime: number): number { - if (transitionTime <= 0) return 1 - return 1 - Math.exp(-deltaSeconds / (transitionTime / 3)) -} - -export interface UseSmoothedWeatherOptions { - /** Whether smoothing is enabled (default: true) */ - enabled?: boolean - /** Callback called each frame with smoothed values */ - onUpdate?: (state: SmoothedWeatherState) => void -} - -/** - * Hook that provides smoothed weather values with hysteresis - */ -export function useSmoothedWeather(options: UseSmoothedWeatherOptions = {}) { - const { enabled = true, onUpdate } = options - - // Store subscriptions - const currentMetar = useWeatherStore((state) => state.currentMetar) - const fogDensity = useWeatherStore((state) => state.fogDensity) - const wind = useWeatherStore((state) => state.wind) - const precipitation = useWeatherStore((state) => state.precipitation) - - // Smoothing state ref (persists across renders) - const stateRef = useRef({ - visibility: 10, - fogDensity: 0, - windDirection: 0, - windSpeed: 0, - windGustSpeed: null, - targetVisibility: 10, - targetFogDensity: 0, - targetWindDirection: 0, - targetWindSpeed: 0, - targetWindGustSpeed: null, - precipitationActive: false, - precipitationIntensityFactor: 0, - precipitationOnsetTime: null, - precipitationCessationTime: null, - thunderstormActive: false, - thunderstormFactor: 0, - thunderstormOnsetTime: null, - thunderstormCessationTime: null, - initialized: false, - }) - - // Last update time for delta calculation - const lastUpdateRef = useRef(0) - - // Animation frame ID for cleanup - const frameIdRef = useRef(null) - - // Smoothed state for external consumption - const smoothedStateRef = useRef({ - visibility: 10, - fogDensity: 0, - wind: { direction: 0, speed: 0, gustSpeed: null, isVariable: false }, - precipitation: { - active: false, - types: [], - visibilityFactor: 1, - hasThunderstorm: false, - intensityFactor: 0, - thunderstormFactor: 0, - }, - isTransitioning: false, - }) - - // Update targets when weather store changes - useEffect(() => { - const state = stateRef.current - - // Update visibility target - if (currentMetar) { - state.targetVisibility = currentMetar.visib - } - - // Update fog density target - state.targetFogDensity = fogDensity - - // Update wind targets - state.targetWindDirection = wind.direction - state.targetWindSpeed = wind.speed - state.targetWindGustSpeed = wind.gustSpeed - - // Initialize on first update - if (!state.initialized && currentMetar) { - state.visibility = currentMetar.visib - state.fogDensity = fogDensity - state.windDirection = wind.direction - state.windSpeed = wind.speed - state.windGustSpeed = wind.gustSpeed - state.initialized = true - } - }, [currentMetar, fogDensity, wind]) - - // Handle precipitation hysteresis - useEffect(() => { - const state = stateRef.current - const now = performance.now() - - if (precipitation.active) { - // Precipitation is reported - if (!state.precipitationActive && state.precipitationOnsetTime === null) { - // Start onset timer - state.precipitationOnsetTime = now - } - // Clear cessation timer - state.precipitationCessationTime = null - } else { - // Precipitation not reported - if (state.precipitationActive && state.precipitationCessationTime === null) { - // Start cessation timer - state.precipitationCessationTime = now - } - // Clear onset timer - state.precipitationOnsetTime = null - } - - // Handle thunderstorm separately - if (precipitation.hasThunderstorm) { - if (!state.thunderstormActive && state.thunderstormOnsetTime === null) { - state.thunderstormOnsetTime = now - } - state.thunderstormCessationTime = null - } else { - if (state.thunderstormActive && state.thunderstormCessationTime === null) { - state.thunderstormCessationTime = now - } - state.thunderstormOnsetTime = null - } - }, [precipitation.active, precipitation.hasThunderstorm]) - - // Animation loop for smooth interpolation - const updateSmoothing = useCallback(() => { - const now = performance.now() - const state = stateRef.current - const smoothed = smoothedStateRef.current - - if (!state.initialized) { - frameIdRef.current = requestAnimationFrame(updateSmoothing) - return - } - - // Calculate delta time (clamped to prevent huge jumps when tab was backgrounded) - const deltaMs = lastUpdateRef.current ? now - lastUpdateRef.current : 16 - const deltaSeconds = Math.min(deltaMs / 1000, 0.1) - lastUpdateRef.current = now - - let isTransitioning = false - - if (enabled) { - // Smooth visibility - const visLerpFactor = calcLerpFactor(deltaSeconds, VISIBILITY_TRANSITION_TIME) - if (Math.abs(state.visibility - state.targetVisibility) > 0.01) { - state.visibility = lerp(state.visibility, state.targetVisibility, visLerpFactor) - isTransitioning = true - } else { - state.visibility = state.targetVisibility - } - - // Smooth fog density - const fogLerpFactor = calcLerpFactor(deltaSeconds, FOG_DENSITY_TRANSITION_TIME) - if (Math.abs(state.fogDensity - state.targetFogDensity) > 0.0001) { - state.fogDensity = lerp(state.fogDensity, state.targetFogDensity, fogLerpFactor) - isTransitioning = true - } else { - state.fogDensity = state.targetFogDensity - } - - // Smooth wind direction (circular interpolation) - const windDirLerpFactor = calcLerpFactor(deltaSeconds, WIND_DIRECTION_TRANSITION_TIME) - const dirDiff = Math.abs(((state.windDirection - state.targetWindDirection + 180) % 360) - 180) - if (dirDiff > 1) { - state.windDirection = lerpAngle(state.windDirection, state.targetWindDirection, windDirLerpFactor) - isTransitioning = true - } else { - state.windDirection = state.targetWindDirection - } - - // Smooth wind speed - const windSpeedLerpFactor = calcLerpFactor(deltaSeconds, WIND_SPEED_TRANSITION_TIME) - if (Math.abs(state.windSpeed - state.targetWindSpeed) > 0.5) { - state.windSpeed = lerp(state.windSpeed, state.targetWindSpeed, windSpeedLerpFactor) - isTransitioning = true - } else { - state.windSpeed = state.targetWindSpeed - } - - // Smooth gust speed - if (state.targetWindGustSpeed !== null) { - const currentGust = state.windGustSpeed ?? state.windSpeed - if (Math.abs(currentGust - state.targetWindGustSpeed) > 0.5) { - state.windGustSpeed = lerp(currentGust, state.targetWindGustSpeed, windSpeedLerpFactor) - isTransitioning = true - } else { - state.windGustSpeed = state.targetWindGustSpeed - } - } else { - state.windGustSpeed = null - } - - // Handle precipitation hysteresis and fade - if (state.precipitationOnsetTime !== null) { - const elapsed = (now - state.precipitationOnsetTime) / 1000 - if (elapsed >= PRECIPITATION_ONSET_DELAY) { - state.precipitationActive = true - state.precipitationOnsetTime = null - } - } - if (state.precipitationCessationTime !== null) { - const elapsed = (now - state.precipitationCessationTime) / 1000 - if (elapsed >= PRECIPITATION_CESSATION_DELAY) { - state.precipitationActive = false - state.precipitationCessationTime = null - } - } - - // Fade precipitation intensity factor - const precipFadeFactor = calcLerpFactor(deltaSeconds, PRECIPITATION_FADE_TIME) - const targetIntensity = state.precipitationActive ? 1 : 0 - if (Math.abs(state.precipitationIntensityFactor - targetIntensity) > 0.01) { - state.precipitationIntensityFactor = lerp(state.precipitationIntensityFactor, targetIntensity, precipFadeFactor) - isTransitioning = true - } else { - state.precipitationIntensityFactor = targetIntensity - } - - // Handle thunderstorm hysteresis and fade - if (state.thunderstormOnsetTime !== null) { - const elapsed = (now - state.thunderstormOnsetTime) / 1000 - if (elapsed >= THUNDERSTORM_ONSET_DELAY) { - state.thunderstormActive = true - state.thunderstormOnsetTime = null - } - } - if (state.thunderstormCessationTime !== null) { - const elapsed = (now - state.thunderstormCessationTime) / 1000 - if (elapsed >= THUNDERSTORM_CESSATION_DELAY) { - state.thunderstormActive = false - state.thunderstormCessationTime = null - } - } - - // Fade thunderstorm factor - const targetThunderstorm = state.thunderstormActive ? 1 : 0 - if (Math.abs(state.thunderstormFactor - targetThunderstorm) > 0.01) { - state.thunderstormFactor = lerp(state.thunderstormFactor, targetThunderstorm, precipFadeFactor) - isTransitioning = true - } else { - state.thunderstormFactor = targetThunderstorm - } - } else { - // Smoothing disabled - use target values directly - state.visibility = state.targetVisibility - state.fogDensity = state.targetFogDensity - state.windDirection = state.targetWindDirection - state.windSpeed = state.targetWindSpeed - state.windGustSpeed = state.targetWindGustSpeed - state.precipitationActive = precipitation.active - state.precipitationIntensityFactor = precipitation.active ? 1 : 0 - state.thunderstormActive = precipitation.hasThunderstorm - state.thunderstormFactor = precipitation.hasThunderstorm ? 1 : 0 - } - - // Update smoothed state for external consumption - smoothed.visibility = state.visibility - smoothed.fogDensity = state.fogDensity - smoothed.wind = { - direction: Math.round(state.windDirection), - speed: Math.round(state.windSpeed), - gustSpeed: state.windGustSpeed !== null ? Math.round(state.windGustSpeed) : null, - isVariable: wind.isVariable, - } - smoothed.precipitation = { - active: state.precipitationIntensityFactor > 0.01, - types: precipitation.types, - visibilityFactor: precipitation.visibilityFactor, - hasThunderstorm: state.thunderstormFactor > 0.01, - intensityFactor: state.precipitationIntensityFactor, - thunderstormFactor: state.thunderstormFactor, - } - smoothed.isTransitioning = isTransitioning - - // Call update callback - onUpdate?.(smoothed) - - // Continue animation loop - frameIdRef.current = requestAnimationFrame(updateSmoothing) - }, [enabled, precipitation, wind.isVariable, onUpdate]) - - // Start/stop animation loop - useEffect(() => { - frameIdRef.current = requestAnimationFrame(updateSmoothing) - - return () => { - if (frameIdRef.current !== null) { - cancelAnimationFrame(frameIdRef.current) - frameIdRef.current = null - } - } - }, [updateSmoothing]) - - // Return current smoothed state getter - const getSmoothedWeather = useCallback((): SmoothedWeatherState => { - return { ...smoothedStateRef.current } - }, []) - - return { - /** Get the current smoothed weather state */ - getSmoothedWeather, - /** Reference to smoothed state (for direct access in render loops) */ - smoothedStateRef, - } -} - -export default useSmoothedWeather diff --git a/src/renderer/hooks/useTowerModel.ts b/src/renderer/hooks/useTowerModel.ts new file mode 100644 index 00000000..1985bed9 --- /dev/null +++ b/src/renderer/hooks/useTowerModel.ts @@ -0,0 +1,302 @@ +/** + * useTowerModel Hook + * + * Loads the modded tower cab glTF for the current airport as a Cesium model, so it writes the + * scene's depth (clouds and fog of the weather stage stop at it, terrain and aircraft hide it + * normally), casts and receives Cesium shadows, and is lit by the Cesium sun. + * + * One cab per airport, swapped on airport change. The position comes from + * `modService.getTowerPlacement` (manifest `modelPosition`, then tower-positions, else no cab). + * The cab's base sits on the ground height `resolveGroundHeight` picks (terrain sample, loaded + * globe tile, field elevation) plus the placement height. When the terrain provider is not ready + * at load, the cab is placed on the field elevation and re-placed once terrain arrives. + * + * At night the cab darkens with the scene: its light colour and image-based lighting follow the + * same sun-elevation curve and the same settings as the imagery darkening. + * + * While the positioning wizard edits the model step for the current airport, every offset or + * rotation change rewrites the model matrix without reloading the glTF. + */ + +import * as Cesium from 'cesium' +import { useCallback, useEffect, useRef } from 'react' +import { geoidService } from '@/services/GeoidService' +import { modService } from '@/services/ModService' +import { useAirportStore } from '@/stores/airportStore' +import { useTowerPositioningStore } from '@/stores/towerPositioningStore' +import type { TowerModelHandle } from '@/types' +import { neutralEnvironmentMapOptions } from '@/utils/environmentMap' +import { towerModelMatrix } from '@/utils/modelOrientation' +import { cabNightFactor } from '@/utils/nightBrightness' +import { convertToAssetUrlSync } from '@/utils/tauriApi' +import { feetToMeters, resolveGroundHeight } from '@/utils/towerHeight' + +export interface UseTowerModelOptions { + /** Sun elevation in degrees at the camera, or null before it is known */ + sunElevation: number | null + /** Whether night darkening applies (the setting is on and lighting is on) */ + nightDarkeningEnabled: boolean + /** Night darkening intensity setting (0.0-1.0) */ + nightDarkeningIntensity: number +} + +/** A terrain provider that can give real heights: the bare ellipsoid stands in until world terrain loads. */ +function terrainReady(provider: Cesium.TerrainProvider | undefined): provider is Cesium.TerrainProvider { + return provider !== undefined && !(provider instanceof Cesium.EllipsoidTerrainProvider) +} + +/** + * Lights the cab at a fraction of the scene's light. At full light the model uses the scene light + * itself, so with night darkening off it is exactly as bright as everything else. + */ +function applyNightFactor(model: Cesium.Model, scene: Cesium.Scene, factor: number): void { + if (factor >= 1) { + // Cesium's declarations omit that undefined restores the scene light. + model.lightColor = undefined as unknown as Cesium.Cartesian3 + } else { + const { color, intensity } = scene.light + model.lightColor = new Cesium.Cartesian3( + color.red * intensity * factor, + color.green * intensity * factor, + color.blue * intensity * factor, + ) + } + model.imageBasedLighting.imageBasedLightingFactor = new Cesium.Cartesian2(factor, factor) +} + +type PositioningState = ReturnType + +/** + * The positioning wizard's live placement of an airport's cab, or null when the wizard is not + * editing that cab's model step. + */ +function wizardMatrix( + state: PositioningState, + icao: string | null, + ground: number, + scale: number, +): Cesium.Matrix4 | null { + if (!state.isActive || state.step !== 'model' || state.targetIcao?.toUpperCase() !== icao) { + return null + } + const position = state.getAbsoluteModelPosition() + if (!position) { + return null + } + return towerModelMatrix(position.lon, position.lat, ground + position.height, position.rotation, scale) +} + +function removeModel(viewer: Cesium.Viewer, model: Cesium.Model): void { + viewer.scene.primitives.remove(model) + if (!model.isDestroyed()) { + model.destroy() + } +} + +/** + * Loads and places the current airport's tower cab model in the Cesium scene. + * + * @param viewer - Cesium viewer (null during initialization) + * @param options - Night lighting inputs + * @returns Getter for the cab's state, for the dev harness + */ +export function useTowerModel(viewer: Cesium.Viewer | null, options: UseTowerModelOptions): () => TowerModelHandle { + const { sunElevation, nightDarkeningEnabled, nightDarkeningIntensity } = options + + const currentAirport = useAirportStore((state) => state.currentAirport) + const icao = currentAirport?.icao?.toUpperCase() ?? null + const fieldElevationFt = currentAirport?.elevation ?? 0 + + const modelRef = useRef(null) + const currentIcaoRef = useRef(null) + const loadingIcaoRef = useRef(null) + // Ground under the cab and its scale, kept for the wizard's live edits. + const groundHeightRef = useRef(0) + const scaleRef = useRef(1.0) + + const nightFactor = cabNightFactor(sunElevation, nightDarkeningIntensity, nightDarkeningEnabled) + const nightFactorRef = useRef(nightFactor) + nightFactorRef.current = nightFactor + + useEffect(() => { + if (!viewer || viewer.isDestroyed() || !icao || icao === currentIcaoRef.current) { + return + } + + loadingIcaoRef.current = icao + if (modelRef.current) { + removeModel(viewer, modelRef.current) + modelRef.current = null + currentIcaoRef.current = null + } + + const towerMod = modService.getTowerModel(icao) + if (!towerMod) { + return + } + const placement = modService.getTowerPlacement(icao) + if (!placement) { + console.warn(`[useTowerModel] No position available for tower mod at ${icao}`) + return + } + + const modelUrl = convertToAssetUrlSync(towerMod.modelUrl) + const scale = towerMod.manifest.scale ?? 1.0 + scaleRef.current = scale + const fieldEllipsoidal = geoidService.mslToEllipsoidal(placement.lat, placement.lon, feetToMeters(fieldElevationFt)) + const cartographic = Cesium.Cartographic.fromDegrees(placement.lon, placement.lat) + // A wizard edit in progress wins over the manifest placement, so late terrain keeps it. + const matrixAt = (ground: number) => + wizardMatrix(useTowerPositioningStore.getState(), icao, ground, scale) ?? + towerModelMatrix(placement.lon, placement.lat, ground + placement.height, placement.rotation, scale) + + console.log(`[useTowerModel] Loading tower model for ${icao}:`, { + modelUrl, + position: { lat: placement.lat, lon: placement.lon }, + height: placement.height, + rotation: placement.rotation, + source: placement.source, + scale, + }) + + let cancelled = false + let loadStarted = false + let removeTerrainListener: Cesium.Event.RemoveCallback | null = null + const isCurrent = () => !cancelled && loadingIcaoRef.current === icao && !viewer.isDestroyed() + const globeHeight = () => viewer.scene.globe.getHeight(cartographic) + + const loadAt = (ground: number): void => { + groundHeightRef.current = ground + if (modelRef.current && currentIcaoRef.current === icao) { + modelRef.current.modelMatrix = matrixAt(ground) + return + } + if (loadStarted) { + // The load in flight reads the latest ground when it lands. + return + } + loadStarted = true + Cesium.Model.fromGltfAsync({ + url: modelUrl, + modelMatrix: matrixAt(ground), + show: true, + shadows: Cesium.ShadowMode.ENABLED, + environmentMapOptions: neutralEnvironmentMapOptions(), + }) + .then((model) => { + if (!isCurrent()) { + model.destroy() + return + } + model.modelMatrix = matrixAt(groundHeightRef.current) + applyNightFactor(model, viewer.scene, nightFactorRef.current) + viewer.scene.primitives.add(model) + modelRef.current = model + currentIcaoRef.current = icao + console.log( + `[useTowerModel] Tower model loaded for ${icao} at ground ${groundHeightRef.current.toFixed(1)} m`, + ) + }) + .catch((error) => { + console.error(`[useTowerModel] Failed to load tower model for ${icao}:`, error) + }) + } + + const sampleAndPlace = (provider: Cesium.TerrainProvider): void => { + Cesium.sampleTerrainMostDetailed(provider, [cartographic.clone()]) + .then((samples) => { + if (isCurrent()) { + loadAt(resolveGroundHeight(samples[0]?.height, globeHeight(), fieldEllipsoidal)) + } + }) + .catch((error) => { + console.warn(`[useTowerModel] Failed to sample terrain for ${icao}; placing on the field elevation:`, error) + if (isCurrent()) { + loadAt(resolveGroundHeight(undefined, globeHeight(), fieldEllipsoidal)) + } + }) + } + + const provider = viewer.terrainProvider as Cesium.TerrainProvider | undefined + if (terrainReady(provider)) { + sampleAndPlace(provider) + } else { + loadAt(resolveGroundHeight(undefined, globeHeight(), fieldEllipsoidal)) + removeTerrainListener = viewer.scene.globe.terrainProviderChanged.addEventListener(() => { + const next = viewer.terrainProvider as Cesium.TerrainProvider | undefined + if (!isCurrent() || !terrainReady(next)) { + return + } + removeTerrainListener?.() + removeTerrainListener = null + sampleAndPlace(next) + }) + } + + return () => { + cancelled = true + removeTerrainListener?.() + if (modelRef.current && !viewer.isDestroyed()) { + removeModel(viewer, modelRef.current) + } + modelRef.current = null + currentIcaoRef.current = null + } + }, [viewer, icao, fieldElevationFt]) + + useEffect(() => { + const model = modelRef.current + if (!viewer || viewer.isDestroyed() || !model) { + return + } + applyNightFactor(model, viewer.scene, nightFactor) + }, [viewer, nightFactor]) + + useEffect(() => { + return () => { + modelRef.current = null + currentIcaoRef.current = null + loadingIcaoRef.current = null + } + }, []) + + useEffect(() => { + if (!viewer || viewer.isDestroyed()) { + return + } + + let prevOffset = { north: 0, east: 0, up: 0 } + let prevRotation = 0 + + return useTowerPositioningStore.subscribe((state) => { + const model = modelRef.current + if (!model) { + return + } + if ( + state.modelOffset.north === prevOffset.north && + state.modelOffset.east === prevOffset.east && + state.modelOffset.up === prevOffset.up && + state.modelRotation === prevRotation + ) { + return + } + const matrix = wizardMatrix(state, currentIcaoRef.current, groundHeightRef.current, scaleRef.current) + if (!matrix) { + return + } + prevOffset = { ...state.modelOffset } + prevRotation = state.modelRotation + model.modelMatrix = matrix + }) + }, [viewer]) + + return useCallback( + () => ({ + loaded: modelRef.current !== null, + modelMatrix: modelRef.current ? Cesium.Matrix4.toArray(modelRef.current.modelMatrix) : null, + nightFactor: nightFactorRef.current, + }), + [], + ) +} diff --git a/src/renderer/hooks/weather/cloudDomeMesh.ts b/src/renderer/hooks/weather/cloudDomeMesh.ts deleted file mode 100644 index 8c97e6d2..00000000 --- a/src/renderer/hooks/weather/cloudDomeMesh.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Cloud dome mesh geometry generation - * Creates hemisphere meshes for overcast cloud layers - */ - -import * as BABYLON from '@babylonjs/core' -import { CLOUD_DOME_CURVATURE, CLOUD_DOME_RADIUS, CLOUD_DOME_SEGMENTS } from '@/constants' - -/** - * Creates a dome/hemisphere mesh for overcast cloud layers. - * The dome curves downward toward the horizon, creating a more realistic - * overcast sky appearance compared to a flat plane. - * - * @param name - Mesh name - * @param scene - Babylon.js scene - * @returns Dome mesh positioned at origin - */ -export function createCloudDomeMesh(name: string, scene: BABYLON.Scene): BABYLON.Mesh { - const segments = CLOUD_DOME_SEGMENTS - const radius = CLOUD_DOME_RADIUS - const curvature = CLOUD_DOME_CURVATURE - - // Create custom mesh with vertices - const positions: number[] = [] - const indices: number[] = [] - const uvs: number[] = [] - const normals: number[] = [] - - // Generate dome vertices in concentric rings - // Ring 0 is center, ring segments is the outer edge - for (let ring = 0; ring <= segments; ring++) { - const ringRadius = (ring / segments) * radius - const ringT = ring / segments // 0 at center, 1 at edge - - // Calculate Y offset based on curvature - lower at edges - // Use smooth curve that drops more at the horizon - const yOffset = -ringRadius * curvature * ringT - - const ringSegments = ring === 0 ? 1 : segments * 2 - - for (let seg = 0; seg < ringSegments; seg++) { - const angle = (seg / ringSegments) * Math.PI * 2 - - let x: number, z: number - if (ring === 0) { - x = 0 - z = 0 - } else { - x = Math.cos(angle) * ringRadius - z = Math.sin(angle) * ringRadius - } - - positions.push(x, yOffset, z) - - // UV mapping: radial from center - const u = 0.5 + (x / radius) * 0.5 - const v = 0.5 + (z / radius) * 0.5 - uvs.push(u, v) - - // Normal pointing up (will be adjusted by curvature) - const normalY = 1 / Math.sqrt(1 + curvature * curvature * ringT * ringT) - const normalRadial = curvature * ringT * normalY - if (ring === 0) { - normals.push(0, 1, 0) - } else { - const nx = -normalRadial * Math.cos(angle) - const nz = -normalRadial * Math.sin(angle) - normals.push(nx, normalY, nz) - } - } - } - - // Generate indices for triangles - // Center triangle fan (ring 0 to ring 1) - const ring1Segments = segments * 2 - for (let seg = 0; seg < ring1Segments; seg++) { - const nextSeg = (seg + 1) % ring1Segments - indices.push(0, 1 + seg, 1 + nextSeg) - } - - // Remaining rings: quad strips between rings - let currentOffset = 1 // Start after center vertex - for (let ring = 1; ring < segments; ring++) { - const currentRingSegments = segments * 2 - const nextOffset = currentOffset + currentRingSegments - - for (let seg = 0; seg < currentRingSegments; seg++) { - const nextSeg = (seg + 1) % currentRingSegments - const currentIdx = currentOffset + seg - const currentNextIdx = currentOffset + nextSeg - const nextIdx = nextOffset + seg - const nextNextIdx = nextOffset + nextSeg - - // Two triangles per quad - indices.push(currentIdx, nextIdx, currentNextIdx) - indices.push(currentNextIdx, nextIdx, nextNextIdx) - } - - currentOffset = nextOffset - } - - // Create the mesh - const mesh = new BABYLON.Mesh(name, scene) - const vertexData = new BABYLON.VertexData() - - vertexData.positions = positions - vertexData.indices = indices - vertexData.uvs = uvs - vertexData.normals = normals - - vertexData.applyToMesh(mesh) - - // Enable both sides visible - mesh.material = null - mesh.isPickable = false - - return mesh -} diff --git a/src/renderer/hooks/weather/cloudNoiseGenerator.ts b/src/renderer/hooks/weather/cloudNoiseGenerator.ts deleted file mode 100644 index d2310cc6..00000000 --- a/src/renderer/hooks/weather/cloudNoiseGenerator.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Procedural noise generation for cloud textures - * Pure functions for generating patchy cloud patterns using fractal Brownian motion - */ - -/** - * Simple hash function for pseudo-random number generation. - * Used as the basis for procedural noise. - */ -export function hash(x: number, y: number): number { - const n = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453 - return n - Math.floor(n) -} - -/** - * Smooth noise interpolation at a point using bilinear interpolation. - */ -export function smoothNoise(x: number, y: number): number { - const x0 = Math.floor(x) - const y0 = Math.floor(y) - const fx = x - x0 - const fy = y - y0 - - // Smoothstep interpolation for smoother transitions - const sx = fx * fx * (3 - 2 * fx) - const sy = fy * fy * (3 - 2 * fy) - - // Sample corners - const n00 = hash(x0, y0) - const n10 = hash(x0 + 1, y0) - const n01 = hash(x0, y0 + 1) - const n11 = hash(x0 + 1, y0 + 1) - - // Bilinear interpolation - const nx0 = n00 * (1 - sx) + n10 * sx - const nx1 = n01 * (1 - sx) + n11 * sx - return nx0 * (1 - sy) + nx1 * sy -} - -/** - * Fractal Brownian Motion (fBm) noise - layered noise for natural cloud patterns. - * Combines multiple octaves of noise at different frequencies. - */ -export function fbmNoise(x: number, y: number, octaves: number, persistence: number): number { - let total = 0 - let amplitude = 1 - let maxValue = 0 - let frequency = 1 - - for (let i = 0; i < octaves; i++) { - total += smoothNoise(x * frequency, y * frequency) * amplitude - maxValue += amplitude - amplitude *= persistence - frequency *= 2 - } - - return total / maxValue // Normalize to 0-1 -} diff --git a/src/renderer/hooks/weather/cloudStage.glsl.test.ts b/src/renderer/hooks/weather/cloudStage.glsl.test.ts new file mode 100644 index 00000000..5adba099 --- /dev/null +++ b/src/renderer/hooks/weather/cloudStage.glsl.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { WEATHER_STAGE_PRESETS } from '@/constants' +import stageHookSource from '@/hooks/useCesiumWeatherStages.ts?raw' +import { CLOUD_STAGE_FS, CLOUD_STAGE_MAX_STEPS, WEATHER_COMPOSITE_FS } from '@/hooks/weather/cloudStage.glsl' +import { deckEdgeElevationDeg, EARTH_RADIUS_M, horizonDipDeg } from '@/utils/cloudDeckGeometry' + +/** Source of the GLSL function whose signature starts with `signature`, up to its closing brace. */ +function glslFunction(source: string, signature: string): string { + const start = source.indexOf(signature) + expect(start, `missing ${signature}`).toBeGreaterThanOrEqual(0) + return source.slice(start, source.indexOf('\n}\n', start) + 2) +} + +function glslNumber(source: string, pattern: RegExp): number { + const match = source.match(pattern) + expect(match, `missing ${pattern}`).not.toBeNull() + return Number(match?.[1]) +} + +describe('cloud deck shader', () => { + it('curves the slab with the earth, using the same radius as the CPU geometry', () => { + expect(CLOUD_STAGE_FS).toContain('float deckZ(vec3 p)') + expect(CLOUD_STAGE_FS).toContain('vec2 belowLevel(vec3 ro, vec3 rd, float h)') + expect(glslFunction(CLOUD_STAGE_FS, 'void marchLayer(')).toContain('belowLevel(') + expect(glslFunction(CLOUD_STAGE_FS, 'float cloudDensity(')).toContain('deckZ(p)') + expect(glslNumber(CLOUD_STAGE_FS, /const float EARTH_R = ([\d.]+);/)).toBe(EARTH_RADIUS_M) + }) + + it('drops the datum by the parabola d^2 / 2R and lights the slab by its curved height', () => { + expect(glslFunction(CLOUD_STAGE_FS, 'float deckZ(')).toContain('return p.z + dot(p.xy, p.xy) / (2.0 * EARTH_R);') + expect(glslFunction(CLOUD_STAGE_FS, 'vec3 cloudLight(')).toContain('(deckZ(p) - layer.x) / layer.y') + }) + + it('orders the layers by their base against the camera, not by the ray slope', () => { + const main = glslFunction(CLOUD_STAGE_FS, 'void main(') + expect(main).toContain('int below = layersBelow(deckZ(ro), count);') + expect(main).toContain('int i = k < below ? below - 1 - k : k;') + expect(main).not.toContain('rd.z >= 0.0 ? k') + expect(glslFunction(CLOUD_STAGE_FS, 'int layersBelow(')).toContain('layerAt(j).x <= camZ') + }) + + it('allows 256 march steps, enough for the configured step count, and clamps u_steps to the same bound', () => { + const maxSteps = glslNumber(CLOUD_STAGE_FS, /const int MAX_STEPS = (\d+);/) + expect(maxSteps).toBe(256) + expect(maxSteps).toBe(CLOUD_STAGE_MAX_STEPS) + expect(maxSteps).toBeGreaterThanOrEqual(WEATHER_STAGE_PRESETS.high.steps) + expect(stageHookSource).toContain('Math.round(qualityRef.current.steps), 4, CLOUD_STAGE_MAX_STEPS)') + }) + + it('spreads the High deck 100 km with 120 steps, leaving under 0.16 deg of sky above the horizon from the tower', () => { + expect(WEATHER_STAGE_PRESETS.high.radius).toBe(100000) + expect(WEATHER_STAGE_PRESETS.high.steps).toBe(120) + const edge = deckEdgeElevationDeg(88.8, 609.6, WEATHER_STAGE_PRESETS.high.radius, true) + expect(edge - -horizonDipDeg(88.8)).toBeLessThan(0.16) + }) + + it.each([ + ['cloud', CLOUD_STAGE_FS], + ['composite', WEATHER_COMPOSITE_FS], + ])('shades the cloud underside and the fog with the same grey in the %s pass', (_pass, source) => { + expect(glslFunction(source, 'vec3 fogColor(')).toContain('FOG_GREY *') + expect(source).not.toContain('0.40, 0.43, 0.50') + }) + + it('takes the cloud underside colour from the fog grey', () => { + expect(glslFunction(CLOUD_STAGE_FS, 'vec3 cloudLight(')).toContain('vec3 dark = FOG_GREY;') + }) + + it('marches each layer with the sun shade its layer data gives it', () => { + const passesShade = (source: string) => glslFunction(source, 'void main(').includes('layerAt(i), layerShade(i),') + expect(passesShade(CLOUD_STAGE_FS)).toBe(true) + expect(passesShade(CLOUD_STAGE_FS.replace('layerShade(i)', '0.0'))).toBe(false) + }) + + it('lights a shaded layer from its shade, not from full sun', () => { + const lightsWithShade = (source: string) => /mix\([^;]*,\s*shade\)/.test(glslFunction(source, 'vec3 cloudLight(')) + expect(lightsWithShade(CLOUD_STAGE_FS)).toBe(true) + expect(lightsWithShade(CLOUD_STAGE_FS.replace(', shade)', ', 0.0)'))).toBe(false) + }) +}) diff --git a/src/renderer/hooks/weather/cloudStage.glsl.ts b/src/renderer/hooks/weather/cloudStage.glsl.ts new file mode 100644 index 00000000..eac4f8fb --- /dev/null +++ b/src/renderer/hooks/weather/cloudStage.glsl.ts @@ -0,0 +1,347 @@ +/** + * GLSL for the cloud and fog post-process stages drawn by `useCesiumWeatherStages`. + * + * Both stages work in a local east-north-up frame anchored on the airport's + * ground (z = meters above the airport). The camera position in that frame is + * computed on the CPU in double precision and passed as `u_camPosEnu`; ray + * directions come from Cesium's inverse projection and inverse view. Cloud + * layers sit on a datum that falls away with the earth's curvature (`deckZ`), + * mirrored on the CPU in `utils/cloudDeckGeometry.ts`. + * + * Scene distance per pixel comes from the stage's `depthTexture`. With the + * logarithmic depth buffer the stage is compiled with LOG_DEPTH, and + * `czm_windowToEyeCoordinates(vec2, float)` undoes the log encoding and returns + * an eye position whose w is 1 / depthFromCamera, so xyz / w is the eye-space + * point. A cleared depth of 1.0 means nothing was drawn (sky). + */ + +const COMMON = /* glsl */ ` +uniform sampler2D colorTexture; +uniform sampler2D depthTexture; +uniform vec3 u_camPosEnu; +uniform mat3 u_ecefToEnu; +uniform float u_visibility; +uniform float u_fogExtinction; +uniform float u_fogHeightScale; +in vec2 v_textureCoordinates; + +const float SKY_DISTANCE = 1.0e6; + +float sceneDistance(vec2 uv) { + float rawDepth = texture(depthTexture, uv).r; + if (rawDepth >= 1.0) { + return SKY_DISTANCE; + } + // Called through czm_windowToEyeCoordinates: Cesium injects a built-in file only for the + // name the file is keyed by, and czm_screenToEyeCoordinates lives in that file. + vec4 eye = czm_windowToEyeCoordinates(uv * czm_viewport.zw + czm_viewport.xy, rawDepth); + return min(length(eye.xyz / eye.w), SKY_DISTANCE); +} + +vec3 viewRayEnu(vec2 uv) { + vec4 p = czm_inverseProjection * vec4(uv * 2.0 - 1.0, 0.0, 1.0); + vec3 dirEye = normalize(p.xyz / p.w); + vec3 dirWc = mat3(czm_inverseView) * dirEye; + return normalize(u_ecefToEnu * dirWc); +} + +vec3 sunEnu() { + return normalize(u_ecefToEnu * czm_sunDirectionWC); +} + +float daylight() { + return smoothstep(-0.1, 0.25, sunEnu().z); +} + +// Daylight fog colour, shared with the cloud underside so the deck and the haze meet without a seam. +const vec3 FOG_GREY = vec3(0.62, 0.66, 0.70); + +vec3 fogColor() { + return FOG_GREY * (0.08 + 0.92 * daylight()); +} + +// Length of the segment from ro over distance d along rd that lies below the anchor ground (z < 0). +float fogBelowGroundLength(vec3 ro, vec3 rd, float d) { + float h1 = ro.z + rd.z * d; + if (ro.z >= 0.0 && h1 >= 0.0) { + return 0.0; + } + if (ro.z < 0.0 && h1 < 0.0) { + return d; + } + float tGround = -ro.z / rd.z; + return ro.z < 0.0 ? tGround : d - tGround; +} + +// Transmittance of height fog between the camera and distance d along rd. +// Density is (1 / visibility) * exp(-max(height, 0) / u_fogHeightScale): never denser than at +// the anchor ground, so terrain below it is not drowned. Below the ground the density is +// constant; above it the integral is analytic, starting at the camera or at the ground. +float fogTransmittance(vec3 ro, vec3 rd, float d) { + if (u_visibility <= 0.0) { + return 1.0; + } + float below = fogBelowGroundLength(ro, rd, d); + float above = d - below; + float integral = below / u_visibility; + float base = exp(-max(ro.z, 0.0) / u_fogHeightScale) / u_visibility; + float k = rd.z / u_fogHeightScale; + if (abs(k * above) < 1.0e-4) { + integral += base * above; + } else { + integral += base * (1.0 - exp(min(-k * above, 80.0))) / k; + } + return exp(-u_fogExtinction * integral); +} +` + +/** Most raymarch steps the cloud pass takes per layer; the `u_steps` uniform is clamped to it. */ +export const CLOUD_STAGE_MAX_STEPS = 256 + +export const CLOUD_STAGE_FS = /* glsl */ ` +${COMMON} +uniform vec4 u_layer0; +uniform vec4 u_layer1; +uniform vec4 u_layer2; +uniform vec4 u_layer3; +// Per layer (x..w = layer 0..3): 1 when a higher BKN or OVC layer shades it from direct sun. +uniform vec4 u_layerShade; +uniform float u_layerCount; +uniform float u_steps; +uniform float u_densityScale; +uniform float u_radius; +uniform float u_time; +// Horizontal position of this frame's origin from a fixed noise origin, in this frame's axes, +// so the noise field stays put horizontally when the anchor moves; vertically it follows the deck. +uniform vec3 u_noiseOffset; + +const int MAX_STEPS = ${CLOUD_STAGE_MAX_STEPS}; +const float EARTH_R = 6371000.0; + +float hash13(vec3 p3) { + p3 = fract(p3 * 0.1031); + p3 += dot(p3, p3.zyx + 31.32); + return fract((p3.x + p3.y) * p3.z); +} + +float valueNoise(vec3 x) { + vec3 i = floor(x); + vec3 f = fract(x); + f = f * f * (3.0 - 2.0 * f); + float n000 = hash13(i); + float n100 = hash13(i + vec3(1.0, 0.0, 0.0)); + float n010 = hash13(i + vec3(0.0, 1.0, 0.0)); + float n110 = hash13(i + vec3(1.0, 1.0, 0.0)); + float n001 = hash13(i + vec3(0.0, 0.0, 1.0)); + float n101 = hash13(i + vec3(1.0, 0.0, 1.0)); + float n011 = hash13(i + vec3(0.0, 1.0, 1.0)); + float n111 = hash13(i + vec3(1.0, 1.0, 1.0)); + float nx00 = mix(n000, n100, f.x); + float nx10 = mix(n010, n110, f.x); + float nx01 = mix(n001, n101, f.x); + float nx11 = mix(n011, n111, f.x); + return mix(mix(nx00, nx10, f.y), mix(nx01, nx11, f.y), f.z); +} + +float fbm(vec3 p) { + float sum = 0.0; + float amplitude = 0.5; + for (int i = 0; i < 4; i++) { + sum += amplitude * valueNoise(p); + p = p * 2.03 + vec3(17.1, 9.7, 3.3); + amplitude *= 0.5; + } + return sum / 0.9375; +} + +// Height above the curved deck datum: the earth falls away by d^2/2R at horizontal distance d. +// The parabola keeps float32 precision, where length(p + vec3(0, 0, R)) would lose about half a meter. +float deckZ(vec3 p) { + return p.z + dot(p.xy, p.xy) / (2.0 * EARTH_R); +} + +// Layer: x = base (m above the curved datum), y = thickness (m), z = coverage (0..1), w = active. +float cloudDensity(vec3 p, vec4 layer) { + float z = deckZ(p); + float hf = (z - layer.x) / layer.y; + if (hf < 0.0 || hf > 1.0) { + return 0.0; + } + float edge = 1.0 - smoothstep(u_radius * 0.75, u_radius, length(p.xy)); + if (edge <= 0.0) { + return 0.0; + } + float shape = smoothstep(0.0, 0.12, hf) * (1.0 - smoothstep(0.5, 1.0, hf)); + vec3 world = vec3(p.xy, z) + u_noiseOffset; + vec3 q = vec3(world.xy / 1800.0, world.z / 600.0) + vec3(u_time * 0.004, u_time * 0.002, 0.0); + // fbm of value noise clusters around 0.5; stretch it so the coverage threshold + // leaves roughly the reported fraction of sky covered. + float n = clamp((fbm(q) - 0.3) / 0.4, 0.0, 1.0); + float overcastBoost = layer.z >= 0.95 ? 0.45 : 0.0; + float d = clamp((n * shape + layer.z - 1.0) * 3.0 + overcastBoost * shape, 0.0, 1.0); + return d * edge; +} + +// shade: 1 under higher cover, whose sunlit end falls to the underside grey, so its tops do not shine. +vec3 cloudLight(vec3 p, vec4 layer, float shade, vec3 sun) { + float lightStep = layer.y * 0.3; + float occluder = cloudDensity(p + sun * lightStep, layer); + float lightT = exp(-occluder * u_densityScale * lightStep * 0.5); + float hf = clamp((deckZ(p) - layer.x) / layer.y, 0.0, 1.0); + vec3 dark = FOG_GREY; + vec3 bright = mix(vec3(1.0, 0.98, 0.95), FOG_GREY, shade); + return mix(dark, bright, clamp(0.35 * hf + 0.65 * lightT, 0.0, 1.0)) * (0.08 + 0.92 * daylight()); +} + +vec4 layerAt(int i) { + if (i == 0) return u_layer0; + if (i == 1) return u_layer1; + if (i == 2) return u_layer2; + return u_layer3; +} + +float layerShade(int i) { + if (i == 0) return u_layerShade.x; + if (i == 1) return u_layerShade.y; + if (i == 2) return u_layerShade.z; + return u_layerShade.w; +} + +// Exit distance of the ray from the cylinder of radius u_radius around the airport. +float cylinderExit(vec3 ro, vec3 rd) { + vec2 o = ro.xy; + vec2 d = rd.xy; + float a = dot(d, d); + if (a < 1.0e-8) { + return SKY_DISTANCE; + } + float b = dot(o, d); + float c = dot(o, o) - u_radius * u_radius; + float disc = b * b - a * c; + if (disc < 0.0) { + return -1.0; + } + return (-b + sqrt(disc)) / a; +} + +// Interval of t where deckZ(ro + rd * t) <= h, as (lo, hi); lo > hi when empty. +// deckZ along the ray is qc + qb t + qa t^2 with qa >= 0, so the set is one interval. +// The roots use the numerically stable form, which keeps the small root accurate for grazing rays. +vec2 belowLevel(vec3 ro, vec3 rd, float h) { + const float BIG = 1.0e9; + float qa = dot(rd.xy, rd.xy) / (2.0 * EARTH_R); + float qb = rd.z + dot(ro.xy, rd.xy) / EARTH_R; + float qc = deckZ(ro) - h; + if (qa < 1.0e-14) { + if (abs(qb) < 1.0e-7) { + return qc <= 0.0 ? vec2(-BIG, BIG) : vec2(1.0, -1.0); + } + float r = -qc / qb; + return qb > 0.0 ? vec2(-BIG, r) : vec2(r, BIG); + } + float disc = qb * qb - 4.0 * qa * qc; + if (disc < 0.0) { + return vec2(1.0, -1.0); + } + float s = sqrt(disc); + float q = -0.5 * (qb + (qb >= 0.0 ? s : -s)); + float r0 = q / qa; + float r1 = qc / q; + return vec2(min(r0, r1), max(r0, r1)); +} + +// Marches the ray's first stretch through the curved slab of one layer, inside the deck's cylinder. +void marchLayer(vec3 ro, vec3 rd, float tScene, vec4 layer, float shade, float jitter, vec3 sun, + inout float transmittance, inout vec3 color, inout float weightedT, inout float weightSum) { + vec2 inTop = belowLevel(ro, rd, layer.x + layer.y); + float t0 = max(inTop.x, 0.0); + float t1 = min(inTop.y, min(tScene, cylinderExit(ro, rd))); + vec2 under = belowLevel(ro, rd, layer.x); + if (under.x <= under.y) { + if (under.x <= t0 && under.y > t0) { + t0 = under.y; + } else if (under.x > t0 && under.x < t1) { + t1 = under.x; + } + } + if (t1 <= t0) { + return; + } + float dt = (t1 - t0) / u_steps; + float t = t0 + dt * jitter; + for (int i = 0; i < MAX_STEPS; i++) { + if (float(i) >= u_steps || transmittance < 0.01) { + break; + } + vec3 p = ro + rd * t; + float density = cloudDensity(p, layer); + if (density > 0.0) { + float alpha = 1.0 - exp(-density * u_densityScale * dt); + float contribution = transmittance * alpha; + color += contribution * cloudLight(p, layer, shade, sun); + weightedT += contribution * t; + weightSum += contribution; + transmittance *= 1.0 - alpha; + } + t += dt; + } +} + +// Number of the first count layers whose base is at or below the camera's height on the curved datum. +int layersBelow(float camZ, int count) { + int below = 0; + for (int j = 0; j < 4; j++) { + if (j < count && layerAt(j).x <= camZ) { + below++; + } + } + return below; +} + +void main() { + vec2 uv = v_textureCoordinates; + vec3 ro = u_camPosEnu; + vec3 rd = viewRayEnu(uv); + float tScene = sceneDistance(uv); + vec3 sun = sunEnu(); + float jitter = hash13(vec3(gl_FragCoord.xy, fract(u_time) * 97.0)); + + float transmittance = 1.0; + vec3 color = vec3(0.0); + float weightedT = 0.0; + float weightSum = 0.0; + int count = int(u_layerCount); + int below = layersBelow(deckZ(ro), count); + // Layers arrive sorted by base, lowest first. A ray meets the layers under the camera from the + // highest down and the ones over it from the lowest up, whatever its slope: on the curved deck a + // ray that starts downward still rises into the layers above it. + for (int k = 0; k < 4; k++) { + if (k >= count) { + break; + } + int i = k < below ? below - 1 - k : k; + marchLayer(ro, rd, tScene, layerAt(i), layerShade(i), jitter, sun, transmittance, color, weightedT, weightSum); + } + + float alpha = 1.0 - transmittance; + if (weightSum > 0.0) { + float fogT = fogTransmittance(ro, rd, weightedT / weightSum); + color = color * fogT + fogColor() * (1.0 - fogT) * alpha; + } + out_FragColor = vec4(color, alpha); +} +` + +export const WEATHER_COMPOSITE_FS = /* glsl */ ` +${COMMON} +uniform sampler2D cloudTexture; + +void main() { + vec2 uv = v_textureCoordinates; + vec4 scene = texture(colorTexture, uv); + vec4 cloud = texture(cloudTexture, uv); + float fogT = fogTransmittance(u_camPosEnu, viewRayEnu(uv), sceneDistance(uv)); + vec3 fogged = mix(fogColor(), scene.rgb, fogT); + out_FragColor = vec4(fogged * (1.0 - cloud.a) + cloud.rgb, scene.a); +} +` diff --git a/src/renderer/hooks/weather/cloudTextureGenerator.ts b/src/renderer/hooks/weather/cloudTextureGenerator.ts deleted file mode 100644 index d95ae137..00000000 --- a/src/renderer/hooks/weather/cloudTextureGenerator.ts +++ /dev/null @@ -1,323 +0,0 @@ -/** - * Cloud texture generation functions - * Creates dynamic textures for patchy and overcast cloud layers - */ - -import * as BABYLON from '@babylonjs/core' -import { - CLOUD_DOME_HORIZON_DARKENING, - CLOUD_EDGE_SOFTNESS, - CLOUD_NOISE_OCTAVES, - CLOUD_NOISE_PERSISTENCE, - CLOUD_NOISE_SCALE, - CLOUD_RADIAL_FADE_END, - CLOUD_RADIAL_FADE_START, -} from '@/constants' -import { fbmNoise } from './cloudNoiseGenerator' - -/** - * Generate a patchy cloud opacity texture using fBm noise. - * Coverage determines how much of the noise becomes visible cloud vs transparent gap. - * - * @param scene - Babylon.js scene - * @param textureSize - Size of the texture in pixels - * @param coverage - Cloud coverage (0-1, from METAR oktas) - * @param seed - Random seed for unique patterns per layer - * @returns DynamicTexture with patchy cloud pattern and radial edge fade - */ -export function createPatchyCloudTexture( - scene: BABYLON.Scene, - textureSize: number, - coverage: number, - seed: number = 0, -): BABYLON.DynamicTexture { - const texture = new BABYLON.DynamicTexture( - `cloud_patchy_${seed}_${coverage.toFixed(3)}`, - textureSize, - scene, - true, // generateMipMaps - prevents jagged edges when stretched over large plane - ) - const ctx = texture.getContext() as CanvasRenderingContext2D - - const imageData = ctx.createImageData(textureSize, textureSize) - const data = imageData.data - - const centerX = textureSize / 2 - const centerY = textureSize / 2 - const maxRadius = textureSize / 2 - - // Coverage determines the noise threshold - // For BKN/OVC (high coverage), we want mostly solid with small breaks - // For FEW/SCT (low coverage), we want mostly clear with cloud patches - const noiseScale = CLOUD_NOISE_SCALE - - for (let y = 0; y < textureSize; y++) { - for (let x = 0; x < textureSize; x++) { - const idx = (y * textureSize + x) * 4 - - // Generate fBm noise value with seed offset for variety between layers - const nx = (x / textureSize) * noiseScale + seed * 100 - const ny = (y / textureSize) * noiseScale + seed * 100 - const noiseValue = fbmNoise(nx, ny, CLOUD_NOISE_OCTAVES, CLOUD_NOISE_PERSISTENCE) - - // Calculate cloud opacity based on coverage level - let cloudAlpha: number - if (coverage >= 0.95) { - // OVC: completely solid, no breaks at all - cloudAlpha = 1.0 - } else if (coverage >= 0.6) { - // BKN: mostly solid with visible breaks where noise is low - // Use higher gap threshold to ensure visible sky breaks - // For BKN (0.6875): gap threshold ≈ 0.45, so ~40% of texture is gaps - const gapThreshold = 1.0 - coverage + 0.15 - if (noiseValue < gapThreshold) { - // Clear gap - fully transparent for visible sky - cloudAlpha = 0.0 - } else if (noiseValue < gapThreshold + 0.1) { - // Soft edge transition - cloudAlpha = (noiseValue - gapThreshold) / 0.1 - } else { - cloudAlpha = 1.0 - } - } else { - // FEW/SCT: patches of cloud where noise is high - // Higher coverage = lower threshold = more cloud patches - const threshold = 0.5 + 0.2 * (1.0 - 2.0 * coverage) - if (noiseValue > threshold) { - const edge = (noiseValue - threshold) / CLOUD_EDGE_SOFTNESS - cloudAlpha = Math.min(1.0, edge) - } else { - cloudAlpha = 0.0 - } - } - - // Apply radial fade (clouds fade to transparent at edges) - // But for high coverage, delay the fade start so center area is fully covered - const dx = x - centerX - const dy = y - centerY - const distance = Math.sqrt(dx * dx + dy * dy) / maxRadius - - // Coverage determines fade start: - // - OVC (0.95+): softer edge starting at 0.5 for gradual horizon fade - // - BKN (0.6+): later fade at 0.7 for mostly solid center - // - FEW/SCT: default fade start - let fadeStart: number - if (coverage >= 0.95) { - fadeStart = 0.5 // OVC: soft gradual edge - } else if (coverage >= 0.6) { - fadeStart = 0.7 // BKN: later start, mostly solid - } else { - fadeStart = CLOUD_RADIAL_FADE_START // FEW/SCT: default - } - let radialFade = 1.0 - if (distance > fadeStart) { - radialFade = 1.0 - (distance - fadeStart) / (CLOUD_RADIAL_FADE_END - fadeStart) - radialFade = Math.max(0, Math.min(1, radialFade)) - } - - // Combine patchy cloud with radial fade - const finalAlpha = cloudAlpha * radialFade - - // Write RGBA - white color with alpha for transparency - data[idx] = 255 // R - data[idx + 1] = 255 // G - data[idx + 2] = 255 // B - data[idx + 3] = Math.round(finalAlpha * 255) // A - } - } - - ctx.putImageData(imageData, 0, 0) - texture.hasAlpha = true - texture.update() - - return texture -} - -/** - * Creates a texture for the overcast dome with horizon darkening effect. - * The texture has subtle noise variation and proper alpha for gradual fade. - * - * @param scene - Babylon.js scene - * @param textureSize - Size of the texture in pixels - * @param seed - Random seed for subtle pattern variation - * @returns DynamicTexture with overcast pattern and horizon gradient - */ -export function createOvercastDomeTexture( - scene: BABYLON.Scene, - textureSize: number, - seed: number = 0, -): BABYLON.DynamicTexture { - const texture = new BABYLON.DynamicTexture(`cloud_overcast_dome_${seed}`, textureSize, scene, true) - const ctx = texture.getContext() as CanvasRenderingContext2D - - const imageData = ctx.createImageData(textureSize, textureSize) - const data = imageData.data - - const centerX = textureSize / 2 - const centerY = textureSize / 2 - const maxRadius = textureSize / 2 - - for (let y = 0; y < textureSize; y++) { - for (let x = 0; x < textureSize; x++) { - const idx = (y * textureSize + x) * 4 - - // Distance from center (0 at center, 1 at edge) - const dx = x - centerX - const dy = y - centerY - const distance = Math.sqrt(dx * dx + dy * dy) / maxRadius - - // Subtle noise for texture variation (very low amplitude for overcast) - const nx = (x / textureSize) * 4 + seed * 100 - const ny = (y / textureSize) * 4 + seed * 100 - const noise = fbmNoise(nx, ny, 3, 0.5) * 0.15 // Subtle variation - - // Horizon darkening: slightly darker at edges, lighter at center - // This simulates looking through more atmosphere at shallow angles - const horizonDarkening = distance * distance * CLOUD_DOME_HORIZON_DARKENING - - // Base gray value (lighter at center, slightly darker at horizon) - // Overcast clouds should appear light gray/white, not dark - const baseGray = 0.92 - horizonDarkening + noise * 0.08 - - // Clamp to valid range (don't let it get too dark) - const gray = Math.max(0.7, Math.min(1.0, baseGray)) - - // Alpha: gradual fade from center to edge - // Start fading early so the dome doesn't block the horizon view - // Use smooth curve: fully opaque at center, fully transparent at edge - let alpha: number - if (distance < 0.3) { - // Center area: fully opaque - alpha = 1.0 - } else if (distance < 0.7) { - // Gradual fade zone - const fadeT = (distance - 0.3) / 0.4 - alpha = 1.0 - fadeT * fadeT * 0.7 // Quadratic fade, max 70% reduction - } else { - // Outer edge: rapid fade to transparent - const edgeT = (distance - 0.7) / 0.3 - alpha = 0.3 * (1.0 - edgeT) - alpha = Math.max(0, alpha) - } - - // Write RGB with gray value, full alpha - const colorValue = Math.round(gray * 255) - data[idx] = colorValue // R - data[idx + 1] = colorValue // G - data[idx + 2] = Math.round(gray * 1.02 * 255) // B slightly higher for cool tint - data[idx + 3] = Math.round(alpha * 255) // A - } - } - - ctx.putImageData(imageData, 0, 0) - texture.hasAlpha = true - texture.update() - - return texture -} - -/** - * Creates a texture for viewing clouds from above (cloud tops). - * Cloud tops appear as bright white/light gray with a fluffy appearance. - * - * @param scene - Babylon.js scene - * @param textureSize - Size of the texture in pixels - * @param coverage - Cloud coverage (0-1, from METAR oktas) - * @param seed - Random seed for pattern variation - * @returns DynamicTexture with cloud top pattern - */ -export function createAboveCloudTexture( - scene: BABYLON.Scene, - textureSize: number, - coverage: number, - seed: number = 0, -): BABYLON.DynamicTexture { - const texture = new BABYLON.DynamicTexture(`cloud_above_${seed}_${coverage.toFixed(3)}`, textureSize, scene, true) - const ctx = texture.getContext() as CanvasRenderingContext2D - - const imageData = ctx.createImageData(textureSize, textureSize) - const data = imageData.data - - const centerX = textureSize / 2 - const centerY = textureSize / 2 - const maxRadius = textureSize / 2 - - // Cloud tops use similar logic to below-cloud but with lighter colors - // and a different noise pattern (more "pillowy" look) - const noiseScale = CLOUD_NOISE_SCALE * 0.8 // Slightly larger features from above - - for (let y = 0; y < textureSize; y++) { - for (let x = 0; x < textureSize; x++) { - const idx = (y * textureSize + x) * 4 - - // Generate fBm noise with seed offset - const nx = (x / textureSize) * noiseScale + seed * 100 - const ny = (y / textureSize) * noiseScale + seed * 100 - const noiseValue = fbmNoise(nx, ny, CLOUD_NOISE_OCTAVES, CLOUD_NOISE_PERSISTENCE) - - // Calculate cloud opacity based on coverage level - let cloudAlpha: number - if (coverage >= 0.95) { - // OVC: completely solid cloud top - cloudAlpha = 1.0 - } else if (coverage >= 0.6) { - // BKN: mostly solid with some gaps showing through - const gapThreshold = 1.0 - coverage + 0.15 - if (noiseValue < gapThreshold) { - cloudAlpha = 0.0 - } else if (noiseValue < gapThreshold + 0.1) { - cloudAlpha = (noiseValue - gapThreshold) / 0.1 - } else { - cloudAlpha = 1.0 - } - } else { - // FEW/SCT: scattered patches - const threshold = 0.5 + 0.2 * (1.0 - 2.0 * coverage) - if (noiseValue > threshold) { - const edge = (noiseValue - threshold) / CLOUD_EDGE_SOFTNESS - cloudAlpha = Math.min(1.0, edge) - } else { - cloudAlpha = 0.0 - } - } - - // Apply radial fade at edges (but NOT for OVC - keep fully opaque) - const dx = x - centerX - const dy = y - centerY - const distance = Math.sqrt(dx * dx + dy * dy) / maxRadius - - let finalAlpha: number - if (coverage >= 0.95) { - // OVC: NO radial fade - fully opaque cloud tops everywhere - finalAlpha = cloudAlpha - } else { - // BKN and below: apply radial fade - const fadeStart = coverage >= 0.6 ? 0.75 : CLOUD_RADIAL_FADE_START - let radialFade = 1.0 - if (distance > fadeStart) { - radialFade = 1.0 - (distance - fadeStart) / (CLOUD_RADIAL_FADE_END - fadeStart) - radialFade = Math.max(0, Math.min(1, radialFade)) - } - finalAlpha = cloudAlpha * radialFade - } - - // Cloud tops from above: bright white with subtle variation - // Add some noise to the brightness for a fluffy appearance - const brightnessNoise = noiseValue * 0.1 // 0-0.1 variation - const brightness = Math.min(1.0, 0.95 + brightnessNoise * 0.05) // 0.95-1.0 - - // Write RGBA - bright white color - const colorValue = Math.round(brightness * 255) - data[idx] = colorValue // R - data[idx + 1] = colorValue // G - data[idx + 2] = Math.min(255, Math.round(colorValue * 1.02)) // B slightly cooler - data[idx + 3] = Math.round(finalAlpha * 255) // A - } - } - - ctx.putImageData(imageData, 0, 0) - texture.hasAlpha = true - texture.update() - - return texture -} diff --git a/src/renderer/hooks/weather/index.ts b/src/renderer/hooks/weather/index.ts deleted file mode 100644 index 9b1b7580..00000000 --- a/src/renderer/hooks/weather/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Weather rendering module - * Re-exports all weather-related utilities and generators - */ - -// Mesh generation -export { createCloudDomeMesh } from './cloudDomeMesh' -// Noise generation -export { fbmNoise, hash, smoothNoise } from './cloudNoiseGenerator' -// Texture generation -export { createAboveCloudTexture, createOvercastDomeTexture, createPatchyCloudTexture } from './cloudTextureGenerator' diff --git a/src/renderer/services/SettingsTreeBuilder.ts b/src/renderer/services/SettingsTreeBuilder.ts index 42685dd8..46000ed9 100644 --- a/src/renderer/services/SettingsTreeBuilder.ts +++ b/src/renderer/services/SettingsTreeBuilder.ts @@ -114,7 +114,7 @@ export const SUBCATEGORY_MAPPINGS: Record { + const items = new Map() + const storage: Storage = { + getItem: (key) => items.get(key) ?? null, + setItem: (key, value) => { + items.set(key, String(value)) + }, + removeItem: (key) => { + items.delete(key) + }, + clear: () => items.clear(), + key: (index) => [...items.keys()][index] ?? null, + get length() { + return items.size + }, + } + Object.defineProperty(globalThis, 'window', { value: { localStorage: storage }, configurable: true, writable: true }) +}) + +interface DeviceStub { + userAgent: string + maxTouchPoints: number + hardwareConcurrency: number + innerWidth: number + touch: boolean + /** Whether the page runs inside the Tauri desktop app */ + tauri?: boolean +} + +const DESKTOP: DeviceStub = { + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0 Safari/537.36', + maxTouchPoints: 0, + hardwareConcurrency: 16, + innerWidth: 1920, + touch: false, +} + +const IPAD: DeviceStub = { + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15', + maxTouchPoints: 5, + hardwareConcurrency: 8, + innerWidth: 1366, + touch: true, +} + +const PHONE: DeviceStub = { + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1', + maxTouchPoints: 5, + hardwareConcurrency: 6, + innerWidth: 390, + touch: true, +} + +const LOW_END_TABLET: DeviceStub = { + userAgent: + 'Mozilla/5.0 (Linux; Android 13; SM-X200) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0 Safari/537.36', + maxTouchPoints: 5, + hardwareConcurrency: 2, + innerWidth: 1340, + touch: true, +} + +const TOUCH_LAPTOP: DeviceStub = { + userAgent: DESKTOP.userAgent, + maxTouchPoints: 10, + hardwareConcurrency: 8, + innerWidth: 1920, + touch: true, +} + +function stubDevice(device: DeviceStub): void { + vi.stubGlobal('navigator', { + userAgent: device.userAgent, + maxTouchPoints: device.maxTouchPoints, + hardwareConcurrency: device.hardwareConcurrency, + platform: device.userAgent.includes('Macintosh') ? 'MacIntel' : 'Win32', + }) + vi.stubGlobal('window', { + innerWidth: device.innerWidth, + ...(device.touch ? { ontouchstart: null } : {}), + ...(device.tauri ? { __TAURI__: {} } : {}), + }) +} + +/** A settings state as version 38 persisted it: no `weather.stageQuality` */ +function v38State(): Record { + const { stageQuality: _dropped, ...weather } = DEFAULT_SETTINGS.weather + return { ...structuredClone(DEFAULT_SETTINGS), weather: { ...weather, fogIntensity: 1.6 } } +} + +function migrate(state: unknown, version: number): typeof DEFAULT_SETTINGS { + const migrateFn = useSettingsStore.persist.getOptions().migrate + if (!migrateFn) throw new Error('settings store has no migrate function') + return migrateFn(state, version) as typeof DEFAULT_SETTINGS +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('deviceSettingsPreset', () => { + it.each([ + ['a desktop', 'desktop', DESKTOP], + ['an iPad', 'ipad', IPAD], + ['a phone', 'mobile', PHONE], + ['a low-end touch tablet', 'mobile', LOW_END_TABLET], + ['an unknown large touch device', 'ipad', TOUCH_LAPTOP], + ] as const)('maps %s to the %s preset', (_name, expected, device) => { + stubDevice(device) + expect(deviceSettingsPreset()).toBe(expected) + }) +}) + +describe('defaultWeatherQuality', () => { + it.each([ + ['an iPad', 'ipad', IPAD], + ['a phone', 'mobile', PHONE], + ] as const)('stays High in the desktop app on %s', (_name, deviceClass, device) => { + stubDevice({ ...device, tauri: true }) + expect(deviceSettingsPreset()).toBe(deviceClass) + expect(defaultWeatherQuality()).toBe('high') + }) + + it.each([ + ['a desktop', 'high', DESKTOP], + ['an iPad', 'medium', IPAD], + ['a phone', 'low', PHONE], + ] as const)('gives a remote browser on %s the %s preset', (_name, expected, device) => { + stubDevice(device) + expect(defaultWeatherQuality()).toBe(expected) + }) +}) + +describe('SETTINGS_PRESETS weather quality', () => { + it('gives desktop High, iPad Medium and phones Low', () => { + expect(SETTINGS_PRESETS.desktop.weather.stageQuality).toBe('high') + expect(SETTINGS_PRESETS.ipad.weather.stageQuality).toBe('medium') + expect(SETTINGS_PRESETS.mobile.weather.stageQuality).toBe('low') + }) +}) + +describe('applyPreset', () => { + it('sets the iPad weather quality and keeps the other weather settings', () => { + useSettingsStore.getState().updateWeatherSettings({ stageQuality: 'high', fogIntensity: 1.7, showClouds: false }) + useSettingsStore.getState().applyPreset('ipad') + const { weather } = useSettingsStore.getState() + expect(weather.stageQuality).toBe('medium') + expect(weather.fogIntensity).toBe(1.7) + expect(weather.showClouds).toBe(false) + }) +}) + +describe('resetToDefaults', () => { + it('resets the weather quality to the device default in a remote browser', () => { + stubDevice(IPAD) + useSettingsStore.getState().updateWeatherSettings({ stageQuality: 'high', fogIntensity: 1.7 }) + useSettingsStore.getState().resetToDefaults() + const { weather } = useSettingsStore.getState() + expect(weather.stageQuality).toBe('medium') + expect(weather.fogIntensity).toBe(DEFAULT_SETTINGS.weather.fogIntensity) + }) +}) + +describe('settings migration to version 39', () => { + it('defaults new installs to High', () => { + expect(DEFAULT_SETTINGS.weather.stageQuality).toBe('high') + }) + + it.each([ + ['a desktop', 'high', DESKTOP], + ['an iPad', 'medium', IPAD], + ['a phone', 'low', PHONE], + ] as const)('gives a version 38 install on %s the %s weather quality', (_name, expected, device) => { + stubDevice(device) + const migrated = migrate(v38State(), 38) + expect(migrated.weather.stageQuality).toBe(expected) + expect(migrated.weather.fogIntensity).toBe(1.6) + }) + + it('keeps High for a touch-screen desktop in the desktop app', () => { + stubDevice({ ...TOUCH_LAPTOP, tauri: true }) + expect(deviceSettingsPreset()).toBe('ipad') + expect(migrate(v38State(), 38).weather.stageQuality).toBe('high') + }) + + it('leaves a version 39 weather quality alone', () => { + stubDevice(PHONE) + const state = { + ...structuredClone(DEFAULT_SETTINGS), + weather: { ...DEFAULT_SETTINGS.weather, stageQuality: 'high' }, + } + expect(migrate(state, 39).weather.stageQuality).toBe('high') + }) +}) diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts index 364ffb79..e7225db0 100644 --- a/src/renderer/stores/settingsStore.ts +++ b/src/renderer/stores/settingsStore.ts @@ -1,5 +1,8 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' +import type { SettingsPreset, WeatherQualityPreset } from '@/types' +import { deviceSettingsPreset } from '@/utils/deviceDetection' +import { isTauri } from '@/utils/tauriApi' import type { AdvancedSettings, AircraftSettings, @@ -20,15 +23,6 @@ import { DEFAULT_INSET_GRAPHICS_SETTINGS, DEFAULT_SETTINGS } from '../types/sett // Preset System Types and Constants // ============================================================================ -/** - * Available settings presets - * - * - 'desktop': High quality for desktop PCs (default settings) - * - 'ipad': Optimized for iPad/tablet with reduced GPU/memory usage - * - 'mobile': Low quality for phones or low-end devices - */ -export type SettingsPreset = 'desktop' | 'ipad' | 'mobile' - /** * Partial settings that a preset can override */ @@ -36,6 +30,7 @@ interface PresetSettings { graphics?: Partial memory?: Partial cesium?: Partial + weather: Pick } /** @@ -61,6 +56,9 @@ export const SETTINGS_PRESETS: Record = { terrainQuality: 3, show3DBuildings: false, }, + weather: { + stageQuality: 'high', + }, }, ipad: { graphics: { @@ -79,6 +77,9 @@ export const SETTINGS_PRESETS: Record = { terrainQuality: 2, show3DBuildings: false, }, + weather: { + stageQuality: 'medium', + }, }, mobile: { graphics: { @@ -98,9 +99,27 @@ export const SETTINGS_PRESETS: Record = { terrainQuality: 1, show3DBuildings: false, }, + weather: { + stageQuality: 'low', + }, }, } +/** + * The weather stage quality a new install starts at, and the one a reset or a + * settings migration gives it: High in the desktop app, whatever touch screen the + * machine reports, since the app runs only on desktops; in a remote browser, the + * device preset's (`SETTINGS_PRESETS`): Medium on an iPad or tablet, Low on a phone. + * Outside a browser (a test run in Node), where there is no device to ask, it is Desktop's. + * + * @returns The `weather.stageQuality` a new or reset install takes + */ +export function defaultWeatherQuality(): WeatherQualityPreset { + if (typeof window === 'undefined') return SETTINGS_PRESETS.desktop.weather.stageQuality + if (isTauri()) return 'high' + return SETTINGS_PRESETS[deviceSettingsPreset()].weather.stageQuality +} + /** * Extended SettingsStore interface with preset support */ @@ -165,6 +184,7 @@ export const useSettingsStore = create()( persist( (set, get) => ({ ...DEFAULT_SETTINGS, + weather: { ...DEFAULT_SETTINGS.weather, stageQuality: defaultWeatherQuality() }, // ======================================================================== // UPDATE FUNCTIONS (Group-based) @@ -341,7 +361,8 @@ export const useSettingsStore = create()( // RESET TO DEFAULTS // ======================================================================== - resetToDefaults: () => set(DEFAULT_SETTINGS), + resetToDefaults: () => + set({ ...DEFAULT_SETTINGS, weather: { ...DEFAULT_SETTINGS.weather, stageQuality: defaultWeatherQuality() } }), // ======================================================================== // EXPORT / IMPORT @@ -438,12 +459,13 @@ export const useSettingsStore = create()( graphics: { ...state.graphics, ...preset.graphics }, memory: { ...state.memory, ...preset.memory }, cesium: { ...state.cesium, ...preset.cesium }, + weather: { ...state.weather, ...preset.weather }, })) }, }), { name: 'settings-store', - version: 38, + version: 39, migrate: (persistedState: unknown, version: number) => { // biome-ignore lint/suspicious/noExplicitAny: migration handles arbitrary persisted state let state: any = persistedState @@ -566,6 +588,14 @@ export const useSettingsStore = create()( } } + // v38 → v39: Weather stage quality preset for this device's class (iPad Medium, phone Low, desktop High; + // the desktop app stays High, whatever touch screen the machine reports) + if (version < 39) { + const stageQuality = defaultWeatherQuality() + console.log(`[Settings] Migrating v38 to v39: weather quality ${stageQuality} for this device`) + state = { ...state, weather: { ...state.weather, stageQuality } } + } + // ======================================================================== // REPAIR STEP: Auto-fill missing settings from defaults // ======================================================================== @@ -703,6 +733,7 @@ function migrateOldSettings(oldSettings: any): typeof DEFAULT_SETTINGS { oldSettings.showLightning ?? oldSettings.weather?.showLightning ?? DEFAULT_SETTINGS.weather.showLightning, enableWeatherInterpolation: oldSettings.enableWeatherInterpolation ?? DEFAULT_SETTINGS.weather.enableWeatherInterpolation, + stageQuality: defaultWeatherQuality(), }, memory: { inMemoryTileCacheSize: oldSettings.inMemoryTileCacheSize ?? DEFAULT_SETTINGS.memory.inMemoryTileCacheSize, diff --git a/src/renderer/stores/towerPositioningStore.ts b/src/renderer/stores/towerPositioningStore.ts index a644aca9..3f84649a 100644 --- a/src/renderer/stores/towerPositioningStore.ts +++ b/src/renderer/stores/towerPositioningStore.ts @@ -48,7 +48,7 @@ interface TowerPositioningState { // Step 1: Model positioning originalModelPosition: ModelPosition | null modelOffset: { north: number; east: number; up: number } // Meters - modelRotation: number // Degrees (0-360, clockwise from north) + modelRotation: number // Degrees (0-360, clockwise from the cab's rest pose, where glTF +X faces east) // Step 1: Control mode toggle (model vs camera controls) step1ControlMode: Step1ControlMode diff --git a/src/renderer/stores/weatherStageStore.ts b/src/renderer/stores/weatherStageStore.ts new file mode 100644 index 00000000..421b7388 --- /dev/null +++ b/src/renderer/stores/weatherStageStore.ts @@ -0,0 +1,19 @@ +import { create } from 'zustand' +import type { WeatherStageCameraOverride, WeatherStageTestAircraft } from '@/types' + +/** + * Non-persisted, dev-only drive aids of the Cesium cloud and fog stage. + * + * `cameraOverride` and `testAircraft` are set only from + * `window.__tc3d.weatherStage` in dev builds; each window (and each inset + * iframe) has its own copy, and nothing is saved. + */ +interface WeatherStageState { + cameraOverride: WeatherStageCameraOverride | null + testAircraft: WeatherStageTestAircraft | null +} + +export const useWeatherStageStore = create(() => ({ + cameraOverride: null, + testAircraft: null, +})) diff --git a/src/renderer/stores/weatherStore.ts b/src/renderer/stores/weatherStore.ts index 6e3689e7..65f0eea3 100644 --- a/src/renderer/stores/weatherStore.ts +++ b/src/renderer/stores/weatherStore.ts @@ -45,9 +45,6 @@ interface WeatherState { // Debug override mode - when true, METAR updates don't overwrite precipitation/wind/clouds isDebugOverriding: boolean - // Instant update flag - when true, smoothing should be bypassed for immediate visual update - instantUpdatePending: boolean - // Actions fetchWeather: (icao: string) => Promise fetchNearestWeather: (lat: number, lon: number) => Promise @@ -65,10 +62,6 @@ interface WeatherState { setPrecipitation: (precipitation: PrecipitationState) => void setWind: (wind: WindState) => void setCloudLayers: (cloudLayers: CloudLayer[]) => void - /** Trigger instant update (bypasses smoothing), returns true if flag was set */ - triggerInstantUpdate: () => void - /** Consume instant update flag, returns true if flag was pending */ - consumeInstantUpdate: () => boolean } /** @@ -253,7 +246,6 @@ export const useWeatherStore = create((set, get) => ({ useInterpolation: false, refreshIntervalId: null, isDebugOverriding: false, - instantUpdatePending: false, fetchWeather: async (icao: string) => { set({ isLoading: true, error: null, useNearestMetar: false }) @@ -589,17 +581,4 @@ export const useWeatherStore = create((set, get) => ({ setCloudLayers: (cloudLayers: CloudLayer[]) => { set({ cloudLayers }) }, - - triggerInstantUpdate: () => { - set({ instantUpdatePending: true }) - }, - - consumeInstantUpdate: () => { - const state = get() - if (state.instantUpdatePending) { - set({ instantUpdatePending: false }) - return true - } - return false - }, })) diff --git a/src/renderer/types/babylon.ts b/src/renderer/types/babylon.ts index 1eac8185..18187f75 100644 --- a/src/renderer/types/babylon.ts +++ b/src/renderer/types/babylon.ts @@ -2,7 +2,7 @@ * Babylon.js-specific type definitions * * These types define interfaces for Babylon.js rendering, including scene - * management, weather effects, aircraft labels, and camera synchronization. + * management, aircraft labels, weather visibility culling, and camera synchronization. */ import type * as BABYLON from '@babylonjs/core' @@ -31,38 +31,6 @@ export interface AircraftLabel { leaderLine: GUI.Line } -// ============================================================================ -// Weather Effect Types -// ============================================================================ - -/** - * Cloud layer mesh and material data for weather visualization. - * - * Each cloud layer can be rendered as either: - * - A flat horizontal plane (for FEW/SCT/BKN coverage with gaps) - * - A curved dome mesh (for OVC overcast creating realistic sky appearance) - * - * The active mesh is determined by coverage level - OVC uses dome, others use plane. - */ -export interface CloudMeshData { - /** Horizontal plane mesh for partial cloud coverage (FEW/SCT/BKN) - viewed from below */ - plane: BABYLON.Mesh - /** Curved dome mesh for overcast coverage (OVC) - creates realistic overcast sky from below */ - dome: BABYLON.Mesh - /** Horizontal plane mesh for above-cloud view - shows cloud tops when camera is above */ - abovePlane: BABYLON.Mesh - /** Semi-transparent material with configurable opacity (for plane from below) */ - material: BABYLON.StandardMaterial - /** Separate material for dome with fresnel effect (for below view) */ - domeMaterial: BABYLON.StandardMaterial - /** Material for above-cloud plane (lighter colors, cloud tops appearance) */ - aboveMaterial: BABYLON.StandardMaterial - /** Last coverage value used to generate the opacity texture (for caching) */ - lastCoverage?: number - /** Whether the dome mesh is currently active (vs plane) */ - usingDome?: boolean -} - // ============================================================================ // Scene Initialization Types // ============================================================================ @@ -72,7 +40,7 @@ export interface CloudMeshData { * * The Babylon overlay is a transparent canvas positioned above the Cesium viewer, * synchronized via camera transforms. It renders screen-space elements (labels, - * leader lines) and weather effects (fog dome, cloud layers). + * leader lines) and precipitation (rain, snow, lightning). */ export interface BabylonOverlayOptions { /** Cesium viewer instance to synchronize with */ @@ -95,25 +63,6 @@ export interface BabylonSceneOptions { transparent?: boolean } -// ============================================================================ -// Camera Synchronization Types -// ============================================================================ - -/** - * Options for Babylon camera synchronization with Cesium. - * - * Used by useBabylonCameraSync to configure ENU coordinate transforms and - * camera matrix updates. - */ -export interface BabylonCameraSyncOptions { - /** Cesium viewer to read camera state from */ - cesiumViewer: Cesium.Viewer | null - /** Babylon camera to update */ - camera: BABYLON.FreeCamera | null - /** Optional fog dome mesh to position at camera location */ - fogDome?: BABYLON.Mesh | null -} - // ============================================================================ // ENU Transform Types // ============================================================================ @@ -203,26 +152,10 @@ export interface UseBabylonSceneResult { camera: BABYLON.FreeCamera | null /** GUI texture for 2D overlays (or null if not initialized) */ guiTexture: GUI.AdvancedDynamicTexture | null - /** Shadow generator on the directional light (or null if not initialized) */ - shadowGenerator: BABYLON.ShadowGenerator | null /** Whether the scene is ready for rendering */ sceneReady: boolean } -/** - * Return type for useBabylonWeather hook. - * - * Provides access to weather effect meshes and visibility culling function. - */ -export interface UseBabylonWeatherResult { - /** Fog dome mesh (or null if not created) */ - fogDome: BABYLON.Mesh | null - /** Getter function for cloud layer mesh data (0-4 layers) - use getter to avoid stale closure */ - getCloudMeshes: () => CloudMeshData[] - /** Function to check if aircraft label should be visible based on weather */ - isVisibleByWeather: (params: WeatherVisibilityParams) => boolean -} - /** * Return type for useBabylonLabels hook. * diff --git a/src/renderer/types/harness.ts b/src/renderer/types/harness.ts index b03ab977..0bd50ac0 100644 --- a/src/renderer/types/harness.ts +++ b/src/renderer/types/harness.ts @@ -4,7 +4,8 @@ * The harness gives agent-driven tests a handle on the running app's Cesium * viewer, Babylon overlay and a few Zustand stores, so a test can read camera * state, aim the camera, project a coordinate to a screen point, read back - * rendered pixels and read frame timings. It is installed by `useAgentHarness` + * rendered pixels, read frame timings, drive the Cesium cloud and fog stage + * (camera override, test aircraft) and read the tower cab's state. It is installed by `useAgentHarness` * and only exists in dev builds; production bundles drop it. * * @see useAgentHarness - Hook that installs the global @@ -134,6 +135,67 @@ export interface AgentHarness { * median and p95 of the last 300 frames' timing series */ perf: () => { current: PerformanceMetrics; window: FrameWindowSummary } + + /** The Cesium cloud and fog stage: drive aids and state */ + weatherStage: WeatherStageHandle + + /** The tower cab model of the main viewport, read live on each access */ + readonly towerModel: TowerModelHandle +} + +/** State of the tower cab model drawn by `useTowerModel` */ +export interface TowerModelHandle { + /** Whether a cab model is in the scene */ + loaded: boolean + /** The cab's model matrix, column-major (`Cesium.Matrix4.toArray`), or null without a cab */ + modelMatrix: number[] | null + /** Light factor applied to the cab: 1 by day or with night darkening off, lower at night */ + nightFactor: number +} + +/** A camera pose that replaces the app's camera every frame while set */ +export interface WeatherStageCameraOverride { + lat: number + lon: number + /** Height above the ellipsoid in meters */ + heightM: number + /** Degrees clockwise from north */ + heading: number + /** Degrees, negative looks down */ + pitch: number +} + +/** The built-in B738 model placed at a fixed position, for occlusion tests */ +export interface WeatherStageTestAircraft { + lat: number + lon: number + /** Height above the ellipsoid in meters */ + heightM: number + /** Degrees clockwise from north */ + heading: number +} + +/** Drive aids accepted by `weatherStage.set`; an omitted field is left alone, null clears it */ +export interface WeatherStageSetParams { + camera?: WeatherStageCameraOverride | null + testAircraft?: WeatherStageTestAircraft | null +} + +/** Dev handle on the cloud and fog stage */ +export interface WeatherStageHandle { + /** Sets or clears the camera override and the test aircraft */ + set: (params: WeatherStageSetParams) => void + /** + * Whether the stage is in the scene and enabled (disabled with nothing to draw), the scene's + * post-process stage names in draw order, and the depth facts its occlusion relies on + */ + info: () => { + stageInScene: boolean + stageEnabled: boolean + stageNames: string[] + numberOfFrustums: number + logDepth: boolean + } } declare global { diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index fbf27820..87d87b7c 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -172,6 +172,7 @@ export type { RealTrafficSettings, // GPU rendering backend (ANGLE) selection RenderingBackend, + SettingsPreset, // Main settings store (NEW grouped structure for Phase 5) SettingsStore, ShadowQuality, @@ -180,6 +181,8 @@ export type { Theme, TimeMode, UISettings, + // Weather stage quality preset + WeatherQualityPreset, WeatherSettings, } from './settings' @@ -316,15 +319,11 @@ export { export type { // Aircraft labels AircraftLabel, - // Camera synchronization - BabylonCameraSyncOptions, // Memory diagnostics BabylonMemoryCounters, // Scene initialization BabylonOverlayOptions, BabylonSceneOptions, - // Weather effects - CloudMeshData, // ENU transforms EnuTransformData, UseBabylonCameraSyncResult, @@ -332,7 +331,6 @@ export type { UseBabylonRootNodeResult, // Hook return types UseBabylonSceneResult, - UseBabylonWeatherResult, // Weather visibility WeatherVisibilityParams, } from './babylon' @@ -432,6 +430,13 @@ export type { FrameTimingSummary, FrameWindowSummary, PercentileStat, + // Tower cab model state + TowerModelHandle, + // Cloud and fog stage drive aids + WeatherStageCameraOverride, + WeatherStageHandle, + WeatherStageSetParams, + WeatherStageTestAircraft, } from './harness' // ============================================================================ @@ -472,8 +477,8 @@ export type { * - Used by: AircraftModelService, TowerModelService * * 8. **Babylon.js types** → `babylon.ts` - * - Babylon rendering, labels, weather effects, ENU transforms - * - Used by: useBabylonOverlay, useBabylonScene, useBabylonWeather, useBabylonLabels + * - Babylon rendering, labels, weather visibility culling, ENU transforms + * - Used by: useBabylonOverlay, useBabylonScene, useBabylonLabels * * 9. **FSLTL types** → `fsltl.ts` * - FSLTL model conversion, registry, VMR parsing diff --git a/src/renderer/types/settings.ts b/src/renderer/types/settings.ts index 1f441f30..b62ae026 100644 --- a/src/renderer/types/settings.ts +++ b/src/renderer/types/settings.ts @@ -112,6 +112,20 @@ export type AircraftTintColor = 'white' | 'lightBlue' | 'tan' | 'yellow' | 'oran */ export type BuildingQuality = 'low' | 'medium' | 'high' +/** + * Settings preset of a device class, keyed into `SETTINGS_PRESETS`: desktop, + * iPad or tablet, phone or low-end touch device. The device optimisation prompt + * suggests the one for the current device. + */ +export type SettingsPreset = 'desktop' | 'ipad' | 'mobile' + +/** + * Quality preset of the Cesium cloud and fog stage; the knobs of each are in + * `WEATHER_STAGE_PRESETS`. Lower presets march fewer steps, at a lower cloud + * pass resolution, over a smaller cloud radius; the look knobs are the same. + */ +export type WeatherQualityPreset = 'low' | 'medium' | 'high' + /** * Ground traffic label display mode * @@ -782,6 +796,13 @@ export interface WeatherSettings { * When disabled, weather uses only the current airport's METAR. */ enableWeatherInterpolation: boolean + + /** + * Quality preset of the cloud and fog stage in the main view (default: 'high'; + * the device presets set 'medium' on iPad and 'low' on phones). Insets derive + * theirs from it by their size against the main view. + */ + stageQuality: WeatherQualityPreset } /** @@ -1962,6 +1983,7 @@ export const DEFAULT_SETTINGS: Omit< precipitationIntensity: 1.0, showLightning: true, enableWeatherInterpolation: true, + stageQuality: 'high', }, memory: { inMemoryTileCacheSize: 2000, diff --git a/src/renderer/types/weather.ts b/src/renderer/types/weather.ts index fcb9f5ba..4d70cdd9 100644 --- a/src/renderer/types/weather.ts +++ b/src/renderer/types/weather.ts @@ -6,7 +6,7 @@ * * @see weatherStore - Store for managing weather state * @see MetarService - Service for fetching and parsing METAR data - * @see useBabylonOverlay - Hook that renders fog dome and cloud layers + * @see useCesiumWeatherStages - Hook that renders the cloud and fog stage */ /** @@ -128,7 +128,7 @@ export type FlightCategory = 'VFR' | 'MVFR' | 'IFR' | 'LIFR' * ``` * * @see weatherStore - Stores computed fog density - * @see useBabylonOverlay - Renders fog dome based on density + * @see useCesiumWeather - Tunes Cesium's scene fog from the density */ export type FogDensity = number diff --git a/src/renderer/utils/cloudDeckGeometry.test.ts b/src/renderer/utils/cloudDeckGeometry.test.ts new file mode 100644 index 00000000..d3eaf267 --- /dev/null +++ b/src/renderer/utils/cloudDeckGeometry.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { + cloudLayerMarchOrder, + curvatureDropM, + deckEdgeElevationDeg, + horizonDipDeg, + minDeckRadiusForHorizonM, +} from '@/utils/cloudDeckGeometry' + +const CAMERA_M = 80 +const OVC_BASE_M = 609.6 +const BKN_BASE_M = 274.3 + +/** Angle in degrees between the deck's far edge and the horizon below it (positive leaves sky between them). */ +function gapDeg(cameraAglM: number, baseAglM: number, deckRadiusM: number, curved: boolean): number { + return deckEdgeElevationDeg(cameraAglM, baseAglM, deckRadiusM, curved) + horizonDipDeg(cameraAglM) +} + +describe('cloud deck geometry', () => { + it('puts the horizon 0.287 deg below level from 80 m', () => { + expect(horizonDipDeg(CAMERA_M)).toBeCloseTo(0.287, 3) + }) + + it('leaves about a degree of sky under a flat 40 km deck', () => { + expect(Math.abs(deckEdgeElevationDeg(CAMERA_M, OVC_BASE_M, 40_000, false) - 0.759)).toBeLessThan(0.005) + expect(gapDeg(CAMERA_M, OVC_BASE_M, 40_000, false)).toBeCloseTo(1.05, 1) + }) + + it('brings a curved 100 km deck to within 0.15 deg of the horizon', () => { + expect(Math.abs(deckEdgeElevationDeg(CAMERA_M, OVC_BASE_M, 100_000, true) - -0.146)).toBeLessThan(0.005) + const gap = gapDeg(CAMERA_M, OVC_BASE_M, 100_000, true) + expect(gap).toBeGreaterThan(0) + expect(gap).toBeLessThan(0.15) + }) + + it('needs about 120 km for the overcast base to reach the horizon', () => { + expect(Math.abs(minDeckRadiusForHorizonM(CAMERA_M, OVC_BASE_M) - 120_000)).toBeLessThan(500) + expect(Math.abs(minDeckRadiusForHorizonM(88.8, OVC_BASE_M) - 121_800)).toBeLessThan(500) + }) + + it('meets the horizon exactly at the minimum radius', () => { + const radius = minDeckRadiusForHorizonM(CAMERA_M, OVC_BASE_M) + expect(gapDeg(CAMERA_M, OVC_BASE_M, radius, true)).toBeCloseTo(0, 4) + }) + + it('closes a 274.3 m base below the horizon at 100 km', () => { + expect(gapDeg(CAMERA_M, BKN_BASE_M, 100_000, true)).toBeLessThan(0) + }) + + it('widens the gap at 100 km as the camera climbs toward the curvature drop, then narrows it', () => { + const gaps = [80, 300, 500].map((h) => gapDeg(h, OVC_BASE_M, 100_000, true)) + expect(gaps[1]).toBeGreaterThan(gaps[0]) + expect(gaps[2]).toBeGreaterThan(gaps[1]) + const peak = gapDeg(curvatureDropM(100_000), OVC_BASE_M, 100_000, true) + expect(peak).toBeGreaterThanOrEqual(gaps[2]) + expect(peak).toBeGreaterThanOrEqual(gapDeg(1500, OVC_BASE_M, 100_000, true)) + }) +}) + +describe('cloudLayerMarchOrder', () => { + it('marches layers above the camera lowest first, even for a ray that starts level or downward', () => { + expect(cloudLayerMarchOrder(CAMERA_M, [BKN_BASE_M, OVC_BASE_M])).toEqual([0, 1]) + }) + + it('marches layers below the camera highest first', () => { + expect(cloudLayerMarchOrder(1524, [BKN_BASE_M, OVC_BASE_M])).toEqual([1, 0]) + }) + + it('marches the layer below a camera between two layers before the one above it', () => { + expect(cloudLayerMarchOrder(400, [BKN_BASE_M, OVC_BASE_M, 1500])).toEqual([0, 1, 2]) + expect(cloudLayerMarchOrder(700, [BKN_BASE_M, OVC_BASE_M, 1500])).toEqual([1, 0, 2]) + }) + + it('counts a camera exactly at a base as above that layer', () => { + expect(cloudLayerMarchOrder(OVC_BASE_M, [BKN_BASE_M, OVC_BASE_M])).toEqual([1, 0]) + }) +}) diff --git a/src/renderer/utils/cloudDeckGeometry.ts b/src/renderer/utils/cloudDeckGeometry.ts new file mode 100644 index 00000000..cb9c9dbd --- /dev/null +++ b/src/renderer/utils/cloudDeckGeometry.ts @@ -0,0 +1,100 @@ +/** + * Geometry of the weather stage's cloud deck against the earth's curvature. + * + * The cloud shader models the deck as a slab that follows the earth down with + * the parabola z + d^2 / (2R) at horizontal distance d. These helpers mirror + * that model on the CPU so tests can check where the deck's far edge sits + * against the geometric horizon seen from the camera. + */ + +/** Mean earth radius in meters; the cloud shader's `EARTH_R` must equal it. */ +export const EARTH_RADIUS_M = 6_371_000 + +const RAD_TO_DEG = 180 / Math.PI + +/** + * How far the earth's surface falls below the local horizontal plane. + * + * Args: + * distanceM: Horizontal distance from the tangent point, in meters. + * + * Returns: + * The drop in meters, d^2 / (2R). + */ +export function curvatureDropM(distanceM: number): number { + return (distanceM * distanceM) / (2 * EARTH_RADIUS_M) +} + +/** + * Angle of the geometric horizon below level, seen from a height above the ground. + * + * Args: + * cameraAglM: Camera height above the ground in meters (0 or more). + * + * Returns: + * The dip in degrees, sqrt(2h / R), positive downward. + */ +export function horizonDipDeg(cameraAglM: number): number { + return Math.sqrt((2 * cameraAglM) / EARTH_RADIUS_M) * RAD_TO_DEG +} + +/** + * Elevation of the deck base's far edge seen from the camera. + * + * Args: + * cameraAglM: Camera height above the ground in meters. + * baseAglM: Deck base height above the ground in meters. + * deckRadiusM: Horizontal distance to the deck's edge in meters. + * curved: Whether the deck follows the earth's curvature (false is a flat plane). + * + * Returns: + * The elevation in degrees above level (negative below level). + */ +export function deckEdgeElevationDeg( + cameraAglM: number, + baseAglM: number, + deckRadiusM: number, + curved: boolean, +): number { + const baseZ = curved ? baseAglM - curvatureDropM(deckRadiusM) : baseAglM + return Math.atan2(baseZ - cameraAglM, deckRadiusM) * RAD_TO_DEG +} + +/** + * Order in which a ray from the camera meets the cloud layers, as the cloud shader marches them. + * + * Layers under the camera come first, highest down, then the layers over it, lowest up. The + * ray's slope does not change the order: on the curved deck a ray that starts downward still + * rises into the layers above the camera. + * + * Args: + * cameraDeckZM: Camera height above the curved deck datum in meters. + * layerBasesM: Layer bases in meters, sorted lowest first. + * + * Returns: + * Layer indices in march order. + */ +export function cloudLayerMarchOrder(cameraDeckZM: number, layerBasesM: readonly number[]): number[] { + const below = layerBasesM.filter((base) => base <= cameraDeckZM).length + return layerBasesM.map((_base, k) => (k < below ? below - 1 - k : k)) +} + +/** + * Smallest curved-deck radius whose base edge reaches the geometric horizon. + * + * Solves (base - d^2 / (2R) - camera) / d = -sqrt(2 camera / R) for d, in the + * small-angle form the deck model uses. + * + * Args: + * cameraAglM: Camera height above the ground in meters (0 or more). + * baseAglM: Deck base height above the ground in meters (0 or more). + * + * Returns: + * The radius in meters. + */ +export function minDeckRadiusForHorizonM(cameraAglM: number, baseAglM: number): number { + const a = 1 / (2 * EARTH_RADIUS_M) + const dip = Math.sqrt((2 * cameraAglM) / EARTH_RADIUS_M) + const rise = baseAglM - cameraAglM + return (dip + Math.sqrt(dip * dip + 4 * a * rise)) / (2 * a) +} diff --git a/src/renderer/utils/deviceDetection.ts b/src/renderer/utils/deviceDetection.ts index 71059186..07386909 100644 --- a/src/renderer/utils/deviceDetection.ts +++ b/src/renderer/utils/deviceDetection.ts @@ -5,6 +5,8 @@ * adapting UI and performance settings. */ +import type { SettingsPreset } from '@/types' + /** * Check if the current device supports touch input */ @@ -92,6 +94,20 @@ export function getDevicePerformanceTier(): 'high' | 'medium' | 'low' { return 'high' } +/** + * The device settings preset (`SETTINGS_PRESETS`) for this device: desktop without + * touch input; on a touch device, mobile for a low tier or a phone, otherwise ipad + * (an iPad, or a touch device not recognised as either). + * + * The device optimisation prompt suggests it, and `defaultWeatherQuality` picks the + * weather quality of a new or migrated install from it. + */ +export function deviceSettingsPreset(): SettingsPreset { + if (!isTouchDevice()) return 'desktop' + if (getDevicePerformanceTier() === 'low' || isMobileDevice()) return 'mobile' + return 'ipad' +} + /** * Check if the device prefers reduced motion */ diff --git a/src/renderer/utils/modelOrientation.test.ts b/src/renderer/utils/modelOrientation.test.ts new file mode 100644 index 00000000..0c765aae --- /dev/null +++ b/src/renderer/utils/modelOrientation.test.ts @@ -0,0 +1,95 @@ +import * as Cesium from 'cesium' +import { describe, expect, it } from 'vitest' +import { aircraftModelHeading, compassToModelHeading, towerModelMatrix } from './modelOrientation' + +const LAT = 37.7213 +const LON = -122.2208 +const HEIGHT = 30 + +/** Bearing in degrees clockwise from north of a model-local axis, read from a model matrix. */ +function axisBearing(modelMatrix: Cesium.Matrix4, localAxis: Cesium.Cartesian3): number { + const origin = Cesium.Matrix4.getTranslation(modelMatrix, new Cesium.Cartesian3()) + const worldAxis = Cesium.Matrix4.multiplyByPointAsVector(modelMatrix, localAxis, new Cesium.Cartesian3()) + const enuToFixed = Cesium.Transforms.eastNorthUpToFixedFrame(origin) + const fixedToEnu = Cesium.Matrix4.inverseTransformation(enuToFixed, new Cesium.Matrix4()) + const enu = Cesium.Matrix4.multiplyByPointAsVector(fixedToEnu, worldAxis, new Cesium.Cartesian3()) + const bearing = (Math.atan2(enu.x, enu.y) * 180) / Math.PI + return (bearing + 360) % 360 +} + +function angleDiff(a: number, b: number): number { + const d = Math.abs(a - b) % 360 + return Math.min(d, 360 - d) +} + +describe('aircraftModelHeading', () => { + it('is numerically the formula it replaced, for every rotation offset', () => { + for (const offset of [0, 90, 180, 270]) { + for (const heading of [0, 37.5, 90, 213.25, 359.9]) { + const before = Cesium.Math.toRadians(heading - 90 + 180 + offset) + expect(aircraftModelHeading(heading, offset)).toBeCloseTo(before, 12) + } + } + }) + + it('goes through the shared compass helper with the +180 default flip', () => { + expect(aircraftModelHeading(123, 0)).toBeCloseTo(compassToModelHeading(123, 180), 12) + }) +}) + +describe('compassToModelHeading', () => { + it('points glTF +X along the compass heading', () => { + for (const compass of [0, 90, 180, 213, 300]) { + const position = Cesium.Cartesian3.fromDegrees(LON, LAT, HEIGHT) + const hpr = new Cesium.HeadingPitchRoll(compassToModelHeading(compass, 0), 0, 0) + const m = Cesium.Transforms.headingPitchRollToFixedFrame(position, hpr) + expect(angleDiff(axisBearing(m, Cesium.Cartesian3.UNIT_X), compass)).toBeLessThan(1e-6) + } + }) +}) + +// The Cesium cab follows the earlier Cesium cab hook: at manifest rotation 0 its glTF +X faces +// east, and it turns clockwise as the rotation grows (glTF +X bearing = rotation + 90, mod 360). +// Existing tower manifests, KOAK among them, were tuned against that convention. +describe('towerModelMatrix', () => { + it('puts glTF +X east at rotation 0, as the earlier Cesium cab did (KOAK is centred on its shaft there)', () => { + const m = towerModelMatrix(LON, LAT, HEIGHT, 0, 1) + expect(angleDiff(axisBearing(m, Cesium.Cartesian3.UNIT_X), 90)).toBeLessThan(1e-6) + // Cesium turns glTF Y-up into Z-up inside the model, so glTF +Z is the model frame's -Y. + const gltfZ = new Cesium.Cartesian3(0, -1, 0) + expect(angleDiff(axisBearing(m, gltfZ), 180)).toBeLessThan(1e-6) + }) + + it('turns clockwise from that rest pose: glTF +X bearing is rotation + 90', () => { + const cases: Array<[number, number]> = [ + [90, 180], + [213, 303], + [300, 30], + ] + for (const [rotation, bearing] of cases) { + const m = towerModelMatrix(LON, LAT, HEIGHT, rotation, 1) + expect(angleDiff(axisBearing(m, Cesium.Cartesian3.UNIT_X), bearing)).toBeLessThan(1e-6) + } + }) + + it('goes through the shared compass helper with the cab 90-degree offset', () => { + const rotation = 213 + const position = Cesium.Cartesian3.fromDegrees(LON, LAT, HEIGHT) + const hpr = new Cesium.HeadingPitchRoll(compassToModelHeading(rotation + 90, 0), 0, 0) + const expected = Cesium.Transforms.headingPitchRollToFixedFrame(position, hpr) + const actual = towerModelMatrix(LON, LAT, HEIGHT, rotation, 1) + for (let i = 0; i < 16; i++) { + expect(actual[i]).toBeCloseTo(expected[i], 6) + } + }) + + it('scales uniformly and places the origin at the given height', () => { + const m = towerModelMatrix(LON, LAT, HEIGHT, 0, 2) + const xLength = Cesium.Cartesian3.magnitude( + Cesium.Matrix4.multiplyByPointAsVector(m, Cesium.Cartesian3.UNIT_X, new Cesium.Cartesian3()), + ) + expect(xLength).toBeCloseTo(2, 9) + const origin = Cesium.Cartographic.fromCartesian(Cesium.Matrix4.getTranslation(m, new Cesium.Cartesian3())) + expect(origin.height).toBeCloseTo(HEIGHT, 6) + }) +}) diff --git a/src/renderer/utils/modelOrientation.ts b/src/renderer/utils/modelOrientation.ts new file mode 100644 index 00000000..43b95d33 --- /dev/null +++ b/src/renderer/utils/modelOrientation.ts @@ -0,0 +1,74 @@ +/** + * One orientation convention for every glTF model drawn in Cesium: aircraft and the tower cab. + * + * Cesium loads a glTF with `forwardAxis` X (its default), so after the Y-up to Z-up turn the + * model's glTF +X axis lies along the model frame's +X. `headingPitchRollToFixedFrame` points that + * axis east at heading 0 and turns it clockwise as the heading grows, so a model whose glTF +X is + * its front faces compass bearing `c` at heading `c - 90`. + */ + +import * as Cesium from 'cesium' + +/** + * Aircraft models are turned 180 degrees on top of their own rotation offset: built-in and mod + * aircraft without an offset have their nose on glTF -X, FSLTL/AIG conversions declare 180. + */ +const AIRCRAFT_DEFAULT_OFFSET_DEG = 180 + +/** + * The cab's rest pose points glTF +X east, so its heading carries a 90-degree cab offset on top of + * the compass bearing. + */ +const CAB_ROTATION_OFFSET_DEG = 90 + +/** + * Cesium heading-pitch-roll heading that points a model's glTF +X along a compass bearing. + * + * @param compassDeg - Bearing in degrees clockwise from north + * @param offsetDeg - Extra clockwise turn in degrees for models whose front is not glTF +X + * @returns Heading in radians for `Cesium.HeadingPitchRoll` + */ +export function compassToModelHeading(compassDeg: number, offsetDeg: number): number { + return Cesium.Math.toRadians(compassDeg - 90 + offsetDeg) +} + +/** + * Heading for an aircraft model flying a compass heading. + * + * @param headingDeg - Aircraft heading in degrees clockwise from north + * @param rotationOffsetDeg - The model's own yaw offset in degrees (0 when it declares none) + * @returns Heading in radians for `Cesium.HeadingPitchRoll` + */ +export function aircraftModelHeading(headingDeg: number, rotationOffsetDeg: number): number { + return compassToModelHeading(headingDeg, AIRCRAFT_DEFAULT_OFFSET_DEG + rotationOffsetDeg) +} + +/** + * Model matrix of a tower cab: placed at a position, turned to the manifest rotation, uniformly + * scaled. + * + * The rotation is the clockwise turn from the cab's rest pose, where glTF +X faces east, so glTF + * +X faces compass bearing `rotation + 90`. + * + * @param lon - Longitude in degrees + * @param lat - Latitude in degrees + * @param height - Height of the model origin in meters above the ellipsoid + * @param rotationDeg - Manifest rotation in degrees clockwise from the rest pose (glTF +X east) + * @param scale - Uniform scale factor + * @returns The model matrix + */ +export function towerModelMatrix( + lon: number, + lat: number, + height: number, + rotationDeg: number, + scale: number, +): Cesium.Matrix4 { + const position = Cesium.Cartesian3.fromDegrees(lon, lat, height) + const hpr = new Cesium.HeadingPitchRoll(compassToModelHeading(rotationDeg, CAB_ROTATION_OFFSET_DEG), 0, 0) + const modelMatrix = Cesium.Transforms.headingPitchRollToFixedFrame(position, hpr) + if (scale !== 1.0) { + Cesium.Matrix4.multiply(modelMatrix, Cesium.Matrix4.fromUniformScale(scale), modelMatrix) + } + return modelMatrix +} diff --git a/src/renderer/utils/nightBrightness.test.ts b/src/renderer/utils/nightBrightness.test.ts new file mode 100644 index 00000000..1c6e37bc --- /dev/null +++ b/src/renderer/utils/nightBrightness.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { NIGHT_BRIGHTNESS_MIN, SUN_ELEVATION_CIVIL_TWILIGHT, SUN_ELEVATION_DAY } from '@/constants' +import { cabNightFactor, nightBrightness } from './nightBrightness' + +const INTENSITY = 0.7 +const NIGHT_FLOOR = NIGHT_BRIGHTNESS_MIN + (1 - INTENSITY) * (1 - NIGHT_BRIGHTNESS_MIN) + +describe('cabNightFactor', () => { + it('is 1 by day', () => { + expect(cabNightFactor(20, INTENSITY, true)).toBe(1) + expect(cabNightFactor(SUN_ELEVATION_DAY, INTENSITY, true)).toBe(1) + }) + + it('is the curve floor in deep night', () => { + expect(cabNightFactor(-40, INTENSITY, true)).toBeCloseTo(NIGHT_FLOOR, 12) + expect(cabNightFactor(-40, 1, true)).toBeCloseTo(NIGHT_BRIGHTNESS_MIN, 12) + }) + + it('falls through twilight between day and the floor', () => { + const twilight = cabNightFactor(SUN_ELEVATION_CIVIL_TWILIGHT / 2, INTENSITY, true) + expect(twilight).toBeLessThan(1) + expect(twilight).toBeGreaterThan(NIGHT_FLOOR) + }) + + it('is 1 when night darkening is off, whatever the sun', () => { + expect(cabNightFactor(-40, INTENSITY, false)).toBe(1) + expect(cabNightFactor(-3, INTENSITY, false)).toBe(1) + }) + + it('is 1 before the sun elevation is known', () => { + expect(cabNightFactor(null, INTENSITY, true)).toBe(1) + }) + + it('matches the imagery darkening curve', () => { + for (const elevation of [5, -2, -7, -13, -25]) { + expect(cabNightFactor(elevation, INTENSITY, true)).toBe(nightBrightness(elevation, INTENSITY)) + } + }) +}) diff --git a/src/renderer/utils/nightBrightness.ts b/src/renderer/utils/nightBrightness.ts new file mode 100644 index 00000000..dbb23f2b --- /dev/null +++ b/src/renderer/utils/nightBrightness.ts @@ -0,0 +1,87 @@ +/** + * The sun-elevation brightness curve shared by the imagery night darkening and the tower cab. + * + * One curve keeps the cab as dark as the ground around it: `useCesiumNightDarkening` writes it into + * the base imagery layer's brightness and `useTowerModel` into the cab model's light colour and + * image-based lighting factor. + */ + +import { + NIGHT_BRIGHTNESS_CIVIL, + NIGHT_BRIGHTNESS_MIN, + NIGHT_BRIGHTNESS_TWILIGHT, + SUN_ELEVATION_CIVIL_TWILIGHT, + SUN_ELEVATION_DAY, + SUN_ELEVATION_NAUTICAL_TWILIGHT, + SUN_ELEVATION_NIGHT, +} from '@/constants' + +/** + * Smooth interpolation between 0 and 1 as x moves from edge0 to edge1. + * + * @param edge0 - Value of x that maps to 0 + * @param edge1 - Value of x that maps to 1 + * @param x - Input value + * @returns 0 at or below edge0, 1 at or above edge1, smooth in between + */ +export function smoothstep(edge0: number, edge1: number, x: number): number { + const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0))) + return t * t * (3 - 2 * t) +} + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t +} + +/** + * Brightness for a sun elevation, through the twilight phases. + * + * - Day (sun above 0°): 1.0 + * - Civil twilight (0° to -6°): 1.0 to 0.6 + * - Nautical twilight (-6° to -12°): 0.6 to 0.3 + * - Astronomical twilight (-12° to -18°): 0.3 to the night floor + * - Night (sun below -18°): the night floor, set by the intensity + * + * @param sunElevation - Sun elevation angle in degrees + * @param intensity - Darkening intensity, 0.0 (no darkening) to 1.0 (darkest nights) + * @returns Brightness from the night floor to 1.0 + */ +export function nightBrightness(sunElevation: number, intensity: number): number { + // intensity 0.0 gives a floor of 1.0 (no darkening), 1.0 gives NIGHT_BRIGHTNESS_MIN. + const minBrightness = NIGHT_BRIGHTNESS_MIN + (1 - intensity) * (1.0 - NIGHT_BRIGHTNESS_MIN) + + if (sunElevation >= SUN_ELEVATION_DAY) { + return 1.0 + } + if (sunElevation > SUN_ELEVATION_CIVIL_TWILIGHT) { + const t = smoothstep(SUN_ELEVATION_CIVIL_TWILIGHT, SUN_ELEVATION_DAY, sunElevation) + return lerp(NIGHT_BRIGHTNESS_CIVIL, 1.0, t) + } + if (sunElevation > SUN_ELEVATION_NAUTICAL_TWILIGHT) { + const t = smoothstep(SUN_ELEVATION_NAUTICAL_TWILIGHT, SUN_ELEVATION_CIVIL_TWILIGHT, sunElevation) + return lerp(NIGHT_BRIGHTNESS_TWILIGHT, NIGHT_BRIGHTNESS_CIVIL, t) + } + if (sunElevation > SUN_ELEVATION_NIGHT) { + const t = smoothstep(SUN_ELEVATION_NIGHT, SUN_ELEVATION_NAUTICAL_TWILIGHT, sunElevation) + return lerp(minBrightness, NIGHT_BRIGHTNESS_TWILIGHT, t) + } + return minBrightness +} + +/** + * How bright the tower cab is lit, as a multiple of the scene's light. + * + * Follows the imagery darkening exactly: with night darkening off (or lighting off, which the + * caller folds into `enabled`) or the sun elevation not yet known, the cab is lit like the scene. + * + * @param sunElevation - Sun elevation in degrees, or null before it is known + * @param intensity - Night darkening intensity setting (0.0-1.0) + * @param enabled - Whether night darkening applies (setting on and lighting on) + * @returns Light factor from the night floor to 1.0 + */ +export function cabNightFactor(sunElevation: number | null, intensity: number, enabled: boolean): number { + if (!enabled || sunElevation === null) { + return 1.0 + } + return nightBrightness(sunElevation, intensity) +} diff --git a/src/renderer/utils/towerHeight.test.ts b/src/renderer/utils/towerHeight.test.ts new file mode 100644 index 00000000..9dfe96cf --- /dev/null +++ b/src/renderer/utils/towerHeight.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { resolveGroundHeight } from './towerHeight' + +const FIELD = 31.5 + +describe('resolveGroundHeight', () => { + it('takes the sampled terrain height when there is one', () => { + expect(resolveGroundHeight(12.25, 14, FIELD)).toBe(12.25) + }) + + it('takes a sampled height of 0 rather than skipping it', () => { + expect(resolveGroundHeight(0, 14, FIELD)).toBe(0) + }) + + it('falls back to the globe height when the sample has none', () => { + expect(resolveGroundHeight(undefined, 14, FIELD)).toBe(14) + expect(resolveGroundHeight(Number.NaN, 14, FIELD)).toBe(14) + }) + + it('falls back to the field elevation when neither terrain source has a height', () => { + expect(resolveGroundHeight(undefined, undefined, FIELD)).toBe(FIELD) + expect(resolveGroundHeight(Number.NaN, Number.POSITIVE_INFINITY, FIELD)).toBe(FIELD) + }) +}) diff --git a/src/renderer/utils/towerHeight.ts b/src/renderer/utils/towerHeight.ts index 3cc904bb..e73ccc6c 100644 --- a/src/renderer/utils/towerHeight.ts +++ b/src/renderer/utils/towerHeight.ts @@ -13,6 +13,33 @@ const HEIGHT_BY_TYPE = { unknown: 35, } +/** + * Ground height under a tower cab, from the best source that has one. + * + * The terrain sample comes first, then the height of the globe tile already loaded there, then the + * airport's field elevation; the first finite value wins. A sample can come back without a height + * and the terrain provider can be missing at startup, so the field elevation keeps the cab near its + * shaft instead of on the ellipsoid. + * + * @param sampled - Height from `sampleTerrainMostDetailed`, in meters above the ellipsoid + * @param globeHeight - Height from `globe.getHeight`, in meters above the ellipsoid + * @param fieldEllipsoidal - Airport field elevation converted to meters above the ellipsoid + * @returns Ground height in meters above the ellipsoid + */ +export function resolveGroundHeight( + sampled: number | undefined, + globeHeight: number | undefined, + fieldEllipsoidal: number, +): number { + if (sampled !== undefined && Number.isFinite(sampled)) { + return sampled + } + if (globeHeight !== undefined && Number.isFinite(globeHeight)) { + return globeHeight + } + return fieldEllipsoidal +} + /** * Get tower height for an airport * Returns height in meters above airport elevation diff --git a/src/renderer/utils/weatherStage.test.ts b/src/renderer/utils/weatherStage.test.ts new file mode 100644 index 00000000..d803d689 --- /dev/null +++ b/src/renderer/utils/weatherStage.test.ts @@ -0,0 +1,811 @@ +import { describe, expect, it, vi } from 'vitest' +import { + STATUTE_MILES_TO_METERS, + WEATHER_STAGE_FOG_EASE_MS, + WEATHER_STAGE_GROUND_BLEND_MS, + WEATHER_STAGE_NOISE_ORIGIN_RESET_M, + WEATHER_STAGE_PRESETS, +} from '@/constants' +import { CLOUD_STAGE_FS, CLOUD_STAGE_MAX_STEPS, WEATHER_COMPOSITE_FS } from '@/hooks/weather/cloudStage.glsl' +import type { CloudLayer, WeatherQualityPreset } from '@/types' +import { + anchorGround, + buildCloudLayerUniforms, + easedFogDensity, + effectiveVisibilitySM, + type FogEase, + type FogEaseInput, + type FrameGround, + groundBlendShift, + groundShiftStart, + horizontalNoiseOffset, + insetStageQuality, + layerSunShade, + NO_FOG_EASE, + nextFogEase, + nextWeatherAnchor, + noiseOriginNeedsReset, + sceneFogValues, + stageDrawsFog, + stageFogDensity, + stageFogExtinction, + stageFogFade, + stagePresetForViewport, + startFogEase, + type WeatherStageGateInput, + weatherStageGates, + weatherStageHasWork, + weatherStageQuality, +} from '@/utils/weatherStage' + +const THICKNESS = { FEW: 250, SCT: 350, BKN: 450, OVC: 600 } + +const PRESETS: WeatherQualityPreset[] = ['low', 'medium', 'high'] + +describe('weatherStageQuality', () => { + it.each(PRESETS)('gives the %s preset all six knobs', (preset) => { + expect(Object.keys(weatherStageQuality(preset)).sort()).toEqual([ + 'densityScale', + 'fogExtinction', + 'fogHeightScale', + 'radius', + 'resolutionScale', + 'steps', + ]) + }) + + it.each(PRESETS)('keeps the %s preset steps an integer within the shader maximum', (preset) => { + const { steps } = weatherStageQuality(preset) + expect(Number.isInteger(steps)).toBe(true) + expect(steps).toBeGreaterThanOrEqual(4) + expect(steps).toBeLessThanOrEqual(CLOUD_STAGE_MAX_STEPS) + }) + + it.each(PRESETS)('keeps the %s preset cloud pass scale within 0.25..1', (preset) => { + const { resolutionScale } = weatherStageQuality(preset) + expect(resolutionScale).toBeGreaterThanOrEqual(0.25) + expect(resolutionScale).toBeLessThanOrEqual(1) + }) + + it('keeps High at the stage values the deck was tuned with', () => { + expect(weatherStageQuality('high')).toEqual({ + steps: 120, + resolutionScale: 0.5, + densityScale: 0.04, + fogExtinction: 1.0, + fogHeightScale: 400, + radius: 100000, + }) + }) + + it('gives Medium and Low lower cost knobs and the same look knobs as High', () => { + expect(weatherStageQuality('medium')).toMatchObject({ steps: 72, resolutionScale: 0.375, radius: 60000 }) + expect(weatherStageQuality('low')).toMatchObject({ steps: 48, resolutionScale: 0.25, radius: 40000 }) + const look = ({ densityScale, fogExtinction, fogHeightScale }: (typeof WEATHER_STAGE_PRESETS)['high']) => ({ + densityScale, + fogExtinction, + fogHeightScale, + }) + expect(look(WEATHER_STAGE_PRESETS.medium)).toEqual(look(WEATHER_STAGE_PRESETS.high)) + expect(look(WEATHER_STAGE_PRESETS.low)).toEqual(look(WEATHER_STAGE_PRESETS.high)) + }) + + it('falls back to High for a stored value that is no preset', () => { + expect(weatherStageQuality('ultra' as WeatherQualityPreset)).toBe(WEATHER_STAGE_PRESETS.high) + }) +}) + +describe('insetStageQuality', () => { + const MAIN = 1000 + + it.each(PRESETS)('keeps the main %s preset for an inset of exactly half the main area or more', (preset) => { + expect(insetStageQuality(preset, 500, MAIN)).toBe(preset) + expect(insetStageQuality(preset, 1000, MAIN)).toBe(preset) + }) + + it.each([ + ['high', 'medium'], + ['medium', 'low'], + ['low', 'low'], + ] as const)('steps %s down to %s from exactly 15% to just under 50%', (main, expected) => { + expect(insetStageQuality(main, 150, MAIN)).toBe(expected) + expect(insetStageQuality(main, 499.9, MAIN)).toBe(expected) + }) + + it.each(PRESETS)('drops the main %s preset to Low under 15% of the main area', (preset) => { + expect(insetStageQuality(preset, 149.9, MAIN)).toBe('low') + }) + + it.each([ + [0, MAIN], + [-5, MAIN], + [500, 0], + [500, -1], + [Number.NaN, MAIN], + ])('keeps the main preset for inset area %s and main area %s', (insetArea, mainArea) => { + expect(insetStageQuality('medium', insetArea, mainArea)).toBe('medium') + }) +}) + +describe('stagePresetForViewport', () => { + const MAIN = 1000 + + it.each(PRESETS)('keeps the stored %s preset in the main view whatever the areas', (preset) => { + expect(stagePresetForViewport({ stageQuality: preset, isInset: false, insetArea: 100, mainArea: MAIN })).toBe( + preset, + ) + expect(stagePresetForViewport({ stageQuality: preset, isInset: false, insetArea: null, mainArea: null })).toBe( + preset, + ) + }) + + it('keeps the stored preset for an inset at 60% of the main area', () => { + expect(stagePresetForViewport({ stageQuality: 'high', isInset: true, insetArea: 600, mainArea: MAIN })).toBe('high') + }) + + it('steps an inset at 30% of the main area one preset down', () => { + expect(stagePresetForViewport({ stageQuality: 'high', isInset: true, insetArea: 300, mainArea: MAIN })).toBe( + 'medium', + ) + }) + + it('drops an inset at 10% of the main area to Low', () => { + expect(stagePresetForViewport({ stageQuality: 'high', isInset: true, insetArea: 100, mainArea: MAIN })).toBe('low') + }) + + it('keeps the stored preset for an inset whose areas cannot be read', () => { + expect(stagePresetForViewport({ stageQuality: 'medium', isInset: true, insetArea: null, mainArea: null })).toBe( + 'medium', + ) + }) + + it('warns once for an invalid stored value and falls back to High', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const invalid = 'insane' as WeatherQualityPreset + expect(stagePresetForViewport({ stageQuality: invalid, isInset: false, insetArea: null, mainArea: null })).toBe( + 'high', + ) + expect(stagePresetForViewport({ stageQuality: invalid, isInset: true, insetArea: 300, mainArea: MAIN })).toBe( + 'medium', + ) + expect(weatherStageQuality(invalid)).toBe(WEATHER_STAGE_PRESETS.high) + expect(warn).toHaveBeenCalledTimes(1) + } finally { + warn.mockRestore() + } + }) +}) + +function layer(altitude: number, coverage: number, type: string): CloudLayer { + return { altitude, coverage, type } as CloudLayer +} + +describe('buildCloudLayerUniforms', () => { + it('returns nothing for no layers', () => { + expect(buildCloudLayerUniforms([], THICKNESS)).toEqual([]) + }) + + it('drops clear layers and sorts the rest lowest first with thickness by cover code', () => { + const uniforms = buildCloudLayerUniforms( + [layer(1500, 1, 'OVC'), layer(300, 0, 'CLR'), layer(760, 0.75, 'BKN')], + THICKNESS, + ) + expect(uniforms).toEqual([ + { base: 760, thickness: 450, coverage: 0.75 }, + { base: 1500, thickness: 600, coverage: 1 }, + ]) + }) + + it('keeps the four lowest layers when more are reported', () => { + const uniforms = buildCloudLayerUniforms( + [1800, 300, 1200, 600, 900].map((altitude) => layer(altitude, 0.5, 'SCT')), + THICKNESS, + ) + expect(uniforms.map((u) => u.base)).toEqual([300, 600, 900, 1200]) + }) + + it('gives an unknown cover code the default thickness', () => { + expect(buildCloudLayerUniforms([layer(500, 0.5, 'VV')], THICKNESS)[0].thickness).toBe(400) + }) + + it('leaves the input order untouched', () => { + const input = [layer(900, 0.5, 'SCT'), layer(300, 0.5, 'SCT')] + buildCloudLayerUniforms(input, THICKNESS) + expect(input.map((l) => l.altitude)).toEqual([900, 300]) + }) +}) + +describe('layerSunShade', () => { + const slab = (base: number, coverage: number) => ({ base, thickness: 450, coverage }) + const FEW = 0.1875 + const SCT = 0.4375 + const BKN = 0.6875 + const OVC = 1 + + it('shades a BKN layer under an OVC layer', () => { + expect(layerSunShade([slab(300, BKN), slab(600, OVC)])).toEqual([1, 0]) + }) + + it('shades a BKN layer under another BKN layer', () => { + expect(layerSunShade([slab(300, BKN), slab(900, BKN)])).toEqual([1, 0]) + }) + + it('leaves a BKN layer lit when only FEW or SCT layers are above it', () => { + expect(layerSunShade([slab(300, BKN), slab(900, SCT), slab(1500, FEW)])).toEqual([0, 0, 0]) + }) + + it('shades every layer under a BKN or OVC one, through a lit layer between them', () => { + expect(layerSunShade([slab(300, BKN), slab(600, SCT), slab(900, OVC)])).toEqual([1, 1, 0]) + }) + + it('never shades the top layer or a single layer', () => { + expect(layerSunShade([slab(300, OVC), slab(600, BKN)])).toEqual([1, 0]) + expect(layerSunShade([slab(300, BKN)])).toEqual([0]) + expect(layerSunShade([])).toEqual([]) + }) +}) + +describe('stageFogFade', () => { + it('is 0 (no fog) at 10 SM and above', () => { + expect(stageFogFade(10)).toBe(0) + expect(stageFogFade(12)).toBe(0) + }) + + it('fades in linearly between 10 and 8 SM', () => { + expect(stageFogFade(9)).toBeCloseTo(0.5, 12) + }) + + it('is 1 (full METAR fog) at 8 SM and below', () => { + expect(stageFogFade(8)).toBe(1) + expect(stageFogFade(3)).toBe(1) + }) +}) + +describe('stageFogDensity', () => { + /** Relative difference, for densities of order 1e-4 per meter */ + const relative = (actual: number, expected: number) => Math.abs(actual - expected) / expected + + it('is 0 (no fog) without a METAR', () => { + expect(stageFogDensity(undefined, 1)).toBe(0) + }) + + it('is 0 (no fog) at 10 SM and above', () => { + expect(stageFogDensity(10, 1)).toBe(0) + expect(stageFogDensity(15, 2)).toBe(0) + }) + + it('is one over the visibility in meters, with visibilityScale applied, at full strength', () => { + expect(relative(stageFogDensity(0.5, 1), 1 / (0.5 * STATUTE_MILES_TO_METERS))).toBeLessThan(1e-12) + expect(relative(stageFogDensity(3, 2), 1 / (6 * STATUTE_MILES_TO_METERS))).toBeLessThan(1e-12) + }) + + it('scales the density by the fade between 10 and 8 SM', () => { + expect(relative(stageFogDensity(9, 1), 0.5 / (9 * STATUTE_MILES_TO_METERS))).toBeLessThan(1e-12) + }) + + it('floors visibility at 0.05 SM so zero visibility still draws finite fog', () => { + expect(relative(stageFogDensity(0, 1), 1 / (0.05 * STATUTE_MILES_TO_METERS))).toBeLessThan(1e-12) + }) +}) + +describe('fog ease', () => { + const density = (visibSM: number) => stageFogDensity(visibSM, 1) + + it('eases a METAR step from 10 SM to 6 SM with a smoothstep, reaching the target exactly at the ease time', () => { + const target = density(6) + const ease = startFogEase(density(10), target, 0) + const fraction = (ms: number) => easedFogDensity(ease, target, ms) / target + expect(fraction(0)).toBe(0) + expect(fraction(1000)).toBeLessThan(0.1) + expect(fraction(4800)).toBeLessThan(0.9) + expect(fraction(4900)).toBeGreaterThanOrEqual(0.9) + expect(easedFogDensity(ease, target, WEATHER_STAGE_FOG_EASE_MS)).toBe(target) + expect(easedFogDensity(ease, target, WEATHER_STAGE_FOG_EASE_MS * 3)).toBe(target) + }) + + it('never overshoots, rising or falling', () => { + for (const [fromSM, toSM] of [ + [10, 6], + [2, 9], + ]) { + const from = density(fromSM) + const target = density(toSM) + const ease = startFogEase(from, target, 0) + let previous = from + for (let ms = 0; ms <= WEATHER_STAGE_FOG_EASE_MS + 2000; ms += 100) { + const value = easedFogDensity(ease, target, ms) + expect(value).toBeGreaterThanOrEqual(Math.min(from, target)) + expect(value).toBeLessThanOrEqual(Math.max(from, target)) + expect(Math.sign(value - previous) * Math.sign(target - from)).toBeGreaterThanOrEqual(0) + previous = value + } + } + }) + + it('starts a new METAR step mid-ease from the current value, without a jump', () => { + const first = startFogEase(density(10), density(6), 0) + const current = easedFogDensity(first, density(6), 3000) + const second = startFogEase(current, density(2), 3000) + expect(Math.abs(easedFogDensity(second, density(2), 3000) - current) / current).toBeLessThan(1e-9) + expect(easedFogDensity(second, density(2), 3100)).toBeGreaterThan(current) + expect(easedFogDensity(second, density(2), 3000 + WEATHER_STAGE_FOG_EASE_MS)).toBe(density(2)) + }) + + it('tracks a target gliding 0.01 SM per frame for 10 s after a METAR step, without stalling', () => { + const ease = startFogEase(density(10), density(7), 0) + let visibSM = 7 + let value = 0 + for (let frame = 0; frame <= 600; frame++) { + visibSM = 7 - 0.01 * frame + value = easedFogDensity(ease, density(visibSM), (frame * 1000) / 60) + } + expect(Math.abs(value - density(visibSM)) / density(visibSM)).toBeLessThan(0.02) + }) +}) + +describe('nextFogEase', () => { + const density = (visibSM: number | undefined, scale = 1) => stageFogDensity(visibSM, scale) + const input = ( + visibilitySM: number | undefined, + targetDensity = density(visibilitySM), + gateOpen = true, + ): FogEaseInput => ({ visibilitySM, gateOpen, targetDensity }) + const draw = (ease: FogEase, targetDensity: number, nowMs: number) => easedFogDensity(ease, targetDensity, nowMs) + + it('eases a METAR step from 10 SM to 6 SM with the gate open', () => { + const target = density(6) + const ease = nextFogEase(NO_FOG_EASE, input(10), input(6), 0) + expect(draw(ease, target, 2000)).toBeGreaterThan(0) + expect(draw(ease, target, 2000)).toBeLessThan(target) + expect(draw(ease, target, WEATHER_STAGE_FOG_EASE_MS)).toBe(target) + }) + + it('eases an interpolated step the same way, the function cannot tell the two apart', () => { + const target = density(3) + const ease = nextFogEase(NO_FOG_EASE, input(5), input(3), 0) + expect(draw(ease, target, 2000)).toBeGreaterThan(0) + expect(draw(ease, target, 2000)).toBeLessThan(target) + expect(draw(ease, target, WEATHER_STAGE_FOG_EASE_MS)).toBe(target) + }) + + it('restarts a step mid-ease from the value drawn at that instant', () => { + const first = nextFogEase(NO_FOG_EASE, input(10), input(6), 0) + const drawn = draw(first, density(6), 3000) + const second = nextFogEase(first, input(6), input(2), 3000) + expect(Math.abs(draw(second, density(2), 3000) - drawn) / drawn).toBeLessThan(1e-12) + expect(draw(second, density(2), 3000 + WEATHER_STAGE_FOG_EASE_MS)).toBe(density(2)) + }) + + it('applies a visibilityScale change at once, with or without an ease in flight', () => { + const scaled = density(6, 2) + const settled = nextFogEase(NO_FOG_EASE, input(6), input(6, scaled), 1000) + expect(draw(settled, scaled, 1001)).toBe(scaled) + const easing = nextFogEase(NO_FOG_EASE, input(10), input(6), 0) + const changed = nextFogEase(easing, input(6), input(6, scaled), 2000) + expect(draw(changed, scaled, 2001)).toBe(scaled) + }) + + it('applies the gate closing at once, also with an ease in flight', () => { + const easing = nextFogEase(NO_FOG_EASE, input(10), input(6), 0) + const closed = nextFogEase(easing, input(6), input(6, 0, false), 2000) + expect(draw(closed, 0, 2000)).toBe(0) + expect(draw(closed, 0, 2001)).toBe(0) + }) + + it('applies the gate opening at once, without an ease from 0, after the input changed while it was closed', () => { + const opened = nextFogEase(NO_FOG_EASE, input(6, 0, false), input(4), 5000) + expect(draw(opened, density(4), 5000)).toBe(density(4)) + expect(draw(opened, density(4), 5001)).toBe(density(4)) + }) + + it('eases the first input in from no fog', () => { + const target = density(5) + const ease = nextFogEase(NO_FOG_EASE, input(undefined, 0), input(5), 0) + expect(draw(ease, target, 0)).toBe(0) + expect(draw(ease, target, 1000)).toBeGreaterThan(0) + expect(draw(ease, target, 1000)).toBeLessThan(target) + expect(draw(ease, target, WEATHER_STAGE_FOG_EASE_MS)).toBe(target) + }) + + it('leaves an ease in flight alone when nothing changed', () => { + const first = nextFogEase(NO_FOG_EASE, input(10), input(6), 0) + expect(nextFogEase(first, input(6), input(6), 3000)).toBe(first) + const closed = input(undefined, 0, false) + expect(nextFogEase(NO_FOG_EASE, closed, closed, 0)).toBe(NO_FOG_EASE) + }) + + it('applies every frame of a visibilityScale drag at once, without restarting the ease', () => { + const visibSM = 6 + let ease = nextFogEase(NO_FOG_EASE, input(10), input(visibSM, density(visibSM, 1)), 0) + let previous = input(visibSM, density(visibSM, 1)) + for (let frame = 1; frame <= 188; frame++) { + const nowMs = frame * 16 + const targetDensity = density(visibSM, 1 + frame * 0.01) + const current = input(visibSM, targetDensity) + ease = nextFogEase(ease, previous, current, nowMs) + expect(draw(ease, targetDensity, nowMs)).toBe(targetDensity) + previous = current + } + }) +}) + +describe('stageFogExtinction', () => { + it('is the base extinction at fogIntensity 1', () => { + expect(stageFogExtinction(1, 1)).toBe(1) + }) + + it('doubles the optical depth at fogIntensity 2', () => { + expect(stageFogExtinction(1, 2)).toBe(2) + }) + + it('is 0 at fogIntensity 0', () => { + expect(stageFogExtinction(1, 0)).toBe(0) + }) + + it('clamps a negative fogIntensity to 0', () => { + expect(stageFogExtinction(1, -1)).toBe(0) + }) +}) + +describe('sceneFogValues', () => { + // What Cesium's scene.fog holds when the hook does not tune it from METAR visibility. + const noWeather = { + density: 0.0006, + visualDensityScalar: 0.15, + screenSpaceErrorFactor: 2.0, + minimumBrightness: 0.03, + } + + it('tunes scene.fog from fogDensity while the Cesium stage draws no fog', () => { + expect(sceneFogValues({ fogEnabled: true, fogDensity: 0.0083, stageDrawsFog: false })).toEqual({ + enabled: true, + density: 0.0083, + visualDensityScalar: 0.15 + (0.0083 / 0.015) * 0.85, + screenSpaceErrorFactor: 2.0, + minimumBrightness: 0.1, + }) + }) + + it('applies the no-weather values while the Cesium stage draws the fog', () => { + expect(sceneFogValues({ fogEnabled: true, fogDensity: 0.0083, stageDrawsFog: true })).toEqual({ + enabled: true, + ...noWeather, + }) + }) + + it('applies the no-weather values at fogDensity 0 either way', () => { + expect(sceneFogValues({ fogEnabled: true, fogDensity: 0, stageDrawsFog: false })).toEqual({ + enabled: true, + ...noWeather, + }) + expect(sceneFogValues({ fogEnabled: true, fogDensity: 0, stageDrawsFog: true })).toEqual({ + enabled: true, + ...noWeather, + }) + }) + + it('leaves scene.fog off when the fog is toggled off, whatever the density or the stage', () => { + expect(sceneFogValues({ fogEnabled: false, fogDensity: 0.0083, stageDrawsFog: false })).toEqual({ + enabled: false, + ...noWeather, + }) + expect(sceneFogValues({ fogEnabled: false, fogDensity: 0.0083, stageDrawsFog: true })).toEqual({ + enabled: false, + ...noWeather, + }) + }) +}) + +describe('stageDrawsFog', () => { + it('is true only with the fog gate on and an anchor frame', () => { + expect(stageDrawsFog(true, true)).toBe(true) + expect(stageDrawsFog(true, false)).toBe(false) + expect(stageDrawsFog(false, true)).toBe(false) + expect(stageDrawsFog(false, false)).toBe(false) + }) +}) + +describe('weatherStageGates', () => { + const allOn: WeatherStageGateInput = { + showWeatherEffects: true, + showClouds: true, + showBabylonFog: true, + viewMode: '3d', + logDepth: true, + } + + it('draws clouds and fog with everything on', () => { + expect(weatherStageGates(allOn)).toEqual({ stage: true, clouds: true, fog: true, missingLogDepth: false }) + }) + + it('maps showClouds to clouds and showBabylonFog to fog', () => { + expect(weatherStageGates({ ...allOn, showClouds: false })).toMatchObject({ stage: true, clouds: false, fog: true }) + expect(weatherStageGates({ ...allOn, showBabylonFog: false })).toMatchObject({ + stage: true, + clouds: true, + fog: false, + }) + }) + + it('drops the stage when neither clouds nor fog are wanted', () => { + expect(weatherStageGates({ ...allOn, showClouds: false, showBabylonFog: false }).stage).toBe(false) + }) + + it.each([ + ['the master weather switch is off', { showWeatherEffects: false }], + ['the view is top-down', { viewMode: 'topdown' as const }], + ])('draws nothing when %s', (_reason, change) => { + expect(weatherStageGates({ ...allOn, ...change })).toEqual({ + stage: false, + clouds: false, + fog: false, + missingLogDepth: false, + }) + }) + + it('hides the stage and flags it when the logarithmic depth buffer is off', () => { + expect(weatherStageGates({ ...allOn, logDepth: false })).toEqual({ + stage: false, + clouds: false, + fog: false, + missingLogDepth: true, + }) + }) + + it('does not flag missing log depth when the stage is not wanted anyway', () => { + expect(weatherStageGates({ ...allOn, logDepth: false, showWeatherEffects: false }).missingLogDepth).toBe(false) + }) +}) + +describe('nextWeatherAnchor', () => { + const oak = { airportIcao: 'KOAK', lat: 37.7213, lon: -122.2208 } + + it('is null without a reference position', () => { + expect(nextWeatherAnchor(null, { airportIcao: null, lat: null, lon: null })).toBeNull() + }) + + it('anchors on the airport reference the first time, carrying the airport elevation as ground hint', () => { + expect(nextWeatherAnchor(null, { ...oak, groundHintM: 2.7 })).toEqual({ + key: 'airport:KOAK', + lat: oak.lat, + lon: oak.lon, + groundHintM: 2.7, + }) + }) + + it('keeps the same anchor object under small reference moves at the same airport', () => { + const first = nextWeatherAnchor(null, oak) + // About 22 m north: inside the airport re-anchor distance. + expect(nextWeatherAnchor(first, { ...oak, lat: oak.lat + 0.0002 })).toBe(first) + }) + + it('re-anchors within the same airport once the reference moves past the airport re-anchor distance', () => { + const first = nextWeatherAnchor(null, oak) + // About 330 m north, e.g. a custom tower position arriving after the first render. + const moved = nextWeatherAnchor(first, { ...oak, lat: oak.lat + 0.003 }) + expect(moved).not.toBe(first) + expect(moved).toEqual({ key: 'airport:KOAK', lat: oak.lat + 0.003, lon: oak.lon, groundHintM: null }) + }) + + it('re-anchors on an airport switch', () => { + const first = nextWeatherAnchor(null, oak) + const sfo = { airportIcao: 'KSFO', lat: 37.6188, lon: -122.375 } + expect(nextWeatherAnchor(first, sfo)).toEqual({ + key: 'airport:KSFO', + lat: sfo.lat, + lon: sfo.lon, + groundHintM: null, + }) + }) + + it('keeps the anchor under orbit-follow jitter without an airport', () => { + const first = nextWeatherAnchor(null, { airportIcao: null, lat: 40, lon: -100 }) + // About 1.1 km north: well inside the free re-anchor distance. + expect(nextWeatherAnchor(first, { airportIcao: null, lat: 40.01, lon: -100 })).toBe(first) + }) + + it('re-anchors a followed aircraft once it has moved past the re-anchor distance', () => { + const first = nextWeatherAnchor(null, { airportIcao: null, lat: 40, lon: -100 }) + // About 5.6 km north. + const moved = nextWeatherAnchor(first, { airportIcao: null, lat: 40.05, lon: -100 }) + expect(moved).not.toBe(first) + expect(moved).toEqual({ key: 'free', lat: 40.05, lon: -100, groundHintM: null }) + }) + + it('re-anchors when the airport is left for orbit-follow', () => { + const first = nextWeatherAnchor(null, oak) + expect(nextWeatherAnchor(first, { airportIcao: null, lat: oak.lat, lon: oak.lon })?.key).toBe('free') + }) +}) + +describe('anchorGround', () => { + const den = 'airport:KDEN' + const base = { key: den, previous: null, moved: true } + const noSample = { sampledM: undefined, ellipsoidProvider: false } + + it('uses the sampled terrain height from a real terrain provider', () => { + expect(anchorGround({ ...base, sampledM: -28.5, ellipsoidProvider: false, hintM: 2.7 })).toEqual({ + key: den, + groundM: -28.5, + sampled: true, + }) + }) + + it('keeps the airport elevation hint, as a placeholder, while the provider is still the bare ellipsoid', () => { + expect(anchorGround({ ...base, sampledM: 0, ellipsoidProvider: true, hintM: 1656 })).toEqual({ + key: den, + groundM: 1656, + sampled: false, + }) + }) + + it('falls back to the hint when the sample has no height', () => { + expect(anchorGround({ ...base, ...noSample, hintM: 1656 })).toEqual({ key: den, groundM: 1656, sampled: false }) + }) + + it('is a 0 placeholder with neither a usable sample, a hint nor a previous ground', () => { + expect(anchorGround({ ...base, sampledM: undefined, ellipsoidProvider: true, hintM: null })).toEqual({ + key: den, + groundM: 0, + sampled: false, + }) + }) + + it('carries the previous sampled ground of the same key to a new location as a placeholder, ahead of the hint', () => { + const previous: FrameGround = { key: den, groundM: 1634.2, sampled: true } + expect(anchorGround({ ...base, ...noSample, hintM: 1656, previous })).toEqual({ + key: den, + groundM: 1634.2, + sampled: false, + }) + }) + + it('keeps the previous sampled ground at the same location when a new sample has no height', () => { + const previous: FrameGround = { key: den, groundM: 1634.2, sampled: true } + expect(anchorGround({ ...base, ...noSample, moved: false, hintM: 1656, previous })).toEqual(previous) + }) + + it('takes the hint over the previous ground of another key', () => { + const previous: FrameGround = { key: 'airport:KOAK', groundM: -28.5, sampled: true } + expect(anchorGround({ ...base, ...noSample, hintM: 1656, previous })).toEqual({ + key: den, + groundM: 1656, + sampled: false, + }) + }) + + it('carries the previous ground of another key as a placeholder when there is no hint (airport to orbit-follow)', () => { + const previous: FrameGround = { key: den, groundM: 1634.2, sampled: true } + expect(anchorGround({ ...base, ...noSample, key: 'free', hintM: null, previous })).toEqual({ + key: 'free', + groundM: 1634.2, + sampled: false, + }) + }) + + it('keeps the previous placeholder of the same key when there is no hint', () => { + const previous: FrameGround = { key: den, groundM: 1656, sampled: false } + expect(anchorGround({ ...base, ...noSample, hintM: null, previous })).toEqual(previous) + }) + + it('takes a new sample over the previous sampled ground', () => { + const previous: FrameGround = { key: den, groundM: 1634.2, sampled: true } + expect(anchorGround({ ...base, sampledM: 1640, ellipsoidProvider: false, hintM: 1656, previous }).groundM).toBe( + 1640, + ) + }) +}) + +describe('noiseOriginNeedsReset', () => { + const origin = { key: 'free', lat: 40, lon: -100 } + + it('resets without an origin', () => { + expect(noiseOriginNeedsReset(null, origin)).toBe(true) + }) + + it('resets when the anchor key changes', () => { + expect(noiseOriginNeedsReset(origin, { ...origin, key: 'airport:KDEN' })).toBe(true) + }) + + it('keeps the origin while the anchor is within the reset distance', () => { + // About 44 km north. + expect(noiseOriginNeedsReset(origin, { ...origin, lat: 40.4 })).toBe(false) + }) + + it('resets once the anchor is beyond the reset distance', () => { + expect(WEATHER_STAGE_NOISE_ORIGIN_RESET_M).toBe(50000) + // About 56 km north. + expect(noiseOriginNeedsReset(origin, { ...origin, lat: 40.5 })).toBe(true) + }) +}) + +describe('groundShiftStart', () => { + const key = 'airport:KDEN' + const sampled = (groundM: number): FrameGround => ({ key, groundM, sampled: true }) + const placeholder = (groundM: number): FrameGround => ({ key, groundM, sampled: false }) + + it('is 0 for the first frame', () => { + expect(groundShiftStart(null, sampled(1634), 0)).toBe(0) + }) + + it('replaces a placeholder ground at once instead of blending from it', () => { + const previous = { ground: placeholder(0), shiftStartM: 0, shiftStartMs: 0 } + expect(groundShiftStart(previous, sampled(1634), 100)).toBe(0) + }) + + it('does not blend to a placeholder ground', () => { + const previous = { ground: sampled(1634), shiftStartM: 0, shiftStartMs: 0 } + expect(groundShiftStart(previous, placeholder(1656), 100)).toBe(0) + }) + + it('does not blend across an anchor key change', () => { + const previous = { ground: { key: 'airport:KOAK', groundM: -28, sampled: true }, shiftStartM: 0, shiftStartMs: 0 } + expect(groundShiftStart(previous, sampled(1634), 100)).toBe(0) + }) + + it('blends between two sampled grounds of the same key, carrying the shift still blending', () => { + const previous = { ground: sampled(1634), shiftStartM: 10, shiftStartMs: 0 } + expect(groundShiftStart(previous, sampled(1640), WEATHER_STAGE_GROUND_BLEND_MS / 2)).toBeCloseTo(6 + 5) + }) +}) + +describe('horizontalNoiseOffset', () => { + it('drops the vertical component, which grows with the distance from the noise origin', () => { + expect(horizontalNoiseOffset({ x: 1200, y: -500000, z: -19600 })).toEqual({ x: 1200, y: -500000, z: 0 }) + }) +}) + +describe('fog shader', () => { + it.each([ + ['cloud', CLOUD_STAGE_FS], + ['composite', WEATHER_COMPOSITE_FS], + ])('caps the fog density at its ground value below the anchor ground in the %s pass', (_pass, source) => { + // Clamping only the camera height let points below the ground grow exponentially denser fog. + expect(source).not.toContain('float h0 = max(ro.z, 0.0);') + expect(source).toContain('fogBelowGroundLength(') + }) +}) + +describe('groundBlendShift', () => { + it('starts at the full ground change', () => { + expect(groundBlendShift(30, 0)).toBe(30) + }) + + it('falls linearly to 0 over the blend time', () => { + expect(groundBlendShift(30, WEATHER_STAGE_GROUND_BLEND_MS / 2)).toBeCloseTo(15) + }) + + it('stays 0 after the blend time', () => { + expect(groundBlendShift(-30, WEATHER_STAGE_GROUND_BLEND_MS * 2)).toBe(0) + }) +}) + +describe('effectiveVisibilitySM', () => { + it('uses the METAR visibility without interpolation', () => { + expect(effectiveVisibilitySM({ metarSM: 3, useInterpolation: false, interpolatedSM: 7 })).toBe(3) + }) + + it('uses the interpolated visibility while weather interpolation is active', () => { + expect(effectiveVisibilitySM({ metarSM: 3, useInterpolation: true, interpolatedSM: 7 })).toBe(7) + }) + + it('falls back to the METAR while interpolation has no result yet', () => { + expect(effectiveVisibilitySM({ metarSM: 3, useInterpolation: true, interpolatedSM: undefined })).toBe(3) + }) +}) + +describe('weatherStageHasWork', () => { + it('is false with no cloud layers and no fog', () => { + expect(weatherStageHasWork(0, 0, true)).toBe(false) + }) + + it('is true with clouds or with fog', () => { + expect(weatherStageHasWork(1, 0, true)).toBe(true) + expect(weatherStageHasWork(0, 800, true)).toBe(true) + }) + + it('is false without a frame to draw in, whatever the weather', () => { + expect(weatherStageHasWork(1, 800, false)).toBe(false) + }) +}) diff --git a/src/renderer/utils/weatherStage.ts b/src/renderer/utils/weatherStage.ts new file mode 100644 index 00000000..31b570d2 --- /dev/null +++ b/src/renderer/utils/weatherStage.ts @@ -0,0 +1,614 @@ +/** + * Pure inputs of the Cesium cloud and fog stage (`useCesiumWeatherStages`): + * cloud layer uniforms from the weather store, fog visibility, settings gating + * and the anchor of the stage's east-north-up frame. Kept free of Cesium so + * they can be unit tested. + */ + +import { + CLOUD_THICKNESS_DEFAULT_M, + STATUTE_MILES_TO_METERS, + WEATHER_STAGE_AIRPORT_REANCHOR_DISTANCE_M, + WEATHER_STAGE_FOG_EASE_MS, + WEATHER_STAGE_FULL_FOG_VISIBILITY_SM, + WEATHER_STAGE_GROUND_BLEND_MS, + WEATHER_STAGE_INSET_FULL_AREA_RATIO, + WEATHER_STAGE_INSET_STEP_DOWN_AREA_RATIO, + WEATHER_STAGE_MAX_LAYERS, + WEATHER_STAGE_MIN_VISIBILITY_SM, + WEATHER_STAGE_NO_FOG_VISIBILITY_SM, + WEATHER_STAGE_NOISE_ORIGIN_RESET_M, + WEATHER_STAGE_PRESETS, + WEATHER_STAGE_REANCHOR_DISTANCE_M, + WEATHER_STAGE_SUN_SHADE_COVERAGE, + type WeatherStageQuality, +} from '@/constants' +import type { CloudLayer, ViewMode, WeatherQualityPreset } from '@/types' +import { haversineDistanceNm } from '@/utils/aircraft/geoMath' +import { nmToMeters } from '@/utils/coordinates' + +/** One cloud slab as the shader's vec4(base, thickness, coverage, active) wants it */ +export interface CloudLayerUniform { + /** Cloud base in meters above the anchor's ground */ + base: number + /** Slab thickness in meters */ + thickness: number + /** Sky fraction covered, 0..1 */ + coverage: number +} + +/** + * Cloud layers for the stage: clear layers dropped, lowest first, at most the + * four the shader takes, each with the slab thickness of its cover code. + * + * @param layers - Weather store cloud layers (altitude in meters AGL) + * @param thicknessByType - Slab thickness in meters by METAR cover code + * @returns The layers the stage draws + */ +export function buildCloudLayerUniforms( + layers: readonly CloudLayer[], + thicknessByType: Readonly>, +): CloudLayerUniform[] { + return layers + .filter((layer) => layer.coverage > 0) + .sort((a, b) => a.altitude - b.altitude) + .slice(0, WEATHER_STAGE_MAX_LAYERS) + .map((layer) => ({ + base: layer.altitude, + thickness: thicknessByType[layer.type] ?? CLOUD_THICKNESS_DEFAULT_M, + coverage: layer.coverage, + })) +} + +/** + * Whether each cloud layer is shaded from direct sun by a higher layer of BKN or + * more (WEATHER_STAGE_SUN_SHADE_COVERAGE): a shaded layer's top is lit no + * brighter than its underside, so its tops seen through gaps in it do not shine. + * + * @param layers - The stage's layers (`buildCloudLayerUniforms`) + * @returns Per layer, 1 when shaded and 0 when lit, in the same order + */ +export function layerSunShade(layers: readonly CloudLayerUniform[]): number[] { + return layers.map((layer) => + layers.some((above) => above.base > layer.base && above.coverage >= WEATHER_STAGE_SUN_SHADE_COVERAGE) ? 1 : 0, + ) +} + +/** + * Strength of the stage's fog against the METAR's, so visibility crossing + * 10 SM fades the fog in instead of switching it on. + * + * @param visibSM - Visibility in statute miles + * @returns 0 at WEATHER_STAGE_NO_FOG_VISIBILITY_SM and above, 1 at + * WEATHER_STAGE_FULL_FOG_VISIBILITY_SM and below, linear in between + */ +export function stageFogFade(visibSM: number): number { + const width = WEATHER_STAGE_NO_FOG_VISIBILITY_SM - WEATHER_STAGE_FULL_FOG_VISIBILITY_SM + return Math.min(Math.max((WEATHER_STAGE_NO_FOG_VISIBILITY_SM - visibSM) / width, 0), 1) +} + +/** + * Fog density for the stage per meter: one over the visibility in meters, scaled by `stageFogFade`. + * + * @param visibSM - Visibility in statute miles, undefined without a METAR + * @param visibilityScale - The `visibilityScale` weather setting (2 = see twice as far) + * @returns Density per meter, 0 at 10 SM and above or without a METAR + */ +export function stageFogDensity(visibSM: number | undefined, visibilityScale: number): number { + if (visibSM === undefined) { + return 0 + } + const fade = stageFogFade(visibSM) + if (fade === 0) { + return 0 + } + return fade / (Math.max(visibSM, WEATHER_STAGE_MIN_VISIBILITY_SM) * STATUTE_MILES_TO_METERS * visibilityScale) +} + +/** + * An ease of the stage's fog density after a visibility step: the gap between + * the density shown and the new target when the step came, closed over + * WEATHER_STAGE_FOG_EASE_MS. The ease is decided by `nextFogEase`. + */ +export interface FogEase { + /** Density shown minus the new target when the ease started, per meter */ + gap: number + startMs: number +} + +/** An ease with nothing left to close: the density is the target */ +export const NO_FOG_EASE: FogEase = { gap: 0, startMs: 0 } + +/** + * Starts an ease toward a new target's fog density from the density shown now. + * + * @param currentDensity - Density shown now (`easedFogDensity` of the previous ease) + * @param targetDensity - The new METAR's density (`stageFogDensity`) + * @param nowMs - Time of the step + */ +export function startFogEase(currentDensity: number, targetDensity: number, nowMs: number): FogEase { + return { gap: currentDensity - targetDensity, startMs: nowMs } +} + +/** + * Fog density to draw: the target plus what is left of the ease's gap, which + * closes with a smoothstep over WEATHER_STAGE_FOG_EASE_MS. + * + * @param ease - The last METAR step's ease (`startFogEase`), or NO_FOG_EASE + * @param targetDensity - Density the fog is heading to now + * @param nowMs - Current time, on the clock `ease.startMs` was taken on + * @returns Density per meter, never below 0; exactly the target once the ease has run + */ +export function easedFogDensity(ease: FogEase, targetDensity: number, nowMs: number): number { + const x = Math.min(Math.max((nowMs - ease.startMs) / WEATHER_STAGE_FOG_EASE_MS, 0), 1) + if (x === 1 || ease.gap === 0) { + return targetDensity + } + const remaining = 1 - x * x * (3 - 2 * x) + return Math.max(0, targetDensity + ease.gap * remaining) +} + +/** The inputs the fog's ease is decided on */ +export interface FogEaseInput { + /** Visibility input in SM (`effectiveVisibilitySM`), undefined when there is none */ + visibilitySM: number | undefined + /** Whether the fog gate is open (`gates.fog`) */ + gateOpen: boolean + /** Target density per meter (`stageFogDensity`, or 0 when the gate is closed) */ + targetDensity: number +} + +/** + * Ease to use after the fog's inputs changed. A visibility step (the gate open on + * both runs and the visibility input changed, METAR or interpolated) eases from + * the density drawn at this instant; any other change of the target (the + * visibility scale, the gate opening or closing, a view change) applies at once; + * with nothing changed the ease in flight is left alone. + * + * @param ease - The ease in flight + * @param previous - The inputs the last run drew with + * @param next - This run's inputs + * @param nowMs - Current time + * @returns The ease to draw with + */ +export function nextFogEase(ease: FogEase, previous: FogEaseInput, next: FogEaseInput, nowMs: number): FogEase { + if (next.gateOpen && previous.gateOpen && next.visibilitySM !== previous.visibilitySM) { + return startFogEase(easedFogDensity(ease, previous.targetDensity, nowMs), next.targetDensity, nowMs) + } + if (next.targetDensity !== previous.targetDensity) { + return NO_FOG_EASE + } + return ease +} + +/** Invalid preset values already warned about, so a bad stored value warns once */ +const WARNED_INVALID_PRESETS = new Set() + +/** + * The stored preset, or High for a value that is no preset (an imported or + * hand-edited settings file), warned about once per distinct value. + */ +function validStagePreset(preset: WeatherQualityPreset): WeatherQualityPreset { + if (WEATHER_STAGE_PRESETS[preset]) return preset + if (!WARNED_INVALID_PRESETS.has(preset)) { + WARNED_INVALID_PRESETS.add(preset) + console.warn(`[WeatherStage] Unknown weather quality preset "${preset}"; using High`) + } + return 'high' +} + +/** + * The stage's knobs at a quality preset. + * + * @param preset - The `stageQuality` weather setting, or the preset an inset resolved + * @returns The preset's knobs; High's, with one warning per distinct bad value, for a stored value that is no preset + */ +export function weatherStageQuality(preset: WeatherQualityPreset): WeatherStageQuality { + return WEATHER_STAGE_PRESETS[validStagePreset(preset)] +} + +/** One preset below each preset; Low stays Low */ +const PRESET_ONE_STEP_DOWN: Readonly> = { + high: 'medium', + medium: 'low', + low: 'low', +} + +/** + * The preset an inset viewport's stage uses, from its size against the main viewport: + * the main view's preset from WEATHER_STAGE_INSET_FULL_AREA_RATIO of the main area, one + * step down from WEATHER_STAGE_INSET_STEP_DOWN_AREA_RATIO, and Low below that. + * + * @param mainPreset - The main view's preset (the `stageQuality` weather setting) + * @param insetArea - The inset's area in CSS pixels squared + * @param mainArea - The main viewport's area in the same unit + * @returns The inset's preset; the main preset, with a warning, when either area is not positive + */ +export function insetStageQuality( + mainPreset: WeatherQualityPreset, + insetArea: number, + mainArea: number, +): WeatherQualityPreset { + const preset = validStagePreset(mainPreset) + if (!(insetArea > 0) || !(mainArea > 0)) { + console.warn( + `[WeatherStage] Inset area ${insetArea} or main area ${mainArea} is not positive; using the main preset ${preset}`, + ) + return preset + } + const ratio = insetArea / mainArea + if (ratio >= WEATHER_STAGE_INSET_FULL_AREA_RATIO) return preset + if (ratio >= WEATHER_STAGE_INSET_STEP_DOWN_AREA_RATIO) return PRESET_ONE_STEP_DOWN[preset] + return 'low' +} + +/** A viewport's kind and the areas its quality preset depends on */ +export interface ViewportStagePresetInput { + /** The `stageQuality` weather setting, the main view's preset */ + stageQuality: WeatherQualityPreset + /** Whether the viewport is an inset */ + isInset: boolean + /** The inset's area in CSS pixels squared; null when it cannot be read */ + insetArea: number | null + /** The main window's area in the same unit; null when it cannot be read */ + mainArea: number | null +} + +/** + * The quality preset a viewport's stage uses: the `stageQuality` setting in the + * main view, or an inset at its size against the main window (`insetStageQuality`). + * + * @param input - The stored preset, the viewport's kind and the areas + * @returns The preset this viewport's stage uses + */ +export function stagePresetForViewport({ + stageQuality, + isInset, + insetArea, + mainArea, +}: ViewportStagePresetInput): WeatherQualityPreset { + if (!isInset || insetArea === null || mainArea === null) return validStagePreset(stageQuality) + return insetStageQuality(stageQuality, insetArea, mainArea) +} + +/** Cesium `scene.fog` values the weather hook writes */ +export interface SceneFogValues { + /** Whether the scene's own fog is on at all */ + enabled: boolean + /** Fog density, how quickly distant terrain fades */ + density: number + /** How strongly the fog shows on screen */ + visualDensityScalar: number + /** Screen space error factor: terrain loads coarser while fogged */ + screenSpaceErrorFactor: number + /** Floor on how dark the fog may get */ + minimumBrightness: number +} + +/** Cesium's default fog, applied whenever METAR visibility does not tune it */ +const SCENE_FOG_DEFAULT = { + density: 0.0006, + visualDensityScalar: 0.15, + screenSpaceErrorFactor: 2.0, + minimumBrightness: 0.03, +} as const + +/** Fog density the derived values reach their densest look at (see weatherStore's visibilityToFogDensity) */ +const SCENE_FOG_DENSITY_MAX = 0.015 + +/** Scene fog brightness floor while tuned from METAR visibility, so dense fog is not black */ +const SCENE_FOG_TUNED_MINIMUM_BRIGHTNESS = 0.1 + +/** + * Extinction the stage's fog uses, from the quality knob and the user's `fogIntensity`. + * + * The shader multiplies the optical depth integral by `u_fogExtinction`, so fogIntensity 1 + * keeps the base extinction, 2 doubles the optical depth and 0 removes the fog. + * + * @param baseExtinction - The active preset's `fogExtinction` (see `weatherStageQuality`) + * @param fogIntensity - The `fogIntensity` weather setting + * @returns Extinction for the shader, floored at 0 + */ +export function stageFogExtinction(baseExtinction: number, fogIntensity: number): number { + return Math.max(0, baseExtinction * fogIntensity) +} + +/** + * The values to write to Cesium's `scene.fog`, from the fog toggle, the METAR + * fog density and whether the Cesium stage draws the fog itself. + * + * While the stage fogs the scene in its composite pass, the scene's own fog stays + * at Cesium's default (it still coarsens terrain LOD) instead of fogging the + * terrain twice, whatever the visibility. While the stage draws no fog (the + * Babylon overlay drawing instead, or the fog toggled off), the scene's fog is + * tuned from `fogDensity` as it always has been. + * + * @param input.fogEnabled - `showWeatherEffects && showCesiumFog` + * @param input.fogDensity - Fog density derived from METAR visibility (0 to 0.015) + * @param input.stageDrawsFog - Whether the Cesium stage draws the fog (`gates.fog`) + * @returns The enabled flag and the four values to copy onto `scene.fog` + */ +export function sceneFogValues(input: { + fogEnabled: boolean + fogDensity: number + stageDrawsFog: boolean +}): SceneFogValues { + if (!input.fogEnabled || input.stageDrawsFog || input.fogDensity <= 0) { + return { enabled: input.fogEnabled, ...SCENE_FOG_DEFAULT } + } + // Dense fog shows strongly: 0.15 at no density up to 1.0 at SCENE_FOG_DENSITY_MAX. + const visualDensityScalar = Math.min( + 1.0, + SCENE_FOG_DEFAULT.visualDensityScalar + (input.fogDensity / SCENE_FOG_DENSITY_MAX) * 0.85, + ) + return { + enabled: true, + density: input.fogDensity, + visualDensityScalar, + screenSpaceErrorFactor: SCENE_FOG_DEFAULT.screenSpaceErrorFactor, + minimumBrightness: SCENE_FOG_TUNED_MINIMUM_BRIGHTNESS, + } +} + +/** Settings and scene state that decide whether and what the stage draws */ +export interface WeatherStageGateInput { + showWeatherEffects: boolean + showClouds: boolean + showBabylonFog: boolean + viewMode: ViewMode + /** Whether the scene uses the logarithmic depth buffer the stage's depth path needs */ + logDepth: boolean +} + +/** What the stage draws */ +export interface WeatherStageGates { + /** Whether the stage is in the scene at all */ + stage: boolean + clouds: boolean + fog: boolean + /** The stage is wanted but hidden because the logarithmic depth buffer is off */ + missingLogDepth: boolean +} + +/** + * Settings gating of the stage: `showClouds` gates the clouds and + * `showBabylonFog` the fog, under the `showWeatherEffects` master switch; the + * top-down view draws none, and without the logarithmic depth buffer the + * stage hides itself. + * + * @param input - Settings, view mode and depth buffer state + * @returns What the stage draws + */ +export function weatherStageGates(input: WeatherStageGateInput): WeatherStageGates { + const active = input.showWeatherEffects && input.viewMode !== 'topdown' + const wanted = active && (input.showClouds || input.showBabylonFog) + if (!wanted || !input.logDepth) { + return { stage: false, clouds: false, fog: false, missingLogDepth: wanted } + } + return { stage: true, clouds: input.showClouds, fog: input.showBabylonFog, missingLogDepth: false } +} + +/** + * Whether the stage actually draws the fog now: the fog gate is on and the + * stage has an anchor frame to draw in. + * + * `gates.fog` alone only says the settings ask for fog. With no anchor (no + * airport picked yet, or orbit-follow before the followed aircraft appears) the + * stage stays off, so Cesium's own scene fog must keep being tuned from METAR + * visibility instead of holding clear-weather values. + * + * @param gatesFog - Whether the settings gate asks the stage for fog (`gates.fog`) + * @param hasAnchor - Whether the stage has an anchor frame yet + * @returns True only when both hold + */ +export function stageDrawsFog(gatesFog: boolean, hasAnchor: boolean): boolean { + return gatesFog && hasAnchor +} + +/** Position of the stage's east-north-up frame and what it is keyed on */ +export interface WeatherAnchor { + /** `airport:` at an airport, `free` while following an aircraft without one */ + key: string + lat: number + lon: number + /** Ground height to use until terrain is sampled (the airport elevation), or null */ + groundHintM: number | null +} + +/** The viewer's reference position: the tower, or the followed aircraft without an airport */ +export interface WeatherReference { + airportIcao: string | null + lat: number | null + lon: number | null + /** Airport elevation in meters, used as the ground until terrain is sampled */ + groundHintM?: number | null +} + +/** + * The anchor the stage's frame should use. The anchor moves only once the + * reference is far enough from it: WEATHER_STAGE_AIRPORT_REANCHOR_DISTANCE_M + * within the same airport (a custom tower position arriving late), and + * WEATHER_STAGE_REANCHOR_DISTANCE_M while following an aircraft without an + * airport, so the terrain under it is not resampled every frame. Changing + * airport, or leaving it for orbit-follow, always re-anchors. + * + * @param previous - The current anchor, or null before the first + * @param reference - The viewer's reference position now + * @returns `previous` itself when it still holds, else a new anchor, or null + * without a reference position + */ +export function nextWeatherAnchor(previous: WeatherAnchor | null, reference: WeatherReference): WeatherAnchor | null { + const { airportIcao, lat, lon } = reference + if (lat === null || lon === null) { + return null + } + const key = airportIcao ? `airport:${airportIcao}` : 'free' + if (previous && previous.key === key) { + const limitMeters = airportIcao ? WEATHER_STAGE_AIRPORT_REANCHOR_DISTANCE_M : WEATHER_STAGE_REANCHOR_DISTANCE_M + const movedMeters = nmToMeters(haversineDistanceNm(previous.lat, previous.lon, lat, lon)) + if (movedMeters < limitMeters) { + return previous + } + } + return { key, lat, lon, groundHintM: reference.groundHintM ?? null } +} + +/** The ground a frame sits on, and whether it came from a terrain sample or is a placeholder */ +export interface FrameGround { + /** Key of the anchor the ground belongs to */ + key: string + /** Ellipsoid height in meters */ + groundM: number + /** False for a placeholder: the airport elevation hint (an MSL height) or 0 */ + sampled: boolean +} + +/** + * Ground for the anchor's frame. A real provider's sample wins. Without one, + * the frame keeps the previous ground at the same location; at a new + * location it takes, as a placeholder, the previous ground of the same anchor + * key (a same-airport re-anchor), then the airport elevation hint, then the + * previous ground of any key (leaving an airport for orbit-follow), then 0. + * A ground carried to a new location is never marked sampled. The bare + * ellipsoid provider, active until the world terrain loads, samples every + * point at 0, so its sample never counts. + * + * @param input.key - Key of the anchor the frame is built for + * @param input.sampledM - Sampled terrain height, undefined before or without a sample + * @param input.ellipsoidProvider - Whether the terrain provider is the bare ellipsoid + * @param input.hintM - Airport elevation hint, or null + * @param input.previous - Ground of the frame being replaced, or null + * @param input.moved - Whether the frame moves to a new anchor (false for a resample at the same one) + * @returns The frame's ground + */ +export function anchorGround(input: { + key: string + sampledM: number | undefined + ellipsoidProvider: boolean + hintM: number | null + previous: FrameGround | null + moved: boolean +}): FrameGround { + const { key, sampledM, ellipsoidProvider, hintM, previous } = input + if (!ellipsoidProvider && sampledM !== undefined && Number.isFinite(sampledM)) { + return { key, groundM: sampledM, sampled: true } + } + if (previous && !input.moved) { + return previous + } + const sameKeyM = previous?.key === key ? previous.groundM : null + return { key, groundM: sameKeyM ?? hintM ?? previous?.groundM ?? 0, sampled: false } +} + +/** Where the cloud noise field is fixed: the anchor it was set at */ +export interface NoiseOriginAnchor { + key: string + lat: number + lon: number +} + +/** + * Whether the noise origin moves to the anchor. Each re-anchor rotates the + * frame's axes, so the horizontal noise offset drifts by an error that grows + * with the distance from the noise origin; moving the origin once the anchor + * is WEATHER_STAGE_NOISE_ORIGIN_RESET_M away trades that drift for one + * deliberate pop. An anchor key change (airport switch) always moves it. + * + * @param origin - The current noise origin, or null before the first + * @param anchor - The anchor the new frame is built for + */ +export function noiseOriginNeedsReset(origin: NoiseOriginAnchor | null, anchor: NoiseOriginAnchor): boolean { + if (!origin || origin.key !== anchor.key) { + return true + } + const distanceM = nmToMeters(haversineDistanceNm(origin.lat, origin.lon, anchor.lat, anchor.lon)) + return distanceM > WEATHER_STAGE_NOISE_ORIGIN_RESET_M +} + +/** + * Vertical deck shift a new frame starts with, so the deck slides from the old + * ground to the new one. Only a change between two sampled grounds of the + * same anchor key blends; a placeholder ground is replaced at once, since + * blending from it would slide the deck by up to the airport's elevation. + * + * @param previous - The frame being replaced (its ground and blend start), or null + * @param next - Ground of the new frame + * @param nowMs - Time the new frame arrives + * @returns New ground minus the old one plus the old shift still blending, or 0 + */ +export function groundShiftStart( + previous: { ground: FrameGround; shiftStartM: number; shiftStartMs: number } | null, + next: FrameGround, + nowMs: number, +): number { + if (!previous || previous.ground.key !== next.key || !previous.ground.sampled || !next.sampled) { + return 0 + } + return next.groundM - previous.ground.groundM + groundBlendShift(previous.shiftStartM, nowMs - previous.shiftStartMs) +} + +/** A vector in a frame's east-north-up axes, in meters */ +export interface EnuVector { + x: number + y: number + z: number +} + +/** + * Noise offset of a frame: its origin's horizontal position from the noise + * origin. The vertical part of that chord is the earth's curvature drop + * (about d² / 2R), which grows with each re-anchor and would pop the noise, + * so the noise is fixed to the deck vertically and to the world horizontally. + * + * @param fromNoiseOrigin - Frame origin minus noise origin, in the frame's axes + * @returns The offset with its vertical component dropped + */ +export function horizontalNoiseOffset(fromNoiseOrigin: EnuVector): EnuVector { + return { x: fromNoiseOrigin.x, y: fromNoiseOrigin.y, z: 0 } +} + +/** + * Remaining vertical shift of the cloud deck after its anchor's ground height + * changed, so the deck slides to the new height instead of jumping. + * + * @param startShiftM - New ground height minus the old one when the new frame arrived, plus any shift still blending + * @param elapsedMs - Time since the new frame arrived + * @returns Shift in meters, falling linearly to 0 over WEATHER_STAGE_GROUND_BLEND_MS + */ +export function groundBlendShift(startShiftM: number, elapsedMs: number): number { + const remaining = Math.max(0, 1 - elapsedMs / WEATHER_STAGE_GROUND_BLEND_MS) + return remaining === 0 ? 0 : startShiftM * remaining +} + +/** + * Visibility the stage's fog uses: the interpolated visibility while weather + * interpolation is active (as the store does for cloud layers and fog + * density), else the METAR's. + * + * @param input.metarSM - Current METAR visibility in statute miles, undefined without one + * @param input.useInterpolation - Whether weather interpolation drives the weather store + * @param input.interpolatedSM - Interpolated visibility, undefined before the first result + * @returns Visibility in statute miles, undefined without either + */ +export function effectiveVisibilitySM(input: { + metarSM: number | undefined + useInterpolation: boolean + interpolatedSM: number | undefined +}): number | undefined { + if (input.useInterpolation && input.interpolatedSM !== undefined) { + return input.interpolatedSM + } + return input.metarSM +} + +/** + * Whether the stage has anything to draw; without a frame (no anchor yet), + * or with no cloud layers and no fog, it is disabled so its passes cost nothing. + * + * @param layerCount - Cloud layers the stage draws + * @param fogDensity - Fog density per meter, 0 for no fog + * @param hasFrame - Whether the stage's east-north-up frame is set + */ +export function weatherStageHasWork(layerCount: number, fogDensity: number, hasFrame: boolean): boolean { + return hasFrame && (layerCount > 0 || fogDensity > 0) +} diff --git a/tests/chunkCycles.test.ts b/tests/chunkCycles.test.ts new file mode 100644 index 00000000..9cb3e030 --- /dev/null +++ b/tests/chunkCycles.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { findChunkCycles, splitKnownCycles } from '../scripts/dev/chunkCycles' + +const chunk = (fileName: string, imports: string[]) => ({ fileName, imports }) + +describe('findChunkCycles', () => { + it('finds no cycle in an import tree', () => { + expect(findChunkCycles([chunk('a.js', ['b.js', 'c.js']), chunk('b.js', ['c.js']), chunk('c.js', [])])).toEqual([]) + }) + + it('finds a two-chunk cycle once, starting at the smallest name', () => { + expect( + findChunkCycles([chunk('index.js', ['settingsStore.js']), chunk('settingsStore.js', ['index.js'])]) + ).toEqual([['index.js', 'settingsStore.js']]) + }) + + it('finds a longer cycle and a separate one', () => { + expect( + findChunkCycles([ + chunk('a.js', ['b.js']), + chunk('b.js', ['c.js']), + chunk('c.js', ['a.js', 'd.js']), + chunk('d.js', ['e.js']), + chunk('e.js', ['d.js']) + ]) + ).toEqual([ + ['a.js', 'b.js', 'c.js'], + ['d.js', 'e.js'] + ]) + }) + + it('ignores imports of files outside the bundle', () => { + expect(findChunkCycles([chunk('a.js', ['missing.css'])])).toEqual([]) + }) + + it('ignores a self import', () => { + expect(findChunkCycles([chunk('a.js', ['a.js'])])).toEqual([]) + }) +}) + +const nameOf = (fileName: string): string => (fileName.split('/').pop() ?? '').split('-')[0] +const settingsCycle = ['assets/index-abc.js', 'assets/settingsStore-def.js'] + +describe('splitKnownCycles', () => { + it('matches a known cycle', () => { + expect(splitKnownCycles([settingsCycle], nameOf, [['index', 'settingsStore']])).toEqual({ + known: [['index', 'settingsStore']], + unknown: [], + gone: [] + }) + }) + + it('reports a new cycle as unknown', () => { + const split = splitKnownCycles([settingsCycle], nameOf, [['other', 'cycle']]) + expect(split.unknown).toEqual([['index', 'settingsStore']]) + expect(split.known).toEqual([]) + }) + + it('reports a known entry that no cycle matches as gone', () => { + expect(splitKnownCycles([settingsCycle], nameOf, [['other', 'cycle']]).gone).toEqual([['other', 'cycle']]) + }) + + it('matches a known entry written in another rotation', () => { + expect(splitKnownCycles([settingsCycle], nameOf, [['settingsStore', 'index']]).known).toEqual([ + ['index', 'settingsStore'] + ]) + }) +}) diff --git a/tests/useTowerModel.test.ts b/tests/useTowerModel.test.ts new file mode 100644 index 00000000..f5b2da9f --- /dev/null +++ b/tests/useTowerModel.test.ts @@ -0,0 +1,433 @@ +import * as Cesium from 'cesium' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * Drives useTowerModel without a DOM: react's hooks are replaced by a minimal renderer that keeps + * refs and effect deps by call order, runs effects after each render and cleans them up when their + * deps change. + */ +const hookState = vi.hoisted(() => { + type Slot = { deps?: unknown[]; cleanup?: (() => void) | undefined; ref?: { current: unknown } } + const slots: Slot[] = [] + const pending: Array<() => void> = [] + let index = 0 + const depsChanged = (a: unknown[] | undefined, b: unknown[] | undefined) => + !a || !b || a.length !== b.length || a.some((value, i) => !Object.is(value, b[i])) + return { + slots, + pending, + begin() { + index = 0 + }, + next(): Slot { + if (!slots[index]) slots[index] = {} + return slots[index++] + }, + depsChanged, + unmount() { + for (const slot of slots) { + slot.cleanup?.() + slot.cleanup = undefined + } + }, + reset() { + slots.length = 0 + pending.length = 0 + index = 0 + }, + } +}) + +vi.mock('react', () => { + const api = { + useRef: (initial: T) => { + const slot = hookState.next() + if (!slot.ref) slot.ref = { current: initial } + return slot.ref + }, + useEffect: (fn: () => (() => void) | undefined, deps?: unknown[]) => { + const slot = hookState.next() + if (!hookState.depsChanged(slot.deps, deps)) return + slot.deps = deps + hookState.pending.push(() => { + slot.cleanup?.() + slot.cleanup = fn() ?? undefined + }) + }, + useCallback: (fn: T) => fn, + } + return { ...api, default: api } +}) + +const airportState = vi.hoisted(() => ({ + currentAirport: null as null | { icao: string; lat: number; lon: number; elevation: number }, +})) +vi.mock('@/stores/airportStore', () => ({ + useAirportStore: (selector: (state: typeof airportState) => unknown) => selector(airportState), +})) + +const positioning = vi.hoisted(() => { + const listeners = new Set<(state: unknown) => void>() + const inactive = { isActive: false, step: 'model', targetIcao: null, getAbsoluteModelPosition: () => null } + return { + listeners, + inactive, + state: inactive as unknown, + getState() { + return this.state + }, + subscribe(listener: (state: unknown) => void) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + } +}) +vi.mock('@/stores/towerPositioningStore', () => ({ + useTowerPositioningStore: { + subscribe: positioning.subscribe, + getState: () => positioning.getState(), + }, +})) + +const mods = vi.hoisted(() => ({ + getTowerModel: vi.fn(), + getTowerPlacement: vi.fn(), +})) +vi.mock('@/services/ModService', () => ({ modService: mods })) + +// Field elevation becomes ellipsoidal height by a fixed 30 m undulation. +const GEOID_UNDULATION = 30 +vi.mock('@/services/GeoidService', () => ({ + geoidService: { mslToEllipsoidal: (_lat: number, _lon: number, msl: number) => msl + GEOID_UNDULATION }, +})) +vi.mock('@/utils/tauriApi', () => ({ convertToAssetUrlSync: (url: string) => url })) + +const cesiumFakes = vi.hoisted(() => ({ fromGltfAsync: vi.fn(), sampleTerrainMostDetailed: vi.fn() })) +vi.mock('cesium', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + Model: { fromGltfAsync: cesiumFakes.fromGltfAsync }, + sampleTerrainMostDetailed: cesiumFakes.sampleTerrainMostDetailed, + } +}) + +import { useTowerModel } from '@/hooks/useTowerModel' +import { towerModelMatrix } from '@/utils/modelOrientation' +import { cabNightFactor } from '@/utils/nightBrightness' + +interface FakeModel { + modelMatrix: Cesium.Matrix4 + lightColor: Cesium.Cartesian3 | undefined + imageBasedLighting: { imageBasedLightingFactor: Cesium.Cartesian2 } + destroy: ReturnType + isDestroyed: () => boolean +} + +const KOAK = { icao: 'KOAK', lat: 37.7213, lon: -122.2208, elevation: 9 } +const KSFO = { icao: 'KSFO', lat: 37.619, lon: -122.375, elevation: 13 } +const FIELD_KOAK = KOAK.elevation * 0.3048 + GEOID_UNDULATION +const MANIFEST_HEIGHT = 2.5 + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +function fakeModel(options: { modelMatrix: Cesium.Matrix4 }): FakeModel { + return { + modelMatrix: options.modelMatrix, + lightColor: undefined, + imageBasedLighting: { imageBasedLightingFactor: new Cesium.Cartesian2(1, 1) }, + destroy: vi.fn(), + isDestroyed: () => false, + } +} + +function fakeViewer(terrainProvider: unknown) { + return { + terrainProvider, + isDestroyed: () => false, + scene: { + primitives: { add: vi.fn(), remove: vi.fn() }, + globe: { getHeight: vi.fn(() => undefined), terrainProviderChanged: new Cesium.Event() }, + light: { color: new Cesium.Color(0.9, 0.8, 0.7, 1), intensity: 2 }, + }, + } +} + +type FakeViewer = ReturnType + +const NIGHT_OFF = { sunElevation: 30, nightDarkeningEnabled: false, nightDarkeningIntensity: 0.7 } + +function render(viewer: FakeViewer, options: typeof NIGHT_OFF = NIGHT_OFF) { + hookState.begin() + const getState = useTowerModel(viewer as unknown as Cesium.Viewer, options) + const effects = hookState.pending.splice(0) + for (const run of effects) run() + return getState +} + +async function flush() { + for (let i = 0; i < 10; i++) await Promise.resolve() +} + +function heightOf(modelMatrix: Cesium.Matrix4): number { + return Cesium.Cartographic.fromCartesian(Cesium.Matrix4.getTranslation(modelMatrix, new Cesium.Cartesian3())).height +} + +function towerMod(icao: string) { + return { manifest: { scale: 1 }, modelUrl: `mods/towers/${icao}/model.glb`, basePath: `mods/towers/${icao}` } +} + +function placement(airport: typeof KOAK) { + return { lat: airport.lat, lon: airport.lon, height: MANIFEST_HEIGHT, rotation: 0, source: 'manifest' } +} + +const loadSpy = cesiumFakes.fromGltfAsync +const sampleSpy = cesiumFakes.sampleTerrainMostDetailed + +beforeEach(() => { + hookState.reset() + positioning.listeners.clear() + positioning.state = positioning.inactive + airportState.currentAirport = KOAK + mods.getTowerModel.mockReset() + mods.getTowerPlacement.mockReset() + mods.getTowerModel.mockImplementation((icao: string) => towerMod(icao)) + mods.getTowerPlacement.mockImplementation((icao: string) => placement(icao === 'KSFO' ? KSFO : KOAK)) + loadSpy.mockReset() + loadSpy.mockImplementation(async (options: { modelMatrix: Cesium.Matrix4 }) => fakeModel(options)) + sampleSpy.mockReset() +}) + +describe('useTowerModel', () => { + it('loads nothing when the airport has no tower mod', async () => { + mods.getTowerModel.mockReturnValue(undefined) + render(fakeViewer(undefined)) + await flush() + expect(loadSpy).not.toHaveBeenCalled() + }) + + it('loads nothing when ModService finds no placement for the mod', async () => { + mods.getTowerPlacement.mockReturnValue(null) + render(fakeViewer(undefined)) + await flush() + expect(mods.getTowerPlacement).toHaveBeenCalledWith('KOAK') + expect(loadSpy).not.toHaveBeenCalled() + }) + + it('drops a model that finishes loading after the airport changed', async () => { + const provider = {} + const viewer = fakeViewer(provider) + sampleSpy.mockImplementation(async (_p: unknown, positions: Cesium.Cartographic[]) => + positions.map((p) => new Cesium.Cartographic(p.longitude, p.latitude, 5)), + ) + const koakLoad = deferred() + loadSpy.mockImplementationOnce(() => koakLoad.promise) + + render(viewer) + await flush() + expect(loadSpy).toHaveBeenCalledTimes(1) + + airportState.currentAirport = KSFO + render(viewer) + await flush() + + const stale = fakeModel({ modelMatrix: Cesium.Matrix4.IDENTITY }) + koakLoad.resolve(stale) + await flush() + + expect(stale.destroy).toHaveBeenCalled() + expect(viewer.scene.primitives.add).not.toHaveBeenCalledWith(stale) + expect(viewer.scene.primitives.add).toHaveBeenCalledTimes(1) + expect(mods.getTowerPlacement).toHaveBeenLastCalledWith('KSFO') + }) + + it('places the cab at the field elevation until terrain arrives, then on the sampled terrain', async () => { + const viewer = fakeViewer(undefined) + const getState = render(viewer) + await flush() + + expect(sampleSpy).not.toHaveBeenCalled() + expect(loadSpy).toHaveBeenCalledTimes(1) + const model = viewer.scene.primitives.add.mock.calls[0][0] as FakeModel + expect(heightOf(model.modelMatrix)).toBeCloseTo(FIELD_KOAK + MANIFEST_HEIGHT, 3) + expect(getState().loaded).toBe(true) + + const TERRAIN = 12 + sampleSpy.mockImplementation(async (_p: unknown, positions: Cesium.Cartographic[]) => + positions.map((p) => new Cesium.Cartographic(p.longitude, p.latitude, TERRAIN)), + ) + viewer.terrainProvider = {} + viewer.scene.globe.terrainProviderChanged.raiseEvent(viewer.terrainProvider) + await flush() + + expect(sampleSpy).toHaveBeenCalledTimes(1) + expect(loadSpy).toHaveBeenCalledTimes(1) + expect(heightOf(model.modelMatrix)).toBeCloseTo(TERRAIN + MANIFEST_HEIGHT, 3) + }) + + it('falls back to the field elevation when the terrain sample has no height', async () => { + const viewer = fakeViewer({}) + // Cesium leaves the height undefined where the terrain has no data. + sampleSpy.mockImplementation(async (_p: unknown, positions: Cesium.Cartographic[]) => + positions.map((p) => ({ longitude: p.longitude, latitude: p.latitude, height: undefined })), + ) + render(viewer) + await flush() + const model = viewer.scene.primitives.add.mock.calls[0][0] as FakeModel + expect(heightOf(model.modelMatrix)).toBeCloseTo(FIELD_KOAK + MANIFEST_HEIGHT, 3) + }) + + it('falls back to the field elevation when the terrain sample fails', async () => { + const viewer = fakeViewer({}) + sampleSpy.mockRejectedValue(new Error('tile request failed')) + render(viewer) + await flush() + const model = viewer.scene.primitives.add.mock.calls[0][0] as FakeModel + expect(heightOf(model.modelMatrix)).toBeCloseTo(FIELD_KOAK + MANIFEST_HEIGHT, 3) + }) + + it('moves the loaded cab when the positioning wizard edits it', async () => { + const TERRAIN = 7 + const viewer = fakeViewer({}) + sampleSpy.mockImplementation(async (_p: unknown, positions: Cesium.Cartographic[]) => + positions.map((p) => new Cesium.Cartographic(p.longitude, p.latitude, TERRAIN)), + ) + const getState = render(viewer) + await flush() + const model = viewer.scene.primitives.add.mock.calls[0][0] as FakeModel + + const moved = { lat: KOAK.lat + 0.0001, lon: KOAK.lon - 0.0001, height: 4, rotation: 45 } + const wizardState = { + isActive: true, + step: 'model', + targetIcao: 'koak', + modelOffset: { north: 11, east: -9, up: 1.5 }, + modelRotation: 45, + getAbsoluteModelPosition: () => moved, + } + for (const listener of positioning.listeners) listener(wizardState) + + const expected = towerModelMatrix(moved.lon, moved.lat, TERRAIN + moved.height, moved.rotation, 1) + for (let i = 0; i < 16; i++) { + expect(model.modelMatrix[i]).toBeCloseTo(expected[i], 6) + } + expect(getState().modelMatrix).toEqual(Cesium.Matrix4.toArray(model.modelMatrix)) + }) + + it('treats the startup ellipsoid provider as no terrain and re-places on world terrain', async () => { + const viewer = fakeViewer(new Cesium.EllipsoidTerrainProvider()) + render(viewer) + await flush() + + expect(sampleSpy).not.toHaveBeenCalled() + const model = viewer.scene.primitives.add.mock.calls[0][0] as FakeModel + expect(heightOf(model.modelMatrix)).toBeCloseTo(FIELD_KOAK + MANIFEST_HEIGHT, 3) + + const TERRAIN = 9 + sampleSpy.mockImplementation(async (_p: unknown, positions: Cesium.Cartographic[]) => + positions.map((p) => new Cesium.Cartographic(p.longitude, p.latitude, TERRAIN)), + ) + viewer.terrainProvider = {} + viewer.scene.globe.terrainProviderChanged.raiseEvent(viewer.terrainProvider) + await flush() + + expect(sampleSpy).toHaveBeenCalledTimes(1) + expect(heightOf(model.modelMatrix)).toBeCloseTo(TERRAIN + MANIFEST_HEIGHT, 3) + }) + + it('keeps a wizard edit in progress when terrain arrives late', async () => { + const viewer = fakeViewer(undefined) + render(viewer) + await flush() + const model = viewer.scene.primitives.add.mock.calls[0][0] as FakeModel + + const moved = { lat: KOAK.lat + 0.0002, lon: KOAK.lon + 0.0001, height: 3, rotation: 30 } + positioning.state = { + isActive: true, + step: 'model', + targetIcao: 'KOAK', + modelOffset: { north: 22, east: 9, up: 0.5 }, + modelRotation: 30, + getAbsoluteModelPosition: () => moved, + } + + const TERRAIN = 11 + sampleSpy.mockImplementation(async (_p: unknown, positions: Cesium.Cartographic[]) => + positions.map((p) => new Cesium.Cartographic(p.longitude, p.latitude, TERRAIN)), + ) + viewer.terrainProvider = {} + viewer.scene.globe.terrainProviderChanged.raiseEvent(viewer.terrainProvider) + await flush() + + const expected = towerModelMatrix(moved.lon, moved.lat, TERRAIN + moved.height, moved.rotation, 1) + for (let i = 0; i < 16; i++) { + expect(model.modelMatrix[i]).toBeCloseTo(expected[i], 6) + } + }) + + it('lights the cab at the night factor times the scene light, and back to the scene light by day', async () => { + const viewer = fakeViewer({}) + sampleSpy.mockImplementation(async (_p: unknown, positions: Cesium.Cartographic[]) => + positions.map((p) => new Cesium.Cartographic(p.longitude, p.latitude, 4)), + ) + const night = { sunElevation: -40, nightDarkeningEnabled: true, nightDarkeningIntensity: 0.7 } + const getState = render(viewer, night) + await flush() + const model = viewer.scene.primitives.add.mock.calls[0][0] as FakeModel + + const f = cabNightFactor(night.sunElevation, night.nightDarkeningIntensity, true) + expect(f).toBeLessThan(1) + expect(getState().nightFactor).toBe(f) + const { color, intensity } = viewer.scene.light + expect(model.lightColor?.x).toBeCloseTo(color.red * intensity * f, 9) + expect(model.lightColor?.y).toBeCloseTo(color.green * intensity * f, 9) + expect(model.lightColor?.z).toBeCloseTo(color.blue * intensity * f, 9) + expect(model.imageBasedLighting.imageBasedLightingFactor.x).toBeCloseTo(f, 9) + expect(model.imageBasedLighting.imageBasedLightingFactor.y).toBeCloseTo(f, 9) + + render(viewer, NIGHT_OFF) + expect(model.lightColor).toBeUndefined() + expect(model.imageBasedLighting.imageBasedLightingFactor.x).toBe(1) + expect(model.imageBasedLighting.imageBasedLightingFactor.y).toBe(1) + + const dusk = { ...night, sunElevation: -9 } + render(viewer, dusk) + const g = cabNightFactor(dusk.sunElevation, dusk.nightDarkeningIntensity, true) + expect(g).toBeLessThan(1) + expect(g).not.toBe(f) + expect(model.lightColor?.x).toBeCloseTo(color.red * intensity * g, 9) + expect(model.imageBasedLighting.imageBasedLightingFactor.x).toBeCloseTo(g, 9) + }) + + it('removes and destroys the cab on unmount', async () => { + const viewer = fakeViewer({}) + sampleSpy.mockImplementation(async (_p: unknown, positions: Cesium.Cartographic[]) => + positions.map((p) => new Cesium.Cartographic(p.longitude, p.latitude, 4)), + ) + render(viewer) + await flush() + const model = viewer.scene.primitives.add.mock.calls[0][0] as FakeModel + + hookState.unmount() + + expect(viewer.scene.primitives.remove).toHaveBeenCalledWith(model) + expect(model.destroy).toHaveBeenCalled() + }) + + it('stops listening for terrain changes on cleanup', async () => { + const viewer = fakeViewer(undefined) + render(viewer) + await flush() + expect(viewer.scene.globe.terrainProviderChanged.numberOfListeners).toBe(1) + + hookState.unmount() + + expect(viewer.scene.globe.terrainProviderChanged.numberOfListeners).toBe(0) + }) +}) diff --git a/vite.config.ts b/vite.config.ts index d2b24399..e70f95f1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,7 @@ import { resolve, join } from 'node:path' import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { defineConfig } from 'vite' +import { chunkCyclePlugin } from './scripts/dev/chunkCycles' import { serveCesiumAssets } from './scripts/dev/cesiumAssets' import { devApiOptionsFromEnv, devApiPlugin } from './scripts/dev/devApi' @@ -126,7 +127,14 @@ export default defineConfig({ // ships in the main index chunk it forms a circular dep — the stores // call `create()` at module top level before the binding is initialized, // producing `TypeError: F is not a function` at boot. - { name: 'zustand', test: /[\\/]node_modules[\\/]zustand[\\/]/, priority: 10 } + { name: 'zustand', test: /[\\/]node_modules[\\/]zustand[\\/]/, priority: 10 }, + // settingsStore reads DEFAULT_SETTINGS and calls isTauri() while its module loads, so these + // modules must not ship in the index chunk that imports the settingsStore chunk (a chunk cycle). + { + name: 'settings-core', + test: /([\\/]src[\\/]renderer[\\/](types[\\/]settings|utils[\\/](tauriApi|remoteMode|deviceDetection))\.ts$)|([\\/]node_modules[\\/]@tauri-apps[\\/])/, + priority: 10 + } ] } } @@ -152,6 +160,13 @@ export default defineConfig({ // (scripts/dev/devApi.ts); every other serve and every build leave it out. devApiPlugin((logger) => devApiOptionsFromEnv(resolve('.'), logger)), writeBuildModeMarker(), + // Cycles a shipped build already has and boots with: they are tracked as TC3D-351, so they warn + // here instead of failing. Remove an entry once its cycle is broken. + chunkCyclePlugin([ + ['AircraftModelService', 'MSFSModelConversionService', 'index'], + ['AircraftModelService', 'index'], + ['MSFSModelConversionService', 'index'] + ]), // Only copy Cesium assets if they don't exist (speeds up rebuilds significantly). // // The dest is `cesium-package` (NOT `cesium-package/Workers` etc) and each