diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3d4666f..148e462 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/USER_GUIDE.md b/USER_GUIDE.md
index e8d604d..bf55478 100644
--- a/USER_GUIDE.md
+++ b/USER_GUIDE.md
@@ -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 |
diff --git a/docs/architecture.md b/docs/architecture.md
index fad0c1c..b035e4a 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -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)
@@ -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
```
diff --git a/docs/plans/canvases/osm-facades/README.md b/docs/plans/canvases/osm-facades/README.md
new file mode 100644
index 0000000..6ef22ba
--- /dev/null
+++ b/docs/plans/canvases/osm-facades/README.md
@@ -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.
diff --git a/docs/plans/canvases/osm-facades/index.html b/docs/plans/canvases/osm-facades/index.html
new file mode 100644
index 0000000..97d6dc4
--- /dev/null
+++ b/docs/plans/canvases/osm-facades/index.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+OSM Facade Captures
+
+
+
+
+ TC3D-164 / TC3D-163: building colours and type-aware facades
+ 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.
+
+
+
Noon
22:00
+
KSFO tower
+


+
KSFO garage
+


+
KOAK tower
+


+
KOAK hangar
+


+
+
+
diff --git a/docs/plans/canvases/osm-facades/koak-hangar-night.png b/docs/plans/canvases/osm-facades/koak-hangar-night.png
new file mode 100644
index 0000000..0b5bb00
Binary files /dev/null and b/docs/plans/canvases/osm-facades/koak-hangar-night.png differ
diff --git a/docs/plans/canvases/osm-facades/koak-hangar-noon.png b/docs/plans/canvases/osm-facades/koak-hangar-noon.png
new file mode 100644
index 0000000..64f51cb
Binary files /dev/null and b/docs/plans/canvases/osm-facades/koak-hangar-noon.png differ
diff --git a/docs/plans/canvases/osm-facades/koak-tower-night.png b/docs/plans/canvases/osm-facades/koak-tower-night.png
new file mode 100644
index 0000000..5116468
Binary files /dev/null and b/docs/plans/canvases/osm-facades/koak-tower-night.png differ
diff --git a/docs/plans/canvases/osm-facades/koak-tower-noon.png b/docs/plans/canvases/osm-facades/koak-tower-noon.png
new file mode 100644
index 0000000..394e01d
Binary files /dev/null and b/docs/plans/canvases/osm-facades/koak-tower-noon.png differ
diff --git a/docs/plans/canvases/osm-facades/ksfo-garage-night.png b/docs/plans/canvases/osm-facades/ksfo-garage-night.png
new file mode 100644
index 0000000..1bca691
Binary files /dev/null and b/docs/plans/canvases/osm-facades/ksfo-garage-night.png differ
diff --git a/docs/plans/canvases/osm-facades/ksfo-garage-noon.png b/docs/plans/canvases/osm-facades/ksfo-garage-noon.png
new file mode 100644
index 0000000..303b955
Binary files /dev/null and b/docs/plans/canvases/osm-facades/ksfo-garage-noon.png differ
diff --git a/docs/plans/canvases/osm-facades/ksfo-tower-night.png b/docs/plans/canvases/osm-facades/ksfo-tower-night.png
new file mode 100644
index 0000000..ff6a5cb
Binary files /dev/null and b/docs/plans/canvases/osm-facades/ksfo-tower-night.png differ
diff --git a/docs/plans/canvases/osm-facades/ksfo-tower-noon.png b/docs/plans/canvases/osm-facades/ksfo-tower-noon.png
new file mode 100644
index 0000000..ce47b49
Binary files /dev/null and b/docs/plans/canvases/osm-facades/ksfo-tower-noon.png differ
diff --git a/src/renderer/components/CesiumViewer/CesiumViewer.tsx b/src/renderer/components/CesiumViewer/CesiumViewer.tsx
index 56ae3c7..b2c0f74 100644
--- a/src/renderer/components/CesiumViewer/CesiumViewer.tsx
+++ b/src/renderer/components/CesiumViewer/CesiumViewer.tsx
@@ -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'
@@ -286,6 +287,9 @@ 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,
@@ -293,6 +297,7 @@ function CesiumViewer({ viewportId = 'main', isInset = false, isActivated = true
enableLighting,
enableGroundAtmosphere,
enableShadows,
+ sunElevationDeg: sunElevation,
shadowMapSize,
shadowMaxDistance,
shadowDarkness,
@@ -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
@@ -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
diff --git a/src/renderer/hooks/useCesiumLighting.test.ts b/src/renderer/hooks/useCesiumLighting.test.ts
new file mode 100644
index 0000000..b7c199a
--- /dev/null
+++ b/src/renderer/hooks/useCesiumLighting.test.ts
@@ -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()),
+ 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)
+ })
+})
diff --git a/src/renderer/hooks/useCesiumLighting.ts b/src/renderer/hooks/useCesiumLighting.ts
index ace8146..0082682 100644
--- a/src/renderer/hooks/useCesiumLighting.ts
+++ b/src/renderer/hooks/useCesiumLighting.ts
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium'
import { useEffect } from 'react'
+import { SUN_ELEVATION_DAY } from '@/constants'
import type { InsetGraphicsSettings, ViewMode } from '@/types'
import { toCesiumShadowDarkness } from '@/utils/shadowDarkness'
@@ -16,6 +17,8 @@ export interface CesiumLightingSettings {
enableGroundAtmosphere: boolean
/** Enable shadows cast by sun */
enableShadows: boolean
+ /** Sun elevation above the horizon in degrees, or null until it is known (shadows are off below the horizon) */
+ sunElevationDeg: number | null
/** Shadow map resolution (512, 1024, 2048, 4096) */
shadowMapSize: number
/** Maximum distance for shadow rendering (meters) */
@@ -24,7 +27,7 @@ export interface CesiumLightingSettings {
shadowDarkness: number
/** Enable soft shadow edges (PCF filtering) */
shadowSoftness: boolean
- /** Enable distance-based shadow fading */
+ /** Fade shadows out as the sun nears the horizon (Cesium's fadingEnabled) */
shadowFadingEnabled: boolean
/** Normal offset to prevent shadow acne */
shadowNormalOffset: boolean
@@ -69,6 +72,7 @@ export interface CesiumLightingSettings {
* isInset: false,
* enableLighting: true,
* enableShadows: true,
+ * sunElevationDeg: null,
* shadowMapSize: 2048,
* shadowMaxDistance: 10000,
* shadowDarkness: 0.5,
@@ -97,8 +101,14 @@ export function useCesiumLighting(viewer: Cesium.Viewer | null, settings: Cesium
shadowDepthBias,
shadowPolygonOffsetFactor,
shadowPolygonOffsetUnits,
+ sunElevationDeg,
} = settings
+ // Below the horizon Cesium stops drawing the sun shadow map but receivers keep sampling the stale daytime
+ // texture, dimming every wall (and its lit windows). Turning the map off there loses nothing real: nothing casts.
+ // Depend on this boolean, not the degrees: the effect below resizes the map and rebuilds every shadow command.
+ const sunAboveHorizon = sunElevationDeg === null || sunElevationDeg >= SUN_ELEVATION_DAY
+
// Update lighting and shadows when settings change
useEffect(() => {
if (!viewer || viewer.isDestroyed()) return
@@ -112,12 +122,13 @@ export function useCesiumLighting(viewer: Cesium.Viewer | null, settings: Cesium
// Update shadows - disabled for insets by default, aircraft-only for topdown, configurable for main 3D viewport
const isTopDown = viewMode === 'topdown'
const insetShadowsEnabled = isInset && insetGraphics.shadows
+ const sunShadowsEnabled = enableShadows && sunAboveHorizon
if (isInset && !insetShadowsEnabled) {
viewer.shadows = false
viewer.terrainShadows = Cesium.ShadowMode.DISABLED
} else if (isTopDown) {
// In top-down view: show aircraft shadows only (terrain receives but doesn't cast)
- viewer.shadows = enableShadows
+ viewer.shadows = sunShadowsEnabled
if (enableShadows) {
viewer.shadowMap.softShadows = shadowSoftness
viewer.shadowMap.size = shadowMapSize
@@ -131,7 +142,7 @@ export function useCesiumLighting(viewer: Cesium.Viewer | null, settings: Cesium
viewer.terrainShadows = Cesium.ShadowMode.DISABLED
}
} else {
- viewer.shadows = enableShadows
+ viewer.shadows = sunShadowsEnabled
if (enableShadows) {
// Configure cascaded shadow maps with user settings
viewer.shadowMap.softShadows = shadowSoftness
@@ -191,5 +202,6 @@ export function useCesiumLighting(viewer: Cesium.Viewer | null, settings: Cesium
shadowDepthBias,
shadowPolygonOffsetFactor,
shadowPolygonOffsetUnits,
+ sunAboveHorizon,
])
}
diff --git a/src/renderer/hooks/useSunElevation.test.ts b/src/renderer/hooks/useSunElevation.test.ts
new file mode 100644
index 0000000..aafeb48
--- /dev/null
+++ b/src/renderer/hooks/useSunElevation.test.ts
@@ -0,0 +1,24 @@
+import * as Cesium from 'cesium'
+import { describe, expect, it } from 'vitest'
+import { computeSunElevationDeg } from '@/hooks/useSunElevation'
+
+const KSFO = Cesium.Cartesian3.fromDegrees(-122.375, 37.6189, 10)
+
+// The app's fixed mode reads an hour as local time at longitude/15, so 22:00 local at KSFO (122.375 W) is
+// 06:09:30 UTC and 12:00 local is 20:09:30 UTC.
+const NIGHT = Cesium.JulianDate.fromDate(new Date(Date.UTC(2025, 5, 21, 6, 9, 30)))
+const DAY = Cesium.JulianDate.fromDate(new Date(Date.UTC(2025, 5, 21, 20, 9, 30)))
+
+describe('computeSunElevationDeg', () => {
+ it('reports the sun below the horizon at 22:00 local', () => {
+ expect(computeSunElevationDeg(KSFO, NIGHT)).toBeLessThan(-5)
+ })
+
+ it('reports the sun above the horizon at 12:00 local', () => {
+ expect(computeSunElevationDeg(KSFO, DAY)).toBeGreaterThan(30)
+ })
+
+ it('returns null for a missing position', () => {
+ expect(computeSunElevationDeg(Cesium.Cartesian3.ZERO, DAY)).toBeNull()
+ })
+})
diff --git a/src/renderer/hooks/useSunElevation.ts b/src/renderer/hooks/useSunElevation.ts
index a128bb9..206a2c8 100644
--- a/src/renderer/hooks/useSunElevation.ts
+++ b/src/renderer/hooks/useSunElevation.ts
@@ -1,19 +1,19 @@
import * as Cesium from 'cesium'
import { useEffect, useRef, useState } from 'react'
import { PARTICLE_PREWARM_JUMP_THRESHOLD, SUN_POSITION_UPDATE_INTERVAL } from '@/constants'
-import type { TimeMode } from '@/types/settings'
-export interface SunElevationOptions {
- /** Current time mode setting - changes trigger immediate recalculation */
- timeMode?: TimeMode
- /** Fixed time hour setting - changes trigger immediate recalculation */
- fixedTimeHour?: number
-}
+/**
+ * Clock jumps larger than this (seconds) recompute the sun at once.
+ *
+ * A time-mode or fixed-hour change moves Cesium's clock by hours in one assignment; the sun moves too little
+ * in ordinary animated time to reach this between frames.
+ */
+const SUN_CLOCK_JUMP_THRESHOLD_SECONDS = 60
/**
- * Calculates the sun elevation angle at the camera position
+ * Calculates the sun elevation angle (degrees) above the horizon for a position and time
*
- * The sun elevation angle determines how high the sun is above the horizon:
+ * The elevation determines how high the sun is above the horizon:
* - +90 degrees: Sun directly overhead (solar noon at tropics)
* - 0 degrees: Sun at horizon (sunrise/sunset)
* - -6 degrees: End of civil twilight
@@ -21,34 +21,69 @@ export interface SunElevationOptions {
* - -18 degrees: End of astronomical twilight (full night)
* - -90 degrees: Sun directly below (solar midnight at poles)
*
- * This hook uses Cesium's built-in sun position calculation which accounts for:
- * - Current time from the Cesium clock
- * - Earth's orbital position
- * - Camera/viewer position on the globe
+ * Elevation is measured against the geodetic surface normal, the same up vector Cesium's shadow map uses
+ * to decide the sun is below the horizon.
+ *
+ * @param position - Camera position in Earth-Centered Earth-Fixed coordinates
+ * @param time - Time to compute the sun position at
+ * @returns Sun elevation angle in degrees (-90 to +90), or null if unavailable
+ */
+export function computeSunElevationDeg(position: Cesium.Cartesian3, time: Cesium.JulianDate): number | null {
+ if (Cesium.Cartesian3.equals(position, Cesium.Cartesian3.ZERO)) return null
+
+ // Sun position in Earth-Centered Inertial (ECI) frame, from the Simon 1994 planetary positions model
+ const sunPositionEci = Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame(
+ time,
+ new Cesium.Cartesian3(),
+ )
+
+ // Transform sun position from ECI to Earth-Centered Earth-Fixed (ECEF) frame
+ // This accounts for Earth's rotation
+ const icrfToFixed = Cesium.Transforms.computeIcrfToFixedMatrix(time)
+ if (icrfToFixed) {
+ Cesium.Matrix3.multiplyByVector(icrfToFixed, sunPositionEci, sunPositionEci)
+ } else {
+ // Fallback to TEME to Fixed if ICRF data unavailable
+ const temeToFixed = Cesium.Transforms.computeTemeToPseudoFixedMatrix(time)
+ if (!temeToFixed) return null
+ Cesium.Matrix3.multiplyByVector(temeToFixed, sunPositionEci, sunPositionEci)
+ }
+
+ // Dot product of up and the sun direction is sin(elevation), i.e. the angle from the horizon
+ const up = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(position, new Cesium.Cartesian3())
+ const sunDirection = Cesium.Cartesian3.normalize(sunPositionEci, new Cesium.Cartesian3())
+ const sinElevation = Cesium.Math.clamp(Cesium.Cartesian3.dot(up, sunDirection), -1, 1)
+ return Cesium.Math.toDegrees(Math.asin(sinElevation))
+}
+
+/**
+ * Tracks the sun elevation angle at the camera position
+ *
+ * The sun is computed from Cesium's clock, so the sun this hook reports is the sun Cesium draws, whichever
+ * time set the clock (fixed time at the tower, real time, or an inset's clock).
*
* ## Performance
* Sun position is only recalculated every 30 seconds since the sun moves slowly.
* However, immediate recalculation is triggered when:
- * - timeMode or fixedTimeHour settings change
+ * - Cesium's clock jumps (time mode or fixed time hour changed)
* - Camera position jumps significantly (>500m, e.g., airport switch)
*
* @param viewer - Initialized Cesium.Viewer instance
- * @param options - Optional time settings that trigger immediate recalculation when changed
* @returns Sun elevation angle in degrees (-90 to +90), or null if unavailable
*
* @example
* ```tsx
- * const sunElevation = useSunElevation(viewer, { timeMode, fixedTimeHour })
+ * const sunElevation = useSunElevation(viewer)
* if (sunElevation !== null && sunElevation < 0) {
* // It's after sunset
* }
* ```
*/
-export function useSunElevation(viewer: Cesium.Viewer | null, options?: SunElevationOptions): number | null {
- const { timeMode, fixedTimeHour } = options ?? {}
+export function useSunElevation(viewer: Cesium.Viewer | null): number | null {
const [sunElevation, setSunElevation] = useState(null)
const lastUpdateRef = useRef(0)
const lastCameraPositionRef = useRef(null)
+ const lastClockTimeRef = useRef(null)
useEffect(() => {
if (!viewer || viewer.isDestroyed()) {
@@ -56,110 +91,49 @@ export function useSunElevation(viewer: Cesium.Viewer | null, options?: SunEleva
return
}
- /**
- * Calculate sun elevation angle at the camera position
- */
- const calculateSunElevation = (): number | null => {
- if (!viewer || viewer.isDestroyed()) return null
-
- const scene = viewer.scene
- const camera = scene.camera
-
- // Get camera position in Cartesian3 (ECEF coordinates)
- const cameraPosition = camera.positionWC
- if (!cameraPosition || Cesium.Cartesian3.equals(cameraPosition, Cesium.Cartesian3.ZERO)) {
- return null
- }
-
- // Calculate the time to use for sun position
- // When time settings are provided, calculate time directly to avoid waiting for clock update
- let currentTime: Cesium.JulianDate
- if (timeMode === 'fixed' && fixedTimeHour !== undefined) {
- // Calculate fixed time based on camera longitude
- const cartographic = Cesium.Cartographic.fromCartesian(cameraPosition)
- 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)
-
- currentTime = Cesium.JulianDate.fromDate(targetTime)
- } else {
- // Real time mode - use current system time
- currentTime = Cesium.JulianDate.now()
- }
-
- // Calculate sun position in Earth-Centered Inertial (ECI) frame
- // This uses the Simon 1994 planetary positions model
- const sunPositionEci = Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame(currentTime)
-
- // Transform sun position from ECI to Earth-Centered Earth-Fixed (ECEF) frame
- // This accounts for Earth's rotation
- const icrfToFixed = Cesium.Transforms.computeIcrfToFixedMatrix(currentTime)
- if (!icrfToFixed) {
- // Fallback to TEME to Fixed if ICRF data unavailable
- const temeToFixed = Cesium.Transforms.computeTemeToPseudoFixedMatrix(currentTime)
- if (!temeToFixed) return null
- Cesium.Matrix3.multiplyByVector(temeToFixed, sunPositionEci, sunPositionEci)
- } else {
- Cesium.Matrix3.multiplyByVector(icrfToFixed, sunPositionEci, sunPositionEci)
- }
-
- // Normalize camera and sun positions to unit vectors
- const cameraNormalized = Cesium.Cartesian3.normalize(cameraPosition, new Cesium.Cartesian3())
- const sunNormalized = Cesium.Cartesian3.normalize(sunPositionEci, new Cesium.Cartesian3())
-
- // Calculate the angle between the "up" direction at camera and direction to sun
- // Dot product gives cosine of angle; we want angle from horizon, not from zenith
- // Up vector at camera position is just the normalized position (on sphere)
- const dotProduct = Cesium.Cartesian3.dot(cameraNormalized, sunNormalized)
-
- // Convert from angle-from-zenith to angle-from-horizon
- // asin of dot product gives elevation angle directly
- // (because dot product of up and sun direction = sin(elevation))
- const elevationRadians = Math.asin(dotProduct)
- const elevationDegrees = Cesium.Math.toDegrees(elevationRadians)
-
- return elevationDegrees
+ const calculateSunElevation = (time: Cesium.JulianDate): number | null => {
+ if (viewer.isDestroyed()) return null
+ return computeSunElevationDeg(viewer.scene.camera.positionWC, time)
}
// Initial calculation
- const initialElevation = calculateSunElevation()
- setSunElevation(initialElevation)
+ setSunElevation(calculateSunElevation(viewer.clock.currentTime))
lastUpdateRef.current = Date.now()
lastCameraPositionRef.current = Cesium.Cartesian3.clone(viewer.scene.camera.positionWC)
+ lastClockTimeRef.current = Cesium.JulianDate.clone(viewer.clock.currentTime)
// Set up periodic recalculation
- // Using postRender to check if enough time has passed or camera jumped significantly
+ // Using postRender to check if enough time has passed, the camera jumped, or the clock jumped
const removeListener = viewer.scene.postRender.addEventListener(() => {
const now = Date.now()
const currentCameraPosition = viewer.scene.camera.positionWC
+ const currentClockTime = viewer.clock.currentTime
// Check if camera jumped significantly (e.g., airport switch)
- let cameraJumped = false
- if (lastCameraPositionRef.current) {
- const distance = Cesium.Cartesian3.distance(lastCameraPositionRef.current, currentCameraPosition)
- if (distance > PARTICLE_PREWARM_JUMP_THRESHOLD) {
- cameraJumped = true
- }
- }
-
- // Recalculate if time elapsed OR camera jumped
- if (cameraJumped || now - lastUpdateRef.current >= SUN_POSITION_UPDATE_INTERVAL) {
- const elevation = calculateSunElevation()
- setSunElevation(elevation)
+ const cameraJumped =
+ lastCameraPositionRef.current !== null &&
+ Cesium.Cartesian3.distance(lastCameraPositionRef.current, currentCameraPosition) >
+ PARTICLE_PREWARM_JUMP_THRESHOLD
+
+ // Check if the clock jumped (e.g., time mode or fixed time hour changed)
+ const clockJumped =
+ lastClockTimeRef.current !== null &&
+ Math.abs(Cesium.JulianDate.secondsDifference(currentClockTime, lastClockTimeRef.current)) >
+ SUN_CLOCK_JUMP_THRESHOLD_SECONDS
+
+ // Recalculate if time elapsed, the camera jumped, or the clock jumped
+ if (clockJumped || cameraJumped || now - lastUpdateRef.current >= SUN_POSITION_UPDATE_INTERVAL) {
+ setSunElevation(calculateSunElevation(currentClockTime))
lastUpdateRef.current = now
lastCameraPositionRef.current = Cesium.Cartesian3.clone(currentCameraPosition)
+ lastClockTimeRef.current = Cesium.JulianDate.clone(currentClockTime)
}
})
return () => {
removeListener()
}
- // Re-run effect when time settings change to trigger immediate recalculation
- }, [viewer, timeMode, fixedTimeHour])
+ }, [viewer])
return sunElevation
}
diff --git a/src/renderer/utils/buildingStyle.test.ts b/src/renderer/utils/buildingStyle.test.ts
new file mode 100644
index 0000000..1951cdc
--- /dev/null
+++ b/src/renderer/utils/buildingStyle.test.ts
@@ -0,0 +1,342 @@
+import * as Cesium from 'cesium'
+import { describe, expect, it } from 'vitest'
+import {
+ applyBuildingStyle,
+ createBuildingStyle,
+ decodePackedColour,
+ FacadeClass,
+ PACK_CHANNEL_MIN,
+ PACK_CODE_MAX,
+ PACK_HASH_STATES,
+ PACK_HEIGHT_STEP_M,
+ type PackedBuilding,
+} from '@/utils/buildingStyle'
+
+/** A batch-table cell: a column can hold null or '' where a building lacks the tag. */
+type Tags = Record
+
+/** A tile feature as the style evaluator sees it: properties only, with every read counted. */
+function stubFeature(tags: Tags, reads: Map = new Map()) {
+ const read = (name: string) => {
+ reads.set(name, (reads.get(name) ?? 0) + 1)
+ return tags[name]
+ }
+ return { getPropertyInherited: read, getProperty: read }
+}
+
+const style = createBuildingStyle()
+
+/** Evaluates the style's colour on a stub feature and returns it as 0-255 channels plus alpha. */
+function evaluate(tags: Tags, reads?: Map): { rgb: [number, number, number]; alpha: number } {
+ const feature = stubFeature(tags, reads) as unknown as Cesium.Cesium3DTileFeature
+ const colour = (style.color as Cesium.StyleExpression).evaluateColor(feature, new Cesium.Color())
+ return {
+ rgb: [Math.round(colour.red * 255), Math.round(colour.green * 255), Math.round(colour.blue * 255)],
+ alpha: colour.alpha,
+ }
+}
+
+function packed(tags: Tags): PackedBuilding {
+ return decodePackedColour(evaluate(tags).rgb)
+}
+
+const AT_KSFO = { 'cesium#latitude': 37.61568, 'cesium#longitude': -122.38905 }
+
+describe('createBuildingStyle class', () => {
+ it.each([
+ [{ building: 'parking' }, FacadeClass.GARAGE],
+ [{ building: 'yes', amenity: 'parking' }, FacadeClass.GARAGE],
+ [{ building: 'yes', amenity: 'parking', parking: 'multi-storey' }, FacadeClass.GARAGE],
+ [{ building: 'yes', parking: 'multi-storey' }, FacadeClass.GARAGE],
+ [{ building: 'garage' }, FacadeClass.PLAIN],
+ [{ building: 'garages' }, FacadeClass.PLAIN],
+ [{ building: 'carport' }, FacadeClass.PLAIN],
+ [{ building: 'hangar' }, FacadeClass.HANGAR],
+ [{ building: 'warehouse' }, FacadeClass.HANGAR],
+ [{ building: 'industrial' }, FacadeClass.HANGAR],
+ [{ building: 'house' }, FacadeClass.HOUSE],
+ [{ building: 'apartments' }, FacadeClass.HOUSE],
+ [{ building: 'detached' }, FacadeClass.HOUSE],
+ [{ building: 'office' }, FacadeClass.OFFICE],
+ [{ building: 'commercial' }, FacadeClass.OFFICE],
+ [{ building: 'hotel' }, FacadeClass.OFFICE],
+ [{ building: 'terminal' }, FacadeClass.TERMINAL],
+ [{ building: 'transportation', aeroway: 'terminal' }, FacadeClass.TERMINAL],
+ [{ building: 'yes', aeroway: 'terminal' }, FacadeClass.TERMINAL],
+ [{ building: 'yes' }, FacadeClass.GENERIC],
+ [{ building: 'roof' }, FacadeClass.GENERIC],
+ [{ building: null }, FacadeClass.GENERIC],
+ [{}, FacadeClass.GENERIC],
+ ])('classes %o as %i', (tags, expected) => {
+ expect(packed({ ...AT_KSFO, ...tags }).facadeClass).toBe(expected)
+ })
+
+ it('lets an earlier class win when tags name two', () => {
+ expect(packed({ building: 'garage', amenity: 'parking' }).facadeClass).toBe(FacadeClass.GARAGE)
+ expect(packed({ building: 'house', aeroway: 'hangar' }).facadeClass).toBe(FacadeClass.HANGAR)
+ })
+})
+
+describe('createBuildingStyle levels', () => {
+ it('reads building:levels, a string on the ion tileset', () => {
+ expect(packed({ building: 'office', 'building:levels': '7' }).levels).toBe(7)
+ })
+
+ it('reads a decimal through the unbucketed levels channel', () => {
+ expect(packed({ 'building:levels': '7,5' }).levels).toBe(8)
+ expect(packed({ 'building:levels': '12.5' }).levels).toBe(13)
+ })
+
+ it('is zero (unknown) when levels are missing or not a number', () => {
+ expect(packed({ building: 'office' }).levels).toBe(0)
+ expect(packed({ building: 'office', 'building:levels': '4;5' }).levels).toBe(0)
+ })
+
+ it("counts a building part's own storeys above its min_level", () => {
+ const part = { 'part#building:levels': '6', 'part#building:min_level': '3', 'building:levels': '9' }
+ expect(packed(part).levels).toBe(3)
+ })
+
+ it('saturates at the largest code', () => {
+ expect(packed({ 'building:levels': '60' }).levels).toBe(PACK_CODE_MAX)
+ })
+})
+
+describe('createBuildingStyle height', () => {
+ it('uses the explicit height, less any min_height, in buckets', () => {
+ expect(packed({ height: '20', 'cesium#estimatedHeight': 99 }).heightM).toBe(20)
+ expect(packed({ 'part#height': '38', 'part#min_height': '20', 'cesium#estimatedHeight': 41 }).heightM).toBe(18)
+ })
+
+ it('falls back to the estimated height when no explicit height parses', () => {
+ expect(packed({ 'cesium#estimatedHeight': 9 }).heightM).toBe(10)
+ expect(packed({ height: 'tall', 'cesium#estimatedHeight': 12 }).heightM).toBe(12)
+ })
+
+ // Every part of one element carries the element's own cesium#estimatedHeight (probe-koak.json),
+ // so a part without a part#height has an unknown height, not the element's top.
+ it('leaves a part without its own height unknown', () => {
+ const part = { 'part#building:levels': '2', 'part#min_height': '10', 'cesium#estimatedHeight': 40 }
+ expect(packed(part).heightM).toBe(0)
+ })
+
+ it('keeps the estimated height for a building, less its min_height', () => {
+ expect(packed({ 'building:levels': '5', min_height: '10', 'cesium#estimatedHeight': 40 }).heightM).toBe(30)
+ })
+
+ it('is zero (unknown) without any height and clamps extreme heights', () => {
+ expect(packed({}).heightM).toBe(0)
+ expect(packed({ height: '-5' }).heightM).toBe(0)
+ expect(packed({ height: '500' }).heightM).toBe(PACK_CODE_MAX * PACK_HEIGHT_STEP_M)
+ })
+
+ it('leaves the roof out of the wall height: roof:height when tagged', () => {
+ expect(packed({ height: '20', 'building:levels': '4', 'roof:height': '4' }).heightM).toBe(16)
+ })
+
+ it('leaves the roof out of the wall height: roof:levels as storeys when no roof:height', () => {
+ expect(packed({ height: '18', 'building:levels': '4', 'roof:levels': '2' }).heightM).toBe(12)
+ })
+
+ it("takes a part's roof from the part", () => {
+ const part = { 'part#height': '30', 'part#building:levels': '5', 'part#roof:height': '6', 'roof:height': '20' }
+ expect(packed(part).heightM).toBe(24)
+ })
+
+ it('takes the roof off a tagged height only, never off the estimated height', () => {
+ expect(packed({ 'cesium#estimatedHeight': 30, 'roof:height': '6' }).heightM).toBe(30)
+ expect(packed({ height: '30', 'roof:height': '6' }).heightM).toBe(24)
+ })
+
+ it('ignores a roof that would leave less than one storey of wall', () => {
+ expect(packed({ height: '20', 'roof:height': '-5' }).heightM).toBe(20)
+ expect(packed({ height: '20', 'roof:height': '25' }).heightM).toBe(20)
+ })
+})
+
+describe('createBuildingStyle absent cells', () => {
+ it('treats null and empty cells as absent, so the estimated height still applies', () => {
+ expect(packed({ height: null, 'building:levels': null, 'cesium#estimatedHeight': 9 })).toMatchObject({
+ levels: 0,
+ heightM: 10,
+ })
+ expect(packed({ height: '', 'building:levels': '', 'cesium#estimatedHeight': 9 })).toMatchObject({
+ levels: 0,
+ heightM: 10,
+ })
+ })
+
+ it('reads the building when the part cells are null or empty', () => {
+ const building = { height: '20', 'building:levels': '5' }
+ expect(packed({ ...building, 'part#height': null, 'part#building:levels': null })).toMatchObject({
+ levels: 5,
+ heightM: 20,
+ })
+ expect(packed({ ...building, 'part#height': '', 'part#building:levels': '' })).toMatchObject({
+ levels: 5,
+ heightM: 20,
+ })
+ })
+})
+
+describe('createBuildingStyle part or building', () => {
+ it('takes storeys from the part levels, and height only from its own height', () => {
+ const levelsOnly = {
+ 'part#building:levels': '4',
+ height: '40',
+ 'building:levels': '12',
+ 'cesium#estimatedHeight': 12,
+ }
+ expect(packed(levelsOnly)).toMatchObject({ levels: 4, heightM: 0 })
+
+ const heightOnly = { 'part#height': '10', height: '40', 'building:levels': '12' }
+ expect(packed(heightOnly)).toMatchObject({ levels: 0, heightM: 10 })
+ })
+
+ it('takes both from the building when the part has neither', () => {
+ expect(packed({ 'part#min_height': '5', height: '40', 'building:levels': '12' })).toMatchObject({
+ levels: 12,
+ heightM: 40,
+ })
+ })
+})
+
+describe('createBuildingStyle tag numbers', () => {
+ // The wall height in 2 m buckets, so the tag's own value shows through the packing.
+ it.each([
+ ['12', 12],
+ ['12.5', 12],
+ ['12 m', 12],
+ ['7,5', 8],
+ [' 9', 10],
+ ['abc', 0],
+ ['', 0],
+ ['4;5', 0],
+ ['30 ft', 0],
+ [14, 14],
+ ])('reads %o as a wall height', (height, expected) => {
+ expect(packed({ height } as Tags).heightM).toBe(expected)
+ })
+})
+
+describe('createBuildingStyle evaluation', () => {
+ // Cesium evaluates a tileset style's colour with no feature for tile content that has none
+ // (Model.applyColorAndShow, @cesium/engine Model.js).
+ it('gives tile content without features white, without throwing', () => {
+ const expression = style.color as Cesium.StyleExpression
+ const noFeature = undefined as unknown as Cesium.Cesium3DTileFeature
+ const reused = new Cesium.Color()
+ const colour = expression.evaluateColor(noFeature, reused)
+
+ expect(colour).toBe(reused)
+ expect([reused.red, reused.green, reused.blue, reused.alpha]).toEqual([1, 1, 1, 1])
+ expect(expression.evaluateColor(noFeature)).toEqual(Cesium.Color.WHITE)
+ })
+
+ it('reads each tag at most once per feature, by its own property only', () => {
+ const reads = new Map()
+ evaluate({ ...AT_KSFO, building: 'office', 'building:levels': '7', height: '25' }, reads)
+
+ expect(reads.size).toBeGreaterThan(0)
+ for (const [name, count] of reads) {
+ expect(count, name).toBe(1)
+ }
+ })
+})
+
+describe('createBuildingStyle bounds', () => {
+ const cases: Tags[] = [
+ {},
+ { building: 'terminal', 'building:levels': '60', height: '500', ...AT_KSFO },
+ { building: 'carport', 'building:levels': '60', height: '500', ...AT_KSFO },
+ { building: 'garage', 'building:levels': '0', height: '0' },
+ { building: 'house', 'cesium#latitude': -89.99999, 'cesium#longitude': 179.99999 },
+ ]
+
+ it.each(cases)('keeps every channel near white and alpha at 1 for %o', (tags) => {
+ const { rgb, alpha } = evaluate(tags)
+ for (const channel of rgb) {
+ expect(channel).toBeGreaterThanOrEqual(PACK_CHANNEL_MIN)
+ expect(channel).toBeLessThanOrEqual(255)
+ }
+ expect(alpha).toBe(1)
+ })
+})
+
+describe('createBuildingStyle packing headroom', () => {
+ it('fits every class and the largest hash in the red channel', () => {
+ for (const code of Object.values(FacadeClass)) {
+ expect(code * PACK_HASH_STATES + (PACK_HASH_STATES - 1)).toBeLessThanOrEqual(PACK_CODE_MAX)
+ }
+ })
+})
+
+describe('createBuildingStyle hash', () => {
+ it('gives the same hash for the same position, whatever the other tags', () => {
+ const a = packed({ ...AT_KSFO, building: 'office' }).hash
+ const b = packed({ ...AT_KSFO, building: 'office', 'building:levels': '3' }).hash
+ expect(a).toBe(b)
+ })
+
+ // On the ion tileset every part feature of one OSM building carries the building's own
+ // cesium#latitude/longitude: the parts of the KSFO central garage (relation 15730419, features
+ // 65..89 of one tile) all sit at 37.61568, -122.38905 whatever their part tags.
+ it('gives every part of one building the same hash', () => {
+ const parts: Tags[] = [
+ { 'part#building:levels': '3', 'part#height': '12' },
+ { 'part#building:levels': '6', 'part#building:min_level': '3', 'part#height': '24', 'part#min_height': '12' },
+ { 'part#building:part': 'roof', 'part#height': '41' },
+ ]
+ const hashes = parts.map((part) => packed({ ...AT_KSFO, type: 'building', ...part }).hash)
+ expect(new Set(hashes).size).toBe(1)
+ })
+
+ it('is zero without a position', () => {
+ expect(packed({ building: 'office' }).hash).toBe(0)
+ expect(packed({ building: 'office', 'cesium#latitude': null, 'cesium#longitude': '' }).hash).toBe(0)
+ })
+
+ it('spreads neighbouring buildings over every hash state', () => {
+ const counts = new Array(PACK_HASH_STATES).fill(0)
+ for (let i = 0; i < 10; i++) {
+ for (let j = 0; j < 10; j++) {
+ const tags = { 'cesium#latitude': 37.615 + i * 1e-4, 'cesium#longitude': -122.389 + j * 1e-4 }
+ counts[packed(tags).hash]++
+ }
+ }
+ for (const count of counts) {
+ expect(count).toBeGreaterThanOrEqual(15)
+ }
+ })
+})
+
+describe('applyBuildingStyle', () => {
+ it('sets the packing style and keeps its colour out of the lit result', () => {
+ const tileset = {} as Cesium.Cesium3DTileset
+ applyBuildingStyle(tileset)
+
+ expect(tileset.style).toBeInstanceOf(Cesium.Cesium3DTileStyle)
+ expect(tileset.colorBlendMode).toBe(Cesium.Cesium3DTileColorBlendMode.MIX)
+ expect(tileset.colorBlendAmount).toBe(0)
+ })
+})
+
+describe('decodePackedColour', () => {
+ it('reads white as a generic building with nothing known', () => {
+ expect(decodePackedColour([255, 255, 255])).toEqual({
+ facadeClass: FacadeClass.GENERIC,
+ levels: 0,
+ heightM: 0,
+ hash: 0,
+ })
+ })
+
+ it('reads the darkest packed colour as the largest codes', () => {
+ expect(decodePackedColour([PACK_CHANNEL_MIN, PACK_CHANNEL_MIN, PACK_CHANNEL_MIN])).toEqual({
+ facadeClass: FacadeClass.PLAIN,
+ levels: PACK_CODE_MAX,
+ heightM: PACK_CODE_MAX * PACK_HEIGHT_STEP_M,
+ hash: PACK_HASH_STATES - 1,
+ })
+ })
+})
diff --git a/src/renderer/utils/buildingStyle.ts b/src/renderer/utils/buildingStyle.ts
new file mode 100644
index 0000000..d894296
--- /dev/null
+++ b/src/renderer/utils/buildingStyle.ts
@@ -0,0 +1,307 @@
+/**
+ * The style that packs each OSM building's type, storeys, wall height and a stable hash into its
+ * feature colour, for the facade shader to read.
+ *
+ * Text tags never reach a custom shader, but a `Cesium3DTileStyle` colour does: the shader reads
+ * it back through `selectedFeatureIdStage()`. The tileset's MIX colour blend at amount 0 (see
+ * `applyBuildingStyle`) keeps the colour itself out of the rendered result. Every channel still
+ * stays near white (228 to 255) and alpha stays 1, so no feature moves to the translucent pass.
+ *
+ * Layout, with each channel holding `255 - code` and every code in 0..27:
+ * - red: `facadeClass * 4 + hash`, the hash in 0..3 from the building's latitude and longitude
+ * - green: storeys of the building or part, 0 when unknown
+ * - blue: wall height (roof left out) in 2 m buckets, 0 when unknown, 27 for 54 m or more
+ *
+ * A tag's cell counts as a number only when it is a finite number, or a string holding one with an
+ * optional trailing `m`; every other cell (missing, null, empty, `12;14`, `30 ft`) counts as absent.
+ */
+
+import * as Cesium from 'cesium'
+
+/** Facade grammar a building gets, by its OSM tags. */
+export const FacadeClass = {
+ GENERIC: 0,
+ GARAGE: 1,
+ HANGAR: 2,
+ HOUSE: 3,
+ OFFICE: 4,
+ TERMINAL: 5,
+ PLAIN: 6,
+} as const
+
+export type FacadeClassCode = (typeof FacadeClass)[keyof typeof FacadeClass]
+
+/** Lowest value a packed channel takes (8-bit). */
+export const PACK_CHANNEL_MIN = 228
+
+/** Largest code a channel carries: `255 - PACK_CHANNEL_MIN`. */
+export const PACK_CODE_MAX = 255 - PACK_CHANNEL_MIN
+
+/** Number of hash states packed beside the class in the red channel. */
+export const PACK_HASH_STATES = 4
+
+/** Height bucket size in metres. */
+export const PACK_HEIGHT_STEP_M = 2
+
+/** The fields the facade shader decodes from a packed colour. */
+export interface PackedBuilding {
+ facadeClass: number
+ levels: number
+ heightM: number
+ hash: number
+}
+
+interface ClassRule {
+ code: FacadeClassCode
+ buildings: readonly string[]
+ tags?: ReadonlyArray
+}
+
+/**
+ * The class rules in priority order: OSM `building` values per class, and the other tags that
+ * also set it. When a building's tags name two classes, the earlier rule wins.
+ */
+const CLASS_RULES: readonly ClassRule[] = [
+ {
+ code: FacadeClass.TERMINAL,
+ buildings: ['terminal', 'transportation', 'train_station'],
+ tags: [['aeroway', 'terminal']],
+ },
+ {
+ code: FacadeClass.GARAGE,
+ buildings: ['parking'],
+ tags: [
+ ['amenity', 'parking'],
+ ['parking', 'multi-storey'],
+ ],
+ },
+ {
+ code: FacadeClass.HANGAR,
+ buildings: ['hangar', 'industrial', 'warehouse', 'manufacture', 'factory'],
+ tags: [['aeroway', 'hangar']],
+ },
+ { code: FacadeClass.PLAIN, buildings: ['garage', 'garages', 'carport'] },
+ {
+ code: FacadeClass.HOUSE,
+ buildings: [
+ 'house',
+ 'residential',
+ 'detached',
+ 'semidetached_house',
+ 'terrace',
+ 'apartments',
+ 'bungalow',
+ 'dormitory',
+ 'cabin',
+ ],
+ },
+ {
+ code: FacadeClass.OFFICE,
+ buildings: [
+ 'office',
+ 'commercial',
+ 'retail',
+ 'hotel',
+ 'hospital',
+ 'clinic',
+ 'public',
+ 'civic',
+ 'government',
+ 'school',
+ 'college',
+ 'university',
+ 'mixed_use',
+ ],
+ },
+]
+
+/** Rule index per `building` value. */
+const RULE_BY_BUILDING = new Map(
+ CLASS_RULES.flatMap((rule, index) => rule.buildings.map((value) => [value, index] as const)),
+)
+
+/** The non-`building` tags the rules test, each read once per feature. */
+const RULE_TAG_KEYS = [...new Set(CLASS_RULES.flatMap((rule) => (rule.tags ?? []).map(([key]) => key)))]
+
+/** Each rule's tag tests as slots into `TAG_VALUES` and the value each slot must equal. */
+const RULE_TAG_SLOTS: readonly (readonly number[])[] = CLASS_RULES.map((rule) =>
+ (rule.tags ?? []).map(([key]) => RULE_TAG_KEYS.indexOf(key)),
+)
+const RULE_TAG_VALUES: readonly (readonly string[])[] = CLASS_RULES.map((rule) =>
+ (rule.tags ?? []).map(([, value]) => value),
+)
+
+/** Per-feature scratch for the rule tags, so each is read once and no Map is built. */
+const TAG_VALUES: unknown[] = new Array(RULE_TAG_KEYS.length).fill(undefined)
+
+/** The height tags a building or a part carries, built once so no key is built per feature. */
+interface HeightKeys {
+ minLevel: string
+ minHeight: string
+ roofHeight: string
+ roofLevels: string
+}
+
+function heightKeys(prefix: string): HeightKeys {
+ return Object.freeze({
+ minLevel: `${prefix}building:min_level`,
+ minHeight: `${prefix}min_height`,
+ roofHeight: `${prefix}roof:height`,
+ roofLevels: `${prefix}roof:levels`,
+ })
+}
+
+/** The height tags of a whole building, and those of a building part. */
+const BUILDING_KEYS = heightKeys('')
+const PART_KEYS = heightKeys('part#')
+
+type Feature = Cesium.Cesium3DTileFeature
+
+/** A cell holding one number with an optional trailing `m`; a decimal comma is replaced first. */
+const TAG_NUMBER = /^(-?\d+(?:\.\d+)?)\s*m?$/
+
+/** A tag's number, NaN when the cell holds anything else (a multi-value, feet, empty). */
+function tagNumber(feature: Feature, name: string): number {
+ const value: unknown = feature.getProperty(name)
+ if (typeof value === 'number') return Number.isFinite(value) ? value : Number.NaN
+ if (typeof value !== 'string') return Number.NaN
+ const match = TAG_NUMBER.exec(value.trim().replace(',', '.'))
+ return match === null ? Number.NaN : Number(match[1])
+}
+
+function orZero(value: number): number {
+ return Number.isNaN(value) ? 0 : value
+}
+
+function classify(feature: Feature): FacadeClassCode {
+ let best = RULE_BY_BUILDING.get(String(feature.getProperty('building'))) ?? CLASS_RULES.length
+ if (best > 0) {
+ for (let slot = 0; slot < RULE_TAG_KEYS.length; slot++) {
+ TAG_VALUES[slot] = feature.getProperty(RULE_TAG_KEYS[slot])
+ }
+ for (let index = 0; index < best; index++) {
+ const slots = RULE_TAG_SLOTS[index]
+ const values = RULE_TAG_VALUES[index]
+ for (let test = 0; test < slots.length; test++) {
+ if (TAG_VALUES[slots[test]] === values[test]) {
+ best = index
+ break
+ }
+ }
+ }
+ }
+ return best < CLASS_RULES.length ? CLASS_RULES[best].code : FacadeClass.GENERIC
+}
+
+/**
+ * Storeys and wall height in metres (NaN when unknown) of the part, or of the building. Every
+ * part of one element carries the element's own `cesium#estimatedHeight`, so a part without its
+ * own height stays unknown and the shader keeps the grammar's storey.
+ */
+function storeysAndWall(feature: Feature): { storeys: number; wallM: number } {
+ const partLevels = tagNumber(feature, 'part#building:levels')
+ const partHeight = tagNumber(feature, 'part#height')
+ const isPart = !Number.isNaN(partLevels) || !Number.isNaN(partHeight)
+ const keys = isPart ? PART_KEYS : BUILDING_KEYS
+ const levels = isPart ? partLevels : tagNumber(feature, 'building:levels')
+ const height = isPart ? partHeight : tagNumber(feature, 'height')
+
+ const storeys = orZero(levels) - orZero(tagNumber(feature, keys.minLevel))
+ if (isPart && Number.isNaN(height)) return { storeys, wallM: Number.NaN }
+ const taggedHeight = !Number.isNaN(height)
+ const topM = taggedHeight ? height : tagNumber(feature, 'cesium#estimatedHeight')
+ const totalM = Math.max(topM - orZero(tagNumber(feature, keys.minHeight)), 0)
+ const roofM = taggedHeight ? roofHeight(feature, keys, totalM, storeys) : 0
+ return { storeys, wallM: totalM - roofM }
+}
+
+/** The wall keeps at least one storey below a tagged `roof:height`. */
+const MIN_WALL_M = 3
+
+/**
+ * The roof's share of the total height: `roof:height` when tagged, else `roof:levels` storeys of
+ * the height spread evenly over wall and roof storeys, else none. A tagged roof is clamped to the
+ * wall's one-storey floor, and one at or above the total is ignored.
+ */
+function roofHeight(feature: Feature, keys: HeightKeys, totalM: number, storeys: number): number {
+ const tagged = tagNumber(feature, keys.roofHeight)
+ if (!Number.isNaN(tagged)) {
+ if (tagged >= totalM) return 0
+ return Math.min(Math.max(tagged, 0), Math.max(totalM - MIN_WALL_M, 0))
+ }
+ const roofLevels = tagNumber(feature, keys.roofLevels)
+ if (Number.isNaN(roofLevels) || roofLevels <= 0 || storeys <= 0) return 0
+ return (totalM * roofLevels) / (storeys + roofLevels)
+}
+
+function hashOf(feature: Feature): number {
+ const seed = tagNumber(feature, 'cesium#latitude') * 12.9898 + tagNumber(feature, 'cesium#longitude') * 78.233
+ const sine = Math.abs(Math.sin(seed)) * 43758.5453
+ return orZero(Math.floor((sine - Math.floor(sine)) * PACK_HASH_STATES))
+}
+
+function clampCode(value: number): number {
+ return Number.isNaN(value) ? 0 : Math.min(Math.max(Math.round(value), 0), PACK_CODE_MAX)
+}
+
+/**
+ * The building style's colour: reads each tag once per feature with `getProperty` and packs the
+ * fields described in the module doc. It overrides the styling-language expression, whose
+ * per-feature cost stalled a frame on every restyle and tile load.
+ */
+class BuildingColour extends Cesium.StyleExpression {
+ override evaluate(feature: Feature | undefined, result?: Cesium.Color): Cesium.Color {
+ return this.evaluateColor(feature, result)
+ }
+
+ override evaluateColor(feature: Feature | undefined, result?: Cesium.Color): Cesium.Color {
+ // Cesium evaluates the style with no feature for tile content that has none.
+ if (feature === undefined) return Cesium.Color.clone(Cesium.Color.WHITE, result)
+ const { storeys, wallM } = storeysAndWall(feature)
+ const red = 255 - (classify(feature) * PACK_HASH_STATES + hashOf(feature))
+ const green = 255 - clampCode(storeys)
+ const blue = 255 - clampCode(wallM / PACK_HEIGHT_STEP_M)
+ return Cesium.Color.fromBytes(red, green, blue, 255, result)
+ }
+}
+
+/**
+ * The style for the OSM Buildings tileset. It replaces Cesium's default style, whose
+ * `cesium#color` tint the facade palette supersedes.
+ *
+ * @returns A new style packing class, storeys, wall height and hash into each feature's colour
+ */
+export function createBuildingStyle(): Cesium.Cesium3DTileStyle {
+ const style = new Cesium.Cesium3DTileStyle()
+ style.color = new BuildingColour()
+ return style
+}
+
+/**
+ * Sets the building style on a tileset and keeps the packed colour out of what it draws: the MIX
+ * colour blend at amount 0 leaves the source colour (Cesium clamps the amount to 1e-4), so the
+ * CPU styling stage multiplies the lit result, specular and image-based light included, by one.
+ *
+ * @param tileset - The OSM Buildings tileset the facade shader draws
+ */
+export function applyBuildingStyle(tileset: Cesium.Cesium3DTileset): void {
+ tileset.style = createBuildingStyle()
+ tileset.colorBlendMode = Cesium.Cesium3DTileColorBlendMode.MIX
+ tileset.colorBlendAmount = 0
+}
+
+/**
+ * Decodes a packed colour, as the facade shader does.
+ *
+ * @param rgb - The feature colour in 8-bit channels
+ * @returns The class, storeys (0 unknown), wall height in metres (0 unknown) and hash
+ */
+export function decodePackedColour(rgb: readonly [number, number, number]): PackedBuilding {
+ const [red, green, blue] = rgb.map((channel) => 255 - channel)
+ return {
+ facadeClass: Math.floor(red / PACK_HASH_STATES),
+ levels: green,
+ heightM: blue * PACK_HEIGHT_STEP_M,
+ hash: red % PACK_HASH_STATES,
+ }
+}
diff --git a/src/renderer/utils/facadeShader.test.ts b/src/renderer/utils/facadeShader.test.ts
index b0b03aa..e01b94f 100644
--- a/src/renderer/utils/facadeShader.test.ts
+++ b/src/renderer/utils/facadeShader.test.ts
@@ -1,6 +1,26 @@
import * as Cesium from 'cesium'
import { describe, expect, it } from 'vitest'
-import { createFacadeShader, meanGlassLinear, windowCoverage } from './facadeShader'
+import {
+ createBuildingStyle,
+ decodePackedColour,
+ FacadeClass,
+ type FacadeClassCode,
+ PACK_HASH_STATES,
+} from '@/utils/buildingStyle'
+import {
+ createFacadeShader,
+ FACADE_GRAMMARS,
+ FACADE_PALETTES,
+ grammarCoverage,
+ meanGlassLinear,
+ openingMeanLinear,
+ RIB_CREST_SHARE,
+ ribCrest,
+ SEAM_FILTER_MIN,
+ seamShade,
+ wallColourSrgb,
+ windowCoverage,
+} from '@/utils/facadeShader'
/** sRGB to linear light by the IEC 61966-2-1 transfer curve. */
function iecSrgbToLinear(channel: number): number {
@@ -34,14 +54,124 @@ describe('meanGlassLinear', () => {
})
})
+describe('facade grammars', () => {
+ it('keeps the generic class on the window grid every building had before types', () => {
+ const generic = FACADE_GRAMMARS[FacadeClass.GENERIC]
+
+ expect(generic.bayM).toBe(2.6)
+ expect(generic.floorM).toBe(3)
+ expect(grammarCoverage(FacadeClass.GENERIC)).toBeCloseTo(0.3, 10)
+ expect(openingMeanLinear(FacadeClass.GENERIC)).toEqual(meanGlassLinear())
+ })
+
+ it('gives offices and terminals continuous bands, more glass than the generic grid', () => {
+ expect(grammarCoverage(FacadeClass.OFFICE)).toBeGreaterThan(0.5)
+ expect(FACADE_GRAMMARS[FacadeClass.TERMINAL]).toEqual(FACADE_GRAMMARS[FacadeClass.OFFICE])
+ expect(openingMeanLinear(FacadeClass.OFFICE)).toEqual(meanGlassLinear())
+ })
+
+ it('gives houses small punched windows, less glass than the generic grid', () => {
+ expect(grammarCoverage(FacadeClass.HOUSE)).toBeLessThan(grammarCoverage(FacadeClass.GENERIC))
+ expect(grammarCoverage(FacadeClass.HOUSE)).toBeGreaterThan(0.1)
+ })
+
+ it('gives garages open slab bands: dark gaps, no glass', () => {
+ const garage = FACADE_GRAMMARS[FacadeClass.GARAGE]
+ const gapMean = openingMeanLinear(FacadeClass.GARAGE)
+
+ expect(garage.opening).toBe('gap')
+ expect(grammarCoverage(FacadeClass.GARAGE)).toBeGreaterThan(0.5)
+ expect(Math.max(...gapMean)).toBeLessThan(Math.min(...meanGlassLinear()))
+ })
+
+ it('gives hangars door panels with no openings', () => {
+ expect(FACADE_GRAMMARS[FacadeClass.HANGAR].opening).toBe('none')
+ expect(FACADE_GRAMMARS[FacadeClass.HANGAR].relief).toBe('panels')
+ expect(grammarCoverage(FacadeClass.HANGAR)).toBe(0)
+ expect(FACADE_GRAMMARS[FacadeClass.HANGAR].bayM).toBeGreaterThan(8)
+ })
+
+ it('gives domestic garages and carports a plain wall: no openings, no relief', () => {
+ expect(FACADE_GRAMMARS[FacadeClass.PLAIN].opening).toBe('none')
+ expect(FACADE_GRAMMARS[FacadeClass.PLAIN].relief).toBe('flat')
+ expect(grammarCoverage(FacadeClass.PLAIN)).toBe(0)
+ })
+
+ it('draws slab lines on every grammar with openings', () => {
+ for (const code of [FacadeClass.GENERIC, FacadeClass.GARAGE, FacadeClass.HOUSE, FacadeClass.OFFICE]) {
+ expect(FACADE_GRAMMARS[code].relief).toBe('slabs')
+ }
+ })
+})
+
+/** The wall colour the shader picks for a building with these tags, through the packed style colour. */
+function wallFor(tags: Record): readonly number[] {
+ const read = (name: string) => tags[name]
+ const feature = { getPropertyInherited: read, getProperty: read } as unknown as Cesium.Cesium3DTileFeature
+ const style = createBuildingStyle()
+ const colour = (style.color as Cesium.StyleExpression).evaluateColor(feature, new Cesium.Color())
+ const rgb = [colour.red, colour.green, colour.blue].map((c) => Math.round(c * 255)) as [number, number, number]
+ const { facadeClass, hash } = decodePackedColour(rgb)
+ return wallColourSrgb(facadeClass as FacadeClassCode, hash)
+}
+
+describe('wall palette', () => {
+ it('has a curated entry per hash state for every class', () => {
+ for (const code of Object.values(FacadeClass)) {
+ expect(FACADE_PALETTES[code]).toHaveLength(PACK_HASH_STATES)
+ for (const entry of FACADE_PALETTES[code]) {
+ for (const channel of entry) {
+ expect(channel).toBeGreaterThan(0.3)
+ expect(channel).toBeLessThanOrEqual(1)
+ }
+ }
+ }
+ })
+
+ it('always picks the same entry for the same position', () => {
+ const at = { building: 'house', 'cesium#latitude': 37.72118, 'cesium#longitude': -122.22054 }
+ const first = wallFor(at)
+
+ expect(wallFor({ ...at, 'building:levels': '2' })).toEqual(first)
+ expect(wallFor({ ...at })).toEqual(first)
+ })
+
+ it('spreads neighbouring buildings across the palette', () => {
+ const seen = new Map()
+ for (let i = 0; i < 8; i++) {
+ for (let j = 0; j < 8; j++) {
+ const tags = {
+ building: 'house',
+ 'cesium#latitude': 37.7212 + i * 1e-4,
+ 'cesium#longitude': -122.2205 + j * 1e-4,
+ }
+ const key = wallFor(tags).join(',')
+ seen.set(key, (seen.get(key) ?? 0) + 1)
+ }
+ }
+
+ expect(seen.size).toBe(PACK_HASH_STATES)
+ for (const count of seen.values()) {
+ expect(count).toBeGreaterThanOrEqual(8)
+ }
+ })
+
+ it('picks from the building class palette', () => {
+ const at = { 'cesium#latitude': 37.61568, 'cesium#longitude': -122.38905 }
+
+ expect(FACADE_PALETTES[FacadeClass.HANGAR]).toContainEqual(wallFor({ ...at, building: 'hangar' }))
+ expect(FACADE_PALETTES[FacadeClass.HOUSE]).toContainEqual(wallFor({ ...at, building: 'house' }))
+ })
+})
+
describe('createFacadeShader', () => {
- it('starts with the fade off and the default fade range and window coverage', () => {
+ it('starts with the fade off and the default fade range', () => {
const { uniforms } = createFacadeShader()
expect(uniforms.u_fade.value).toBe(0)
expect(uniforms.u_fadeStart.value).toBe(0.25)
expect(uniforms.u_fadeEnd.value).toBe(0.6)
- expect(uniforms.u_cover.value).toBeCloseTo(0.3, 10)
+ expect(uniforms.u_cover).toBeUndefined()
expect(uniforms.u_fade.type).toBe(Cesium.UniformType.FLOAT)
expect(uniforms.u_glassMean.type).toBe(Cesium.UniformType.VEC3)
})
@@ -55,4 +185,75 @@ describe('createFacadeShader', () => {
expect(body.indexOf('if (detail')).toBeGreaterThan(firstDerivative)
expect(body.indexOf('if (')).toBeGreaterThan(firstDerivative)
})
+
+ it('reads the packed style colour only where the tileset carries feature ids', () => {
+ const text = createFacadeShader().fragmentShaderText ?? ''
+ const body = text.slice(text.indexOf('void fragmentMain('))
+ const guard = body.indexOf('#ifdef HAS_SELECTED_FEATURE_ID')
+ const read = body.indexOf('selectedFeatureIdStage(')
+
+ expect(guard).toBeGreaterThan(-1)
+ expect(read).toBeGreaterThan(guard)
+ expect(body.indexOf('#endif', guard)).toBeGreaterThan(read)
+ })
+
+ // applyBuildingStyle sets the MIX blend at amount 0, so Cesium's CPU styling multiplies the lit
+ // result (diffuse, specular and image-based light alike) by one: nothing is left to divide out.
+ it('leaves the material as lit, with no division by the style colour', () => {
+ const text = createFacadeShader().fragmentShaderText ?? ''
+
+ expect(text).not.toContain('facadeDivideStyle')
+ expect(text).not.toContain('czm_gammaCorrect(styled')
+ })
+
+ it('carries the wall palettes and the garage gap as linear constants, converted on the CPU', () => {
+ const text = createFacadeShader().fragmentShaderText ?? ''
+ const linearEntry = FACADE_PALETTES[FacadeClass.HOUSE][1].map(iecSrgbToLinear)
+
+ expect(text).not.toContain('facadeSrgbToLinear')
+ expect(text).not.toContain('pow(')
+ expect(text).toContain(`vec3(${linearEntry.join(', ')})`)
+ expect(text).toContain(`vec3(${openingMeanLinear(FacadeClass.GARAGE).join(', ')})`)
+ })
+
+ it('box-filters the hangar ribs over the pixel footprint, with no hard wrap', () => {
+ const text = createFacadeShader().fragmentShaderText ?? ''
+
+ expect(text).toContain('facadeRibCrest(ribCoord, ribAa)')
+ expect(text).not.toContain('fract(ribCoord)')
+ })
+
+ it('box-filters the hangar panel seam over the pixel footprint, with no one-sided wrap', () => {
+ const text = createFacadeShader().fragmentShaderText ?? ''
+ const definition = text.indexOf('float facadeSeam(float x, float footprint)')
+ const call = text.indexOf('facadeSeam(f.x, aa.x)')
+
+ expect(definition).toBeGreaterThan(-1)
+ expect(call).toBeGreaterThan(definition)
+ expect(text).not.toContain('0.01 + aa.x')
+ expect(text).toContain('facadeSeamArea(1.0)')
+ })
+})
+
+describe('facadeRibCrest', () => {
+ it('is continuous across the rib wrap and tends to its mean as the footprint grows', () => {
+ const near = [0.999, 1.0, 1.001].map((x) => ribCrest(x, 0.01))
+
+ expect(Math.abs(near[1] - near[0])).toBeLessThan(0.2)
+ expect(Math.abs(near[2] - near[1])).toBeLessThan(0.2)
+ expect(ribCrest(0.3, 4)).toBeCloseTo(RIB_CREST_SHARE, 6)
+ expect(ribCrest(0.7, 1e-6)).toBeCloseTo(1, 9)
+ expect(ribCrest(0.2, 1e-6)).toBeCloseTo(0, 9)
+ })
+})
+
+describe('seamShade', () => {
+ it('keeps the seam at its full depth at the smallest footprint', () => {
+ let darkest = Number.POSITIVE_INFINITY
+ for (let step = 0; step <= 100; step++) {
+ darkest = Math.min(darkest, seamShade((step / 100) * SEAM_FILTER_MIN, SEAM_FILTER_MIN))
+ }
+
+ expect(darkest).toBeLessThan(0.75)
+ })
})
diff --git a/src/renderer/utils/facadeShader.ts b/src/renderer/utils/facadeShader.ts
index 4c5c872..844cbbd 100644
--- a/src/renderer/utils/facadeShader.ts
+++ b/src/renderer/utils/facadeShader.ts
@@ -1,11 +1,16 @@
/**
* Procedural facade shader for the Cesium OSM Buildings tileset.
*
- * Walls get a grid of windows laid out in model space, one window per storey and bay. Where a
- * grid cell shrinks under a pixel, the grid fades to a flat tint, the area-weighted mean of
- * wall and glass in linear light, so distant buildings neither shimmer nor show moiré. At night
- * a share of the windows glow, and the flat tint glows by the same share. Roofs are left as
- * the tiles draw them.
+ * Each building's class, storeys and height arrive packed in its style colour (see
+ * `buildingStyle.ts`) and pick a facade grammar: a grid of openings laid out in model space, one
+ * per storey and bay. Offices and terminals get continuous window bands, houses small punched
+ * windows, parking garages open slab bands with dark gaps, hangars door panels with corrugated
+ * ribs, domestic garages and carports a plain wall, and every other building the generic window
+ * grid. Walls take a colour from a curated palette per
+ * class, picked by the building's position hash, so neighbours differ. Where a grid cell shrinks under a pixel, the grid
+ * can fade to a flat tint, the area-weighted mean of wall and opening in linear light. At night a
+ * share of the windows glow, and the flat tint glows by the same share. Roofs are left as the
+ * tiles draw them.
*/
import * as Cesium from 'cesium'
@@ -18,6 +23,13 @@ import {
FACADE_WINDOW_HI,
FACADE_WINDOW_LO,
} from '@/constants'
+import {
+ FacadeClass,
+ type FacadeClassCode,
+ PACK_CODE_MAX,
+ PACK_HASH_STATES,
+ PACK_HEIGHT_STEP_M,
+} from '@/utils/buildingStyle'
type Vec2 = readonly [number, number]
type Vec3 = readonly [number, number, number]
@@ -28,9 +40,236 @@ const GLASS_LIGHT_SRGB: Vec3 = [0.22, 0.27, 0.32]
const GLASS_ROUGHNESS = 0.12
+/** The shadowed inside of a garage storey, seen through its open slab band, in sRGB. */
+const GAP_SRGB: Vec3 = [0.035, 0.035, 0.04]
+
+const GAP_ROUGHNESS = 0.95
+
/** Lit-window emissive colour, already scaled by its intensity. */
const LIT_WINDOW_EMISSIVE: Vec3 = [1.4, 1.4 * 0.78, 1.4 * 0.48]
+/** Hangar cladding ribs per metre, and the wall shade at the trough and crest of a rib. */
+const RIBS_PER_M = 1.2
+const RIB_SHADE_LOW = 0.92
+const RIB_SHADE_HIGH = 1.0
+
+/** Share of each rib period on the crest; the crest runs from `1 - share` to the period's end. */
+export const RIB_CREST_SHARE = 0.55
+
+/** Narrowest box the rib profile is filtered over, in rib periods, against float noise up close. */
+const RIB_FILTER_MIN = 0.01
+
+/** Mean of the rib shade over one rib. */
+const RIB_SHADE_MEAN = RIB_SHADE_LOW + (RIB_SHADE_HIGH - RIB_SHADE_LOW) * RIB_CREST_SHARE
+
+/** Hangar door-panel seam: its shade at the seam and its width in cells. */
+const SEAM_SHADE = 0.3
+const SEAM_WIDTH = 0.01
+
+/** Narrowest box the seam profile is filtered over: a tenth of its width, above float noise. */
+export const SEAM_FILTER_MIN = SEAM_WIDTH / 10
+
+/**
+ * The rib crest (0 trough, 1 crest) box-filtered over a footprint, as the shader's
+ * `facadeRibCrest` computes it: the integral of the square crest profile over the box, divided by
+ * its width, so the edges and the wrap between periods blend over the pixel instead of aliasing.
+ *
+ * @param x - Position in rib periods
+ * @param footprint - Box width in rib periods (the pixel footprint)
+ * @returns The crest share of the box, 0 to 1
+ */
+export function ribCrest(x: number, footprint: number): number {
+ const width = Math.max(footprint, RIB_FILTER_MIN)
+ const troughEnd = 1 - RIB_CREST_SHARE
+ const x0 = x - width / 2
+ const x1 = x + width / 2
+ const base = Math.floor(x0)
+ const whole = (Math.floor(x1) - base) * RIB_CREST_SHARE
+ const rise = whole + Math.max(x1 - Math.floor(x1) - troughEnd, 0) - Math.max(x0 - base - troughEnd, 0)
+ return rise / width
+}
+
+/**
+ * The hangar door-panel seam's shade (1 away from the seam) box-filtered over a footprint, as the
+ * shader's `facadeSeam` computes it: the profile's integral over the box, divided by its width,
+ * with the seam repeating at the start of every cell.
+ *
+ * @param x - Position in cells
+ * @param footprint - Box width in cells (the pixel footprint)
+ * @returns The mean seam shade over the box, 0.7 at the seam centre when unfiltered
+ */
+export function seamShade(x: number, footprint: number): number {
+ const width = Math.max(footprint, SEAM_FILTER_MIN)
+ const x0 = x - width / 2
+ const x1 = x + width / 2
+ const base = Math.floor(x0)
+ const area = (Math.floor(x1) - base) * seamArea(1) + seamArea(x1 - Math.floor(x1)) - seamArea(x0 - base)
+ return area / width
+}
+
+/** The seam profile's integral over `[0, t]` within one cell. */
+function seamArea(t: number): number {
+ const ramp = Math.min(Math.max(t, 0), SEAM_WIDTH)
+ const u = ramp / SEAM_WIDTH
+ const smoothed = SEAM_WIDTH * (u * u * u - 0.5 * u * u * u * u)
+ return (1 - SEAM_SHADE) * ramp + SEAM_SHADE * smoothed + Math.max(t - SEAM_WIDTH, 0)
+}
+
+/** Bounds on a storey height derived from height and storeys, against bad OSM tags. */
+const STOREY_MIN_M = 2.4
+const STOREY_MAX_M = 6.0
+
+/** What fills the opening of a facade grammar's grid cell. */
+export type FacadeOpening = 'glass' | 'gap' | 'none'
+
+/**
+ * The wall's relief: slab lines at each storey, those plus the panel seams and corrugated ribs of
+ * a hangar, or none.
+ */
+export type FacadeRelief = 'slabs' | 'panels' | 'flat'
+
+/** One facade grammar: its grid cell, the opening in it and the wall's relief. */
+export interface FacadeGrammar {
+ /** Horizontal spacing of the openings */
+ bayM: number
+ /** Storey height when the building's storeys and height are not both known */
+ floorM: number
+ /** Whether a known storey count and height set the storey height */
+ storeysFromLevels: boolean
+ opening: FacadeOpening
+ /** Lower-left corner of the opening in cell space */
+ lo: Vec2
+ /** Upper-right corner of the opening in cell space */
+ hi: Vec2
+ relief: FacadeRelief
+}
+
+const OFFICE_GRAMMAR: FacadeGrammar = {
+ bayM: 1.6,
+ floorM: 3.6,
+ storeysFromLevels: true,
+ opening: 'glass',
+ lo: [0.04, 0.28],
+ hi: [0.96, 0.9],
+ relief: 'slabs',
+}
+
+/** The facade grammar per class. Terminals take the office grammar. */
+export const FACADE_GRAMMARS: Readonly> = {
+ [FacadeClass.GENERIC]: {
+ bayM: FACADE_BAY_WIDTH_M,
+ floorM: FACADE_FLOOR_HEIGHT_M,
+ storeysFromLevels: true,
+ opening: 'glass',
+ lo: FACADE_WINDOW_LO,
+ hi: FACADE_WINDOW_HI,
+ relief: 'slabs',
+ },
+ [FacadeClass.GARAGE]: {
+ bayM: 8.0,
+ floorM: 3.0,
+ storeysFromLevels: true,
+ opening: 'gap',
+ lo: [0.03, 0.3],
+ hi: [0.97, 0.92],
+ relief: 'slabs',
+ },
+ [FacadeClass.HANGAR]: {
+ bayM: 12.0,
+ floorM: 12.0,
+ storeysFromLevels: false,
+ opening: 'none',
+ lo: [0, 0],
+ hi: [0, 0],
+ relief: 'panels',
+ },
+ [FacadeClass.HOUSE]: {
+ bayM: 3.4,
+ floorM: 2.8,
+ storeysFromLevels: true,
+ opening: 'glass',
+ lo: [0.3, 0.35],
+ hi: [0.7, 0.8],
+ relief: 'slabs',
+ },
+ [FacadeClass.OFFICE]: OFFICE_GRAMMAR,
+ [FacadeClass.TERMINAL]: OFFICE_GRAMMAR,
+ [FacadeClass.PLAIN]: {
+ bayM: 3.0,
+ floorM: 3.0,
+ storeysFromLevels: false,
+ opening: 'none',
+ lo: [0, 0],
+ hi: [0, 0],
+ relief: 'flat',
+ },
+}
+
+/**
+ * Wall colours per class in sRGB, one per hash state, so neighbouring buildings of one class
+ * differ in tone. The tiles' own base colour is white, so these are the walls' colours.
+ */
+export const FACADE_PALETTES: Readonly> = {
+ [FacadeClass.GENERIC]: [
+ [0.82, 0.8, 0.76],
+ [0.74, 0.75, 0.76],
+ [0.86, 0.84, 0.8],
+ [0.78, 0.72, 0.64],
+ ],
+ [FacadeClass.GARAGE]: [
+ [0.72, 0.72, 0.7],
+ [0.66, 0.66, 0.65],
+ [0.78, 0.77, 0.74],
+ [0.7, 0.68, 0.64],
+ ],
+ [FacadeClass.HANGAR]: [
+ [0.84, 0.85, 0.86],
+ [0.76, 0.78, 0.8],
+ [0.88, 0.87, 0.83],
+ [0.7, 0.73, 0.74],
+ ],
+ [FacadeClass.HOUSE]: [
+ [0.9, 0.88, 0.84],
+ [0.66, 0.44, 0.36],
+ [0.8, 0.76, 0.66],
+ [0.7, 0.7, 0.7],
+ ],
+ [FacadeClass.OFFICE]: [
+ [0.8, 0.8, 0.78],
+ [0.68, 0.72, 0.76],
+ [0.86, 0.84, 0.8],
+ [0.72, 0.7, 0.66],
+ ],
+ [FacadeClass.TERMINAL]: [
+ [0.88, 0.89, 0.9],
+ [0.8, 0.82, 0.84],
+ [0.9, 0.88, 0.84],
+ [0.76, 0.78, 0.8],
+ ],
+ [FacadeClass.PLAIN]: [
+ [0.84, 0.82, 0.78],
+ [0.72, 0.7, 0.66],
+ [0.78, 0.74, 0.68],
+ [0.66, 0.65, 0.63],
+ ],
+}
+
+/**
+ * The wall colour a building gets: its class palette's entry for its hash.
+ *
+ * @param facadeClass - The building's facade class
+ * @param hash - The building's packed hash, 0 to `PACK_HASH_STATES - 1`
+ * @returns The wall colour in sRGB
+ */
+export function wallColourSrgb(facadeClass: FacadeClassCode, hash: number): Vec3 {
+ const palette = FACADE_PALETTES[facadeClass]
+ return palette[hash % palette.length]
+}
+
+const CLASS_CODES = Object.values(FacadeClass)
+
+const OPENING_KIND: Record = { glass: 0, gap: 1, none: 2 }
+
/**
* Share of a grid cell the window rectangle covers.
*
@@ -42,11 +281,26 @@ export function windowCoverage(lo: Vec2, hi: Vec2): number {
return (hi[0] - lo[0]) * (hi[1] - lo[1])
}
+/**
+ * Share of a class's grid cell its opening covers; 0 for a grammar without openings.
+ *
+ * @param facadeClass - The building's facade class
+ * @returns The opening's area as a fraction of the unit cell
+ */
+export function grammarCoverage(facadeClass: FacadeClassCode): number {
+ const grammar = FACADE_GRAMMARS[facadeClass]
+ return grammar.opening === 'none' ? 0 : windowCoverage(grammar.lo, grammar.hi)
+}
+
/** sRGB to linear light by the IEC 61966-2-1 transfer curve. */
function srgbChannelToLinear(channel: number): number {
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
}
+function srgbToLinear(colour: Vec3): [number, number, number] {
+ return [srgbChannelToLinear(colour[0]), srgbChannelToLinear(colour[1]), srgbChannelToLinear(colour[2])]
+}
+
/**
* Mean glass colour in linear light: the mean of the two glass end colours, each converted
* from sRGB by the IEC 61966-2-1 curve.
@@ -58,19 +312,84 @@ export function meanGlassLinear(): [number, number, number] {
return [mean(0), mean(1), mean(2)]
}
+/**
+ * Mean colour of a class's opening in linear light, which the far tint mixes with the wall by
+ * the opening's coverage: the dark gap for garages, the mean glass otherwise (a grammar without
+ * openings covers nothing, so its mean never shows).
+ *
+ * @param facadeClass - The building's facade class
+ * @returns Linear RGB of the opening
+ */
+export function openingMeanLinear(facadeClass: FacadeClassCode): [number, number, number] {
+ return FACADE_GRAMMARS[facadeClass].opening === 'gap' ? srgbToLinear(GAP_SRGB) : meanGlassLinear()
+}
+
/** A number as a GLSL float literal (GLSL ES rejects `3` where a float is expected). */
function glslFloat(value: number): string {
return Number.isInteger(value) ? `${value}.0` : `${value}`
}
-function glslVec2(value: Vec2): string {
- return `vec2(${value.map(glslFloat).join(', ')})`
-}
-
function glslVec3(value: Vec3): string {
return `vec3(${value.map(glslFloat).join(', ')})`
}
+function glslVec4(value: readonly [number, number, number, number]): string {
+ return `vec4(${value.map(glslFloat).join(', ')})`
+}
+
+/**
+ * A GLSL function of the class code returning one value per class, as a chain of selects, so
+ * calling it adds no branch before the derivatives are taken.
+ */
+function glslPerClass(
+ name: string,
+ type: string,
+ params: string,
+ value: (facadeClass: FacadeClassCode) => string,
+): string {
+ const chain = CLASS_CODES.filter((code) => code !== FacadeClass.GENERIC).reduceRight(
+ (otherwise, code) => `facadeClass == ${glslFloat(code)} ? ${value(code)} : (${otherwise})`,
+ value(FacadeClass.GENERIC),
+ )
+ return `${type} ${name}(${params}) {\n return ${chain};\n}`
+}
+
+/** Selects a class's palette entry by `hash`, mirroring `wallColourSrgb`, in linear light. */
+function glslPaletteEntry(facadeClass: FacadeClassCode): string {
+ const palette = FACADE_PALETTES[facadeClass].map(srgbToLinear)
+ return palette
+ .slice(1)
+ .reduceRight(
+ (otherwise, entry, i) => `hash == ${glslFloat(i + 1)} ? ${glslVec3(entry)} : (${otherwise})`,
+ glslVec3(palette[0]),
+ )
+}
+
+const WALL_GLSL = glslPerClass('facadeWallLinear', 'vec3', 'float facadeClass, float hash', glslPaletteEntry)
+
+/** Per relief: (slab lines on, panel seams and ribs on). */
+const RELIEF_VALUES: Record = { slabs: [1, 0], panels: [1, 1], flat: [0, 0] }
+
+const RELIEF_GLSL = glslPerClass('facadeRelief', 'vec2', 'float facadeClass', (code) => {
+ const [slabs, panels] = RELIEF_VALUES[FACADE_GRAMMARS[code].relief]
+ return `vec2(${glslFloat(slabs)}, ${glslFloat(panels)})`
+})
+
+const CELL_GLSL = glslPerClass('facadeCell', 'vec4', 'float facadeClass', (code) => {
+ const grammar = FACADE_GRAMMARS[code]
+ return glslVec4([grammar.bayM, grammar.floorM, OPENING_KIND[grammar.opening], grammar.storeysFromLevels ? 1 : 0])
+})
+
+const OPENING_RECT_GLSL = glslPerClass('facadeOpeningRect', 'vec4', 'float facadeClass', (code) => {
+ const { lo, hi } = FACADE_GRAMMARS[code]
+ return glslVec4([lo[0], lo[1], hi[0], hi[1]])
+})
+
+const COVER_GLSL = glslPerClass('facadeCover', 'float', 'float facadeClass', (code) => glslFloat(grammarCoverage(code)))
+
+/** Packed heights at or above this are saturated and say nothing about the storey height. */
+const SATURATED_HEIGHT_M = PACK_CODE_MAX * PACK_HEIGHT_STEP_M
+
const FACADE_FRAGMENT_SHADER = /* glsl */ `
float facadeHash12(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
@@ -86,15 +405,63 @@ float facadeBox(vec2 f, vec2 lo, vec2 hi, vec2 aa) {
vec2 a = smoothstep(lo - aa, lo + aa, f) * (1.0 - smoothstep(hi - aa, hi + aa, f));
return a.x * a.y;
}
-// The IEC 61966-2-1 curve, so the near glass and the far mean (computed on the CPU) agree;
-// czm_srgbToLinear is a plain 2.2 power.
-vec3 facadeSrgbToLinear(vec3 c) {
- vec3 low = c / 12.92;
- vec3 high = pow((c + 0.055) / 1.055, vec3(2.4));
- return mix(low, high, step(vec3(0.04045), c));
+// The rib crest box-filtered over the footprint; mirrors ribCrest() in facadeShader.ts.
+float facadeRibCrest(float x, float footprint) {
+ float width = max(footprint, ${glslFloat(RIB_FILTER_MIN)});
+ float troughEnd = ${glslFloat(1 - RIB_CREST_SHARE)};
+ float x0 = x - 0.5 * width;
+ float x1 = x + 0.5 * width;
+ float base = floor(x0);
+ float whole = (floor(x1) - base) * ${glslFloat(RIB_CREST_SHARE)};
+ float rise = whole + max(x1 - floor(x1) - troughEnd, 0.0) - max(x0 - base - troughEnd, 0.0);
+ return rise / width;
+}
+// The hangar door-panel seam's shade (1 away from the seam), box-filtered over the pixel footprint
+// like facadeRibCrest: the profile's integral over the box, divided by its width, so the wrap
+// between cells blends instead of stepping. facadeSeamArea integrates the profile over one cell,
+// which repeats with the seam at its start.
+float facadeSeamArea(float t) {
+ float w = ${glslFloat(SEAM_WIDTH)};
+ float u = clamp(t, 0.0, w) / w;
+ float smoothed = w * (u * u * u - 0.5 * u * u * u * u);
+ float ramp = ${glslFloat(1 - SEAM_SHADE)} * clamp(t, 0.0, w) + ${glslFloat(SEAM_SHADE)} * smoothed;
+ return ramp + max(t - w, 0.0);
}
+float facadeSeam(float x, float footprint) {
+ float width = max(footprint, ${glslFloat(SEAM_FILTER_MIN)});
+ float x0 = x - 0.5 * width;
+ float x1 = x + 0.5 * width;
+ float base = floor(x0);
+ float area = (floor(x1) - base) * facadeSeamArea(1.0)
+ + facadeSeamArea(x1 - floor(x1)) - facadeSeamArea(x0 - base);
+ return area / width;
+}
+// The packed style colour (see buildingStyle.ts) as (class, storeys, wall height in metres, hash).
+vec4 facadeDecode(vec3 styleRgb) {
+ vec3 code = floor((1.0 - styleRgb) * 255.0 + 0.5);
+ float facadeClass = floor(code.r / ${glslFloat(PACK_HASH_STATES)});
+ float hash = code.r - facadeClass * ${glslFloat(PACK_HASH_STATES)};
+ return vec4(facadeClass, code.g, code.b * ${glslFloat(PACK_HEIGHT_STEP_M)}, hash);
+}
+// (bay width, default storey height, opening kind: 0 glass 1 gap 2 none, storeys from levels)
+${CELL_GLSL}
+${OPENING_RECT_GLSL}
+${COVER_GLSL}
+${RELIEF_GLSL}
+${WALL_GLSL}
void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
+ vec4 building = vec4(0.0);
+ #ifdef HAS_SELECTED_FEATURE_ID
+ SelectedFeature styled;
+ selectedFeatureIdStage(styled, fsInput.featureIds);
+ building = facadeDecode(styled.color.rgb);
+ #endif
+ vec4 grammar = facadeCell(building.x);
+ float levelsKnown = step(0.5, building.y) * step(0.5, building.z) * step(building.z, ${glslFloat(SATURATED_HEIGHT_M - 1)}) * grammar.w;
+ float levelStorey = clamp(building.z / max(building.y, 1.0), ${glslFloat(STOREY_MIN_M)}, ${glslFloat(STOREY_MAX_M)});
+ float storey = mix(grammar.y, levelStorey, levelsKnown);
+
vec3 positionMC = fsInput.attributes.positionMC;
vec3 upWC = normalize(fsInput.attributes.positionWC);
vec3 normalWC = normalize(czm_inverseViewRotation * normalize(fsInput.attributes.normalEC));
@@ -108,24 +475,37 @@ void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
vec3 tangentMC = across / max(length(across), 1e-4);
float s = dot(positionMC, tangentMC);
float h = dot(positionMC, upMC);
- vec2 cell = vec2(s / ${glslFloat(FACADE_BAY_WIDTH_M)}, h / ${glslFloat(FACADE_FLOOR_HEIGHT_M)});
+ vec2 cell = vec2(s / grammar.x, h / storey);
vec2 aa = max(fwidth(cell), vec2(1e-4));
+ float ribCoord = s * ${glslFloat(RIBS_PER_M)};
+ float ribAa = max(fwidth(ribCoord), 1e-4);
float fadeDetail = 1.0 - smoothstep(u_fadeStart, u_fadeEnd, max(aa.x, aa.y));
float detail = mix(1.0, fadeDetail, u_fade);
+ float ribDetail = 1.0 - smoothstep(0.25, 0.6, ribAa);
if (dot(normalWC, upWC) > 0.6) {
return;
}
- vec3 wall = material.diffuse;
+ float glassy = 1.0 - step(0.5, grammar.z);
+ float gappy = step(0.5, grammar.z) * (1.0 - step(1.5, grammar.z));
+ float hasOpening = 1.0 - step(1.5, grammar.z);
+ vec2 relief = facadeRelief(building.x);
+ float panels = relief.y;
+ float cover = facadeCover(building.x);
+ vec3 gap = ${glslVec3(srgbToLinear(GAP_SRGB))};
+ float openingRoughness = mix(${glslFloat(GLASS_ROUGHNESS)}, ${glslFloat(GAP_ROUGHNESS)}, gappy);
+
+ vec3 wall = material.diffuse * facadeWallLinear(building.x, building.w);
float wallRoughness = material.roughness;
float night = facadeNight(fsInput.attributes.positionWC);
vec3 litEmissive = ${glslVec3(LIT_WINDOW_EMISSIVE)} * night;
- float litShare = ${glslFloat(FACADE_LIT_SHARE)};
+ float litShare = ${glslFloat(FACADE_LIT_SHARE)} * glassy;
- vec3 farColour = mix(wall, u_glassMean, u_cover);
- float farRoughness = mix(wallRoughness, ${glslFloat(GLASS_ROUGHNESS)}, u_cover);
- vec3 farEmissive = litEmissive * u_cover * litShare;
+ vec3 farWall = wall * mix(1.0, ${glslFloat(RIB_SHADE_MEAN)}, panels);
+ vec3 farColour = mix(farWall, mix(u_glassMean, gap, gappy), cover);
+ float farRoughness = mix(wallRoughness, openingRoughness, cover);
+ vec3 farEmissive = litEmissive * cover * litShare;
if (detail <= 0.001) {
material.diffuse = farColour;
material.roughness = farRoughness;
@@ -133,16 +513,21 @@ void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
return;
}
+ vec4 rect = facadeOpeningRect(building.x);
vec2 id = floor(cell);
vec2 f = fract(cell);
- float slab = 1.0 - 0.12 * detail * (1.0 - smoothstep(0.0, 0.06 + aa.y, f.y));
- float win = facadeBox(f, ${glslVec2(FACADE_WINDOW_LO)}, ${glslVec2(FACADE_WINDOW_HI)}, aa);
- vec3 glass = facadeSrgbToLinear(mix(${glslVec3(GLASS_DARK_SRGB)}, ${glslVec3(GLASS_LIGHT_SRGB)}, facadeHash12(id)));
- float lit = step(1.0 - litShare, facadeHash12(id * 1.37));
+ float slab = 1.0 - 0.12 * relief.x * detail * (1.0 - smoothstep(0.0, 0.06 + aa.y, f.y));
+ float seam = mix(1.0, facadeSeam(f.x, aa.x), panels * detail);
+ float rib = mix(${glslFloat(RIB_SHADE_LOW)}, ${glslFloat(RIB_SHADE_HIGH)}, facadeRibCrest(ribCoord, ribAa));
+ float ribShade = mix(${glslFloat(RIB_SHADE_MEAN)}, rib, ribDetail);
+ float shade = slab * seam * mix(1.0, ribShade, panels);
+ float opening = facadeBox(f, rect.xy, rect.zw, aa) * hasOpening;
+ vec3 glass = mix(${glslVec3(srgbToLinear(GLASS_DARK_SRGB))}, ${glslVec3(srgbToLinear(GLASS_LIGHT_SRGB))}, facadeHash12(id));
+ float lit = step(1.0 - litShare, facadeHash12(id * 1.37)) * glassy;
- vec3 nearColour = mix(wall * slab, glass, win);
- float nearRoughness = mix(wallRoughness, ${glslFloat(GLASS_ROUGHNESS)}, win);
- vec3 nearEmissive = litEmissive * win * lit;
+ vec3 nearColour = mix(wall * shade, mix(glass, gap, gappy), opening);
+ float nearRoughness = mix(wallRoughness, openingRoughness, opening);
+ vec3 nearEmissive = litEmissive * opening * lit;
material.diffuse = mix(farColour, nearColour, detail);
material.roughness = mix(farRoughness, nearRoughness, detail);
@@ -151,12 +536,12 @@ void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
`
/**
- * The facade shader for an OSM Buildings tileset.
+ * The facade shader for an OSM Buildings tileset whose style is `createBuildingStyle()`.
*
* Its uniforms can be changed live with `tileset.customShader.setUniform(name, value)`:
- * `u_fade` (0, the default, keeps the full window grid at any range; 1 fades it), `u_fadeStart` and
- * `u_fadeEnd` (the cell footprint range of the fade, in cells per pixel), `u_cover` (window
- * share of a cell) and `u_glassMean` (linear mean glass colour).
+ * `u_fade` (0, the default, keeps the full grid at any range; 1 fades it), `u_fadeStart` and
+ * `u_fadeEnd` (the cell footprint range of the fade, in cells per pixel) and `u_glassMean`
+ * (linear mean glass colour).
*
* @returns A new `CustomShader` in `MODIFY_MATERIAL` mode
*/
@@ -168,7 +553,6 @@ export function createFacadeShader(): Cesium.CustomShader {
u_fade: { type: Cesium.UniformType.FLOAT, value: 0 },
u_fadeStart: { type: Cesium.UniformType.FLOAT, value: FACADE_FADE_START },
u_fadeEnd: { type: Cesium.UniformType.FLOAT, value: FACADE_FADE_END },
- u_cover: { type: Cesium.UniformType.FLOAT, value: windowCoverage(FACADE_WINDOW_LO, FACADE_WINDOW_HI) },
u_glassMean: {
type: Cesium.UniformType.VEC3,
value: new Cesium.Cartesian3(glassMean[0], glassMean[1], glassMean[2]),