Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ All notable changes to TowerCab 3D will be documented in this file.
### Added
- 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.
- 3D buildings now show walls that suit their type, such as window rows lit at night, open parking-garage decks and hangar doors, and vary in colour.

### 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.

### Fixed
- Stale sun shadows no longer darken buildings and the ground at night, or flicker across walls after switching to a night time.
- 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.
Expand Down
2 changes: 1 addition & 1 deletion USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -852,7 +852,7 @@ See the [MSFS Model Support](#msfs-model-support) section for details on using F
| **Show 3D Buildings** | OpenStreetMap building models | Off |
| **Building Quality** | LOD for 3D buildings | Low |
| **Time of Day** | Real-time or fixed local hour | Real |
| **Enable Shadows** | Render terrain and aircraft shadows | On |
| **Enable Shadows** | Render terrain and aircraft shadows (automatically off at night, while the sun is below the horizon) | On |
| **Shadow Mode** | Which objects cast shadows | Aircraft Only |
| **Enable Fog** | METAR-based fog effects | On |
| **Fog Intensity** | Fog opacity multiplier | 1x |
Expand Down
14 changes: 13 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,9 +287,11 @@ CesiumViewer Component Mount
│ ├─ Sets maximumScreenSpaceError based on quality (1-5)
│ └─ Handles runtime quality changes with cache eviction
│
├─ 3. useCesiumLighting(viewer, lightingSettings)
├─ 3. useSunElevation(viewer) (sun from Cesium's clock, geodetic up), then useCesiumLighting(viewer, lightingSettings)
│ ├─ Sun elevation is computed first and passed in as lightingSettings.sunElevationDeg (null until known)
│ ├─ Configures sun position and lighting
│ ├─ Sets up shadow maps (size, distance, darkness)
│ ├─ Sun shadows are off while sunElevationDeg is below the horizon (null keeps them on)
│ └─ Updates runtime when settings change
│
├─ 4. useCesiumWeather(viewer, showWeatherEffects, showCesiumFog, fogDensity)
Expand Down Expand Up @@ -546,6 +548,16 @@ requestAnimationFrame

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.

### OSM Buildings Facades

With `show3DBuildings` on, the buildings effect in `CesiumViewer.tsx` creates the OSM Buildings tileset (`createOsmBuildingsAsync`) and sets both `tileset.customShader = createFacadeShader()` and the building style through `applyBuildingStyle(tileset)`. No separate setting; the facades ride `show3DBuildings`. OSM `building:colour` tints (Cesium's default `cesium#color` style) no longer show.

`utils/buildingStyle.ts` builds a `Cesium3DTileStyle` whose colour (a `StyleExpression` subclass reading each tag once per feature) packs the building's facade class (from `building`, `amenity`, `aeroway` and `parking` tags), storeys, a wall-height bucket and a hash of its latitude and longitude into the RGB channels, each 228-255 with alpha 1 (near white, so no feature turns translucent). Styled content with no feature evaluates to white. `utils/facadeShader.ts` reads the colour back in the custom shader through `selectedFeatureIdStage()` and decodes it. `applyBuildingStyle` sets the tileset's colour blend to MIX at amount 0, so the packed colour itself is not multiplied into the lit result. The class picks the facade grammar and a wall palette; the hash picks one of four palette entries, so neighbours differ.

Height: a tagged `height` (the `part#` forms for a building part) less `min_height` and less a tagged roof; else, for a non-part, `cesium#estimatedHeight` less `min_height`. A building part with no tagged `part#height` has an unknown height (code 0) and uses the grammar's default storey. Tag numbers accept only a whole or decimal number with an optional `m` (one decimal comma allowed); `4;5` and `30 ft` count as absent. A tagged `roof:height` that is negative or at or above the total is ignored, and one leaving less than one 3 m storey is capped. Storeys are `building:levels` less `building:min_level`; the wall height packs in 2 m buckets, saturating at 54 m.

Classes: terminals (`building=terminal`/`transportation`/`train_station`, `aeroway=terminal`) and offices (office, commercial, retail, hotel, hospital, clinic, public, civic, government, school, college, university, mixed_use) get continuous window bands; houses (house, residential, detached, semidetached_house, terrace, apartments, bungalow, dormitory, cabin) small punched windows; parking garages (`building=parking`, `amenity=parking`, `parking=multi-storey`) open slab bands with no lit windows; hangars (hangar, industrial, warehouse, manufacture, factory, `aeroway=hangar`) door panels and ribbed cladding with no windows, their panel seams and ribs box-filtered over the pixel footprint; domestic garages (`building=garage`/`garages`/`carport`) a plain wall; everything else the generic window grid. When tags name two classes, the earlier rule in `CLASS_RULES` wins. Live shader uniforms are `u_fade`, `u_fadeStart`, `u_fadeEnd` and `u_glassMean`. Grid, fade and lit-share constants are `FACADE_*` in `constants/rendering.ts`; ground-hemisphere lighting is `utils/environmentMap.ts` with `constants/lighting.ts` (see above). Tests: `utils/buildingStyle.test.ts`, `utils/facadeShader.test.ts`, `utils/environmentMap.test.ts`. Design and options: `docs/plans/osm-buildings.md`; regression captures: `docs/plans/canvases/osm-facades/`.

### Coordinate Transform Pipeline

```
Expand Down
20 changes: 20 additions & 0 deletions docs/plans/canvases/osm-facades/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Type-aware facades and per-building palette: regression captures

Real desktop app (dev build), 1596×800 Cesium canvas, OSM Buildings on at quality high, the packed building style plus the facade shader (`u_fade` 0). Time through the app's fixed-time mode: noon = 12:00 local, night = 22:00 local. Heading, pitch and FOV in degrees; poses match `../facade-fade-ab/` so the two sets compare frame for frame.

Cesium documents a custom shader together with a `Cesium3DTileStyle` as undefined behaviour; these frames pin that the combination renders in Cesium 1.146.

The tileset's colour blend is MIX at amount 0, so the packed colour never tints the lit result (diffuse, specular or image-based light). `building=garage`, `garages` and `carport` get a plain wall with no openings; only `building=parking`, `amenity=parking` and `parking=multi-storey` get the slab-band garage grammar.

| File | Pose | What it shows |
|------|------|---------------|
| `ksfo-garage-noon.png` | KSFO tower eye, heading 235, pitch −14, FOV 35 | The curved central garage below the tower. It is OSM relation 15730419, tagged only `type=building`, so it gets the generic window grid; the terminal behind it gets office bands. |
| `ksfo-garage-night.png` | same, 22:00 | Lit share 0.28 on the glass classes; garages tagged `amenity=parking` have no lit windows. |
| `ksfo-tower-noon.png` | KSFO tower eye, heading 290, pitch −4, FOV 60 (wide) | Terminals and boarding areas (tagged `aeroway=terminal`, so the office grammar and the terminal palette) in front, the central garage's ring at the bottom, San Bruno 2–5 km behind. |
| `ksfo-tower-night.png` | same, 22:00 | Night look of the wide view. |
| `koak-tower-noon.png` | KOAK tower eye +25 m, heading 70, pitch −6, FOV 60 (wide) | Cargo ramp, Oakland behind. The large ramp buildings in front show a window row, so they are not in the hangar class (their tags were not read in this run). |
| `koak-tower-night.png` | same, 22:00 | Night look of the wide view. |
| `koak-hangar-noon.png` | KOAK tower eye +25 m, heading 182.8, pitch −9.9, FOV 12 | The long building on the south field. It still shows windows, so its tag is not one the hangar class reads (it was not picked in this run). |
| `koak-hangar-night.png` | same, 22:00 | Night look of the same building. |

Palette values are a proposal for the owner to judge from these frames.
38 changes: 38 additions & 0 deletions docs/plans/canvases/osm-facades/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OSM Facade Captures</title>
<style>
:root { --bg:#14171c; --panel:#1d2128; --text:#e6e8eb; --muted:#9aa3ad; --line:#2c323b; }
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--text); font:15px/1.5 system-ui, sans-serif; }
header { padding:20px 16px 8px; max-width:1600px; margin:0 auto; }
h1 { font-size:22px; margin:0 0 4px; }
header p { color:var(--muted); margin:0; }
.grid { display:grid; grid-template-columns:110px 1fr 1fr; gap:12px; padding:16px; max-width:1600px; margin:0 auto; align-items:start; }
@media (max-width: 900px) { .grid { grid-template-columns:1fr; } .rowlabel { margin-top:12px; } }
.colhead { font-weight:600; color:var(--muted); }
.rowlabel { font-weight:600; padding-top:6px; }
img { width:100%; display:block; border-radius:6px; border:1px solid var(--line); background:#000; }
</style>
</head>
<body>
<header>
<h1>TC3D-164 / TC3D-163: building colours and type-aware facades</h1>
<p>Per-type palette chosen by a stable per-building hash; facades by type (offices and terminals banded, parking garages slab bands, hangars ribbed with door panels, houses punched windows, domestic garages and carports a plain wall, the rest the previous window grid). Same poses as the earlier window-fade captures.</p>
</header>
<div class="grid">
<div></div><div class="colhead">Noon</div><div class="colhead">22:00</div>
<div class="rowlabel">KSFO tower</div>
<img src="ksfo-tower-noon.png" alt="KSFO tower at noon"><img src="ksfo-tower-night.png" alt="KSFO tower at night">
<div class="rowlabel">KSFO garage</div>
<img src="ksfo-garage-noon.png" alt="KSFO garage at noon"><img src="ksfo-garage-night.png" alt="KSFO garage at night">
<div class="rowlabel">KOAK tower</div>
<img src="koak-tower-noon.png" alt="KOAK tower at noon"><img src="koak-tower-night.png" alt="KOAK tower at night">
<div class="rowlabel">KOAK hangar</div>
<img src="koak-hangar-noon.png" alt="KOAK hangar at noon"><img src="koak-hangar-night.png" alt="KOAK hangar at night">
</div>
</body>
</html>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 6 additions & 3 deletions src/renderer/components/CesiumViewer/CesiumViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium'
import { useEffect, useMemo, useRef, useState } from 'react'
import { applyBuildingStyle } from '@/utils/buildingStyle'
import { createFacadeShader } from '@/utils/facadeShader'
import { useAgentHarness } from '../../hooks/useAgentHarness'
import { setInterpolationTerrainData, useAircraftInterpolation } from '../../hooks/useAircraftInterpolation'
Expand Down Expand Up @@ -286,13 +287,17 @@ function CesiumViewer({ viewportId = 'main', isInset = false, isActivated = true
// =========================================================================
// 3. Lighting and Shadow Configuration
// =========================================================================
// Sun elevation at camera position, from Cesium's clock: sun shadows are off below the horizon, and imagery darkens at night
const sunElevation = useSunElevation(viewer)

useCesiumLighting(viewer, {
isInset,
insetGraphics,
viewMode,
enableLighting,
enableGroundAtmosphere,
enableShadows,
sunElevationDeg: sunElevation,
shadowMapSize,
shadowMaxDistance,
shadowDarkness,
Expand All @@ -308,9 +313,6 @@ function CesiumViewer({ viewportId = 'main', isInset = false, isActivated = true
// =========================================================================
// 3a. Night-Time Darkening (requires enableLighting)
// =========================================================================
// Calculate sun elevation angle at camera position
const sunElevation = useSunElevation(viewer, { timeMode, fixedTimeHour })

// Darken satellite imagery based on sun position
useCesiumNightDarkening(viewer, sunElevation, {
enabled: enableNightDarkening && enableLighting, // Only works with lighting enabled
Expand Down Expand Up @@ -722,6 +724,7 @@ function CesiumViewer({ viewportId = 'main', isInset = false, isActivated = true
tileset.shadows = aircraftShadowsOnly ? Cesium.ShadowMode.DISABLED : Cesium.ShadowMode.ENABLED

tileset.customShader = createFacadeShader()
applyBuildingStyle(tileset)

viewer.scene.primitives.add(tileset)
currentTileset = tileset
Expand Down
85 changes: 85 additions & 0 deletions src/renderer/hooks/useCesiumLighting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type * as Cesium from 'cesium'
import { describe, expect, it, vi } from 'vitest'
import { type CesiumLightingSettings, useCesiumLighting } from '@/hooks/useCesiumLighting'
import { DEFAULT_SETTINGS } from '@/types/settings'

// Run the hook's effect at once: the repo has no React renderer for tests, and the effect is the behaviour under test.
vi.mock('react', async (importOriginal) => ({
...(await importOriginal<typeof import('react')>()),
useEffect: (effect: () => void) => effect(),
}))

function fakeViewer(): Cesium.Viewer {
return {
isDestroyed: () => false,
scene: { globe: {} },
shadows: false,
terrainShadows: undefined,
shadowMap: { _terrainBias: {}, _primitiveBias: {}, debugCreateRenderStates: () => {}, dirty: false },
} as unknown as Cesium.Viewer
}

function settings(
sunElevationDeg: number | null,
viewMode: '3d' | 'topdown' = '3d',
insetShadows?: boolean,
): CesiumLightingSettings {
const g = DEFAULT_SETTINGS.graphics
return {
isInset: insetShadows !== undefined,
insetGraphics: insetShadows === undefined ? g.insetGraphics : { ...g.insetGraphics, shadows: insetShadows },
viewMode,
enableLighting: true,
enableGroundAtmosphere: true,
enableShadows: true,
shadowMapSize: g.shadowMapSize,
shadowMaxDistance: g.shadowMaxDistance,
shadowDarkness: g.shadowDarkness,
shadowSoftness: g.shadowSoftness,
shadowFadingEnabled: g.shadowFadingEnabled,
shadowNormalOffset: g.shadowNormalOffset,
aircraftShadowsOnly: false,
shadowDepthBias: g.shadowDepthBias,
shadowPolygonOffsetFactor: g.shadowPolygonOffsetFactor,
shadowPolygonOffsetUnits: g.shadowPolygonOffsetUnits,
sunElevationDeg,
}
}

describe('useCesiumLighting sun shadows', () => {
it('turns sun shadows off once the sun is below the horizon', () => {
const viewer = fakeViewer()
useCesiumLighting(viewer, settings(-30))
expect(viewer.shadows).toBe(false)
})

it('turns sun shadows off below the horizon in the top-down view too', () => {
const viewer = fakeViewer()
useCesiumLighting(viewer, settings(-30, 'topdown'))
expect(viewer.shadows).toBe(false)
})

it('keeps sun shadows on while the sun is up', () => {
const viewer = fakeViewer()
useCesiumLighting(viewer, settings(30))
expect(viewer.shadows).toBe(true)
})

it('keeps the shadow setting while the sun elevation is not known yet', () => {
const viewer = fakeViewer()
useCesiumLighting(viewer, settings(null))
expect(viewer.shadows).toBe(true)
})

it('turns sun shadows off below the horizon in an inset viewport that has shadows enabled', () => {
const viewer = fakeViewer()
useCesiumLighting(viewer, settings(-30, '3d', true))
expect(viewer.shadows).toBe(false)
})

it('keeps sun shadows on by day in an inset viewport that has shadows enabled', () => {
const viewer = fakeViewer()
useCesiumLighting(viewer, settings(30, '3d', true))
expect(viewer.shadows).toBe(true)
})
})
Loading
Loading