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 tower at noonKSFO tower at night +
KSFO garage
+ KSFO garage at noonKSFO garage at night +
KOAK tower
+ KOAK tower at noonKOAK tower at night +
KOAK hangar
+ KOAK hangar at noonKOAK hangar at night +
+ + 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]),