diff --git a/apps/geoportal/public/data/wuppertal-mesh-2024.three-tiles.style.json b/apps/geoportal/public/data/wuppertal-mesh-2024.three-tiles.style.json new file mode 100644 index 0000000000..6f7a444582 --- /dev/null +++ b/apps/geoportal/public/data/wuppertal-mesh-2024.three-tiles.style.json @@ -0,0 +1,28 @@ +{ + "version": 8, + "name": "Wuppertal Mesh 2024", + "sources": {}, + "layers": [], + "metadata": { + "carmaConf": { + "instant": true, + "layerInfo": { + "title": "Wuppertal Mesh 2024", + "description": "3D-Tiles-Mesh mit Clay-Material für die gemeinsame Three.js-Szene", + "keywords": [] + }, + "threeTiles": { + "url": "https://wupp-3d-data.cismet.de/mesh2024/tileset.json", + "origin": [7.15, 51.256], + "shader": { + "kind": "clay", + "color": "#d8d1c4", + "roughness": 0.92, + "metalness": 0 + }, + "errorTarget": 8, + "requestConcurrency": 2 + } + } + } +} diff --git a/apps/geoportal/src/app/App.tsx b/apps/geoportal/src/app/App.tsx index 5fd38f2339..3f538fb4ce 100644 --- a/apps/geoportal/src/app/App.tsx +++ b/apps/geoportal/src/app/App.tsx @@ -66,6 +66,7 @@ import { useManageLayers } from "./hooks/useManageLayers"; import { useSyncToken } from "./hooks/useSyncToken"; import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; import { useMeasurementLayerButton } from "./hooks/useMeasurementLayerButton"; +import { useShadowSimulationLayerButton } from "./hooks/useShadowSimulationLayerButton"; import { useGeoportalAppSearchParams } from "./hooks/use-geoportal-app-search-params"; import { useAdhocFeatureRehydrate } from "./hooks/use-adhoc-feature-rehydrate"; @@ -174,6 +175,11 @@ function MeasurementLayerSyncInner() { return null; } +function ShadowSimulationLayerSyncInner() { + useShadowSimulationLayerButton(); + return null; +} + function GeoportalAppSearchParamsIntegration() { useGeoportalAppSearchParams(); return null; @@ -276,6 +282,7 @@ function App({ > +
diff --git a/apps/geoportal/src/app/components/GeoportalMap/geoportalBackgroundToLibreLayers.spec.ts b/apps/geoportal/src/app/components/GeoportalMap/geoportalBackgroundToLibreLayers.spec.ts new file mode 100644 index 0000000000..db34d75107 --- /dev/null +++ b/apps/geoportal/src/app/components/GeoportalMap/geoportalBackgroundToLibreLayers.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import type { BackgroundLayer } from "@carma-mapping/layers"; + +import { geoportalBackgroundToLibreLayers } from "./geoportalBackgroundToLibreLayers"; + +const background = { + id: "karte", + title: "Stadtplan", + visible: true, + opacity: 0.8, + layers: "rvrGrundriss@100|amtlich@90|rvrSchriftNT@100", +} as BackgroundLayer; + +describe("Geoportal shaded terrain background composition", () => { + it("replaces raster-labelled bases with a separable vector basemap", () => { + const layers = geoportalBackgroundToLibreLayers( + background, + { + amtlich: { + type: "tiles", + url: "https://example.test/city-map/{z}/{x}/{y}.png", + }, + rvrGrundriss: { + type: "wmts", + url: "https://example.test/opaque-ground-plan", + layers: "ground-plan", + }, + rvrSchriftNT: { + type: "wmts-nt", + url: "https://example.test/labels", + layers: "labels", + transparent: true, + }, + basemap_relief: { + type: "vector", + style: "https://example.test/vector-basemap.json", + }, + }, + { shadowTerrainActive: true } + ); + + expect(layers).toHaveLength(1); + expect( + layers.some( + (layer) => "layers" in layer && layer.layers === "ground-plan" + ) + ).toBe(false); + expect(layers).toEqual([ + expect.objectContaining({ + type: "vector", + name: "bg-basemap_relief", + style: "https://example.test/vector-basemap.json", + opacity: 0.8, + }), + ]); + }); + + it("keeps the authored background unchanged without shaded terrain", () => { + const layers = geoportalBackgroundToLibreLayers(background, { + amtlich: { + type: "tiles", + url: "https://example.test/city-map/{z}/{x}/{y}.png", + }, + rvrGrundriss: { + type: "wmts", + url: "https://example.test/opaque-ground-plan", + layers: "ground-plan", + }, + rvrSchriftNT: { + type: "wmts-nt", + url: "https://example.test/labels", + layers: "labels", + transparent: true, + }, + basemap_relief: { + type: "vector", + style: "https://example.test/vector-basemap.json", + }, + }); + + expect(layers).toHaveLength(3); + expect( + layers.some( + (layer) => "layers" in layer && layer.layers === "ground-plan" + ) + ).toBe(true); + }); +}); diff --git a/apps/geoportal/src/app/components/GeoportalMap/geoportalBackgroundToLibreLayers.ts b/apps/geoportal/src/app/components/GeoportalMap/geoportalBackgroundToLibreLayers.ts index 02086d37ad..e8fb3e5ba3 100644 --- a/apps/geoportal/src/app/components/GeoportalMap/geoportalBackgroundToLibreLayers.ts +++ b/apps/geoportal/src/app/components/GeoportalMap/geoportalBackgroundToLibreLayers.ts @@ -13,6 +13,23 @@ type NamedLayerConfig = { maxNativeZoom?: number; }; +type GeoportalBackgroundLibreOptions = { + terrainMeshActive?: boolean; + shadowTerrainActive?: boolean; +}; + +// Raster bases bake place names into their ground pixels. Shaded terrain uses +// the existing vector basemap instead so its ground can be projected while +// point-based place names remain a separate symbol pass above Three. +const TERRAIN_MESH_OVERLAY_LAYERS = "basemap_relief@100"; +const TERRAIN_MESH_REPLACED_LAYER_NAMES = new Set([ + "amtlich", + "amtlichBasiskarte", + "rvrGrundriss", + "rvrSchriftNT", + "basemap_relief", +]); + const isTransparent = (value: unknown): boolean => { if (typeof value === "boolean") return value; if (typeof value === "string") return value.toLowerCase() === "true"; @@ -21,7 +38,8 @@ const isTransparent = (value: unknown): boolean => { export const geoportalBackgroundToLibreLayers = ( backgroundLayer: BackgroundLayer | null | undefined, - extraNamedLayers?: Record + extraNamedLayers?: Record, + options: GeoportalBackgroundLibreOptions = {} ): LibreLayer[] => { if (!backgroundLayer || !backgroundLayer.visible) { return []; @@ -34,11 +52,24 @@ export const geoportalBackgroundToLibreLayers = ( ...extraNamedLayers, }; const layerOpacity = backgroundLayer.opacity ?? 1; + const separateLocationLabels = + options.terrainMeshActive === true || options.shadowTerrainActive === true; // All named layers of a background spec belong to the single background // button, so they share one id and their loading states aggregate. const carmaLayerId = backgroundLayer.id; - for (const spec of backgroundLayer.layers.split("|")) { + const originalLayerSpecs = backgroundLayer.layers + .split("|") + .filter( + (spec) => + !separateLocationLabels || + !TERRAIN_MESH_REPLACED_LAYER_NAMES.has(spec.split("@")[0]) + ); + const layerSpecs = separateLocationLabels + ? [...originalLayerSpecs, ...TERRAIN_MESH_OVERLAY_LAYERS.split("|")] + : originalLayerSpecs; + + for (const spec of layerSpecs) { const [name, opacityStr] = spec.split("@"); const cfg = namedLayers[name]; if (!cfg) { diff --git a/apps/geoportal/src/app/components/GeoportalMap/geoportalLayersToLibreLayers.spec.ts b/apps/geoportal/src/app/components/GeoportalMap/geoportalLayersToLibreLayers.spec.ts new file mode 100644 index 0000000000..f714e11ba2 --- /dev/null +++ b/apps/geoportal/src/app/components/GeoportalMap/geoportalLayersToLibreLayers.spec.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; + +import type { Layer } from "@carma-mapping/layers"; + +import { + geoportalLayersToLibreLayers, + layerProvidesTerrainMesh, + parseThreeTilesLayer, +} from "./geoportalLayersToLibreLayers"; + +const buildLayer = (threeTiles: unknown): Layer => + ({ + id: "custom:mesh-2024", + title: "Wuppertal Mesh 2024", + visible: true, + opacity: 0.75, + layerType: "vector", + conf: { threeTiles }, + props: { style: { version: 8, sources: {}, layers: [] } }, + } as unknown as Layer); + +describe("Geoportal 3D Tiles layer conversion", () => { + it("maps a declared clay shader into the shared Three.js layer contract", () => { + const layer = buildLayer({ + url: "https://example.test/tileset.json", + origin: [7.15, 51.256], + shader: { + kind: "clay", + color: "#d8d1c4", + roughness: 0.92, + metalness: 0, + }, + errorTarget: 8, + requestConcurrency: 2, + }); + + expect(geoportalLayersToLibreLayers([layer])).toEqual([ + { + type: "three-tiles", + name: "Wuppertal Mesh 2024", + carmaLayerId: "custom:mesh-2024", + url: "https://example.test/tileset.json", + origin: [7.15, 51.256], + shader: { + kind: "clay", + color: "#d8d1c4", + roughness: 0.92, + metalness: 0, + }, + errorTarget: 8, + requestConcurrency: 2, + opacity: 0.75, + }, + ]); + }); + + it("rejects unknown shader contracts", () => { + expect( + parseThreeTilesLayer( + buildLayer({ + url: "https://example.test/tileset.json", + shader: { kind: "custom-glsl", color: "#fff" }, + }) + ) + ).toBeNull(); + }); + + it("recognizes the remote photogrammetry mesh before its style is fetched", () => { + const layer = { + id: "mesh-2024", + title: "3D-MeshX 2024", + visible: true, + layerType: "vector", + props: { + style: "https://tiles.cismet.de/lod2/mesh2024.style.json", + }, + } as Layer; + + expect(layerProvidesTerrainMesh(layer)).toBe(true); + expect(layerProvidesTerrainMesh({ ...layer, visible: false })).toBe(false); + }); + + it("recognizes inline mesh metadata and direct terrain mesh declarations", () => { + const taggedLayer = { + id: "inline-mesh", + visible: true, + props: { + style: { + version: 8, + metadata: { + carmaConf: { layerInfo: { tags: ["Basis", "Mesh"] } }, + }, + sources: {}, + layers: [], + }, + }, + } as unknown as Layer; + const directLayer = { + id: "direct-mesh", + visible: true, + conf: { threeTiles: { providesTerrain: true } }, + } as unknown as Layer; + + expect(layerProvidesTerrainMesh(taggedLayer)).toBe(true); + expect(layerProvidesTerrainMesh(directLayer)).toBe(true); + }); +}); diff --git a/apps/geoportal/src/app/components/GeoportalMap/geoportalLayersToLibreLayers.ts b/apps/geoportal/src/app/components/GeoportalMap/geoportalLayersToLibreLayers.ts index 01fef59816..eeab304f6f 100644 --- a/apps/geoportal/src/app/components/GeoportalMap/geoportalLayersToLibreLayers.ts +++ b/apps/geoportal/src/app/components/GeoportalMap/geoportalLayersToLibreLayers.ts @@ -2,11 +2,111 @@ import type { StyleSpecification } from "maplibre-gl"; import type { DynamicStylingOptionsConfig, Layer } from "@carma-mapping/layers"; import type { LibreLayer } from "@carma-mapping/core"; +import { + THREE_TILES_LAYER_TYPE, + THREE_TILES_SHADER_KIND, +} from "@carma-mapping/engines/maplibre"; import { applyDynamicStylingToStylesheet, buildFilterExpression, } from "@carma-mapping/components"; +type ThreeTilesLibreLayer = Extract; + +const isFiniteNumber = (value: unknown): value is number => + typeof value === "number" && Number.isFinite(value); + +const hasMeshTag = (value: unknown): boolean => + Array.isArray(value) && + value.some((tag) => typeof tag === "string" && tag.toLowerCase() === "mesh"); + +const styleProvidesTerrainMesh = (value: unknown): boolean => { + if (!value || typeof value !== "object") return false; + const style = value as { + metadata?: { carmaConf?: { layerInfo?: { tags?: unknown } } }; + layers?: Array<{ + metadata?: { + carmaConf?: { "3d"?: { providesTerrain?: unknown } }; + }; + }>; + }; + return ( + hasMeshTag(style.metadata?.carmaConf?.layerInfo?.tags) || + style.layers?.some( + (styleLayer) => + styleLayer.metadata?.carmaConf?.["3d"]?.providesTerrain === true + ) === true + ); +}; + +/** Detect a terrain-providing mesh before or after its style was fetched. */ +export const layerProvidesTerrainMesh = (layer: Layer): boolean => { + if (!layer.visible) return false; + const directConfig = (layer.conf as Record | undefined) + ?.threeTiles as Record | undefined; + if (directConfig?.providesTerrain === true) return true; + + const style = (layer.props as { style?: unknown } | undefined)?.style; + if (styleProvidesTerrainMesh(style)) return true; + if (typeof style !== "string") return false; + + const stylePath = style.split(/[?#]/, 1)[0].toLowerCase(); + return /\/mesh[^/]*\.style\.json$/.test(stylePath); +}; + +export const parseThreeTilesLayer = ( + layer: Layer +): ThreeTilesLibreLayer | null => { + const candidate = (layer.conf as Record | undefined) + ?.threeTiles; + if (!candidate || typeof candidate !== "object") return null; + + const config = candidate as Record; + const shader = config.shader; + if (typeof config.url !== "string" || !shader || typeof shader !== "object") { + return null; + } + + const shaderConfig = shader as Record; + if ( + shaderConfig.kind !== THREE_TILES_SHADER_KIND.CLAY || + typeof shaderConfig.color !== "string" + ) { + return null; + } + + const origin = config.origin; + const validOrigin = + Array.isArray(origin) && origin.length === 2 && origin.every(isFiniteNumber) + ? ([origin[0], origin[1]] as [number, number]) + : undefined; + + return { + type: THREE_TILES_LAYER_TYPE, + name: layer.title || layer.id, + carmaLayerId: layer.id, + url: config.url, + shader: { + kind: THREE_TILES_SHADER_KIND.CLAY, + color: shaderConfig.color, + ...(isFiniteNumber(shaderConfig.roughness) + ? { roughness: shaderConfig.roughness } + : {}), + ...(isFiniteNumber(shaderConfig.metalness) + ? { metalness: shaderConfig.metalness } + : {}), + }, + ...(validOrigin ? { origin: validOrigin } : {}), + ...(isFiniteNumber(config.errorTarget) + ? { errorTarget: config.errorTarget } + : {}), + ...(isFiniteNumber(config.requestConcurrency) + ? { requestConcurrency: config.requestConcurrency } + : {}), + opacity: layer.opacity ?? 1, + }; +}; + const collectDynamicStylingConfigs = ( layer: Layer ): DynamicStylingOptionsConfig[] => { @@ -66,6 +166,11 @@ export const geoportalLayersToLibreLayers = (layers: Layer[]): LibreLayer[] => { if (!layer.visible) { continue; } + const threeTilesLayer = parseThreeTilesLayer(layer); + if (threeTilesLayer) { + result.push(threeTilesLayer); + continue; + } if (!layer.props) { continue; } diff --git a/apps/geoportal/src/app/components/layers/GeoportalLayerButton.tsx b/apps/geoportal/src/app/components/layers/GeoportalLayerButton.tsx index d45f64f867..046e971885 100644 --- a/apps/geoportal/src/app/components/layers/GeoportalLayerButton.tsx +++ b/apps/geoportal/src/app/components/layers/GeoportalLayerButton.tsx @@ -194,9 +194,9 @@ const GeoportalLayerButton = ({ useEffect(() => { if (!inView && selectedLayerIndex === index) { - document.getElementById(`layer-${id}`).scrollIntoView(); + document.getElementById(`layer-${id}`)?.scrollIntoView(); } - }, [inView, selectedLayerIndex]); + }, [id, inView, index, selectedLayerIndex]); useEffect(() => { if (index === layersLength - 1 && inView) { @@ -308,8 +308,8 @@ const GeoportalLayerButton = ({ userSelect: "none", touchAction: "none", }} - {...listeners} - {...attributes} + {...(isPinned ? {} : listeners)} + {...(isPinned ? {} : attributes)} classNames={[ getGeoportalLayerButtonBackgroundClassName({ showsNoSelection, diff --git a/apps/geoportal/src/app/components/layers/GeoportalLayerButtonSlot.tsx b/apps/geoportal/src/app/components/layers/GeoportalLayerButtonSlot.tsx index 5e7c434bc5..3ca6e98f8c 100644 --- a/apps/geoportal/src/app/components/layers/GeoportalLayerButtonSlot.tsx +++ b/apps/geoportal/src/app/components/layers/GeoportalLayerButtonSlot.tsx @@ -9,6 +9,8 @@ import { useDispatch, useSelector } from "react-redux"; import { faFloppyDisk, + faPause, + faPlay, faTimes, faTrashCan, } from "@fortawesome/free-solid-svg-icons"; @@ -25,6 +27,7 @@ import { useAnnotationsRuntime, } from "@carma-mapping/annotations/runtime"; import { useMeasurements } from "@carma-mapping/measurements"; +import { useAddonState } from "@carma-mapping/addons"; import { useLibreMapEnabled } from "../../hooks/useLibreMapEnabled"; import { geoportalAnnotationModeText } from "../../config/geoportalTextConfig"; @@ -32,9 +35,11 @@ import { geoportalAnnotationModeText } from "../../config/geoportalTextConfig"; import { getActiveInteractionButtonID, getActiveInteractionLayerID, + getSelectedLayerIndex, removeLayer, setActiveInteractionButtonID, setActiveInteractionLayerID, + setSelectedLayerIndexNoSelection, } from "../../store/slices/mapping"; import type { AppDispatch } from "../../store"; import { @@ -43,6 +48,8 @@ import { } from "../annotations/cesium-annotations.constants"; import { MeasurementDeleteConfirmationModal } from "../annotations/MeasurementDeleteConfirmationModal"; import { MEASUREMENT_LAYER_ID } from "../../hooks/useMeasurementLayerButton"; +import { SHADOW_SIMULATION_LAYER_ID } from "../../hooks/useShadowSimulationLayerButton"; +import { formatShadowSelection } from "@carma-mapping/shadow-simulation"; import { AdhocModelFlyToLayerbarAction, AdhocModelLayerbarActions, @@ -358,7 +365,6 @@ const CesiumAnnotationLayerButton = (props: GeoportalLayerButtonProps) => { actionSlot={} closeButton={{ icon: faTimes, onClick: handleClose }} closeButtonVariant="compact" - interactionActivationMode="button" overflowVisible /> ); @@ -388,6 +394,74 @@ const MeasurementLayerButton = (props: GeoportalLayerButtonProps) => { ); }; +const ShadowSimulationLayerButton = (props: GeoportalLayerButtonProps) => { + const dispatch = useDispatch(); + const [shadowState, setShadowState] = useAddonState("shadowSimulation"); + const [shadowDate] = useAddonState("shadowDate"); + const selectedLayerIndex = useSelector(getSelectedLayerIndex); + const infoViewOpen = selectedLayerIndex === props.index; + + const handleClose = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation(); + if (shadowState) { + setShadowState({ + ...shadowState, + enabled: false, + isAnimating: false, + }); + } + if (infoViewOpen) { + dispatch(setSelectedLayerIndexNoSelection()); + } + dispatch(removeLayer(SHADOW_SIMULATION_LAYER_ID)); + }, + [dispatch, infoViewOpen, setShadowState, shadowState] + ); + + return ( + + + + {formatShadowSelection(shadowDate)} + + + ), + active: shadowState.isAnimating ?? false, + onClick: () => + setShadowState({ + ...shadowState, + isAnimating: !shadowState.isAnimating, + }), + }, + ]} + /> +
+ ) : null + } + closeButton={{ icon: faTimes, onClick: handleClose }} + closeButtonVariant="compact" + overflowVisible + /> + ); +}; + const SavedCesiumMeasurementLayerButton = ( props: GeoportalLayerButtonProps & { annotationsGeoJson: AnnotationsRuntimeGeoJsonFeatureCollection; @@ -424,6 +498,10 @@ const GeoportalLayerButtonSlot = (props: GeoportalLayerButtonProps) => { return ; } + if (props.id === SHADOW_SIMULATION_LAYER_ID) { + return ; + } + const isAdhocModelLayer = props.layer.type === "object" && !!props.layer.props?.style; const layerServiceName = diff --git a/apps/geoportal/src/app/components/layers/SecondaryView.tsx b/apps/geoportal/src/app/components/layers/SecondaryView.tsx index 4a44fdf9e3..01b9c49ccf 100644 --- a/apps/geoportal/src/app/components/layers/SecondaryView.tsx +++ b/apps/geoportal/src/app/components/layers/SecondaryView.tsx @@ -17,6 +17,12 @@ import { TopicMapContext } from "react-cismap/contexts/TopicMapContextProvider"; import { useDispatch, useSelector } from "react-redux"; import { SELECTED_LAYER_INDEX } from "@carma-appframeworks/portals"; import { cn } from "@carma-commons/utils"; +import { + resolveSecondaryViewTargetAddon, + ShadowSimulationHeaderControls, + TargetAddonHost, + useAddonState, +} from "@carma-mapping/addons"; import { changeBackgroundVisibility, @@ -62,6 +68,7 @@ import DynamicStylingLayerIcon from "./DynamicStylingLayerIcon"; import { hasLayerFilterControl } from "./LayerFilterControl"; import { InteractionContent } from "./InteractionView"; import { DEFAULT_LAYER_VISIBILITY_TOGGLE_LABELS } from "./layer-visibility-toggle-props"; +import { SHADOW_SIMULATION_LAYER_ID } from "../../hooks/useShadowSimulationLayerButton"; type Ref = HTMLDivElement; @@ -79,6 +86,7 @@ const SecondaryView = forwardRef(({}, _ref) => { const selectedEntry = useSelector(getSelectedStackEntry); const backgroundLayer = useSelector(getBackgroundLayer); const { favorites, addFavorite, removeFavorite } = useLayerCatalog(); + const [shadowState, setShadowState] = useAddonState("shadowSimulation"); const activeInteractionLayerID = useSelector(getActiveInteractionLayerID); const entry = (selectedLayerIndex >= 0 ? selectedEntry : backgroundLayer) ?? @@ -113,6 +121,13 @@ const SecondaryView = forwardRef(({}, _ref) => { ? "gärten" : undefined; const isBaseLayer = selectedLayerIndex === -1; + const secondaryViewAddon = + !isBaseLayer && !group + ? resolveSecondaryViewTargetAddon(layer as Layer) + : undefined; + const isShadowSimulationLayer = + entry.id === SHADOW_SIMULATION_LAYER_ID && + secondaryViewAddon?.kind === "shadowSimulation"; const isInteractionActive = activeInteractionLayerID === entry.id; const canFilter = @@ -120,7 +135,9 @@ const SecondaryView = forwardRef(({}, _ref) => { const filterInfo = (layer as Layer)?.filterInfo; const canFavorite = - !isBaseLayer && (entry.type === "layer" || entry.type === "object"); + !isBaseLayer && + !secondaryViewAddon && + (entry.type === "layer" || entry.type === "object"); const isFavorite = canFavorite && favorites.some( @@ -199,7 +216,13 @@ const SecondaryView = forwardRef(({}, _ref) => { }; const handleOutsideClick = (event: PointerEvent) => { - if ((event.target as Element)?.closest?.(".ant-dropdown")) { + // antd portals (dropdowns, date pickers) live on document.body but + // belong to controls inside this view - clicking them must not close it. + if ( + (event.target as Element)?.closest?.( + ".ant-dropdown, .ant-picker-dropdown" + ) + ) { return; } let newLayerIndex = -2; @@ -321,7 +344,9 @@ const SecondaryView = forwardRef(({}, _ref) => { "min-w-[280px] sm:max-w-[560px] md:max-w-[720px] lg:w-full w-[100vw] sm:w-3/4 sm:mx-0 shrink-0", "h-fit bg-white button-shadow rounded-[10px] flex flex-col relative secondary-view gap-2 py-2 transition-all duration-300", showInfo - ? "sm:max-h-[600px] sm:h-[70vh] h-[80vh]" + ? secondaryViewAddon + ? "max-h-[min(600px,80vh)]" + : "sm:max-h-[600px] sm:h-[70vh] h-[80vh]" : isBaseLayer ? "h-fit" : "h-fit sm:h-12" @@ -349,8 +374,18 @@ const SecondaryView = forwardRef(({}, _ref) => { > -
-
+
+
{group ? ( (({}, _ref) => { ) : ( (({}, _ref) => { {isBaseLayer ? "Hintergrund" : entry.title}
-
- -
- + +
+ +
+
+ )} + {isShadowSimulationLayer && ( +
+
-
+ )} {canFilter && (
-
- -
- + {!secondaryViewAddon && ( +
+ +
+ +
+ + {Math.round((1 - (entry.opacity ?? 1)) * 100)}% +
- - {Math.round((1 - (entry.opacity ?? 1)) * 100)}% - -
+ )} {isInteractionActive && !group && (
@@ -504,6 +557,15 @@ const SecondaryView = forwardRef(({}, _ref) => {
)} + {showInfo && secondaryViewAddon && !group && ( +
+ +
+ )} + {isBaseLayer && (
@@ -513,10 +575,11 @@ const SecondaryView = forwardRef(({}, _ref) => {
)} - {showInfoText && ( + {showInfoText && !secondaryViewAddon && (
)} {showInfoText && + !secondaryViewAddon && (isBaseLayer ? ( ) : group ? ( diff --git a/apps/geoportal/src/app/components/layers/items.tsx b/apps/geoportal/src/app/components/layers/items.tsx index 81d878bbb2..53fece4896 100644 --- a/apps/geoportal/src/app/components/layers/items.tsx +++ b/apps/geoportal/src/app/components/layers/items.tsx @@ -3,6 +3,7 @@ import { faGlobe, faLayerGroup, faSquare, + faSun, } from "@fortawesome/free-solid-svg-icons"; import { Layer } from "@carma-mapping/layers"; @@ -81,10 +82,12 @@ export const iconMap = { gärten: faSquare, ortho: faGlobe, background: faLayerGroup, + "shadow-simulation": faSun, }; export const iconColorMap = { bäume: "green", gärten: "purple", ortho: "black", + "shadow-simulation": "#d97706", }; diff --git a/apps/geoportal/src/app/config/app.config.ts b/apps/geoportal/src/app/config/app.config.ts index 815b1cff34..20c493ceb5 100644 --- a/apps/geoportal/src/app/config/app.config.ts +++ b/apps/geoportal/src/app/config/app.config.ts @@ -243,4 +243,5 @@ export const LEAFLET_CONFIG: LeafletConfig = { export const URL_PARAM_KEYS = { mapStyle: "m", measurements: "mm", + shadowSimulation: "shadow", } as const; diff --git a/apps/geoportal/src/app/constants/fachzwillinge/addons.ts b/apps/geoportal/src/app/constants/fachzwillinge/addons.ts index 3f8e21ae77..d8ead7f2bb 100644 --- a/apps/geoportal/src/app/constants/fachzwillinge/addons.ts +++ b/apps/geoportal/src/app/constants/fachzwillinge/addons.ts @@ -1,6 +1,8 @@ import type { FachzwillingRoute } from "."; import { DEFAULT_HOME_VIEW_REF } from "../../config/view.config"; +import { WUPP_TERRAIN_PROVIDER } from "@carma-commons/resources"; + export const addonsFachzwilling: FachzwillingRoute = { path: "addons", hideFromCatalog: true, @@ -30,6 +32,7 @@ export const addonsFachzwilling: FachzwillingRoute = { // circle that is dragged over it, and wheeled larger or smaller. Two panels // and no more, which the shared state holds the layout to. { kind: "compareSpyglass", config: {} }, + { kind: "cameraRestriction", config: { mode: "never" } }, { kind: "vectorHighlight", config: { @@ -59,6 +62,28 @@ export const addonsFachzwilling: FachzwillingRoute = { kind: "libreTerrain", config: { appKey: "geoportal", show: "while3dLayersActive" }, }, + { + kind: "shadowSimulation", + config: { + initialMinutes: 15 * 60, + terrain: { + url: WUPP_TERRAIN_PROVIDER.url, + errorTargetPixels: 0.5, + shadowLevelOffset: 3, + minimumLevel: 8, + maximumLevel: 18, + noDataHeightMeters: 0, + heightRangeMeters: [-100, 600], + maxSelectionTiles: 1_536, + requestConcurrency: 24, + maxCacheBytes: 268_435_456, + maxCachedMeshes: 2_048, + material: { + color: "#d3d3d3", + }, + }, + }, + }, // dev harness for highlightByIds; this route is localDev/dev/pr only { kind: "vectorHighlightDebug", diff --git a/apps/geoportal/src/app/helper/geoportal-custom-hash-state.spec.ts b/apps/geoportal/src/app/helper/geoportal-custom-hash-state.spec.ts index 7c039dc34c..cb272740e7 100644 --- a/apps/geoportal/src/app/helper/geoportal-custom-hash-state.spec.ts +++ b/apps/geoportal/src/app/helper/geoportal-custom-hash-state.spec.ts @@ -1,10 +1,17 @@ import { describe, expect, it } from "vitest"; -import { HASH_LAUNCH_MODE } from "@carma-commons/utils"; +import { + buildOrderedSearchParamsString, + getHashParams, + HASH_LAUNCH_MODE, +} from "@carma-commons/utils"; import { buildGeoportalMeasurementModeHashUpdate, + buildGeoportalShadowSimulationHashUpdate, + isGeoportalShadowSimulationHashSelectionValidForYear, resolveGeoportalCustomHashState, + resolveGeoportalShadowSimulationHashSelection, } from "./geoportal-custom-hash-state"; describe("geoportal-custom-hash-state", () => { @@ -35,11 +42,11 @@ describe("geoportal-custom-hash-state", () => { }); it("keeps explicit launch flags stronger than the measurement default", () => { - expect(resolveGeoportalCustomHashState({ mm: "1", "2d": "1" })).toMatchObject( - { - launchMode: HASH_LAUNCH_MODE.TWO_D, - } - ); + expect( + resolveGeoportalCustomHashState({ mm: "1", "2d": "1" }) + ).toMatchObject({ + launchMode: HASH_LAUNCH_MODE.TWO_D, + }); }); it("serializes the measurement hash parameter from mode state", () => { @@ -48,4 +55,70 @@ describe("geoportal-custom-hash-state", () => { mm: undefined, }); }); + + it("decodes the shadow minute and day-of-year tuple", () => { + expect( + resolveGeoportalCustomHashState({ shadow: "660;140" }) + ).toMatchObject({ + shadowSimulationSelection: { + minutes: 660, + dayOfYear: 140, + }, + }); + }); + + it("round-trips the semicolon through the shared hash encoding", () => { + const encoded = buildOrderedSearchParamsString({ shadow: "660;140" }); + + expect(encoded).toBe("shadow=660%3B140"); + expect( + resolveGeoportalCustomHashState(getHashParams(encoded)) + ).toMatchObject({ + shadowSimulationSelection: { + minutes: 660, + dayOfYear: 140, + }, + }); + }); + + it.each([ + undefined, + "", + "660", + "660;140;1", + "660.5;140", + "-1;140", + "1440;140", + "660;0", + "660;367", + " 660;140", + ])("rejects an invalid shadow tuple %s", (value) => { + expect(resolveGeoportalShadowSimulationHashSelection(value)).toBeNull(); + }); + + it("validates day 366 against the selection year", () => { + const selection = { minutes: 660, dayOfYear: 366 }; + + expect( + isGeoportalShadowSimulationHashSelectionValidForYear(selection, 2024) + ).toBe(true); + expect( + isGeoportalShadowSimulationHashSelectionValidForYear(selection, 2026) + ).toBe(false); + }); + + it("serializes enabled shadow state and removes disabled shadow state", () => { + expect( + buildGeoportalShadowSimulationHashUpdate({ + enabled: true, + dateState: { minutes: 660, dayOfYear: 140 }, + }) + ).toEqual({ shadow: "660;140" }); + expect( + buildGeoportalShadowSimulationHashUpdate({ + enabled: false, + dateState: { minutes: 660, dayOfYear: 140 }, + }) + ).toEqual({ shadow: undefined }); + }); }); diff --git a/apps/geoportal/src/app/helper/geoportal-custom-hash-state.ts b/apps/geoportal/src/app/helper/geoportal-custom-hash-state.ts index e73faa6e10..effd942388 100644 --- a/apps/geoportal/src/app/helper/geoportal-custom-hash-state.ts +++ b/apps/geoportal/src/app/helper/geoportal-custom-hash-state.ts @@ -24,17 +24,65 @@ export type GeoportalCustomHashLaunchPolicy = { export type GeoportalCustomHashState = { measurementModeRequested: boolean; + shadowSimulationSelection: GeoportalShadowSimulationHashSelection | null; launchMode: GeoportalResolvedLaunchMode; initialMapFramework: CarmaMapFramework; }; +export type GeoportalShadowSimulationHashSelection = { + minutes: number; + dayOfYear: number; +}; + +type GeoportalShadowSimulationHashSource = { + enabled: boolean; + dateState: GeoportalShadowSimulationHashSelection; +}; + +const SHADOW_SIMULATION_HASH_VALUE_PATTERN = /^(\d{1,4});(\d{1,3})$/; + +export const resolveGeoportalShadowSimulationHashSelection = ( + value: unknown +): GeoportalShadowSimulationHashSelection | null => { + if (typeof value !== "string") { + return null; + } + + const match = SHADOW_SIMULATION_HASH_VALUE_PATTERN.exec(value); + if (!match) { + return null; + } + + const minutes = Number(match[1]); + const dayOfYear = Number(match[2]); + if (minutes > 1439 || dayOfYear < 1 || dayOfYear > 366) { + return null; + } + + return { minutes, dayOfYear }; +}; + +export const isGeoportalShadowSimulationHashSelectionValidForYear = ( + selection: GeoportalShadowSimulationHashSelection, + year: number +): boolean => { + if (!Number.isInteger(year)) { + return false; + } + + const daysInYear = + new Date(Date.UTC(year, 1, 29)).getUTCMonth() === 1 ? 366 : 365; + return selection.dayOfYear <= daysInYear; +}; + const resolveGeoportalMeasurementModeRequested = ( hashParams: Record ) => isTruthyHashValue(hashParams[URL_PARAM_KEYS.measurements]); export const resolveGeoportalCustomHashState = ( - hashParams: Record = - typeof window === "undefined" ? {} : getHashParams(), + hashParams: Record = typeof window === "undefined" + ? {} + : getHashParams(), { fallbackLaunchMode = HASH_LAUNCH_MODE.TWO_D, measurementModeLaunchMode = HASH_LAUNCH_MODE.THREE_D, @@ -52,6 +100,9 @@ export const resolveGeoportalCustomHashState = ( return { measurementModeRequested, + shadowSimulationSelection: resolveGeoportalShadowSimulationHashSelection( + hashParams[URL_PARAM_KEYS.shadowSimulation] + ), launchMode, initialMapFramework: launchMode === HASH_LAUNCH_MODE.THREE_D @@ -65,3 +116,21 @@ export const buildGeoportalMeasurementModeHashUpdate = ( ): Record => ({ [URL_PARAM_KEYS.measurements]: measurementModeActive ? "1" : undefined, }); + +export const buildGeoportalShadowSimulationHashUpdate = ( + state: GeoportalShadowSimulationHashSource | undefined +): Record => { + if (!state?.enabled) { + return { [URL_PARAM_KEYS.shadowSimulation]: undefined }; + } + + const { minutes, dayOfYear } = state.dateState; + const serialized = `${minutes};${dayOfYear}`; + + return { + [URL_PARAM_KEYS.shadowSimulation]: + resolveGeoportalShadowSimulationHashSelection(serialized) !== null + ? serialized + : undefined, + }; +}; diff --git a/apps/geoportal/src/app/helper/geoportal-shadow-simulation-state.spec.ts b/apps/geoportal/src/app/helper/geoportal-shadow-simulation-state.spec.ts new file mode 100644 index 0000000000..10b8a2cef0 --- /dev/null +++ b/apps/geoportal/src/app/helper/geoportal-shadow-simulation-state.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import type { + ShadowDateState, + ShadowSimulationState, +} from "@carma-mapping/shadow-simulation"; + +import { + applyShadowHashSelection, + resolveGeoportalShadowHashSelection, + shadowStateMatchesHashSelection, +} from "./geoportal-shadow-simulation-state"; + +describe("geoportal shadow simulation state", () => { + it("matches enabled state and hash selection by value", () => { + const selection = { dayOfYear: 172, minutes: 720 }; + expect(shadowStateMatchesHashSelection(true, selection, selection)).toBe( + true + ); + expect(shadowStateMatchesHashSelection(false, selection, null)).toBe(true); + }); + + it("rejects invalid dates and clamps valid night selections", () => { + expect( + resolveGeoportalShadowHashSelection( + { dayOfYear: 366, minutes: 720 }, + 2025, + { latitude: 51.256, longitude: 7.15 }, + "Europe/Berlin" + ) + ).toBeNull(); + + expect( + resolveGeoportalShadowHashSelection( + { dayOfYear: 172, minutes: 0 }, + 2026, + { latitude: 51.256, longitude: 7.15 }, + "Europe/Berlin" + )?.minutes + ).toBeGreaterThan(0); + }); + + it("restores only the hash-owned state fields", () => { + const state = { + enabled: false, + terrainColor: "#fff", + } as ShadowSimulationState; + const dateState: ShadowDateState = { + year: 2026, + dayOfYear: 172, + minutes: 720, + timeZone: "Europe/Berlin", + }; + + expect( + applyShadowHashSelection(state, dateState, { + dayOfYear: 173, + minutes: 800, + }) + ).toEqual({ + shadowState: { ...state, enabled: true }, + dateState: { + year: 2026, + dayOfYear: 173, + minutes: 800, + timeZone: "Europe/Berlin", + }, + }); + }); +}); diff --git a/apps/geoportal/src/app/helper/geoportal-shadow-simulation-state.ts b/apps/geoportal/src/app/helper/geoportal-shadow-simulation-state.ts new file mode 100644 index 0000000000..65ea5abc34 --- /dev/null +++ b/apps/geoportal/src/app/helper/geoportal-shadow-simulation-state.ts @@ -0,0 +1,58 @@ +import type { + ShadowDateState, + ShadowSimulationState, +} from "@carma-mapping/shadow-simulation"; +import { clampShadowSimulationSelectionToDaylight } from "@carma-mapping/shadow-simulation"; + +import { + isGeoportalShadowSimulationHashSelectionValidForYear, + type GeoportalShadowSimulationHashSelection, +} from "./geoportal-custom-hash-state"; + +type ShadowSelection = Readonly<{ + minutes: number; + dayOfYear: number; +}>; + +export const shadowStateMatchesHashSelection = ( + enabled: boolean, + selection: ShadowSelection, + hashSelection: GeoportalShadowSimulationHashSelection | null +): boolean => + hashSelection === null + ? !enabled + : enabled && + selection.minutes === hashSelection.minutes && + selection.dayOfYear === hashSelection.dayOfYear; + +export const resolveGeoportalShadowHashSelection = ( + selection: GeoportalShadowSimulationHashSelection | null, + year: number | undefined, + position: { latitude?: number; longitude?: number }, + timeZone: string +): GeoportalShadowSimulationHashSelection | null => { + if (!selection || year === undefined) return selection; + if (!isGeoportalShadowSimulationHashSelectionValidForYear(selection, year)) { + return null; + } + + const daylightSelection = clampShadowSimulationSelectionToDaylight( + { ...selection, year }, + { ...position, timeZone } + ); + return daylightSelection + ? { + minutes: daylightSelection.minutes, + dayOfYear: daylightSelection.dayOfYear, + } + : null; +}; + +export const applyShadowHashSelection = ( + shadowState: ShadowSimulationState, + dateState: ShadowDateState, + selection: GeoportalShadowSimulationHashSelection | null +): { shadowState: ShadowSimulationState; dateState: ShadowDateState } => ({ + shadowState: { ...shadowState, enabled: selection !== null }, + dateState: selection ? { ...dateState, ...selection } : dateState, +}); diff --git a/apps/geoportal/src/app/helper/shadow-simulation-layer.ts b/apps/geoportal/src/app/helper/shadow-simulation-layer.ts new file mode 100644 index 0000000000..30e12885a2 --- /dev/null +++ b/apps/geoportal/src/app/helper/shadow-simulation-layer.ts @@ -0,0 +1,43 @@ +import { + applyAddonOverrides, + resolveAddonEntries, + type AddonEntry, + type AddonOverridesState, + type ResolvedAddon, +} from "@carma-mapping/addons"; +import type { Layer } from "@carma-mapping/layers"; + +export const SHADOW_SIMULATION_LAYER_ID = "__shadow_simulation__"; + +type ShadowSimulationAddon = Extract< + ResolvedAddon, + { kind: "shadowSimulation" } +>; + +export const resolveShadowSimulationAddon = ( + routeAddons: readonly AddonEntry[] | undefined, + overrides: AddonOverridesState | undefined +): ShadowSimulationAddon | null => + applyAddonOverrides(resolveAddonEntries(routeAddons), overrides).find( + (entry): entry is ShadowSimulationAddon => + entry.kind === "shadowSimulation" + ) ?? null; + +export const createShadowSimulationLayer = ( + addon: ShadowSimulationAddon | null, + visible: boolean +): Layer | null => + addon + ? { + id: SHADOW_SIMULATION_LAYER_ID, + title: "Schatten", + description: + "Sonnenstand und Schattenwurf in der gemeinsamen Three.js-Szene.", + type: "object", + icon: "shadow-simulation", + iconColor: "#d97706", + visible, + pinned: "last", + tools: [addon], + } + : null; diff --git a/apps/geoportal/src/app/hooks/libre/useLibreLayers.ts b/apps/geoportal/src/app/hooks/libre/useLibreLayers.ts index 289f67ba61..8ee2783436 100644 --- a/apps/geoportal/src/app/hooks/libre/useLibreLayers.ts +++ b/apps/geoportal/src/app/hooks/libre/useLibreLayers.ts @@ -1,24 +1,32 @@ import { useMemo, useRef } from "react"; import { useSelector } from "react-redux"; +import { useAddonState } from "@carma-mapping/addons"; import type { LibreLayer } from "@carma-mapping/core"; import { geoportalBackgroundToLibreLayers } from "../../components/GeoportalMap/geoportalBackgroundToLibreLayers"; -import { geoportalLayersToLibreLayers } from "../../components/GeoportalMap/geoportalLayersToLibreLayers"; +import { + geoportalLayersToLibreLayers, + layerProvidesTerrainMesh, +} from "../../components/GeoportalMap/geoportalLayersToLibreLayers"; import { getLayers } from "../../store/slices/mapping"; import { useRouteBackground } from "../useRouteBackground"; export const useLibreLayers = (): LibreLayer[] => { const geoportalLayers = useSelector(getLayers); const { backgroundLayer, namedLayers } = useRouteBackground(); + const [shadowState] = useAddonState("shadowSimulation"); - const computedLibreLayers = useMemo( - () => [ - ...geoportalBackgroundToLibreLayers(backgroundLayer, namedLayers), + const computedLibreLayers = useMemo(() => { + const terrainMeshActive = geoportalLayers.some(layerProvidesTerrainMesh); + return [ + ...geoportalBackgroundToLibreLayers(backgroundLayer, namedLayers, { + terrainMeshActive, + shadowTerrainActive: shadowState?.enabled === true, + }), ...geoportalLayersToLibreLayers(geoportalLayers), - ], - [backgroundLayer, namedLayers, geoportalLayers] - ); + ]; + }, [backgroundLayer, namedLayers, geoportalLayers, shadowState?.enabled]); const libreLayersRef = useRef(computedLibreLayers); return useMemo(() => { diff --git a/apps/geoportal/src/app/hooks/use-geoportal-app-search-params.ts b/apps/geoportal/src/app/hooks/use-geoportal-app-search-params.ts index da213b0c47..eff9d11782 100644 --- a/apps/geoportal/src/app/hooks/use-geoportal-app-search-params.ts +++ b/apps/geoportal/src/app/hooks/use-geoportal-app-search-params.ts @@ -6,15 +6,14 @@ import { useAppSearchParams } from "@carma-appframeworks/portals"; import { useMapFrameworkSwitcherContext } from "@carma-mapping/components"; import { useHashState } from "@carma-providers/hash-state"; -import { - buildGeoportalMeasurementModeHashUpdate, -} from "../helper/geoportal-custom-hash-state"; +import { buildGeoportalMeasurementModeHashUpdate } from "../helper/geoportal-custom-hash-state"; import { geoportalAppSearchParamsOptions, geoportalAppSearchParamsOptionsWithoutDefaultView, } from "../config/app-search-params"; import { findFachzwillingByPathname } from "../constants/fachzwillinge"; import { getUIMode, UIMode } from "../store/slices/ui"; +import { useGeoportalShadowSimulationHash } from "./use-geoportal-shadow-simulation-hash"; export const useGeoportalAppSearchParams = () => { const uiMode = useSelector(getUIMode); @@ -26,11 +25,12 @@ export const useGeoportalAppSearchParams = () => { () => findFachzwillingByPathname(pathname)?.disableHashWrite ?? false, [pathname] ); - useAppSearchParams( + const { customHashState } = useAppSearchParams( disableHashWrite ? geoportalAppSearchParamsOptionsWithoutDefaultView : geoportalAppSearchParamsOptions ); + useGeoportalShadowSimulationHash({ customHashState }); const { isCesium } = useMapFrameworkSwitcherContext(); useEffect(() => { diff --git a/apps/geoportal/src/app/hooks/use-geoportal-shadow-simulation-hash.spec.tsx b/apps/geoportal/src/app/hooks/use-geoportal-shadow-simulation-hash.spec.tsx new file mode 100644 index 0000000000..abbb3b9d0b --- /dev/null +++ b/apps/geoportal/src/app/hooks/use-geoportal-shadow-simulation-hash.spec.tsx @@ -0,0 +1,392 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { AppSearchParamsCustomStateSnapshot } from "@carma-appframeworks/portals"; +import { HASH_LAUNCH_MODE } from "@carma-commons/utils"; +import { CARMA_MAP_FRAMEWORKS } from "@carma-mapping/components"; + +import type { GeoportalCustomHashState } from "../helper/geoportal-custom-hash-state"; + +type ShadowStateFixture = { + enabled: boolean; + terrainColor: string; + buildingsFullOpacity: boolean; + buildingColorMix: number; + buildingColor: string; + shadowQuality: 4 | 16 | 64; + showSunDebugVector: boolean; +}; + +type ShadowDateFixture = { + year: number; + dayOfYear: number; + minutes: number; + timeZone: string; +}; + +const addonStateMock = vi.hoisted(() => ({ + setShadowState: vi.fn(), + shadowState: undefined as ShadowStateFixture | undefined, + setShadowDate: vi.fn(), + shadowDate: undefined as ShadowDateFixture | undefined, +})); + +const hashStateMock = vi.hoisted(() => ({ updateHashState: vi.fn() })); + +const libreContextMock = vi.hoisted(() => ({ + getCenter: vi.fn(() => ({ lat: 51.256, lng: 7.15 })), +})); + +vi.mock("@carma-mapping/addons", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useAddonState: (key: string) => + key === "shadowDate" + ? [addonStateMock.shadowDate, addonStateMock.setShadowDate] + : [addonStateMock.shadowState, addonStateMock.setShadowState], + }; +}); + +vi.mock("@carma-providers/hash-state", async (importOriginal) => { + const actual = await importOriginal< + typeof import("@carma-providers/hash-state") + >(); + return { + ...actual, + useHashState: () => ({ updateHashState: hashStateMock.updateHashState }), + }; +}); + +vi.mock("@carma-mapping/contexts", () => ({ + useLibreContext: () => ({ map: { getCenter: libreContextMock.getCenter } }), +})); + +import { clampShadowSimulationSelectionToDaylight } from "@carma-mapping/shadow-simulation"; +import { useGeoportalShadowSimulationHash } from "./use-geoportal-shadow-simulation-hash"; + +const createShadowState = ( + overrides: Partial = {} +): ShadowStateFixture => ({ + enabled: false, + terrainColor: "#d8d1c4", + buildingsFullOpacity: true, + buildingColorMix: 0, + buildingColor: "#d8d1c4", + shadowQuality: 4, + showSunDebugVector: false, + ...overrides, +}); + +const createShadowDate = ( + overrides: Partial = {} +): ShadowDateFixture => ({ + year: 2026, + dayOfYear: 172, + minutes: 900, + timeZone: "Europe/Berlin", + ...overrides, +}); + +const createCustomHashState = ({ + selection, + source = "initial", + version = 0, +}: { + selection: GeoportalCustomHashState["shadowSimulationSelection"]; + source?: AppSearchParamsCustomStateSnapshot["source"]; + version?: number; +}): AppSearchParamsCustomStateSnapshot => ({ + measurementModeRequested: false, + shadowSimulationSelection: selection, + launchMode: HASH_LAUNCH_MODE.TWO_D, + initialMapFramework: CARMA_MAP_FRAMEWORKS.LEAFLET, + source, + version, +}); + +describe("useGeoportalShadowSimulationHash", () => { + beforeEach(() => { + addonStateMock.shadowState = undefined; + addonStateMock.shadowDate = undefined; + addonStateMock.setShadowState.mockReset(); + addonStateMock.setShadowDate.mockReset(); + hashStateMock.updateHashState.mockReset(); + libreContextMock.getCenter.mockReset(); + libreContextMock.getCenter.mockReturnValue({ lat: 51.256, lng: 7.15 }); + }); + + afterEach(() => vi.useRealTimers()); + + it("waits for both state channels before restoring and writing", async () => { + const customHashState = createCustomHashState({ + selection: { minutes: 660, dayOfYear: 140 }, + }); + const { rerender } = renderHook(() => + useGeoportalShadowSimulationHash({ customHashState }) + ); + + expect(addonStateMock.setShadowState).not.toHaveBeenCalled(); + expect(addonStateMock.setShadowDate).not.toHaveBeenCalled(); + expect(hashStateMock.updateHashState).not.toHaveBeenCalled(); + + act(() => { + addonStateMock.shadowState = createShadowState(); + addonStateMock.shadowDate = createShadowDate(); + rerender(); + }); + + await waitFor(() => { + expect(addonStateMock.setShadowState).toHaveBeenCalledWith( + expect.objectContaining({ enabled: true, terrainColor: "#d8d1c4" }) + ); + expect(addonStateMock.setShadowDate).toHaveBeenCalledWith({ + year: 2026, + minutes: 660, + dayOfYear: 140, + timeZone: "Europe/Berlin", + }); + }); + expect(hashStateMock.updateHashState).not.toHaveBeenCalled(); + + act(() => { + addonStateMock.shadowState = addonStateMock.setShadowState.mock + .calls[0][0] as ShadowStateFixture; + addonStateMock.shadowDate = addonStateMock.setShadowDate.mock + .calls[0][0] as ShadowDateFixture; + rerender(); + }); + + await waitFor(() => { + expect(hashStateMock.updateHashState).toHaveBeenCalledWith( + { shadow: "660;140" }, + { label: "geoportal:sync-shadow-simulation", replace: true } + ); + }); + }); + + it("writes local date changes without replaying the stale hash", async () => { + const customHashState = createCustomHashState({ + selection: { minutes: 660, dayOfYear: 140 }, + }); + addonStateMock.shadowState = createShadowState({ enabled: true }); + addonStateMock.shadowDate = createShadowDate({ + minutes: 660, + dayOfYear: 140, + }); + const { rerender } = renderHook(() => + useGeoportalShadowSimulationHash({ customHashState }) + ); + + await waitFor(() => + expect(hashStateMock.updateHashState).toHaveBeenCalled() + ); + hashStateMock.updateHashState.mockClear(); + + act(() => { + addonStateMock.shadowDate = createShadowDate({ + minutes: 720, + dayOfYear: 141, + }); + rerender(); + }); + + await waitFor(() => { + expect(hashStateMock.updateHashState).toHaveBeenCalledWith( + { shadow: "720;141" }, + { label: "geoportal:sync-shadow-simulation", replace: true } + ); + }); + expect(addonStateMock.setShadowState).not.toHaveBeenCalled(); + expect(addonStateMock.setShadowDate).not.toHaveBeenCalled(); + }); + + it("throttles rapid date changes and writes the latest value", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-28T12:00:00Z")); + const customHashState = createCustomHashState({ + selection: { minutes: 660, dayOfYear: 140 }, + }); + addonStateMock.shadowState = createShadowState({ enabled: true }); + addonStateMock.shadowDate = createShadowDate({ + minutes: 660, + dayOfYear: 140, + }); + const { rerender } = renderHook(() => + useGeoportalShadowSimulationHash({ customHashState }) + ); + + expect(hashStateMock.updateHashState).toHaveBeenCalledTimes(1); + hashStateMock.updateHashState.mockClear(); + + act(() => { + addonStateMock.shadowDate = createShadowDate({ + minutes: 661, + dayOfYear: 140, + }); + rerender(); + addonStateMock.shadowDate = createShadowDate({ + minutes: 662, + dayOfYear: 141, + }); + rerender(); + }); + + expect(hashStateMock.updateHashState).not.toHaveBeenCalled(); + await act(async () => vi.advanceTimersByTimeAsync(500)); + expect(hashStateMock.updateHashState).toHaveBeenCalledWith( + { shadow: "662;141" }, + { label: "geoportal:sync-shadow-simulation", replace: true } + ); + }); + + it("cancels a pending local write when history navigation wins", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-28T12:00:00Z")); + let customHashState = createCustomHashState({ + selection: { minutes: 660, dayOfYear: 140 }, + }); + addonStateMock.shadowState = createShadowState({ enabled: true }); + addonStateMock.shadowDate = createShadowDate({ + minutes: 660, + dayOfYear: 140, + }); + const { rerender } = renderHook(() => + useGeoportalShadowSimulationHash({ customHashState }) + ); + + expect(hashStateMock.updateHashState).toHaveBeenCalledTimes(1); + hashStateMock.updateHashState.mockClear(); + + act(() => { + addonStateMock.shadowDate = createShadowDate({ + minutes: 720, + dayOfYear: 141, + }); + rerender(); + }); + expect(hashStateMock.updateHashState).not.toHaveBeenCalled(); + + act(() => { + customHashState = createCustomHashState({ + selection: null, + source: "popstate", + version: 1, + }); + rerender(); + }); + + await act(async () => vi.advanceTimersByTimeAsync(500)); + expect(hashStateMock.updateHashState).not.toHaveBeenCalled(); + expect(addonStateMock.setShadowState).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }) + ); + }); + + it("disables the simulation when history removes the hash state", async () => { + const customHashState = createCustomHashState({ + selection: null, + source: "popstate", + version: 1, + }); + addonStateMock.shadowState = createShadowState({ enabled: true }); + addonStateMock.shadowDate = createShadowDate({ + minutes: 660, + dayOfYear: 140, + }); + const { rerender } = renderHook(() => + useGeoportalShadowSimulationHash({ customHashState }) + ); + + await waitFor(() => { + expect(addonStateMock.setShadowState).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }) + ); + }); + expect(hashStateMock.updateHashState).not.toHaveBeenCalled(); + + act(() => { + addonStateMock.shadowState = addonStateMock.setShadowState.mock + .calls[0][0] as ShadowStateFixture; + rerender(); + }); + + await waitFor(() => { + expect(hashStateMock.updateHashState).toHaveBeenCalledWith( + { shadow: undefined }, + { label: "geoportal:sync-shadow-simulation", replace: true } + ); + }); + }); + + it("does not restore day 366 in a non-leap selection year", async () => { + const customHashState = createCustomHashState({ + selection: { minutes: 660, dayOfYear: 366 }, + }); + addonStateMock.shadowState = createShadowState(); + addonStateMock.shadowDate = createShadowDate(); + + renderHook(() => useGeoportalShadowSimulationHash({ customHashState })); + + await waitFor(() => { + expect(hashStateMock.updateHashState).toHaveBeenCalledWith( + { shadow: undefined }, + { label: "geoportal:sync-shadow-simulation", replace: true } + ); + }); + expect(addonStateMock.setShadowState).not.toHaveBeenCalled(); + }); + + it("clamps a night hash selection at the map center", async () => { + const berlinCenter = { lat: 52.52, lng: 13.405 }; + libreContextMock.getCenter.mockReturnValue(berlinCenter); + const hashSelection = { year: 2026, dayOfYear: 64, minutes: 0 }; + const expectedSelection = clampShadowSimulationSelectionToDaylight( + hashSelection, + { + latitude: berlinCenter.lat, + longitude: berlinCenter.lng, + timeZone: "Europe/Berlin", + } + ); + expect(expectedSelection).not.toBeNull(); + + const customHashState = createCustomHashState({ + selection: { + minutes: hashSelection.minutes, + dayOfYear: hashSelection.dayOfYear, + }, + }); + addonStateMock.shadowState = createShadowState(); + addonStateMock.shadowDate = createShadowDate(); + const { rerender } = renderHook(() => + useGeoportalShadowSimulationHash({ customHashState }) + ); + + await waitFor(() => { + expect(addonStateMock.setShadowState).toHaveBeenCalledWith( + expect.objectContaining({ enabled: true }) + ); + expect(addonStateMock.setShadowDate).toHaveBeenCalledWith( + expectedSelection + ); + }); + + act(() => { + addonStateMock.shadowState = addonStateMock.setShadowState.mock + .calls[0][0] as ShadowStateFixture; + addonStateMock.shadowDate = addonStateMock.setShadowDate.mock + .calls[0][0] as ShadowDateFixture; + rerender(); + }); + + await waitFor(() => { + expect(hashStateMock.updateHashState).toHaveBeenCalledWith( + { + shadow: `${expectedSelection?.minutes};${expectedSelection?.dayOfYear}`, + }, + { label: "geoportal:sync-shadow-simulation", replace: true } + ); + }); + }); +}); diff --git a/apps/geoportal/src/app/hooks/use-geoportal-shadow-simulation-hash.ts b/apps/geoportal/src/app/hooks/use-geoportal-shadow-simulation-hash.ts new file mode 100644 index 0000000000..e978c69e15 --- /dev/null +++ b/apps/geoportal/src/app/hooks/use-geoportal-shadow-simulation-hash.ts @@ -0,0 +1,217 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; + +import type { AppSearchParamsCustomStateSnapshot } from "@carma-appframeworks/portals"; +import { useAddonState } from "@carma-mapping/addons"; +import { useLibreContext } from "@carma-mapping/contexts"; +import { DEFAULT_SHADOW_SIMULATION_TIME_ZONE } from "@carma-mapping/shadow-simulation"; +import { useHashState } from "@carma-providers/hash-state"; + +import { + buildGeoportalShadowSimulationHashUpdate, + type GeoportalCustomHashState, +} from "../helper/geoportal-custom-hash-state"; +import { + applyShadowHashSelection, + resolveGeoportalShadowHashSelection, + shadowStateMatchesHashSelection, +} from "../helper/geoportal-shadow-simulation-state"; + +type UseGeoportalShadowSimulationHashOptions = { + customHashState: AppSearchParamsCustomStateSnapshot | null; +}; + +type ShadowHashUpdate = ReturnType< + typeof buildGeoportalShadowSimulationHashUpdate +>; + +const SHADOW_HASH_WRITE_INTERVAL_MS = 500; + +export const useGeoportalShadowSimulationHash = ({ + customHashState, +}: UseGeoportalShadowSimulationHashOptions) => { + const [shadowState, setShadowState] = useAddonState("shadowSimulation"); + const [shadowDate, setShadowDate] = useAddonState("shadowDate"); + const { map: libreMap } = useLibreContext(); + const { updateHashState } = useHashState(); + const handledHashStateVersionRef = useRef(null); + const pendingHashStateVersionRef = useRef(null); + const pendingHashUpdateRef = useRef(null); + const hashWriteTimerRef = useRef(null); + const lastHashWriteAtRef = useRef(null); + const updateHashStateRef = useRef(updateHashState); + updateHashStateRef.current = updateHashState; + + const flushPendingHashUpdate = useCallback(() => { + hashWriteTimerRef.current = null; + const update = pendingHashUpdateRef.current; + if (!update) { + return; + } + + pendingHashUpdateRef.current = null; + lastHashWriteAtRef.current = Date.now(); + updateHashStateRef.current(update, { + label: "geoportal:sync-shadow-simulation", + replace: true, + }); + }, []); + + const cancelPendingHashUpdate = useCallback(() => { + if (hashWriteTimerRef.current !== null) { + window.clearTimeout(hashWriteTimerRef.current); + hashWriteTimerRef.current = null; + } + pendingHashUpdateRef.current = null; + }, []); + + const scheduleHashUpdate = useCallback( + (update: ShadowHashUpdate) => { + pendingHashUpdateRef.current = update; + if (hashWriteTimerRef.current !== null) { + return; + } + + const lastWriteAt = lastHashWriteAtRef.current; + const elapsed = + lastWriteAt === null ? Infinity : Date.now() - lastWriteAt; + const delay = Math.max(0, SHADOW_HASH_WRITE_INTERVAL_MS - elapsed); + if (delay === 0) { + flushPendingHashUpdate(); + return; + } + + hashWriteTimerRef.current = window.setTimeout( + flushPendingHashUpdate, + delay + ); + }, + [flushPendingHashUpdate] + ); + + const hashStateVersion = customHashState?.version; + const decodedHashSelection = + customHashState?.shadowSimulationSelection ?? null; + const mapCenter = libreMap?.getCenter(); + const shadowYear = shadowDate?.year; + const shadowTimeZone = + shadowDate?.timeZone ?? DEFAULT_SHADOW_SIMULATION_TIME_ZONE; + const hashSelection = useMemo( + () => + resolveGeoportalShadowHashSelection( + decodedHashSelection, + shadowYear, + { latitude: mapCenter?.lat, longitude: mapCenter?.lng }, + shadowTimeZone + ), + [ + decodedHashSelection, + mapCenter?.lat, + mapCenter?.lng, + shadowTimeZone, + shadowYear, + ] + ); + + useEffect(() => { + if (!shadowState || !shadowDate) { + cancelPendingHashUpdate(); + handledHashStateVersionRef.current = null; + pendingHashStateVersionRef.current = null; + } + }, [cancelPendingHashUpdate, shadowDate, shadowState]); + + useEffect( + () => () => { + cancelPendingHashUpdate(); + }, + [cancelPendingHashUpdate] + ); + + useEffect(() => { + if ( + hashStateVersion === undefined || + !shadowState || + !shadowDate || + handledHashStateVersionRef.current === hashStateVersion + ) { + return; + } + + handledHashStateVersionRef.current = hashStateVersion; + cancelPendingHashUpdate(); + + if ( + shadowStateMatchesHashSelection( + shadowState.enabled, + shadowDate, + hashSelection + ) + ) { + pendingHashStateVersionRef.current = null; + return; + } + + pendingHashStateVersionRef.current = hashStateVersion; + const next = applyShadowHashSelection( + shadowState, + shadowDate, + hashSelection + ); + setShadowState(next.shadowState); + setShadowDate(next.dateState); + }, [ + cancelPendingHashUpdate, + hashSelection, + hashStateVersion, + setShadowState, + setShadowDate, + shadowDate, + shadowState, + ]); + + const shadowEnabled = shadowState?.enabled; + const shadowMinutes = shadowDate?.minutes; + const shadowDayOfYear = shadowDate?.dayOfYear; + + useEffect(() => { + if ( + hashStateVersion === undefined || + shadowEnabled === undefined || + shadowMinutes === undefined || + shadowDayOfYear === undefined || + handledHashStateVersionRef.current !== hashStateVersion + ) { + return; + } + + if (pendingHashStateVersionRef.current === hashStateVersion) { + if ( + !shadowStateMatchesHashSelection( + shadowEnabled, + { minutes: shadowMinutes, dayOfYear: shadowDayOfYear }, + hashSelection + ) + ) { + return; + } + pendingHashStateVersionRef.current = null; + } + + scheduleHashUpdate( + buildGeoportalShadowSimulationHashUpdate({ + enabled: shadowEnabled, + dateState: { + minutes: shadowMinutes, + dayOfYear: shadowDayOfYear, + }, + }) + ); + }, [ + hashSelection, + hashStateVersion, + scheduleHashUpdate, + shadowDayOfYear, + shadowEnabled, + shadowMinutes, + ]); +}; diff --git a/apps/geoportal/src/app/hooks/useShadowSimulationLayerButton.spec.tsx b/apps/geoportal/src/app/hooks/useShadowSimulationLayerButton.spec.tsx new file mode 100644 index 0000000000..a49c48059c --- /dev/null +++ b/apps/geoportal/src/app/hooks/useShadowSimulationLayerButton.spec.tsx @@ -0,0 +1,220 @@ +import type { PropsWithChildren } from "react"; + +import { act, renderHook, waitFor } from "@testing-library/react"; +import { configureStore } from "@reduxjs/toolkit"; +import { Provider } from "react-redux"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const addonStateMock = vi.hoisted(() => ({ + routeAddons: [] as unknown[], + overrides: undefined as + | { suspended: string[]; enabled: string[] } + | undefined, + setShadowState: vi.fn(), + shadowState: undefined as + | { enabled: boolean } + | undefined, +})); + +vi.mock("@carma-mapping/addons", () => ({ + applyAddonOverrides: ( + entries: Array<{ kind: string }>, + overrides?: { suspended: string[] } + ) => entries.filter((entry) => !overrides?.suspended.includes(entry.kind)), + resolveAddonEntries: (entries?: unknown[]) => entries ?? [], + useAddonState: (key: string) => + key === "shadowSimulation" + ? [addonStateMock.shadowState, addonStateMock.setShadowState] + : [undefined, vi.fn()], + usePersistedAddonOverrides: () => [addonStateMock.overrides, vi.fn()], + useRouteAddons: () => addonStateMock.routeAddons, +})); + +import mappingReducer from "../store/slices/mapping"; +import uiReducer from "../store/slices/ui"; +import { formatShadowSelection } from "@carma-mapping/shadow-simulation"; +import { + SHADOW_SIMULATION_LAYER_ID, + useShadowSimulationLayerButton, +} from "./useShadowSimulationLayerButton"; + +const createTestStore = () => + configureStore({ + reducer: { + mapping: mappingReducer, + ui: uiReducer, + }, + }); + +const createNonUpdatingLayerStore = (visible: boolean) => { + const mappingState = mappingReducer(undefined, { type: "test/setup" }); + const fixedMappingState = { + ...mappingState, + layers: [ + { + id: SHADOW_SIMULATION_LAYER_ID, + title: "Schatten", + type: "object" as const, + visible, + }, + ], + }; + + return configureStore({ + reducer: { + mapping: () => fixedMappingState, + ui: uiReducer, + }, + }); +}; + +type TestStore = ReturnType; + +const createWrapper = + (store: TestStore) => + ({ children }: PropsWithChildren) => + {children}; + +const findShadowLayer = (store: TestStore) => + store + .getState() + .mapping.layers.find((layer) => layer.id === SHADOW_SIMULATION_LAYER_ID); + +describe("useShadowSimulationLayerButton", () => { + it("formats the local selection for layerbar text", () => { + expect( + formatShadowSelection({ year: 2026, dayOfYear: 237, minutes: 900 }) + ).toBe("25. Aug. · 15:00"); + }); + + beforeEach(() => { + addonStateMock.overrides = undefined; + addonStateMock.setShadowState.mockReset(); + addonStateMock.routeAddons = [ + { kind: "shadowSimulation", config: { initialMinutes: 900 } }, + ]; + addonStateMock.shadowState = { + enabled: false, + }; + }); + + it("adds the top-level layer and opens its info view when enabled", async () => { + const store = createTestStore(); + const { rerender } = renderHook(() => useShadowSimulationLayerButton(), { + wrapper: createWrapper(store), + }); + + expect(findShadowLayer(store)).toBeUndefined(); + + addonStateMock.shadowState = { + ...addonStateMock.shadowState!, + enabled: true, + }; + rerender(); + + await waitFor(() => { + expect(findShadowLayer(store)).toEqual( + expect.objectContaining({ + id: SHADOW_SIMULATION_LAYER_ID, + pinned: "last", + visible: true, + tools: [ + expect.objectContaining({ + kind: "shadowSimulation", + config: { initialMinutes: 900 }, + }), + ], + }) + ); + // The entry stays selectable so the info view's arrows reach it. + expect(findShadowLayer(store)).not.toHaveProperty("skipSelection"); + const { layers, selectedLayerIndex } = store.getState().mapping; + expect(selectedLayerIndex).toBe( + layers.findIndex((layer) => layer.id === SHADOW_SIMULATION_LAYER_ID) + ); + }); + }); + + it("keeps the layer entry but hides it when the simulation is disabled", async () => { + const store = createTestStore(); + addonStateMock.shadowState = { + ...addonStateMock.shadowState!, + enabled: true, + }; + const { rerender } = renderHook(() => useShadowSimulationLayerButton(), { + wrapper: createWrapper(store), + }); + + await waitFor(() => expect(findShadowLayer(store)).toBeDefined()); + + act(() => { + addonStateMock.shadowState = { + ...addonStateMock.shadowState!, + enabled: false, + }; + rerender(); + }); + + await waitFor(() => { + expect(findShadowLayer(store)?.visible).toBe(false); + }); + }); + + it("removes and disables the layer when the addon manager suspends it", async () => { + const store = createTestStore(); + addonStateMock.shadowState = { + ...addonStateMock.shadowState!, + enabled: true, + }; + const { rerender } = renderHook(() => useShadowSimulationLayerButton(), { + wrapper: createWrapper(store), + }); + + await waitFor(() => expect(findShadowLayer(store)).toBeDefined()); + + addonStateMock.overrides = { + suspended: ["shadowSimulation"], + enabled: [], + }; + rerender(); + + await waitFor(() => { + expect(findShadowLayer(store)).toBeUndefined(); + expect(addonStateMock.setShadowState).toHaveBeenCalled(); + }); + + const updateState = addonStateMock.setShadowState.mock.calls[0]?.[0]; + expect(updateState).toBeTypeOf("function"); + const latestState = { + enabled: true, + }; + expect(updateState(latestState)).toEqual({ + ...latestState, + enabled: false, + }); + }); + + it("does not rerun the layer lifecycle when only the time selection changes", async () => { + const store = createNonUpdatingLayerStore(false); + addonStateMock.shadowState = { + ...addonStateMock.shadowState!, + enabled: true, + }; + const dispatch = vi.spyOn(store, "dispatch"); + const { rerender } = renderHook(() => useShadowSimulationLayerButton(), { + wrapper: createWrapper(store), + }); + + await waitFor(() => expect(dispatch).toHaveBeenCalled()); + dispatch.mockClear(); + + act(() => { + addonStateMock.shadowState = { + ...addonStateMock.shadowState, + }; + rerender(); + }); + + expect(dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/geoportal/src/app/hooks/useShadowSimulationLayerButton.tsx b/apps/geoportal/src/app/hooks/useShadowSimulationLayerButton.tsx new file mode 100644 index 0000000000..2062b0fc11 --- /dev/null +++ b/apps/geoportal/src/app/hooks/useShadowSimulationLayerButton.tsx @@ -0,0 +1,91 @@ +import { useEffect, useMemo, useRef } from "react"; +import { useDispatch, useSelector } from "react-redux"; + +import { + useAddonState, + usePersistedAddonOverrides, + useRouteAddons, +} from "@carma-mapping/addons"; + +import { + createShadowSimulationLayer, + resolveShadowSimulationAddon, + SHADOW_SIMULATION_LAYER_ID, +} from "../helper/shadow-simulation-layer"; +import { + appendLayer, + getLayerStack, + removeLayer, + setSelectedLayerIndex, + updateLayer, +} from "../store/slices/mapping"; + +export { SHADOW_SIMULATION_LAYER_ID } from "../helper/shadow-simulation-layer"; + +export const useShadowSimulationLayerButton = () => { + const dispatch = useDispatch(); + const layerStack = useSelector(getLayerStack); + const routeAddons = useRouteAddons(); + const [addonOverrides] = usePersistedAddonOverrides(); + const [shadowState, setShadowState] = useAddonState("shadowSimulation"); + const shadowEnabled = shadowState?.enabled ?? false; + const wasEnabled = useRef(false); + + const shadowAddon = useMemo( + () => resolveShadowSimulationAddon(routeAddons, addonOverrides), + [addonOverrides, routeAddons] + ); + const shadowLayer = useMemo( + () => createShadowSimulationLayer(shadowAddon, shadowEnabled), + [shadowAddon, shadowEnabled] + ); + + useEffect(() => { + const layerIndex = layerStack.findIndex( + (entry) => entry.id === SHADOW_SIMULATION_LAYER_ID + ); + const currentLayer = layerIndex >= 0 ? layerStack[layerIndex] : undefined; + const justEnabled = shadowEnabled && !wasEnabled.current; + wasEnabled.current = shadowEnabled; + + if (!shadowAddon || !shadowLayer) { + if (shadowEnabled) { + setShadowState((previous) => { + if (!previous || !previous.enabled) return previous!; + return { ...previous, enabled: false }; + }); + } + if (currentLayer) { + dispatch(removeLayer(SHADOW_SIMULATION_LAYER_ID)); + } + return; + } + + if (shadowEnabled && !currentLayer) { + dispatch(appendLayer(shadowLayer)); + // Switching the simulation on opens its info view; the appended entry + // lands at the end of the stack. + dispatch(setSelectedLayerIndex(layerStack.length)); + return; + } + + if (!currentLayer || currentLayer.type === "group") { + return; + } + + if (currentLayer.visible !== shadowEnabled) { + dispatch(updateLayer({ ...currentLayer, visible: shadowEnabled })); + } + + if (justEnabled) { + dispatch(setSelectedLayerIndex(layerIndex)); + } + }, [ + dispatch, + layerStack, + setShadowState, + shadowAddon, + shadowEnabled, + shadowLayer, + ]); +}; diff --git a/apps/geoportal/src/main.tsx b/apps/geoportal/src/main.tsx index aba9b2db98..34078e11cb 100644 --- a/apps/geoportal/src/main.tsx +++ b/apps/geoportal/src/main.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo } from "react"; -import { createRoot } from "react-dom/client"; +import { createRoot, type Root } from "react-dom/client"; import { Provider, useDispatch } from "react-redux"; import { RouterProvider, @@ -92,9 +92,7 @@ const RoutedApp = () => { useEffect(() => { dispatch(setUIVisibleControls(resolveFachzwillingUi(fachzwilling?.ui))); - dispatch( - setUIMapInteractionEnabled(!fachzwilling?.disableMapInteraction) - ); + dispatch(setUIMapInteractionEnabled(!fachzwilling?.disableMapInteraction)); dispatch(setUIHashWriteEnabled(!fachzwilling?.disableHashWrite)); }, [dispatch, fachzwilling]); @@ -133,7 +131,19 @@ suppressReactCismapErrors(); preventPinchZoom(); -const root = createRoot(document.getElementById("root") as HTMLElement); +/** + * Vite's React plugin treats this export-less entry as a Fast Refresh + * boundary, so a hot update that bubbles up here re-executes the module in + * place instead of reloading the page. A second `createRoot()` on the same + * container would then render a parallel tree over the old one, which ends in + * `removeChild` errors while the old map tears down. Reuse the root instead. + */ +type HotRootData = { root?: Root }; +const hotRootData = import.meta.hot?.data as HotRootData | undefined; +const root = + hotRootData?.root ?? + createRoot(document.getElementById("root") as HTMLElement); +if (hotRootData) hotRootData.root = root; document.getElementById("splash-loading")?.remove(); diff --git a/libraries/collaboration/carma-pecher-collab/pecher-collab-submodule b/libraries/collaboration/carma-pecher-collab/pecher-collab-submodule index 44b7e5b638..3012e64ad7 160000 --- a/libraries/collaboration/carma-pecher-collab/pecher-collab-submodule +++ b/libraries/collaboration/carma-pecher-collab/pecher-collab-submodule @@ -1 +1 @@ -Subproject commit 44b7e5b63883c72a17d60cb0a1a6523890030cf2 +Subproject commit 3012e64ad76984b570a8f4302fdfb8d36578d6ca diff --git a/libraries/collaboration/carma-wuppertal-collab/wuppertal-collab-submodule b/libraries/collaboration/carma-wuppertal-collab/wuppertal-collab-submodule index 46ee4a8da5..4b78762041 160000 --- a/libraries/collaboration/carma-wuppertal-collab/wuppertal-collab-submodule +++ b/libraries/collaboration/carma-wuppertal-collab/wuppertal-collab-submodule @@ -1 +1 @@ -Subproject commit 46ee4a8da5295233637c201b2ad67510818b0864 +Subproject commit 4b78762041439cfef55c29b7d7640c01aeaed3a4 diff --git a/libraries/commons/math/src/lib/numeric/index.ts b/libraries/commons/math/src/lib/numeric/index.ts index 847d20fda9..bdfdda9b7e 100644 --- a/libraries/commons/math/src/lib/numeric/index.ts +++ b/libraries/commons/math/src/lib/numeric/index.ts @@ -4,6 +4,7 @@ export { isZeroish } from "./is-zeroish"; export { lerp } from "./lerp"; export { parseFiniteNumber } from "./parse-finite-number"; export { parseNumberCandidate } from "./parse-number-candidate"; +export { quantize } from "./quantize"; export { interpolateTimedNumber, readTimedInterpolationEasedProgress, diff --git a/libraries/commons/math/src/lib/numeric/quantize.spec.ts b/libraries/commons/math/src/lib/numeric/quantize.spec.ts new file mode 100644 index 0000000000..848fef89c5 --- /dev/null +++ b/libraries/commons/math/src/lib/numeric/quantize.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; + +import { quantize } from "./quantize"; + +describe("quantize", () => { + it("rounds a value to the nearest step", () => { + expect(quantize(12.6, 5)).toBe(15); + expect(quantize(-12.6, 5)).toBe(-15); + }); + + it("rejects invalid steps", () => { + expect(() => quantize(1, 0)).toThrow(RangeError); + }); +}); diff --git a/libraries/commons/math/src/lib/numeric/quantize.ts b/libraries/commons/math/src/lib/numeric/quantize.ts new file mode 100644 index 0000000000..dbd7120291 --- /dev/null +++ b/libraries/commons/math/src/lib/numeric/quantize.ts @@ -0,0 +1,6 @@ +export const quantize = (value: number, step: number): number => { + if (!Number.isFinite(step) || step <= 0) { + throw new RangeError("Quantization step must be a positive finite number"); + } + return Math.round(value / step) * step; +}; diff --git a/libraries/commons/utils/src/index.ts b/libraries/commons/utils/src/index.ts index 4160f3a0e9..8464097561 100644 --- a/libraries/commons/utils/src/index.ts +++ b/libraries/commons/utils/src/index.ts @@ -37,6 +37,22 @@ export { export { isNumberArrayEqual } from "./lib/arrays"; +export { + getDayOfYear, + getDaysInYear, + getUtcDateForDayOfYear, + offsetYearDay, + type YearDay, + type YearDayTime, + type ZonedYearDayTime, +} from "./lib/calendar"; + +export { + getZonedUtcOffsetMinutes, + instantToZonedYearDayTime, + zonedYearDayTimeToInstant, +} from "./lib/zoned-date-time"; + export { extractCarmaConfig, resolveLayerTitle, diff --git a/libraries/commons/utils/src/lib/calendar.spec.ts b/libraries/commons/utils/src/lib/calendar.spec.ts new file mode 100644 index 0000000000..cdbb6d87af --- /dev/null +++ b/libraries/commons/utils/src/lib/calendar.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { + getDayOfYear, + getDaysInYear, + getUtcDateForDayOfYear, + offsetYearDay, +} from "./calendar"; + +describe("calendar", () => { + it("resolves leap years and UTC day-of-year values", () => { + expect(getDaysInYear(2024)).toBe(366); + expect(getDaysInYear(2025)).toBe(365); + expect(getDayOfYear(2024, 1, 29)).toBe(60); + expect(getUtcDateForDayOfYear(2024, 60).toISOString()).toBe( + "2024-02-29T00:00:00.000Z" + ); + }); + + it("offsets year-day values across year boundaries", () => { + expect(offsetYearDay({ year: 2024, dayOfYear: 366 }, 1)).toEqual({ + year: 2025, + dayOfYear: 1, + }); + expect(offsetYearDay({ year: 2025, dayOfYear: 1 }, -1)).toEqual({ + year: 2024, + dayOfYear: 366, + }); + }); +}); diff --git a/libraries/commons/utils/src/lib/calendar.ts b/libraries/commons/utils/src/lib/calendar.ts new file mode 100644 index 0000000000..7ae2f1f6dc --- /dev/null +++ b/libraries/commons/utils/src/lib/calendar.ts @@ -0,0 +1,50 @@ +export type YearDay = { + year: number; + dayOfYear: number; +}; + +export type YearDayTime = YearDay & { + minutes: number; +}; + +export type ZonedYearDayTime = YearDayTime & { + timeZone: string; +}; + +const MILLISECONDS_PER_DAY = 86_400_000; + +export const getDaysInYear = (year: number): number => + new Date(Date.UTC(year, 1, 29)).getUTCMonth() === 1 ? 366 : 365; + +export const getDayOfYear = ( + year: number, + zeroBasedMonth: number, + day: number +): number => + Math.floor( + (Date.UTC(year, zeroBasedMonth, day) - Date.UTC(year, 0, 1)) / + MILLISECONDS_PER_DAY + ) + 1; + +export const getUtcDateForDayOfYear = ( + year: number, + dayOfYear: number +): Date => new Date(Date.UTC(year, 0, dayOfYear)); + +export const offsetYearDay = ( + value: YearDay, + dayOffset: number +): YearDay => { + const date = getUtcDateForDayOfYear( + value.year, + value.dayOfYear + dayOffset + ); + return { + year: date.getUTCFullYear(), + dayOfYear: getDayOfYear( + date.getUTCFullYear(), + date.getUTCMonth(), + date.getUTCDate() + ), + }; +}; diff --git a/libraries/commons/utils/src/lib/layer-parser.ts b/libraries/commons/utils/src/lib/layer-parser.ts index d04f4f218e..c2e4569862 100644 --- a/libraries/commons/utils/src/lib/layer-parser.ts +++ b/libraries/commons/utils/src/lib/layer-parser.ts @@ -41,8 +41,6 @@ export const parseDescription = (description: string) => { return result; }; -const parser = new DOMParser(); - const getIdFromUrl = (url: string) => { const urlObj = new URL(url); @@ -61,7 +59,7 @@ export const extractInformation = async (layer: ExtendedLayer) => { try { const response = await fetch(urlWithoutWhitespace); const text = await response.text(); - const xml = parser.parseFromString(text, "text/xml"); + const xml = new DOMParser().parseFromString(text, "text/xml"); const abstract = xml.getElementsByTagName("gmd:abstract")[0]; if (abstract && abstract.textContent) { metadata.text = abstract.textContent; diff --git a/libraries/commons/utils/src/lib/zoned-date-time.spec.ts b/libraries/commons/utils/src/lib/zoned-date-time.spec.ts new file mode 100644 index 0000000000..1e8d272a91 --- /dev/null +++ b/libraries/commons/utils/src/lib/zoned-date-time.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { + getZonedUtcOffsetMinutes, + instantToZonedYearDayTime, + zonedYearDayTimeToInstant, +} from "./zoned-date-time"; + +const BERLIN = "Europe/Berlin"; + +describe("zoned date time", () => { + it("converts an instant to local civil time in its IANA time zone", () => { + expect( + instantToZonedYearDayTime( + new Date("2026-06-21T10:00:00.000Z"), + BERLIN + ) + ).toEqual({ + year: 2026, + dayOfYear: 172, + minutes: 12 * 60, + timeZone: BERLIN, + }); + }); + + it("uses the zone's daylight-saving offset for the selected date", () => { + expect( + getZonedUtcOffsetMinutes({ + year: 2026, + dayOfYear: 15, + minutes: 12 * 60, + timeZone: BERLIN, + }) + ).toBe(60); + expect( + getZonedUtcOffsetMinutes({ + year: 2026, + dayOfYear: 172, + minutes: 12 * 60, + timeZone: BERLIN, + }) + ).toBe(120); + }); + + it("applies Temporal-compatible DST disambiguation", () => { + expect( + zonedYearDayTimeToInstant({ + year: 2026, + dayOfYear: 88, + minutes: 2 * 60 + 30, + timeZone: BERLIN, + }).toISOString() + ).toBe("2026-03-29T01:30:00.000Z"); + expect( + zonedYearDayTimeToInstant({ + year: 2026, + dayOfYear: 298, + minutes: 2 * 60 + 30, + timeZone: BERLIN, + }).toISOString() + ).toBe("2026-10-25T00:30:00.000Z"); + }); +}); diff --git a/libraries/commons/utils/src/lib/zoned-date-time.ts b/libraries/commons/utils/src/lib/zoned-date-time.ts new file mode 100644 index 0000000000..0349499e29 --- /dev/null +++ b/libraries/commons/utils/src/lib/zoned-date-time.ts @@ -0,0 +1,59 @@ +import { Temporal as TemporalPolyfill } from "@js-temporal/polyfill"; + +import type { ZonedYearDayTime } from "./calendar"; + +type TemporalApi = typeof TemporalPolyfill; + +const nativeTemporal = ( + globalThis as typeof globalThis & { Temporal?: TemporalApi } +).Temporal; + +// Prefer the standardized browser API and provide the same API on browsers +// that have not shipped Temporal yet. Do not install globals from the polyfill. +const Temporal = nativeTemporal ?? TemporalPolyfill; + +const toPlainDate = ({ year, dayOfYear }: ZonedYearDayTime) => + Temporal.PlainDate.from({ year, month: 1, day: 1 }).add({ + days: dayOfYear - 1, + }); + +const toZonedDateTime = (value: ZonedYearDayTime) => { + const date = toPlainDate(value); + const minutes = Math.round(value.minutes); + + return Temporal.ZonedDateTime.from( + { + timeZone: value.timeZone, + year: date.year, + month: date.month, + day: date.day, + hour: Math.floor(minutes / 60), + minute: minutes % 60, + }, + { disambiguation: "compatible" } + ); +}; + +export const instantToZonedYearDayTime = ( + instant: Date, + timeZone: string +): ZonedYearDayTime => { + const zonedDateTime = Temporal.Instant.fromEpochMilliseconds( + instant.getTime() + ).toZonedDateTimeISO(timeZone); + + return { + year: zonedDateTime.year, + dayOfYear: zonedDateTime.dayOfYear, + minutes: zonedDateTime.hour * 60 + zonedDateTime.minute, + timeZone: zonedDateTime.timeZoneId, + }; +}; + +export const zonedYearDayTimeToInstant = ( + value: ZonedYearDayTime +): Date => new Date(toZonedDateTime(value).epochMilliseconds); + +export const getZonedUtcOffsetMinutes = ( + value: ZonedYearDayTime +): number => toZonedDateTime(value).offsetNanoseconds / 60_000_000_000; diff --git a/libraries/geo/helpers/src/lib/bounds/geographic-bounds.spec.ts b/libraries/geo/helpers/src/lib/bounds/geographic-bounds.spec.ts new file mode 100644 index 0000000000..2992cf0528 --- /dev/null +++ b/libraries/geo/helpers/src/lib/bounds/geographic-bounds.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { + geographicBoundsContain, + geographicBoundsIntersect, + getGeographicRingBounds, + padGeographicBounds, + unionGeographicBounds, +} from "./geographic-bounds"; + +describe("geographic bounds", () => { + const left = { west: 7, south: 51, east: 7.2, north: 51.2 }; + const right = { west: 7.1, south: 51.1, east: 7.3, north: 51.3 }; + + it("tests intersection and containment", () => { + expect(geographicBoundsIntersect(left, right)).toBe(true); + expect(geographicBoundsContain(left, right)).toBe(false); + expect( + geographicBoundsContain(unionGeographicBounds(left, right), left) + ).toBe(true); + }); + + it("pads an extent and clamps latitude", () => { + expect( + padGeographicBounds({ west: 7, south: -89, east: 8, north: 89 }, 0.5) + ).toEqual({ west: 6.5, south: -90, east: 8.5, north: 90 }); + }); + + it("computes a ring extent", () => { + expect( + getGeographicRingBounds([ + [7.2, 51.3], + [7, 51.1], + [7.4, 51.2], + ]) + ).toEqual({ west: 7, south: 51.1, east: 7.4, north: 51.3 }); + }); +}); diff --git a/libraries/geo/helpers/src/lib/bounds/geographic-bounds.ts b/libraries/geo/helpers/src/lib/bounds/geographic-bounds.ts new file mode 100644 index 0000000000..dc83d5c43a --- /dev/null +++ b/libraries/geo/helpers/src/lib/bounds/geographic-bounds.ts @@ -0,0 +1,64 @@ +export type GeographicBounds = Readonly<{ + west: number; + south: number; + east: number; + north: number; +}>; + +export const geographicBoundsIntersect = ( + left: GeographicBounds, + right: GeographicBounds +): boolean => + left.west <= right.east && + left.east >= right.west && + left.south <= right.north && + left.north >= right.south; + +export const geographicBoundsContain = ( + outer: GeographicBounds, + inner: GeographicBounds +): boolean => + outer.west <= inner.west && + outer.south <= inner.south && + outer.east >= inner.east && + outer.north >= inner.north; + +export const getGeographicRingBounds = ( + ring: readonly (readonly [number, number])[] +): GeographicBounds => { + let west = Number.POSITIVE_INFINITY; + let south = Number.POSITIVE_INFINITY; + let east = Number.NEGATIVE_INFINITY; + let north = Number.NEGATIVE_INFINITY; + for (const [longitude, latitude] of ring) { + west = Math.min(west, longitude); + south = Math.min(south, latitude); + east = Math.max(east, longitude); + north = Math.max(north, latitude); + } + return { west, south, east, north }; +}; + +export const padGeographicBounds = ( + bounds: T, + factor: number +): GeographicBounds => { + const longitudePadding = (bounds.east - bounds.west) * factor; + const latitudePadding = (bounds.north - bounds.south) * factor; + return { + west: bounds.west - longitudePadding, + south: Math.max(-90, bounds.south - latitudePadding), + east: bounds.east + longitudePadding, + north: Math.min(90, bounds.north + latitudePadding), + }; +}; + +export const unionGeographicBounds = ( + left: GeographicBounds, + right: GeographicBounds +): GeographicBounds => ({ + west: Math.min(left.west, right.west), + south: Math.min(left.south, right.south), + east: Math.max(left.east, right.east), + north: Math.max(left.north, right.north), +}); diff --git a/libraries/geo/helpers/src/lib/bounds/index.ts b/libraries/geo/helpers/src/lib/bounds/index.ts new file mode 100644 index 0000000000..76e6ea8593 --- /dev/null +++ b/libraries/geo/helpers/src/lib/bounds/index.ts @@ -0,0 +1,8 @@ +export { + geographicBoundsContain, + geographicBoundsIntersect, + getGeographicRingBounds, + padGeographicBounds, + unionGeographicBounds, +} from "./geographic-bounds"; +export type { GeographicBounds } from "./geographic-bounds"; diff --git a/libraries/geo/helpers/src/lib/index.ts b/libraries/geo/helpers/src/lib/index.ts index a8794893e7..8da75d992a 100644 --- a/libraries/geo/helpers/src/lib/index.ts +++ b/libraries/geo/helpers/src/lib/index.ts @@ -1,5 +1,13 @@ export * from "./conversions"; export * from "./validators"; +export { + geographicBoundsContain, + geographicBoundsIntersect, + getGeographicRingBounds, + padGeographicBounds, + unionGeographicBounds, +} from "./bounds"; +export type { GeographicBounds } from "./bounds"; // Re-export angle constants and helpers commonly used with geo operations export { diff --git a/libraries/mapping/addons/README.md b/libraries/mapping/addons/README.md index 1b8468269f..776cfa5018 100644 --- a/libraries/mapping/addons/README.md +++ b/libraries/mapping/addons/README.md @@ -47,7 +47,8 @@ so the second folder is the list of what actually exists: | `addons/OriginSearch/` | the "von wo?" search: where the user starts from (see below) | | `addons/VectorHighlight.tsx` | highlight/dim mode for the maplibre map | | `addons/LayerVisibility.tsx` | per-member visibility toggles for a group | -| `addons/LibreTerrain.tsx` | terrain toggle button for the maplibre map | +| `addons/LibreTerrain.tsx` | terrain toggle button for the maplibre map | +| `addons/ShadowSimulation/` | daylight-clamped sun control for MapLibre and Three.js content | An addon that needs more than one file gets its own folder there (`addons/CameraTour/index.tsx` plus its parts). diff --git a/libraries/mapping/addons/project.json b/libraries/mapping/addons/project.json index f2543d6afe..8e1f3567a0 100644 --- a/libraries/mapping/addons/project.json +++ b/libraries/mapping/addons/project.json @@ -5,6 +5,13 @@ "projectType": "library", "tags": [], "targets": { + "test": { + "executor": "@nx/vite:test", + "outputs": ["{options.reportsDirectory}"], + "options": { + "reportsDirectory": "../../../coverage/libraries/mapping/addons" + } + }, "lint": { "executor": "@nx/eslint:lint" } diff --git a/libraries/mapping/addons/src/addons/CameraRestriction.tsx b/libraries/mapping/addons/src/addons/CameraRestriction.tsx index 73ba4a46e9..56d7293c93 100644 --- a/libraries/mapping/addons/src/addons/CameraRestriction.tsx +++ b/libraries/mapping/addons/src/addons/CameraRestriction.tsx @@ -2,10 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import type { Map as MaplibreMap } from "maplibre-gl"; import type { carma as carmaApi } from "@carma-api"; -import { - DEFAULT_MAX_PITCH, - setCameraRestrictionOverride, -} from "@carma-mapping/engines/maplibre"; +import { setCameraRestrictionOverride } from "@carma-mapping/engines/maplibre"; import type { AddonComponentProps } from "../lib/registry"; import { use3dLayers } from "../lib/use3dLayers"; @@ -120,6 +117,13 @@ const useZoom = (map: MaplibreMap | null, enabled: boolean): number | null => { return zoom; }; +/** + * How far the addon lets the camera tilt once it decides the view is free: + * 5 degrees above the horizon. MapLibre's stock cap of 60 stays the base for + * maps without this addon; a config `maxPitch` still overrides. + */ +const ADDON_UNRESTRICTED_MAX_PITCH = 85; + export const CameraRestriction = ({ carma, config, @@ -131,7 +135,7 @@ export const CameraRestriction = ({ requireVisible = true, restrictBelowZoom, restrictAboveZoom, - maxPitch = DEFAULT_MAX_PITCH, + maxPitch = ADDON_UNRESTRICTED_MAX_PITCH, } = config ?? {}; const usesLayers = diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx b/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx new file mode 100644 index 0000000000..ef168a3c68 --- /dev/null +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx @@ -0,0 +1,71 @@ +import { lazy, Suspense } from "react"; + +import { useLibreContext } from "@carma-mapping/contexts"; +import type { + ShadowDateState, + ShadowSimulationConfig, + ShadowSimulationState, +} from "@carma-mapping/shadow-simulation"; + +import { useAddonState } from "../../lib/AddonStateContext"; +import type { AddonComponentProps } from "../../lib/registry"; + +export type { + ShadowDateState, + ShadowSimulationConfig, + ShadowSimulationState, +}; + +const LazyShadowSimulationView = lazy(async () => { + const module = await import("@carma-mapping/shadow-simulation"); + return { default: module.ShadowSimulationView }; +}); + +const LazyShadowSimulationHeaderControlsView = lazy(async () => { + const module = await import("@carma-mapping/shadow-simulation"); + return { default: module.ShadowSimulationHeaderControlsView }; +}); + +export const ShadowSimulation = ({ + config, + libreMap, + target, +}: AddonComponentProps<"shadowSimulation">) => { + const [state, setState] = useAddonState("shadowSimulation"); + const [dateState, setDateState] = useAddonState("shadowDate"); + return ( + + + + ); +}; + +export const ShadowSimulationHeaderControls = ({ + config, +}: { + config?: ShadowSimulationConfig; +}) => { + const { map } = useLibreContext(); + const [state, setState] = useAddonState("shadowSimulation"); + const [dateState, setDateState] = useAddonState("shadowDate"); + return ( + + + + ); +}; diff --git a/libraries/mapping/addons/src/index.ts b/libraries/mapping/addons/src/index.ts index eb4f3299be..a29462b6dd 100644 --- a/libraries/mapping/addons/src/index.ts +++ b/libraries/mapping/addons/src/index.ts @@ -4,6 +4,7 @@ export { getTargetAddonsWithTrigger, hasTargetAddonsWithTrigger, resolveActiveTargetAddon, + resolveSecondaryViewTargetAddon, toAddonButtonId, } from "./lib/target-addons"; export { @@ -78,7 +79,11 @@ export { } from "./addons/TimeSlider"; export { useHasAddonStateProducer } from "./lib/addon-channels"; -export { useAddonState, useAddonStateSnapshot } from "./lib/AddonStateContext"; +export { + useAddonState, + useAddonStateSnapshot, + useRouteAddons, +} from "./lib/AddonStateContext"; export type { AddonStateAction } from "./lib/AddonStateContext"; export { AddonManager, type AddonManagerConfig } from "./addons/AddonManager"; @@ -166,6 +171,12 @@ export { type LayerVisibilityConfig, } from "./addons/LayerVisibility"; export { LibreTerrain, type LibreTerrainConfig } from "./addons/LibreTerrain"; +export { + ShadowSimulation, + ShadowSimulationHeaderControls, + type ShadowSimulationConfig, + type ShadowSimulationState, +} from "./addons/ShadowSimulation"; export { OutletAddon, type OutletConfig } from "./addons/outlet/Outlet"; export { CompareSwipe, diff --git a/libraries/mapping/addons/src/lib/TargetAddonHost.tsx b/libraries/mapping/addons/src/lib/TargetAddonHost.tsx index f9ebf96432..cf1f8252a3 100644 --- a/libraries/mapping/addons/src/lib/TargetAddonHost.tsx +++ b/libraries/mapping/addons/src/lib/TargetAddonHost.tsx @@ -16,7 +16,7 @@ import { /** * Renders one addon declared on a stack entry, with the same interaction * inputs `AddonHost` hands route addons. Placement is the caller's business: - * the interaction view supplies the slot. + * interaction and secondary views can each supply a slot. */ export const TargetAddonHost = ({ addon, diff --git a/libraries/mapping/addons/src/lib/addon-overrides.ts b/libraries/mapping/addons/src/lib/addon-overrides.ts index 1c592bcb62..a36b598cda 100644 --- a/libraries/mapping/addons/src/lib/addon-overrides.ts +++ b/libraries/mapping/addons/src/lib/addon-overrides.ts @@ -49,6 +49,7 @@ export const SWITCHABLE_KINDS = [ "vectorHighlightControl", "vectorHighlightDebug", "libreTerrain", + "shadowSimulation", "visibleFeatureStatsSource", "visibleFeatureStatsPanel", "timeSlider", diff --git a/libraries/mapping/addons/src/lib/registry.ts b/libraries/mapping/addons/src/lib/registry.ts index 8a119cd151..8a7c8655c5 100644 --- a/libraries/mapping/addons/src/lib/registry.ts +++ b/libraries/mapping/addons/src/lib/registry.ts @@ -53,6 +53,12 @@ import { type VectorHighlightDebugPanelConfig, } from "../addons/VectorHighlight"; import { LibreTerrain, type LibreTerrainConfig } from "../addons/LibreTerrain"; +import { + ShadowSimulation, + type ShadowDateState, + type ShadowSimulationConfig, + type ShadowSimulationState, +} from "../addons/ShadowSimulation"; import { LayerVisibility, layerVisibilityTrigger, @@ -120,6 +126,7 @@ export type AddonConfigMap = { vectorHighlightDebug: VectorHighlightDebugPanelConfig; layerVisibility: LayerVisibilityConfig; libreTerrain: LibreTerrainConfig; + shadowSimulation: ShadowSimulationConfig; infoBoxZoomImage: InfoBoxZoomImageConfig; outlet: OutletConfig; visibleFeatureStatsSource: VisibleFeatureStatsSourceConfig; @@ -177,6 +184,10 @@ export type AddonStateMap = { * addon, which is why it has no consumer among the registry's `requires`. */ addonOverrides: AddonOverridesState; + /** rendering and animation state shared by the shadow controls */ + shadowSimulation: ShadowSimulationState; + /** selected civil date and time, separate for future shared-time sync */ + shadowDate: ShadowDateState; }; export type AddonStateKey = keyof AddonStateMap; @@ -235,6 +246,13 @@ export type AddonComponentProps = { target: LayerStackEntry | null; }; +export const ADDON_TARGET_PLACEMENT = { + SECONDARY_VIEW: "secondary-view", +} as const; + +export type AddonTargetPlacement = + (typeof ADDON_TARGET_PLACEMENT)[keyof typeof ADDON_TARGET_PLACEMENT]; + export type AddonContext = { config?: AddonConfigMap[K]; target: LayerStackEntry | null; @@ -262,6 +280,8 @@ export type AddonTrigger = { export type AddonRegistryEntry = { Component?: ComponentType>; trigger?: AddonTrigger; + /** Render this target-bound component inside the host's secondary view. */ + targetPlacement?: AddonTargetPlacement; /** state channels this addon writes (headless producers declare these) */ provides?: readonly AddonStateKey[]; /** @@ -329,6 +349,11 @@ export const addonRegistry: { trigger: layerVisibilityTrigger, }, libreTerrain: { Component: LibreTerrain }, + shadowSimulation: { + Component: ShadowSimulation, + targetPlacement: ADDON_TARGET_PLACEMENT.SECONDARY_VIEW, + provides: ["shadowSimulation", "shadowDate"], + }, infoBoxZoomImage: { Component: InfoBoxZoomImage, provides: ["infoBoxImage"], diff --git a/libraries/mapping/addons/src/lib/target-addons.ts b/libraries/mapping/addons/src/lib/target-addons.ts index 6b3309291a..21ffe412f0 100644 --- a/libraries/mapping/addons/src/lib/target-addons.ts +++ b/libraries/mapping/addons/src/lib/target-addons.ts @@ -1,6 +1,8 @@ import type { LayerStackEntry } from "@carma-mapping/layers"; import { + ADDON_TARGET_PLACEMENT, + addonRegistry, resolveAddonEntries, resolveAddonTrigger, type AddonEntry, @@ -36,3 +38,15 @@ export const resolveActiveTargetAddon = ( (entry) => toAddonButtonId(entry.kind) === activeButtonId ) : undefined; + +/** The first addon a target asks the host to place in its secondary view. */ +export const resolveSecondaryViewTargetAddon = ( + target?: LayerStackEntry | null +): ResolvedAddon | undefined => + resolveAddonEntries( + (target as { tools?: AddonEntry[] } | null | undefined)?.tools + ).find( + (entry) => + addonRegistry[entry.kind].targetPlacement === + ADDON_TARGET_PLACEMENT.SECONDARY_VIEW + ); diff --git a/libraries/mapping/addons/tsconfig.json b/libraries/mapping/addons/tsconfig.json index f474c15225..b7aecea93c 100644 --- a/libraries/mapping/addons/tsconfig.json +++ b/libraries/mapping/addons/tsconfig.json @@ -5,6 +5,9 @@ "references": [ { "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" } ] } diff --git a/libraries/mapping/addons/tsconfig.spec.json b/libraries/mapping/addons/tsconfig.spec.json new file mode 100644 index 0000000000..726684cbb9 --- /dev/null +++ b/libraries/mapping/addons/tsconfig.spec.json @@ -0,0 +1,21 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ] + }, + "include": [ + "vite.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.d.ts" + ] +} diff --git a/libraries/mapping/addons/vite.config.ts b/libraries/mapping/addons/vite.config.ts new file mode 100644 index 0000000000..6f205b6c00 --- /dev/null +++ b/libraries/mapping/addons/vite.config.ts @@ -0,0 +1,22 @@ +/// + +import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + root: __dirname, + cacheDir: "../../../node_modules/.vite/libraries/mapping/addons", + plugins: [react(), nxViteTsPaths()], + test: { + globals: true, + environment: "jsdom", + setupFiles: ["./vitest.setup.ts"], + include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + reporters: ["default"], + coverage: { + reportsDirectory: "../../../coverage/libraries/mapping/addons", + provider: "v8", + }, + }, +}); diff --git a/libraries/mapping/addons/vitest.setup.ts b/libraries/mapping/addons/vitest.setup.ts new file mode 100644 index 0000000000..b798e35dc6 --- /dev/null +++ b/libraries/mapping/addons/vitest.setup.ts @@ -0,0 +1,4 @@ +if (typeof window !== "undefined") { + window.URL.createObjectURL ??= () => ""; + window.URL.revokeObjectURL ??= () => undefined; +} diff --git a/libraries/mapping/components/src/lib/components/LibreTerrainControl.tsx b/libraries/mapping/components/src/lib/components/LibreTerrainControl.tsx index d2cd978db4..5694a4bf93 100644 --- a/libraries/mapping/components/src/lib/components/LibreTerrainControl.tsx +++ b/libraries/mapping/components/src/lib/components/LibreTerrainControl.tsx @@ -100,8 +100,9 @@ export const LibreTerrainControl = ({ return; } map.setTerrain({ source: terrainSource, exaggeration }); - setShowTerrain(true); - persistTerrainState(storageKey, true); + const enabled = map.getTerrain() != null; + setShowTerrain(enabled); + persistTerrainState(storageKey, enabled); } }, [map, terrainSource, exaggeration, storageKey]); diff --git a/libraries/mapping/components/src/lib/components/ViewStateVisualizer.spec.tsx b/libraries/mapping/components/src/lib/components/ViewStateVisualizer.spec.tsx index 41fbab2205..d125021876 100644 --- a/libraries/mapping/components/src/lib/components/ViewStateVisualizer.spec.tsx +++ b/libraries/mapping/components/src/lib/components/ViewStateVisualizer.spec.tsx @@ -26,6 +26,7 @@ const { primitiveMock, createPrimitiveMock } = vi.hoisted(() => { setOverview: vi.fn(() => null), setVisualized: vi.fn(() => null), setDisplay: vi.fn(() => null), + setVolumeBoxes: vi.fn(() => null), setInteractive: vi.fn(), readLabelAnchors: vi.fn(() => labelAnchors), dispose: vi.fn(), @@ -89,6 +90,7 @@ describe("ViewStateVisualizer", () => { primitiveMock.setOverview.mockClear(); primitiveMock.setVisualized.mockClear(); primitiveMock.setDisplay.mockClear(); + primitiveMock.setVolumeBoxes.mockClear(); primitiveMock.setInteractive.mockClear(); primitiveMock.readLabelAnchors.mockClear(); primitiveMock.dispose.mockClear(); @@ -152,6 +154,35 @@ describe("ViewStateVisualizer", () => { }); }); + it("updates volume boxes without recreating the primitive", async () => { + const first = { + boxes: [{ minimum: [0, 0, 0], maximum: [1, 1, 1] }] as const, + }; + const second = { + boxes: [{ minimum: [1, 1, 1], maximum: [2, 2, 2] }] as const, + }; + const { rerender } = render( + + ); + + rerender( + + ); + + await waitFor(() => { + expect(primitiveMock.setVolumeBoxes).toHaveBeenLastCalledWith(second); + expect(createPrimitiveMock).toHaveBeenCalledTimes(1); + }); + + rerender(); + + await waitFor(() => { + expect(primitiveMock.setVolumeBoxes).toHaveBeenLastCalledWith({ + boxes: [], + }); + }); + }); + it("forwards active camera and indexed pose callbacks to the primitive", async () => { const onCameraPoseChange = vi.fn(); const onCameraPoseDragStateChange = vi.fn(); diff --git a/libraries/mapping/components/src/lib/components/ViewStateVisualizer.tsx b/libraries/mapping/components/src/lib/components/ViewStateVisualizer.tsx index 7449a02953..5364068edd 100644 --- a/libraries/mapping/components/src/lib/components/ViewStateVisualizer.tsx +++ b/libraries/mapping/components/src/lib/components/ViewStateVisualizer.tsx @@ -12,6 +12,7 @@ import { type ViewStateVisualizerOverviewOptions, type ViewStateVisualizerPrimitive, type ViewStateVisualizerVisualizedOptions, + type ViewStateVisualizerVolumeBoxesOptions, } from "@carma-mapping/engines/three/primitives"; import { useLayoutEffect, @@ -51,6 +52,7 @@ export type ViewStateVisualizerProps = { interactive?: boolean; visualizedOptions?: ViewStateVisualizerVisualizedOptions; displayOptions?: ViewStateVisualizerDisplayOptions; + volumeBoxes?: ViewStateVisualizerVolumeBoxesOptions; activeCameraIndex?: number; /** Called when the user drags the camera cube to change bearing/pitch (radians). */ onPoseChange?: (bearing: number, pitch: number) => void; @@ -87,6 +89,7 @@ export const ViewStateVisualizer = ({ interactive = false, visualizedOptions, displayOptions, + volumeBoxes, activeCameraIndex = 0, onPoseChange, onCameraPoseChange, @@ -126,6 +129,9 @@ export const ViewStateVisualizer = ({ const resolvedOverviewOptionsRef = useRef( mergeViewStateVisualizerOverviewOptions(overviewOptions) ); + const volumeBoxesRef = useRef( + volumeBoxes ?? { boxes: [] } + ); const resolvedVisualizedOptionsRef = useRef( mergeViewStateVisualizerVisualizedOptions(visualizedOptions) ); @@ -332,6 +338,7 @@ export const ViewStateVisualizer = ({ resolvedDisplayOptionsRef.current = resolvedDisplayOptionsWithCueColors; resolvedOverviewOptionsRef.current = resolvedOverviewOptions; resolvedVisualizedOptionsRef.current = resolvedVisualizedOptions; + volumeBoxesRef.current = volumeBoxes ?? { boxes: [] }; const formatCssPx = (value: number) => `${value.toFixed(1)}px`; const readDefaultLabelPosition = (key: ViewStateVisualizerCueKey) => { @@ -438,6 +445,13 @@ export const ViewStateVisualizer = ({ primitive.setInteractive(interactive); }, [interactive]); + useLayoutEffect(() => { + const primitive = primitiveRef.current; + if (!primitive) return; + const anchors = primitive.setVolumeBoxes(volumeBoxes ?? { boxes: [] }); + if (anchors) applyLabelAnchors(anchors); + }, [volumeBoxes]); + useLayoutEffect(() => { const primitive = primitiveRef.current; if (!primitive) return; @@ -461,6 +475,7 @@ export const ViewStateVisualizer = ({ interactive, visualized: resolvedVisualizedOptionsRef.current, display: resolvedDisplayOptionsRef.current, + volumeBoxes: volumeBoxesRef.current, activeCameraIndex, onInteraction: applyLabelAnchors, onPoseChange: (bearing, pitch) => diff --git a/libraries/mapping/components/src/lib/components/iconMapping.ts b/libraries/mapping/components/src/lib/components/iconMapping.ts index b16ec04ca5..e8b92fdb67 100644 --- a/libraries/mapping/components/src/lib/components/iconMapping.ts +++ b/libraries/mapping/components/src/lib/components/iconMapping.ts @@ -6,6 +6,7 @@ import { faObjectGroup, faRuler, faSquare, + faSun, faTableColumns, } from "@fortawesome/free-solid-svg-icons"; @@ -14,6 +15,7 @@ export const iconMap = { highlight: faObjectGroup, comparing: faTableColumns, timeSeries: faClock, + "shadow-simulation": faSun, background: faLayerGroup, ortho: faGlobe, }; @@ -22,4 +24,5 @@ export const iconColorMap = { bäume: "green", gärten: "purple", ortho: "black", + "shadow-simulation": "#d97706", }; diff --git a/libraries/mapping/engines/cesium/terrain/project.json b/libraries/mapping/engines/cesium/terrain/project.json new file mode 100644 index 0000000000..46955f88af --- /dev/null +++ b/libraries/mapping/engines/cesium/terrain/project.json @@ -0,0 +1,26 @@ +{ + "name": "cesium-terrain", + "$schema": "../../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libraries/mapping/engines/cesium/terrain/src", + "projectType": "library", + "tags": ["type:library", "scope:cesium"], + "targets": { + "build": { + "executor": "@nx/vite:build", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libraries/mapping/engines/cesium/terrain" + } + }, + "test": { + "executor": "@nx/vite:test", + "outputs": ["{options.reportsDirectory}"], + "options": { + "reportsDirectory": "../../../../../coverage/libraries/mapping/engines/cesium/terrain" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libraries/mapping/engines/cesium/terrain/src/index.ts b/libraries/mapping/engines/cesium/terrain/src/index.ts new file mode 100644 index 0000000000..bdc00c508e --- /dev/null +++ b/libraries/mapping/engines/cesium/terrain/src/index.ts @@ -0,0 +1,12 @@ +export { + acquireCesiumTerrainTileSource, + cesiumTerrainTileKey, + isConfirmedTerrainServerError, +} from "./lib/cesium-terrain-tile-source"; +export type { + CesiumTerrainTile, + CesiumTerrainTileBounds, + CesiumTerrainTileId, + CesiumTerrainTileSource, + CesiumTerrainTileSourceOptions, +} from "./lib/cesium-terrain-tile-source"; diff --git a/libraries/mapping/engines/cesium/terrain/src/lib/cesium-terrain-tile-source.spec.ts b/libraries/mapping/engines/cesium/terrain/src/lib/cesium-terrain-tile-source.spec.ts new file mode 100644 index 0000000000..cd896cfcb1 --- /dev/null +++ b/libraries/mapping/engines/cesium/terrain/src/lib/cesium-terrain-tile-source.spec.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, vi } from "vitest"; + +const { fromUrl } = vi.hoisted(() => ({ fromUrl: vi.fn() })); + +vi.mock("@carma-cesium", () => { + class Cartographic { + constructor(public longitude: number, public latitude: number) {} + + static fromDegrees(longitude: number, latitude: number) { + return new Cartographic( + (longitude * Math.PI) / 180, + (latitude * Math.PI) / 180 + ); + } + } + + return { + Cartographic, + CesiumTerrainProvider: { fromUrl }, + }; +}); + +import { acquireCesiumTerrainTileSource } from "./cesium-terrain-tile-source"; + +const buildProvider = () => { + const terrainData = { + _quantizedVertices: new Uint16Array([ + 0, 0, 32767, 32767, 0, 32767, 0, 32767, 0, 16384, 16384, 32767, + ]), + _indices: new Uint16Array([0, 3, 1, 0, 2, 3]), + _minimumHeight: 100, + _maximumHeight: 200, + _westIndices: [0, 1], + _southIndices: [0, 2], + _eastIndices: [2, 3], + _northIndices: [1, 3], + _childTileMask: 15, + interpolateHeight: vi.fn(() => 151.5), + }; + const tileXYToRectangle = (x: number, y: number, level: number) => { + const width = (Math.PI * 2) / 2 ** (level + 1); + const height = Math.PI / 2 ** level; + const west = -Math.PI + x * width; + const north = Math.PI / 2 - y * height; + return { west, east: west + width, north, south: north - height }; + }; + const provider = { + requestTileGeometry: vi.fn(async () => terrainData), + getLevelMaximumGeometricError: vi.fn((level: number) => 64 / 2 ** level), + getTileDataAvailable: vi.fn(() => true), + tilingScheme: { + tileXYToRectangle, + positionToTileXY: ( + position: { longitude: number; latitude: number }, + level: number + ) => { + const xTiles = 2 ** (level + 1); + const yTiles = 2 ** level; + return { + x: Math.min( + xTiles - 1, + Math.max( + 0, + Math.floor( + ((position.longitude + Math.PI) / (2 * Math.PI)) * xTiles + ) + ) + ), + y: Math.min( + yTiles - 1, + Math.max( + 0, + Math.floor(((Math.PI / 2 - position.latitude) / Math.PI) * yTiles) + ) + ), + }; + }, + }, + }; + return { provider, terrainData }; +}; + +describe("Cesium terrain tile source", () => { + it("decodes and caches native quantized-mesh tiles", async () => { + const { provider } = buildProvider(); + fromUrl.mockResolvedValueOnce(provider); + const source = await acquireCesiumTerrainTileSource( + "https://example.test/terrain-a" + ); + + const first = await source.requestTile({ level: 2, x: 4, y: 1 }); + const second = await source.requestTile({ level: 2, x: 4, y: 1 }); + + expect(second).toBe(first); + expect(provider.requestTileGeometry).toHaveBeenCalledTimes(1); + expect([...first.u]).toEqual([0, 0, 1, 1]); + expect([...first.v]).toEqual([0, 1, 0, 1]); + expect(first.heightMeters[0]).toBeCloseTo(100); + expect(first.heightMeters[3]).toBeCloseTo(200); + expect(first.minimumHeightMeters).toBe(100); + expect(first.maximumHeightMeters).toBe(200); + expect([...first.indices]).toEqual([0, 3, 1, 0, 2, 3]); + expect(first.geometricErrorMeters).toBe(16); + }); + + it("retries a dropped tile transfer and gives up on a refused tile", async () => { + vi.useFakeTimers(); + try { + const { provider, terrainData } = buildProvider(); + provider.requestTileGeometry + .mockRejectedValueOnce({ statusCode: 503 }) + .mockRejectedValueOnce({ statusCode: undefined }) + .mockResolvedValueOnce(terrainData) + .mockRejectedValueOnce({ statusCode: 404 }); + fromUrl.mockResolvedValueOnce(provider); + const source = await acquireCesiumTerrainTileSource( + "https://example.test/terrain-retry" + ); + + const pending = source.requestTile({ level: 2, x: 4, y: 1 }); + await vi.runAllTimersAsync(); + const tile = await pending; + expect(tile.minimumHeightMeters).toBe(100); + expect(provider.requestTileGeometry).toHaveBeenCalledTimes(3); + + const refused = source + .requestTile({ level: 2, x: 5, y: 1 }) + .catch((error: unknown) => error); + await vi.runAllTimersAsync(); + expect(await refused).toMatchObject({ statusCode: 404 }); + expect(provider.requestTileGeometry).toHaveBeenCalledTimes(4); + } finally { + vi.useRealTimers(); + } + }); + + it("retries browser transport failures without a status code", async () => { + vi.useFakeTimers(); + try { + const { provider, terrainData } = buildProvider(); + provider.requestTileGeometry + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockResolvedValueOnce(terrainData); + fromUrl.mockResolvedValueOnce(provider); + const source = await acquireCesiumTerrainTileSource( + "https://example.test/terrain-browser-retry" + ); + + const pending = source.requestTile({ level: 2, x: 4, y: 1 }); + await vi.runAllTimersAsync(); + + await expect(pending).resolves.toMatchObject({ + minimumHeightMeters: 100, + maximumHeightMeters: 200, + }); + expect(provider.requestTileGeometry).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it("backs off when Cesium's request scheduler is saturated", async () => { + vi.useFakeTimers(); + try { + const { provider, terrainData } = buildProvider(); + provider.requestTileGeometry + .mockReturnValueOnce(undefined as never) + .mockResolvedValueOnce(terrainData); + fromUrl.mockResolvedValueOnce(provider); + const source = await acquireCesiumTerrainTileSource( + "https://example.test/terrain-scheduler-backoff" + ); + + const pending = source.requestTile({ level: 2, x: 4, y: 1 }); + expect(provider.requestTileGeometry).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(15); + expect(provider.requestTileGeometry).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + await expect(pending).resolves.toMatchObject({ + minimumHeightMeters: 100, + maximumHeightMeters: 200, + }); + expect(provider.requestTileGeometry).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it("maps bounds to the provider pyramid and samples cached heights", async () => { + const { provider, terrainData } = buildProvider(); + fromUrl.mockResolvedValueOnce(provider); + const source = await acquireCesiumTerrainTileSource( + "https://example.test/terrain-b" + ); + const ids = source.getTileGridIdsForBounds( + { west: 0, south: 0, east: 20, north: 20 }, + 2 + ); + expect(ids.length).toBeGreaterThan(0); + + await source.requestTile(ids[0]); + expect(source.sampleHeight(10, 10)).toBe(151.5); + expect(terrainData.interpolateHeight).toHaveBeenCalledOnce(); + }); +}); diff --git a/libraries/mapping/engines/cesium/terrain/src/lib/cesium-terrain-tile-source.ts b/libraries/mapping/engines/cesium/terrain/src/lib/cesium-terrain-tile-source.ts new file mode 100644 index 0000000000..2dd80fd2cb --- /dev/null +++ b/libraries/mapping/engines/cesium/terrain/src/lib/cesium-terrain-tile-source.ts @@ -0,0 +1,434 @@ +import { Cartographic, CesiumTerrainProvider } from "@carma-cesium"; +import { degToRadNumeric, radToDegNumeric } from "@carma-units"; + +const MAX_QUANTIZED_VALUE = 32_767; +const DEFAULT_MAX_CACHE_BYTES = 96 * 1024 ** 2; + +export type CesiumTerrainTileId = Readonly<{ + level: number; + x: number; + y: number; +}>; + +export type CesiumTerrainTileBounds = Readonly<{ + west: number; + south: number; + east: number; + north: number; +}>; + +export type CesiumTerrainTile = Readonly<{ + id: CesiumTerrainTileId; + bounds: CesiumTerrainTileBounds; + u: Float32Array; + v: Float32Array; + heightMeters: Float32Array; + minimumHeightMeters: number; + maximumHeightMeters: number; + indices: Uint32Array; + westIndices: Uint32Array; + southIndices: Uint32Array; + eastIndices: Uint32Array; + northIndices: Uint32Array; + childTileMask: number; + geometricErrorMeters: number; + byteLength: number; +}>; + +const TILE_RETRY_BASE_DELAY_MS = 250; +const TILE_RETRY_MAX_DELAY_MS = 8_000; +const REQUEST_SCHEDULER_RETRY_DELAY_MS = 16; +/** Tries per tile before a broken transfer is given up on, the first one included. */ +const TILE_RETRY_MAX_ATTEMPTS = 12; + +const getRequestStatusCode = (error: unknown): number | undefined => { + if (!error || typeof error !== "object" || !("statusCode" in error)) { + return undefined; + } + const { statusCode } = error as { statusCode?: unknown }; + return typeof statusCode === "number" ? statusCode : undefined; +}; + +/** + * A refusal is the server's answer to this tile and stays that way; only a + * broken transfer, a timeout or an overloaded host is worth asking again. + */ +export const isConfirmedTerrainServerError = (error: unknown): boolean => { + const status = getRequestStatusCode(error); + return ( + status !== undefined && + status >= 400 && + status < 500 && + status !== 408 && + status !== 429 + ); +}; + +const waitWithSignal = (ms: number, signal?: AbortSignal): Promise => + new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason ?? new Error("aborted")); + return; + } + const onAbort = () => { + clearTimeout(timer); + reject(signal?.reason ?? new Error("aborted")); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + }); + +export type CesiumTerrainTileSourceOptions = Readonly<{ + maxCacheBytes?: number; +}>; + +export interface CesiumTerrainTileSource { + terrainUrl: string; + requestTile: ( + id: CesiumTerrainTileId, + signal?: AbortSignal + ) => Promise; + getTileGridIdsForBounds: ( + bounds: CesiumTerrainTileBounds, + level: number + ) => CesiumTerrainTileId[]; + getTileBounds: (id: CesiumTerrainTileId) => CesiumTerrainTileBounds; + getLevelMaximumGeometricError: (level: number) => number; + getTileDataAvailable: (id: CesiumTerrainTileId) => boolean | undefined; + sampleHeight: (longitude: number, latitude: number) => number | undefined; + trimCache: (retainedKeys?: ReadonlySet) => void; +} + +type QuantizedMeshTerrainData = { + _quantizedVertices?: Uint16Array; + _indices?: Uint16Array | Uint32Array; + _minimumHeight?: number; + _maximumHeight?: number; + _westIndices?: number[]; + _southIndices?: number[]; + _eastIndices?: number[]; + _northIndices?: number[]; + _childTileMask?: number; + interpolateHeight: ( + rectangle: unknown, + longitudeRadians: number, + latitudeRadians: number + ) => number | undefined; +}; + +type CacheEntry = { + tile: CesiumTerrainTile; + terrainData: QuantizedMeshTerrainData; + rectangle: unknown; + lastUsed: number; +}; + +const sourcePromises = new Map>(); + +export const cesiumTerrainTileKey = ({ level, x, y }: CesiumTerrainTileId) => + `${level}/${x}/${y}`; + +const assertTileId = ({ level, x, y }: CesiumTerrainTileId) => { + if (![level, x, y].every(Number.isInteger) || level < 0 || x < 0 || y < 0) { + throw new RangeError( + "Terrain tile coordinates must be non-negative integers" + ); + } +}; + +const decodeTile = ( + provider: CesiumTerrainProvider, + id: CesiumTerrainTileId, + terrainData: QuantizedMeshTerrainData, + rectangle: { + west: number; + south: number; + east: number; + north: number; + } +): CesiumTerrainTile => { + const quantized = terrainData._quantizedVertices; + const sourceIndices = terrainData._indices; + const minimumHeight = terrainData._minimumHeight; + const maximumHeight = terrainData._maximumHeight; + if ( + !quantized || + !sourceIndices || + !Number.isFinite(minimumHeight) || + !Number.isFinite(maximumHeight) + ) { + throw new TypeError("Terrain endpoint did not return quantized-mesh data"); + } + + const vertexCount = quantized.length / 3; + const u = new Float32Array(vertexCount); + const v = new Float32Array(vertexCount); + const heightMeters = new Float32Array(vertexCount); + const heightRange = maximumHeight! - minimumHeight!; + for (let index = 0; index < vertexCount; index += 1) { + u[index] = quantized[index] / MAX_QUANTIZED_VALUE; + v[index] = quantized[vertexCount + index] / MAX_QUANTIZED_VALUE; + heightMeters[index] = + minimumHeight! + + (quantized[vertexCount * 2 + index] / MAX_QUANTIZED_VALUE) * heightRange; + } + + const indices = Uint32Array.from(sourceIndices); + const westIndices = Uint32Array.from(terrainData._westIndices ?? []); + const southIndices = Uint32Array.from(terrainData._southIndices ?? []); + const eastIndices = Uint32Array.from(terrainData._eastIndices ?? []); + const northIndices = Uint32Array.from(terrainData._northIndices ?? []); + const arrays = [ + u, + v, + heightMeters, + indices, + westIndices, + southIndices, + eastIndices, + northIndices, + ]; + + return { + id, + bounds: { + west: radToDegNumeric(rectangle.west), + south: radToDegNumeric(rectangle.south), + east: radToDegNumeric(rectangle.east), + north: radToDegNumeric(rectangle.north), + }, + u, + v, + heightMeters, + minimumHeightMeters: minimumHeight!, + maximumHeightMeters: maximumHeight!, + indices, + westIndices, + southIndices, + eastIndices, + northIndices, + childTileMask: terrainData._childTileMask ?? 15, + geometricErrorMeters: provider.getLevelMaximumGeometricError(id.level), + byteLength: arrays.reduce((sum, array) => sum + array.byteLength, 0), + }; +}; + +const buildSource = async ( + terrainUrl: string, + options: CesiumTerrainTileSourceOptions +): Promise => { + // The layer.json request is the one that decides whether terrain exists at + // all; a dropped connection here would otherwise leave the runtime without + // terrain until it is rebuilt. + let provider: CesiumTerrainProvider; + for (let attempt = 0; ; attempt += 1) { + try { + provider = await CesiumTerrainProvider.fromUrl(terrainUrl, { + requestVertexNormals: false, + requestWaterMask: false, + requestMetadata: false, + }); + break; + } catch (error) { + if ( + isConfirmedTerrainServerError(error) || + attempt + 1 >= TILE_RETRY_MAX_ATTEMPTS + ) { + throw error; + } + await waitWithSignal( + Math.min( + TILE_RETRY_MAX_DELAY_MS, + TILE_RETRY_BASE_DELAY_MS * 2 ** attempt + ) * + (0.5 + Math.random()) + ); + } + } + const maxCacheBytes = Math.max( + 1, + Math.floor(options.maxCacheBytes ?? DEFAULT_MAX_CACHE_BYTES) + ); + const cache = new Map(); + const pending = new Map>(); + let cachedBytes = 0; + let useClock = 0; + + const trimCache = (retainedKeys: ReadonlySet = new Set()) => { + if (cachedBytes <= maxCacheBytes) return; + const candidates = [...cache.entries()] + .filter(([key]) => !retainedKeys.has(key)) + .sort(([, left], [, right]) => left.lastUsed - right.lastUsed); + for (const [key, entry] of candidates) { + cache.delete(key); + cachedBytes -= entry.tile.byteLength; + if (cachedBytes <= maxCacheBytes) break; + } + }; + + const requestTile = async ( + id: CesiumTerrainTileId, + signal?: AbortSignal + ): Promise => { + assertTileId(id); + signal?.throwIfAborted(); + const key = cesiumTerrainTileKey(id); + const cached = cache.get(key); + if (cached) { + cached.lastUsed = ++useClock; + return cached.tile; + } + const inFlight = pending.get(key); + if (inFlight) return inFlight; + + const fetchTerrainData = async (): Promise => { + // A dropped connection or an overloaded host is retried with backoff + // until the server confirms the tile is unavailable; the caller's + // abort signal ends the wait early. + for (let attempt = 0; ; attempt += 1) { + try { + let requested = provider.requestTileGeometry(id.x, id.y, id.level); + while (!requested) { + await waitWithSignal(REQUEST_SCHEDULER_RETRY_DELAY_MS, signal); + requested = provider.requestTileGeometry(id.x, id.y, id.level); + } + return (await requested) as unknown as QuantizedMeshTerrainData; + } catch (error) { + signal?.throwIfAborted(); + if ( + isConfirmedTerrainServerError(error) || + attempt + 1 >= TILE_RETRY_MAX_ATTEMPTS + ) { + throw error; + } + const backoff = Math.min( + TILE_RETRY_MAX_DELAY_MS, + TILE_RETRY_BASE_DELAY_MS * 2 ** attempt + ); + // Jittered so tiles that failed together do not return as one burst. + await waitWithSignal(backoff * (0.5 + Math.random()), signal); + } + } + }; + const load = (async () => { + const terrainData = await fetchTerrainData(); + signal?.throwIfAborted(); + const rectangle = provider.tilingScheme.tileXYToRectangle( + id.x, + id.y, + id.level + ); + const tile = decodeTile(provider, id, terrainData, rectangle); + cache.set(key, { + tile, + terrainData, + rectangle, + lastUsed: ++useClock, + }); + cachedBytes += tile.byteLength; + trimCache(); + return tile; + })(); + pending.set(key, load); + try { + return await load; + } finally { + pending.delete(key); + } + }; + + const getTileGridIdsForBounds = ( + bounds: CesiumTerrainTileBounds, + level: number + ) => { + if (!Number.isInteger(level) || level < 0) { + throw new RangeError("Terrain level must be a non-negative integer"); + } + const epsilon = 1e-10; + const northWest = provider.tilingScheme.positionToTileXY( + Cartographic.fromDegrees(bounds.west, bounds.north - epsilon), + level + ); + const southEast = provider.tilingScheme.positionToTileXY( + Cartographic.fromDegrees(bounds.east - epsilon, bounds.south + epsilon), + level + ); + if (!northWest || !southEast) return []; + const result: CesiumTerrainTileId[] = []; + for (let y = northWest.y; y <= southEast.y; y += 1) { + for (let x = northWest.x; x <= southEast.x; x += 1) { + result.push({ level, x, y }); + } + } + return result; + }; + + return { + terrainUrl, + requestTile, + getTileGridIdsForBounds, + getTileBounds(id) { + assertTileId(id); + const rectangle = provider.tilingScheme.tileXYToRectangle( + id.x, + id.y, + id.level + ); + return { + west: radToDegNumeric(rectangle.west), + south: radToDegNumeric(rectangle.south), + east: radToDegNumeric(rectangle.east), + north: radToDegNumeric(rectangle.north), + }; + }, + getLevelMaximumGeometricError: (level) => + provider.getLevelMaximumGeometricError(level), + getTileDataAvailable: ({ x, y, level }) => + provider.getTileDataAvailable(x, y, level), + sampleHeight(longitude, latitude) { + const longitudeRadians = degToRadNumeric(longitude); + const latitudeRadians = degToRadNumeric(latitude); + const candidates = [...cache.values()] + .filter(({ tile }) => { + const { bounds } = tile; + return ( + longitude >= bounds.west && + longitude <= bounds.east && + latitude >= bounds.south && + latitude <= bounds.north + ); + }) + .sort((left, right) => right.tile.id.level - left.tile.id.level); + for (const entry of candidates) { + const height = entry.terrainData.interpolateHeight( + entry.rectangle, + longitudeRadians, + latitudeRadians + ); + if (Number.isFinite(height)) { + entry.lastUsed = ++useClock; + return height; + } + } + return undefined; + }, + trimCache, + }; +}; + +export const acquireCesiumTerrainTileSource = ( + terrainUrl: string, + options: CesiumTerrainTileSourceOptions = {} +): Promise => { + const normalizedUrl = terrainUrl.trim().replace(/\/+$/, ""); + if (!normalizedUrl) throw new TypeError("Terrain URL must not be empty"); + const cached = sourcePromises.get(normalizedUrl); + if (cached) return cached; + const pending = buildSource(normalizedUrl, options); + sourcePromises.set(normalizedUrl, pending); + void pending.catch(() => sourcePromises.delete(normalizedUrl)); + return pending; +}; diff --git a/libraries/mapping/engines/cesium/terrain/tsconfig.json b/libraries/mapping/engines/cesium/terrain/tsconfig.json new file mode 100644 index 0000000000..cca841b2b0 --- /dev/null +++ b/libraries/mapping/engines/cesium/terrain/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "files": [], + "references": [ + { "path": "./tsconfig.lib.json" }, + { "path": "./tsconfig.spec.json" } + ] +} diff --git a/libraries/mapping/engines/cesium/terrain/tsconfig.lib.json b/libraries/mapping/engines/cesium/terrain/tsconfig.lib.json new file mode 100644 index 0000000000..28d3b767b3 --- /dev/null +++ b/libraries/mapping/engines/cesium/terrain/tsconfig.lib.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "outDir": "../../../../../dist/out-tsc", + "declaration": true, + "noEmit": false, + "types": ["node", "vite/client"], + "skipLibCheck": true, + "lib": ["ES2021", "DOM"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"], + "exclude": ["vite.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libraries/mapping/engines/cesium/terrain/tsconfig.spec.json b/libraries/mapping/engines/cesium/terrain/tsconfig.spec.json new file mode 100644 index 0000000000..7531e996e9 --- /dev/null +++ b/libraries/mapping/engines/cesium/terrain/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../../dist/out-tsc", + "types": ["vitest/globals", "node", "vite/client"], + "skipLibCheck": true + }, + "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "src/**/*.d.ts"] +} diff --git a/libraries/mapping/engines/cesium/terrain/vite.config.ts b/libraries/mapping/engines/cesium/terrain/vite.config.ts new file mode 100644 index 0000000000..2483be275c --- /dev/null +++ b/libraries/mapping/engines/cesium/terrain/vite.config.ts @@ -0,0 +1,45 @@ +/// +import * as path from "path"; + +import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin"; +import { defineConfig } from "vite"; +import dts from "vite-plugin-dts"; + +export default defineConfig({ + root: __dirname, + cacheDir: + "../../../../../node_modules/.vite/libraries/mapping/engines/cesium/terrain", + plugins: [ + nxViteTsPaths(), + dts({ + entryRoot: "src", + tsconfigPath: path.join(__dirname, "tsconfig.lib.json"), + }), + ], + build: { + outDir: "../../../../../dist/libraries/mapping/engines/cesium/terrain", + emptyOutDir: true, + reportCompressedSize: true, + lib: { + entry: "src/index.ts", + name: "carma-cesium-terrain", + fileName: "index", + formats: ["es"], + }, + rollupOptions: { + external: ["@carma-cesium"], + }, + }, + test: { + watch: false, + globals: true, + environment: "node", + include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + reporters: ["default"], + coverage: { + reportsDirectory: + "../../../../../coverage/libraries/mapping/engines/cesium/terrain", + provider: "v8", + }, + }, +}); diff --git a/libraries/mapping/engines/maplibre/README.md b/libraries/mapping/engines/maplibre/README.md index 27ac9df7a6..a32b44ac78 100644 --- a/libraries/mapping/engines/maplibre/README.md +++ b/libraries/mapping/engines/maplibre/README.md @@ -17,3 +17,22 @@ nx test engines/maplibre ```sh nx lint engines/maplibre ``` + +## Shared Three.js scene + +`buildSharedThreeSceneLayer` creates one MapLibre custom layer whose Three.js +scene can host multiple `SharedThreeSceneRuntime` roots. Point clouds, 3D Tiles, +and simulation geometry can therefore share one renderer and scene graph. +`buildThreeTilesRuntime` adds a streamed Cesium 3D Tiles tileset to that scene; +callers can retain its `root` or use the layer's `getScene()` accessor for +custom shadow or simulation passes. + +Existing `carma3d` building and vegetation layers still own their established +custom scenes for selection and overlays. Their generic-layer registry emits +lifecycle and geometry changes so simulation addons can discover and light +that content consistently while it is visible. + +MapLibre raster-DEM terrain is not Three.js geometry in this scene. The shared +layer currently queries its elevation only to align the frame camera target. +A simulation that needs terrain as a shadow receiver must add an explicit +terrain-mesh runtime to the shared scene. diff --git a/libraries/mapping/engines/maplibre/src/components/LibreMap.tsx b/libraries/mapping/engines/maplibre/src/components/LibreMap.tsx index 58b0978148..b8079ae9af 100644 --- a/libraries/mapping/engines/maplibre/src/components/LibreMap.tsx +++ b/libraries/mapping/engines/maplibre/src/components/LibreMap.tsx @@ -3,8 +3,14 @@ import maplibregl from "maplibre-gl"; import type { StyleSpecification } from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; +import { + RETRY_TILE_PROTOCOL, + retryTileProtocol, +} from "../utils/retryTileProtocol"; // Register COG protocol once maplibregl.addProtocol("cog", cogProtocol as any); +// Tiles that must not stay missing after a dropped transfer (terrain DEM) +maplibregl.addProtocol(RETRY_TILE_PROTOCOL, retryTileProtocol); import { useCallback, useContext, @@ -14,6 +20,10 @@ import { useState, } from "react"; import { getHashParams } from "@carma-commons/utils"; +import type { + Carma3dConfig, + ThreePerfData, +} from "@carma-mapping/engines/threejs"; import { FeatureCollectionContext } from "react-cismap/contexts/FeatureCollectionContextProvider"; import PhotoLightBox from "react-cismap/topicmaps/PhotoLightbox"; import { TopicMapStylingContext } from "react-cismap/contexts/TopicMapStylingContextProvider"; @@ -75,9 +85,19 @@ import { FeatureInfobox } from "@carma-appframeworks/portals"; import { SelectionItem, useSelection } from "@carma-appframeworks/portals"; import { defaultLayerConf } from "@carma-appframeworks/portals"; import { useMapHashRouting } from "@carma-appframeworks/portals"; -import { ThreeLayerManager, get3dLayers } from "./ThreeLayerManager"; +import { ThreeLayerManager } from "./ThreeLayerManager"; +import { getGenericThreeLayers as get3dLayers } from "../lib/runtime/integrations/generic-three-layer-registry"; import { Tiles3dLayerManager } from "./Tiles3dLayerManager"; import type { Tiles3dConfig } from "./Tiles3dLayerManager"; +import { SharedThreeTilesLayerManager } from "./SharedThreeTilesLayerManager"; +import { + THREE_TILES_LAYER_TYPE, + type ThreeTilesLayer, +} from "../lib/runtime/integrations/three-tiles-layer"; +import { + notifyMapLibreStyleCompositionReady, + notifyMapLibreStyleCompositionStarted, +} from "../lib/runtime/integrations/map-style-layer-suppression"; const buildGazetteerRouteInfobox = (pos: number[], label: string) => ({ properties: { @@ -124,7 +144,7 @@ export interface VectorStyle { * e.g. "id" so selection/highlight feature-state keys by the stable DB pk. */ promoteId?: string; /** Optional 3D layer config; when present, a Three.js layer is auto-created. */ - carma3d?: import("@carma-mapping/engines/threejs").Carma3dConfig; + carma3d?: Carma3dConfig; /** Optional filter expression to AND into every style layer in this vector style * during style construction. The original filter is preserved at * metadata.originalFilter so consumers can still recover it. */ @@ -165,6 +185,7 @@ export interface RasterPaintOverrides { export type LibreLayer = | ({ type: "vector" } & VectorStyle) + | ThreeTilesLayer | { type: "geojson"; name: string; @@ -305,9 +326,7 @@ export interface LibreMapProps { /** Runtime parameters for 3D layers (e.g. radiusMix, useLoft) */ threeRuntimeParams?: Record; /** Ref for 3D layer performance data */ - threePerfRef?: React.MutableRefObject< - import("@carma-mapping/engines/threejs").ThreePerfData - >; + threePerfRef?: React.MutableRefObject; /** Maximum tilt (pitch) in degrees. Defaults to 60 (MapLibre's stock cap). */ maxPitch?: number; minZoom?: number; @@ -417,6 +436,18 @@ export const LibreMap = ({ }: LibreMapProps) => { const mapContainer = useRef(null); const map = useRef(null); + const mapStyleLayers = useMemo( + () => layers?.filter((layer) => layer.type !== THREE_TILES_LAYER_TYPE), + [layers] + ); + const threeTilesLayers = useMemo( + () => + (layers ?? []).filter( + (layer): layer is ThreeTilesLayer => + layer.type === THREE_TILES_LAYER_TYPE + ), + [layers] + ); const hidingManagerRef = useRef(null); const detachNonTiledRef = useRef<(() => void) | null>(null); const selectedFeaturesRef = useRef< @@ -443,7 +474,7 @@ export const LibreMap = ({ const vectorSourcesReadyRef = useRef(false); const [selectedFeature, setSelectedFeature] = useState(null); const [detectedCarma3dConfigs, setDetectedCarma3dConfigs] = useState< - import("@carma-mapping/engines/threejs").Carma3dConfig[] + Carma3dConfig[] >([]); const [detectedTiles3dConfigs, setDetectedTiles3dConfigs] = useState< Array @@ -847,7 +878,7 @@ export const LibreMap = ({ useImperativeStyle({ enabled: layerMode === "imperative", map: map.current, - layers, + layers: mapStyleLayers, backgroundStyle, vectorBackgroundLayers, clusteringEnabled, @@ -925,7 +956,9 @@ export const LibreMap = ({ publishMapThreeRuntimeParams(mapInstance, threeRuntimeParams); setLibreMap?.(mapInstance); setContextMap(mapInstance); - if (exposeMapToWindow) { + if (exposeMapToWindow || import.meta.env?.DEV) { + // Always exposed in dev builds: the perf and shadow debugging flows + // drive the map from the console. (window as unknown as Record).__carmaMap = mapInstance; } @@ -1461,17 +1494,17 @@ export const LibreMap = ({ // catch below can release them; otherwise an unexpected throw leaves every // layer button spinning forever. let preparedLayerIds: string[] = []; - const trackerRef = { current: null as ReturnType< - typeof ensureLayerLoadingTracker - > | null }; + const trackerRef = { + current: null as ReturnType | null, + }; const updateMapStyle = async () => { try { // Prepend vector background layers before data layers const effectiveLayers = vectorBackgroundLayers.length > 0 - ? [...vectorBackgroundLayers, ...(layers || [])] - : layers; + ? [...vectorBackgroundLayers, ...(mapStyleLayers || [])] + : mapStyleLayers; if (effectiveLayers) { // The style (re)build below refetches vector styles before any source @@ -1553,7 +1586,16 @@ export const LibreMap = ({ styleForMap = withoutTerrain as StyleSpecification; } - map.current?.setStyle(styleForMap); + const mapInstance = map.current; + if (mapInstance) { + notifyMapLibreStyleCompositionStarted(mapInstance); + mapInstance.setStyle(styleForMap); + // setStyle installs the complete layer graph synchronously. Mesh + // integrations may already be mounted from the previous style, + // or mount just after detectedTiles3dConfigs updates below; the + // revision signal handles both without a styledata feedback loop. + notifyMapLibreStyleCompositionReady(mapInstance); + } if (debugLog) console.log("[LAYER_MODE] merged: derived style", style); @@ -1576,8 +1618,7 @@ export const LibreMap = ({ // Detect carma3d configs from style metadata and explicit layer props { - const configs: import("@carma-mapping/engines/threejs").Carma3dConfig[] = - []; + const configs: Carma3dConfig[] = []; const sourceToIdx = new Map(); // Tilesets are collected in the same pass over the layers below. @@ -1669,7 +1710,8 @@ export const LibreMap = ({ const meta = (layer as any).metadata?.carmaConf?.["3d"]; if (!meta?.skipIn2D) continue; const sourceId = meta.sourceId ?? (layer as any).source; - const idx = sourceId === undefined ? undefined : sourceToIdx.get(sourceId); + const idx = + sourceId === undefined ? undefined : sourceToIdx.get(sourceId); if (idx === undefined) continue; configs[idx].skipIn2DLayerIds!.push((layer as any).id); } @@ -1773,7 +1815,7 @@ export const LibreMap = ({ } // Get mapping for vector layers (only from user-provided layers, not backgrounds) - const vectorLayers = (layers || []).filter( + const vectorLayers = (mapStyleLayers || []).filter( (layer) => layer.type === "vector" ); let mapping = {}; @@ -1805,7 +1847,7 @@ export const LibreMap = ({ if (filterFunction && map.current) { const applyFilter = () => { if (map.current) { - filterFunction(map.current, layers); + filterFunction(map.current, mapStyleLayers); } }; @@ -1879,7 +1921,7 @@ export const LibreMap = ({ }, [ backgroundStyle, vectorBackgroundLayers, - layers, + mapStyleLayers, clusteringEnabled, markerSymbolSize, filterFunction, @@ -2214,7 +2256,6 @@ export const LibreMap = ({ perfRef={threePerfRef} /> ))} - {/* Tilesets named by a style's own metadata, see Tiles3dLayerManager */} {detectedTiles3dConfigs.map((config) => ( ))} + ); }; diff --git a/libraries/mapping/engines/maplibre/src/components/SharedThreeTilesLayerManager.tsx b/libraries/mapping/engines/maplibre/src/components/SharedThreeTilesLayerManager.tsx new file mode 100644 index 0000000000..c013c837bf --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/components/SharedThreeTilesLayerManager.tsx @@ -0,0 +1,74 @@ +import { useEffect } from "react"; + +import { + buildThreeTilesRuntime, + THREE_TILES_DEFAULT_REQUEST_CONCURRENCY, +} from "../lib/runtime/integrations/three-tiles-runtime"; +import { + notifySharedThreeSceneContentChanged, + notifySharedThreeSceneRequestStateChanged, + registerSharedThreeSceneRuntime, +} from "../lib/runtime/integrations/shared-three-scene-content-registry"; +import { acquireSharedThreeScene } from "../lib/runtime/integrations/shared-three-scene-registry"; +import type { ThreeTilesLayer } from "../lib/runtime/integrations/three-tiles-layer"; +import { useLibreContext } from "../contexts/LibreContext"; + +const runtimeId = (name: string) => + `three-tiles-${name.replace(/[^a-zA-Z0-9_-]+/g, "-")}`; + +export const SharedThreeTilesLayerManager = ({ + layers, +}: { + layers: readonly ThreeTilesLayer[]; +}) => { + const { map } = useLibreContext(); + const layerKey = JSON.stringify(layers); + + useEffect(() => { + if (!map || layers.length === 0) return; + + const lease = acquireSharedThreeScene(map); + const runtimeRegistrations = layers.map((layer) => { + const center = map.getCenter(); + const origin = layer.origin ?? [center.lng, center.lat]; + const runtime = buildThreeTilesRuntime( + runtimeId(layer.carmaLayerId ?? layer.name), + layer.url, + origin, + { + requestConcurrency: + layer.requestConcurrency ?? THREE_TILES_DEFAULT_REQUEST_CONCURRENCY, + onContentChanged: () => notifySharedThreeSceneContentChanged(map), + onRequestStateChange: () => + notifySharedThreeSceneRequestStateChanged(map), + } + ); + runtime.setClayMaterial({ + color: layer.shader.color, + roughness: layer.shader.roughness, + metalness: layer.shader.metalness, + }); + runtime.setWhiteShading(true); + runtime.setOpacity(layer.opacity ?? 1); + runtime.setErrorTarget(layer.errorTarget ?? 8); + lease.layer.addRuntime(runtime); + return { + runtime, + unregister: registerSharedThreeSceneRuntime(map, runtime), + }; + }); + + return () => { + for (const { runtime, unregister } of runtimeRegistrations) { + unregister(); + lease.layer.removeRuntime(runtime.id); + } + lease.release(); + }; + // layerKey is the serializable lifecycle contract; depending on the array + // identity would reload tiles after unrelated host rerenders. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [layerKey, map]); + + return null; +}; diff --git a/libraries/mapping/engines/maplibre/src/components/ThreeLayerManager.spec.ts b/libraries/mapping/engines/maplibre/src/components/ThreeLayerManager.spec.ts new file mode 100644 index 0000000000..dcb3b4a72d --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/components/ThreeLayerManager.spec.ts @@ -0,0 +1,140 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; + +import { + getFootprintRadiusMeters, + retainBuildingGroupsInView, + type CachedBuildingGroup, +} from "./building-group-cache"; + +const buildGroup = ( + bounds: CachedBuildingGroup["bounds"], + fragment: number[][], + height = 10 +): CachedBuildingGroup => ({ + fragments: [fragment], + height, + zGround: 0, + isPublic: false, + roofColor: null, + wallColor: null, + sourceFeature: { + id: "building-1", + properties: {}, + source: "buildings", + sourceLayer: "building", + }, + bounds, +}); + +describe("retainBuildingGroupsInView", () => { + it("retains a missing building until its complete footprint leaves the viewport", () => { + const cache = new Map([ + [ + "building-1", + buildGroup({ west: -1, south: 0, east: 1, north: 1 }, [ + [-1, 0], + [1, 0], + [1, 1], + ]), + ], + ]); + + retainBuildingGroupsInView( + cache, + new Map(), + { west: 0, south: 0, east: 2, north: 2 }, + 0 + ); + expect(cache.has("building-1")).toBe(true); + + retainBuildingGroupsInView( + cache, + new Map(), + { west: 2, south: 0, east: 3, north: 2 }, + 0 + ); + expect(cache.has("building-1")).toBe(false); + }); + + it("merges newly queried fragments with retained parts of the same building", () => { + const firstFragment = [ + [0, 0], + [1, 0], + [1, 1], + ]; + const secondFragment = [ + [1, 0], + [2, 0], + [2, 1], + ]; + const cache = new Map([ + [ + "building-1", + buildGroup({ west: 0, south: 0, east: 1, north: 1 }, firstFragment), + ], + ]); + const queried = new Map([ + [ + "building-1", + buildGroup( + { west: 1, south: 0, east: 2, north: 1 }, + secondFragment, + 15 + ), + ], + ]); + + retainBuildingGroupsInView( + cache, + queried, + { west: 0, south: 0, east: 2, north: 2 }, + 0 + ); + + expect(cache.get("building-1")).toMatchObject({ + fragments: [firstFragment, secondFragment], + height: 15, + bounds: { west: 0, south: 0, east: 2, north: 1 }, + }); + }); + + it("does not retain prefetched buildings outside the padded viewport", () => { + const cache = new Map(); + const queried = new Map([ + [ + "building-1", + buildGroup({ west: 5, south: 5, east: 6, north: 6 }, [ + [5, 5], + [6, 5], + [6, 6], + ]), + ], + ]); + + retainBuildingGroupsInView( + cache, + queried, + { west: 0, south: 0, east: 1, north: 1 }, + 0.1 + ); + + expect(cache.size).toBe(0); + }); +}); + +describe("getFootprintRadiusMeters", () => { + it("measures a geographic ring in local metres", () => { + expect( + getFootprintRadiusMeters( + [ + [7.2, 51.201], + [7.2, 51.199], + ], + 7.2, + 51.2 + ) + ).toBeCloseTo(111.5, 0); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/components/ThreeLayerManager.tsx b/libraries/mapping/engines/maplibre/src/components/ThreeLayerManager.tsx index e8357f4a9d..7fb66d1264 100644 --- a/libraries/mapping/engines/maplibre/src/components/ThreeLayerManager.tsx +++ b/libraries/mapping/engines/maplibre/src/components/ThreeLayerManager.tsx @@ -1,10 +1,13 @@ import { useEffect, useMemo, useRef } from "react"; import { LngLat, MercatorCoordinate } from "maplibre-gl"; -import type { Map as MaplibreMap } from "maplibre-gl"; +import type { Map as MaplibreMap, MapGeoJSONFeature } from "maplibre-gl"; import * as THREE from "three"; -import type { Scene } from "three"; +import { + getGeographicRingBounds, + unionGeographicBounds, +} from "@carma-geo/helpers"; import { buildGenericLayer, buildOverlayLayer, @@ -31,30 +34,25 @@ import type { GenericCustomLayer, } from "@carma-mapping/engines/threejs"; +import { MAPLIBRE_EVENT } from "../constants/mapEvents"; import { useLibreContext } from "../contexts/LibreContext"; -import { add3dPresence, remove3dPresence } from "../utils/threeDPresence"; -// ───────────────────────────────────────────────────────────── -// ThreeLayerManager: bridges carma3d configs to the threejs engine -// ───────────────────────────────────────────────────────────── - -/** MapLibre paint properties that control opacity, keyed by layer type. */ -const OPACITY_PROPS: Record = { - circle: ["circle-opacity", "circle-stroke-opacity"], - fill: ["fill-opacity"], - line: ["line-opacity"], - symbol: ["icon-opacity", "text-opacity"], - "fill-extrusion": ["fill-extrusion-opacity"], - raster: ["raster-opacity"], - heatmap: ["heatmap-opacity"], -}; +import { + notifyGenericThreeLayerContentChanged, + registerGenericThreeLayer, + unregisterGenericThreeLayer, +} from "../lib/runtime/integrations/generic-three-layer-registry"; +import { getMapLibreLayerOpacityProperties } from "../lib/runtime/integrations/map-style-layer-suppression"; +import { + getSharedThreeTerrainElevation, + subscribeSharedThreeTerrain, +} from "../lib/runtime/integrations/shared-three-terrain-registry"; +import { + getFootprintRadiusMeters, + retainBuildingGroupsInView, + type CachedBuildingGroup, +} from "./building-group-cache"; -/** Whether the map can still be asked about its layers. - * - * A panel that goes away destroys its map, and React tears a deleted subtree - * down from the top, so this component's cleanup runs after that. `remove()` - * drops the style on its way out, which leaves every `getLayer` call reading - * a property of nothing. There is also nothing left worth removing at that - * point: the map took its layers with it. */ +/** Whether layer cleanup can still access the map style. */ function mapIsUsable(map: MaplibreMap | null | undefined): map is MaplibreMap { return !!map && !map._removed && !!map.style; } @@ -65,49 +63,11 @@ export interface ThreeLayerManagerProps { perfRef?: React.MutableRefObject; } -// ───────────────────────────────────────────────────────────── -// Registry: expose 3D layers on the map instance for click handling -// ───────────────────────────────────────────────────────────── - -const LAYER_REGISTRY_KEY = "__carma3dLayers"; - -function register3dLayer(map: MaplibreMap, layer: GenericCustomLayer): void { - const registry = - ((map as any)[LAYER_REGISTRY_KEY] as GenericCustomLayer[] | undefined) ?? []; - if (!registry.includes(layer)) { - registry.push(layer); - } - (map as any)[LAYER_REGISTRY_KEY] = registry; - add3dPresence(map, layer.id); - // console.log("[3D-SELECT] registered layer:", layer.id, "total:", registry.length); -} - -function unregister3dLayer(map: MaplibreMap, layer: GenericCustomLayer): void { - const registry = - ((map as any)[LAYER_REGISTRY_KEY] as GenericCustomLayer[] | undefined) ?? []; - const idx = registry.indexOf(layer); - remove3dPresence(map, layer.id); - if (idx >= 0) { - registry.splice(idx, 1); - // console.log("[3D-SELECT] unregistered layer:", layer.id, "remaining:", registry.length); - } - (map as any)[LAYER_REGISTRY_KEY] = registry; -} - -/** - * The colour a feature gets: a colour it carries wins, then a colour its - * category is mapped to. - * - * A colour in the data is more specific than one derived from a class, so the - * field is asked first and the mapping only fills in behind it. The mapping's - * `default` also answers for a feature that has no such property at all, which - * is what makes a half-filled table colour what it knows and leave the rest - * uniform rather than blank. - */ +/** Resolve a direct feature color before its category mapping and default. */ function resolveFeatureColor( properties: Record | undefined, field: string | undefined, - mapping: ColorMapping | undefined, + mapping: ColorMapping | undefined ): string | null { if (field) { const carried = properties?.[field]; @@ -128,16 +88,11 @@ function resolveFeatureColor( return null; } -/** Get all registered 3D layers from a map instance. */ -export function get3dLayers(map: MaplibreMap): GenericCustomLayer[] { - return ((map as any)[LAYER_REGISTRY_KEY] as GenericCustomLayer[] | undefined) ?? []; -} - /** Apply building color/opacity overrides to existing building meshes in-place. */ function applyBuildingAppearance( layer: GenericCustomLayer | null, color: string | undefined, - opacity: number | undefined, + opacity: number | undefined ): void { if (!layer) return; for (const child of layer.scene.children) { @@ -152,7 +107,9 @@ function applyBuildingAppearance( } // Update vertex colors - const colorAttr = mesh.geometry.getAttribute("color") as THREE.BufferAttribute | undefined; + const colorAttr = mesh.geometry.getAttribute("color") as + | THREE.BufferAttribute + | undefined; if (!colorAttr) continue; const colorArray = colorAttr.array as Float32Array; const origColors = mesh.userData.originalColors as Float32Array | undefined; @@ -189,27 +146,26 @@ export function ThreeLayerManager({ const overlayIdRef = useRef(null); const profilesEnsuredRef = useRef(false); const addingRef = useRef(false); - /** Saved 2D layer opacity values for restore when 3D layer is removed */ - const savedOpacityRef = useRef>>(new Map()); - /** Current building appearance overrides (kept in ref so syncBuildings can access) */ - const buildingAppearanceRef = useRef<{ color?: string; opacity?: number }>({}); - /** Where building colours come from this render (same ref reason) */ + const savedOpacityRef = useRef>>( + new Map() + ); + const buildingAppearanceRef = useRef<{ color?: string; opacity?: number }>( + {} + ); const buildingColorsRef = useRef(undefined); - /** Last logged building count to suppress repeated log lines */ - const lastLoggedCountRef = useRef(-1); const useLoft = (Number(runtimeParams.useLoft) || 0) > 0; const radiusMix = Number(runtimeParams.radiusMix) || 0; - const viewportPadding = typeof runtimeParams.viewportPadding === "number" ? runtimeParams.viewportPadding : undefined; + const viewportPadding = + typeof runtimeParams.viewportPadding === "number" + ? runtimeParams.viewportPadding + : undefined; - // Merge runtime viewportPadding override into config const effectiveConfig = useMemo( - () => - viewportPadding != null ? { ...config, viewportPadding } : config, + () => (viewportPadding != null ? { ...config, viewportPadding } : config), [config, viewportPadding] ); - // Effect 1: Layer lifecycle (tear down on mode change or unmount) useEffect(() => { if (!map) return; return () => { @@ -219,7 +175,7 @@ export function ThreeLayerManager({ } if (layerRef.current) { layerRef.current.unhighlight(); - unregister3dLayer(map, layerRef.current); + unregisterGenericThreeLayer(map, layerRef.current); const layerId = layerRef.current.id; if (map.getLayer(layerId)) { map.removeLayer(layerId); @@ -233,12 +189,9 @@ export function ThreeLayerManager({ }; }, [map, useLoft]); - // Effect 2: Restore 2D layer opacity on unmount (opacity is managed by addLayer/removeLayer) useEffect(() => { if (!map) return; return () => { - // Restore any saved 2D layer opacity on unmount. A map that has gone - // has taken those layers with it, so there is nothing to put back. if (mapIsUsable(map)) { for (const [layerId, originals] of savedOpacityRef.current) { if (!map.getLayer(layerId)) continue; @@ -260,15 +213,14 @@ export function ThreeLayerManager({ map?.triggerRepaint(); }, [config, map]); - // Effect 3: Data sync (re-runs on radius change without tearing down) useEffect(() => { if (!map) return; const isLod2 = config.renderMode === "lod2"; - // Both building modes want the same surroundings: a footprint layer, ground - // read from the terrain, and the same layer, selection and idle-sync - // plumbing. What the roof is built from is decided inside syncBuildings. const isExtrusion = config.renderMode === "extrusion" || isLod2; + const buildingElevationCache = new Map(); + const buildingGroupCache = new Map(); + let buildingGroupCacheZoom: number | null = null; const rebuildFn = useLoft ? ( @@ -300,7 +252,7 @@ export function ThreeLayerManager({ } if (layerRef.current) { layerRef.current.unhighlight(); - unregister3dLayer(map, layerRef.current); + unregisterGenericThreeLayer(map, layerRef.current); if (map.getLayer(layerRef.current.id)) { map.removeLayer(layerRef.current.id); } @@ -319,6 +271,8 @@ export function ThreeLayerManager({ layerRef.current = null; addingRef.current = false; savedOpacityRef.current.clear(); + buildingGroupCache.clear(); + buildingGroupCacheZoom = null; }; // The 3D custom layers should render above fill/line layers but below @@ -340,7 +294,8 @@ export function ThreeLayerManager({ // If the last source is our own, nothing to insert before const srcId = config.sourceId; - if (lastSource === srcId || lastSource.endsWith(`::${srcId}`)) return undefined; + if (lastSource === srcId || lastSource.endsWith(`::${srcId}`)) + return undefined; // Find the first layer from that last source for (const sl of layers) { @@ -368,13 +323,12 @@ export function ThreeLayerManager({ requestAnimationFrame(() => { try { if (!map.getLayer(layer.id)) return; - console.log("[3D-ZORDER] moving", layer.id, "before", beforeId); map.moveLayer(layer.id, beforeId); if (overlayIdRef.current && map.getLayer(overlayIdRef.current)) { map.moveLayer(overlayIdRef.current, beforeId); } } catch (err) { - console.warn("[3D-ZORDER] moveLayer failed:", err); + console.error("[3D-ZORDER] moveLayer failed:", err); zOrderTarget = undefined; // allow retry } }); @@ -393,16 +347,20 @@ export function ThreeLayerManager({ const layerId = isExtrusion ? `3d-${isLod2 ? "lod2" : "extrusion"}-${config.sourceId}` - : useLoft ? "3d-generic-loft" : "3d-generic"; - const customLayer = buildGenericLayer(effectiveConfig, rebuildFn, layerId); + : useLoft + ? "3d-generic-loft" + : "3d-generic"; + const customLayer = buildGenericLayer( + effectiveConfig, + rebuildFn, + layerId + ); layerRef.current = customLayer; try { const initialBeforeId = findInsertBefore(); - console.log("[3D-ZORDER] addLayer", layerId, "beforeId:", initialBeforeId, - "source:", config.sourceId); map.addLayer(customLayer, initialBeforeId); - register3dLayer(map, customLayer); + registerGenericThreeLayer(map, customLayer); const oId = layerId + "-overlay"; const overlay = buildOverlayLayer(customLayer, oId); @@ -414,8 +372,8 @@ export function ThreeLayerManager({ for (const lid of config.skipIn2DLayerIds) { const layer2d = map.getLayer(lid); if (!layer2d) continue; - const props = OPACITY_PROPS[layer2d.type]; - if (!props) continue; + const props = getMapLibreLayerOpacityProperties(layer2d.type); + if (props.length === 0) continue; const originals: Array<[string, unknown]> = []; for (const prop of props) { originals.push([prop, map.getPaintProperty(lid, prop)]); @@ -425,7 +383,7 @@ export function ThreeLayerManager({ } } } catch (err) { - console.warn("[3D-SELECT] addLayer failed:", err); + console.error("[3D-SELECT] addLayer failed:", err); layerRef.current = null; } finally { addingRef.current = false; @@ -440,7 +398,10 @@ export function ThreeLayerManager({ // Initialize origin if not yet set (extrusion layers skip the tree rebuild() path) if (!layer._originMerc) { - const originMerc = MercatorCoordinate.fromLngLat(resolveOrigin(config), 0); + const originMerc = MercatorCoordinate.fromLngLat( + resolveOrigin(config), + 0 + ); layer._originMerc = originMerc; layer._mScale = originMerc.meterInMercatorCoordinateUnits(); } @@ -451,63 +412,49 @@ export function ThreeLayerManager({ const wallColorField = config.fields?.wallColorField; const roofColorMap = config.roofColorMap; const wallColorMap = config.wallColorMap; - // Where the roof surfaces live and what they call the plane they lie in. - // Defaults are the names the LoD2 tileset uses. const roofSourceLayer = config.roofSourceLayer ?? "roof"; const parentField = config.fields?.roofParentField ?? "parent_fid"; const groundField = config.fields?.groundField ?? "z_ground"; const gradEField = config.fields?.planeGradEField ?? "grad_e"; const gradNField = config.fields?.planeGradNField ?? "grad_n"; const zRefField = config.fields?.planeZRefField ?? "z_ref"; - // In lod2 mode the height decides nothing about the shape, which comes - // from the roof surfaces. It is still read where it is configured, - // because the raycast grid needs a rough height per building. - if (!isLod2 && !heightField) { console.warn("[3D-BUILDINGS] no heightField configured"); return; } + // LoD2 geometry comes from roof surfaces; height only sizes its ray grid. + if (!isLod2 && !heightField) { + console.error("[3D-BUILDINGS] no heightField configured"); + return; + } const hasTerrain = map.getTerrain() != null; - // How the ground under a building is read. - // - // `map.queryTerrainElevation` decides which zoom to read the DEM at by - // running `coveringTiles` over the whole viewport, and it does that on - // every call before reading its one pixel. Asked once per building that - // is about 0.3 ms of tile arithmetic against a few microseconds of - // lookup, so a rebuild of several thousand buildings spends around a - // second of the main thread in there and the map stands still while the - // geometry is put together. Every tile that arrives asks for another one. - // - // The zoom it arrives at belongs to the camera, not to the point, so it - // is the same for every building in one rebuild. Worked out once and - // handed to the per-point entry point, the same lookup costs about a - // hundred and fiftieth of that and returns the same elevation. + // Resolve the terrain zoom once; MapLibre otherwise recomputes viewport + // coverage for every building elevation lookup. const terrain = map.terrain; const terrainZoom = Math.floor(map.getZoom()); const elevationAt = (lng: number, lat: number): number => { - if (!hasTerrain) { - return 0; - } - if (terrain) { - return terrain.getElevationForLngLatZoom( + const cacheKey = `${lng.toFixed(7)}/${lat.toFixed(7)}`; + let elevation: number | undefined; + if (hasTerrain && terrain) { + elevation = terrain.getElevationForLngLatZoom( new LngLat(lng, lat), terrainZoom ); + } else if (hasTerrain) { + elevation = map.queryTerrainElevation({ lng, lat }) ?? undefined; + } else { + elevation = getSharedThreeTerrainElevation(map, lng, lat); + } + if (Number.isFinite(elevation)) { + buildingElevationCache.set(cacheKey, elevation!); + return elevation!; } - return map.queryTerrainElevation({ lng, lat }) ?? 0; + return buildingElevationCache.get(cacheKey) ?? 0; }; const raw = map.querySourceFeatures(config.sourceId, { sourceLayer: config.sourceLayer, }); - // Logging moved to end-of-sync summary (only on change) - - // The roof surfaces, gathered per footprint. - // - // Deduplicated by feature id first. The tileset is cut without clipping, - // so a surface that straddles a tile boundary comes back once per tile it - // touches; left in, every one of its edges would be counted twice and the - // outline hashing in the factory would take the outside for the inside - // and leave the walls off. + // Tile-boundary duplicates would cancel roof outline edges. const facesByParent = new Map(); if (isLod2) { const seenRoofIds = new Set(); @@ -524,7 +471,11 @@ export function ThreeLayerManager({ const gradE = Number(rf.properties?.[gradEField]); const gradN = Number(rf.properties?.[gradNField]); const zRef = Number(rf.properties?.[zRefField]); - if (!Number.isFinite(gradE) || !Number.isFinite(gradN) || !Number.isFinite(zRef)) { + if ( + !Number.isFinite(gradE) || + !Number.isFinite(gradN) || + !Number.isFinite(zRef) + ) { continue; } @@ -555,24 +506,14 @@ export function ThreeLayerManager({ } } - // Group tile fragments by feature ID, keeping raw feature refs for _sourceFeatures - interface BldgGroup { - fragments: number[][][]; - height: number; - /** the footprint's ground height above sea level; lod2 mode only */ - zGround: number; - isPublic: boolean; - /** hex strings straight off the feature; the factory parses them */ - roofColor: string | null; - wallColor: string | null; - /** First raw feature for this group (used for _sourceFeatures snapshot) */ - rawFeature: (typeof raw)[0]; - } - const groups = new Map(); + // Source tiles may disappear while their building still crosses the + // viewport. Group the current fragments, then merge them into the + // zoom-local retention cache below. + const queriedGroups = new Map(); for (const f of raw) { const height = heightField - ? ((f.properties?.[heightField] as number) ?? 0) + ? (f.properties?.[heightField] as number) ?? 0 : 0; // In lod2 mode the height is only used for the raycast grid, so a // building without one is still worth drawing. @@ -592,32 +533,68 @@ export function ThreeLayerManager({ for (const ring of polyRings) { if (!ring || ring.length < 3) continue; - const fid = f.id ?? `${f.properties?.gml_id ?? ""}`; - const g = groups.get(fid); + const fragment = ring.map(([longitude, latitude]) => [ + longitude, + latitude, + ]); + const fid = + f.id ?? `${f.properties?.gml_id ?? JSON.stringify(fragment)}`; + const fragmentBounds = getGeographicRingBounds( + fragment as [number, number][] + ); + const mapFeature = f as MapGeoJSONFeature & { + sourceLayer?: string; + }; + const g = queriedGroups.get(fid); if (g) { - g.fragments.push(ring); + g.fragments.push(fragment); + g.bounds = unionGeographicBounds(g.bounds, fragmentBounds); } else { - groups.set(fid, { - fragments: [ring], + queriedGroups.set(fid, { + fragments: [fragment], height, zGround: Number(f.properties?.[groundField]) || 0, - isPublic: f.properties?.[publicField] === "1", + roofFaces: + facesByParent.get(String(f.properties?.fid ?? fid)) ?? + facesByParent.get(String(fid)), + isPublic: + publicField !== undefined && + f.properties?.[publicField] === "1", roofColor: resolveFeatureColor( f.properties, roofColorField, - roofColorMap, + roofColorMap ), wallColor: resolveFeatureColor( f.properties, wallColorField, - wallColorMap, + wallColorMap ), - rawFeature: f, + sourceFeature: { + id: f.id, + properties: { ...(f.properties ?? {}) }, + source: mapFeature.source ?? config.sourceId, + sourceLayer: mapFeature.sourceLayer ?? config.sourceLayer, + }, + bounds: fragmentBounds, }); } } } + const currentZoom = Math.floor(map.getZoom()); + if (buildingGroupCacheZoom !== currentZoom) { + buildingGroupCache.clear(); + buildingGroupCacheZoom = currentZoom; + } + const viewport = map.getBounds(); + retainBuildingGroupsInView(buildingGroupCache, queriedGroups, { + west: viewport.getWest(), + south: viewport.getSouth(), + east: viewport.getEast(), + north: viewport.getNorth(), + }); + // Build _sourceFeatures snapshot (parallel array, one entry per building group) // and assign sourceIndex to each building feature const sourceFeatures: Array<{ @@ -627,15 +604,21 @@ export function ThreeLayerManager({ sourceLayer: string; geometry: GeoJSON.Geometry | null; }> = []; - const groupEntries = Array.from(groups.entries()); + const groupEntries = Array.from(buildingGroupCache.entries()); for (const [, g] of groupEntries) { - const rf = g.rawFeature; + const sourceFeature = g.sourceFeature; sourceFeatures.push({ - id: rf.id, - properties: { ...(rf.properties ?? {}) }, - source: (rf as any).source ?? config.sourceId, - sourceLayer: (rf as any).sourceLayer ?? config.sourceLayer, - geometry: rf.geometry ?? null, + id: sourceFeature.id, + properties: { ...sourceFeature.properties }, + source: sourceFeature.source, + sourceLayer: sourceFeature.sourceLayer, + geometry: + g.fragments.length === 1 + ? { type: "Polygon", coordinates: [g.fragments[0]] } + : { + type: "MultiPolygon", + coordinates: g.fragments.map((ring) => [[...ring]]), + }, }); } @@ -652,32 +635,20 @@ export function ThreeLayerManager({ if (isLod2) { // One entry per building, not per fragment: the roof surfaces are the // geometry, and they are already gathered for the whole footprint. - const faces = - facesByParent.get(String(g.rawFeature.properties?.fid ?? groupEntries[gi][0])) ?? - facesByParent.get(String(groupEntries[gi][0])); + const faces = g.roofFaces; if (faces && faces.length > 0) { const ring = ringsToExtrude[0]; let cLng = 0; let cLat = 0; - for (const pt of ring) { cLng += pt[0]; cLat += pt[1]; } + for (const pt of ring) { + cLng += pt[0]; + cLat += pt[1]; + } cLng /= ring.length; cLat /= ring.length; const elevation = elevationAt(cLng, cLat); - // What the building is measured against. - // - // With terrain on, the survey's own heights are the ones that - // count: the 3D tileset of the same model is drawn at absolute - // heights, and so is the Cesium view, while anchoring a building - // to the DEM instead shifts it against both by however much the - // terrain model and the survey disagree about the ground under it. - // Handing `zGround` in as the elevation makes `elevation + (z - - // zGround)` come out at plain `z`, so the two draw the same - // building in the same place. - // - // Without terrain there is no ground to be absolute against: the - // map is flat at zero and the building is dropped onto it, which - // is what `elevationAt` returns there. + // Terrain mode preserves the survey's absolute LoD2 heights. const groundReference = hasTerrain ? g.zGround : elevation; lod2Buildings.push({ @@ -690,13 +661,7 @@ export function ThreeLayerManager({ sourceIndex, }); - let maxR = 0; - for (const pt of ring) { - const dLng = (pt[0] - cLng) * 111320 * Math.cos(cLat * Math.PI / 180); - const dLat = (pt[1] - cLat) * 110540; - const r = Math.sqrt(dLng * dLng + dLat * dLat); - if (r > maxR) maxR = r; - } + const maxR = getFootprintRadiusMeters(ring, cLng, cLat); mappedFeatures.push({ type: "building", lng: cLng, @@ -722,7 +687,10 @@ export function ThreeLayerManager({ for (const ring of ringsToExtrude) { let cLng = 0; let cLat = 0; - for (const pt of ring) { cLng += pt[0]; cLat += pt[1]; } + for (const pt of ring) { + cLng += pt[0]; + cLat += pt[1]; + } cLng /= ring.length; cLat /= ring.length; const elevation = elevationAt(cLng, cLat); @@ -736,14 +704,7 @@ export function ThreeLayerManager({ sourceIndex, }); - // Approximate footprint radius: max distance from centroid to any vertex - let maxR = 0; - for (const pt of ring) { - const dLng = (pt[0] - cLng) * 111320 * Math.cos(cLat * Math.PI / 180); - const dLat = (pt[1] - cLat) * 110540; - const r = Math.sqrt(dLng * dLng + dLat * dLat); - if (r > maxR) maxR = r; - } + const maxR = getFootprintRadiusMeters(ring, cLng, cLat); mappedFeatures.push({ type: "building", @@ -782,7 +743,7 @@ export function ThreeLayerManager({ layer.scene, layer._originMerc, layer._mScale, - buildingColorsRef.current, + buildingColorsRef.current ); } else { buildExtrusionMeshes( @@ -791,7 +752,7 @@ export function ThreeLayerManager({ layer._originMerc, layer._mScale, buildingColorsRef.current, - config.wallAngleThreshold, + config.wallAngleThreshold ); } @@ -800,6 +761,7 @@ export function ThreeLayerManager({ if (color || opacity != null) { applyBuildingAppearance(layer, color, opacity); } + notifyGenericThreeLayerContentChanged(map); // Build the spatial grid for raycast pre-filtering (reuse rebuild() logic) // We call rebuild() which rebuilds the grid from _features, but for extrusion @@ -808,7 +770,17 @@ export function ThreeLayerManager({ // Instead, build the grid inline to avoid double geometry creation. const originMerc = layer._originMerc; const mScale = layer._mScale; - const grid = new Map>(); + const grid = new Map< + string, + Array<{ + sourceIndex: number; + x: number; + z: number; + yBase: number; + height: number; + radius: number; + }> + >(); for (const f of mappedFeatures) { const mrc = MercatorCoordinate.fromLngLat([f.lng, f.lat], f.elevation); const x = (mrc.x - originMerc.x) / mScale; @@ -818,7 +790,14 @@ export function ThreeLayerManager({ const cellX = Math.floor(x / GRID_CELL_SIZE); const cellZ = Math.floor(z / GRID_CELL_SIZE); const key = `${cellX},${cellZ}`; - const entry = { sourceIndex: f._sourceIndex, x, z, yBase, height: f.heightMax, radius: f.radiusMax }; + const entry = { + sourceIndex: f._sourceIndex, + x, + z, + yBase, + height: f.heightMax, + radius: f.radiusMax, + }; const bucket = grid.get(key); if (bucket) bucket.push(entry); else grid.set(key, [entry]); @@ -842,22 +821,7 @@ export function ThreeLayerManager({ } } - if (builtCount !== lastLoggedCountRef.current) { - lastLoggedCountRef.current = builtCount; - console.log("[3D-BUILDINGS]", builtCount, "buildings,", - sourceFeatures.length, "sourceFeatures,", - grid.size, "grid cells"); - } - - // Ask for the frame this rebuild is meant to be seen in. - // - // What a custom layer holds is not part of MapLibre's own state, so - // replacing the geometry in the scene tells it nothing: it draws when it - // has a reason to, and new buildings are not one. Most rebuilds get away - // with it because what triggered them was a movement, which is drawing - // anyway. Switching terrain on is not: the camera stands still, the - // rebuild finishes, and the canvas keeps showing the frame from before it - // until the user nudges the map. + // Custom-layer geometry changes do not invalidate MapLibre by themselves. map.triggerRepaint(); }; @@ -870,6 +834,7 @@ export function ThreeLayerManager({ layerRef.current, radiusMix ); + if (result) notifyGenericThreeLayerContentChanged(map); if (result && perfRef) { perfRef.current = { ...result, @@ -878,111 +843,150 @@ export function ThreeLayerManager({ } }; + let extrusionSyncPending = isExtrusion; + let syncInFlight = false; + let rerunRequested = false; + let sourceSyncTimer: ReturnType | null = null; + const trySync = async () => { - // If the source's 2D layers are hidden, tear down the 3D layer - if (!isSourceVisible()) { - if (layerRef.current) { - // console.log("[3D-LAYER] hiding 3D layer (source layers not visible):", config.sourceId); - removeLayer(); - } + if (syncInFlight) { + rerunRequested = true; return; } + syncInFlight = true; + rerunRequested = false; + try { + // If the source's 2D layers are hidden, tear down the 3D layer + if (!isSourceVisible()) { + if (layerRef.current) { + removeLayer(); + } + return; + } - await addLayerIfReady(); - if (!layerRef.current || !map.getSource(config.sourceId)) return; + await addLayerIfReady(); + if (!layerRef.current || !map.getSource(config.sourceId)) return; - if (isExtrusion) { - syncBuildings(); - } else { - syncTrees(); + if (isExtrusion) { + if (!extrusionSyncPending) return; + extrusionSyncPending = false; + syncBuildings(); + } else { + syncTrees(); + } + } finally { + syncInFlight = false; + if (rerunRequested) void trySync(); } }; - map.on("moveend", trySync); + const requestSync = () => { + if (isExtrusion) extrusionSyncPending = true; + void trySync(); + }; - // For extrusion layers, also sync after idle (all tiles loaded) - const handleIdle = isExtrusion ? () => { trySync(); } : undefined; - if (handleIdle) map.on("idle", handleIdle); + const scheduleSourceSync = () => { + if (!isExtrusion) { + void trySync(); + return; + } + extrusionSyncPending = true; + if (sourceSyncTimer) clearTimeout(sourceSyncTimer); + sourceSyncTimer = setTimeout(() => { + sourceSyncTimer = null; + void trySync(); + }, 500); + }; + + map.on(MAPLIBRE_EVENT.MOVE_END, requestSync); + + // Idle also follows unrelated style/light changes. Use it only to flush + // source work that was explicitly marked dirty. + const handleIdle = isExtrusion + ? () => { + if (extrusionSyncPending && !sourceSyncTimer) void trySync(); + } + : undefined; + if (handleIdle) map.on(MAPLIBRE_EVENT.IDLE, handleIdle); const handleSourceData = (e: { sourceId: string; isSourceLoaded: boolean; }) => { - if (e.sourceId === config.sourceId && e.isSourceLoaded) { - trySync(); - } + if (e.sourceId !== config.sourceId) return; + // A vector source emits this once per arriving tile. Rebuilding here + // repeatedly triangulates progressively larger partial snapshots. Mark + // the batch dirty and consume it once the tile burst has settled. + scheduleSourceSync(); }; map.on("sourcedata", handleSourceData); // Force rebuild when terrain is toggled so elevation is applied/removed - const handleTerrain = () => { - trySync(); - }; - map.on("terrain", handleTerrain); + const handleTerrain = requestSync; + map.on(MAPLIBRE_EVENT.TERRAIN, handleTerrain); + const unsubscribeSharedTerrain = subscribeSharedThreeTerrain( + map, + requestSync + ); // Re-add layer after background style change (style swap removes custom layers) // Also re-checks visibility so toggling a layer back on re-creates the 3D layer const handleStyleData = () => { + let layerWasRemoved = false; if (layerRef.current && !map.getLayer(layerRef.current.id)) { - unregister3dLayer(map, layerRef.current); + unregisterGenericThreeLayer(map, layerRef.current); overlayIdRef.current = null; layerRef.current = null; addingRef.current = false; zOrderTarget = undefined; + layerWasRemoved = true; } // A later sub-style (e.g. POI) may have loaded after the 3D layer was // added, pushing it behind. Re-position if needed. ensureZOrder(); - trySync(); + // Paint and light changes emit styledata too. Source data and terrain + // have dedicated handlers, so rebuilding the complete extrusion here + // only turns a static sun adjustment into needless retriangulation. + if (!isSourceVisible()) { + if (layerRef.current) removeLayer(); + } else if (layerWasRemoved || !layerRef.current) { + requestSync(); + } }; - map.on("styledata", handleStyleData); + map.on(MAPLIBRE_EVENT.STYLE_DATA, handleStyleData); // Sync immediately if the map is already idle if (map.isStyleLoaded()) { - trySync(); + requestSync(); } else { - map.once("idle", trySync); + map.once(MAPLIBRE_EVENT.IDLE, requestSync); } return () => { - map.off("moveend", trySync); - if (handleIdle) map.off("idle", handleIdle); + if (sourceSyncTimer) clearTimeout(sourceSyncTimer); + map.off(MAPLIBRE_EVENT.MOVE_END, requestSync); + if (handleIdle) map.off(MAPLIBRE_EVENT.IDLE, handleIdle); map.off("sourcedata", handleSourceData); - map.off("terrain", handleTerrain); - map.off("styledata", handleStyleData); + map.off(MAPLIBRE_EVENT.TERRAIN, handleTerrain); + unsubscribeSharedTerrain(); + map.off(MAPLIBRE_EVENT.STYLE_DATA, handleStyleData); if (perfRef) { perfRef.current = EMPTY_PERF; } }; }, [map, useLoft, radiusMix, config, effectiveConfig, perfRef]); - // Effect 4: Update building appearance (color + opacity) in-place, no rebuild needed - const buildingColor = typeof runtimeParams.buildingColor === "string" ? runtimeParams.buildingColor : undefined; - // How opaque the buildings end up: what the layer is worth on its own, times - // what the host has asked of the layer as a whole. - // - // The multiplication is the same one the 2D side does, where the layer bar's - // slider scales each paint property against the value the style gave it. So a - // layer drawn at 0.65 sits at 0.325 when the slider is halfway, and a slider - // at the top leaves the style's own number alone. Without this the slider - // moves nothing at all on a three.js layer, since it has no paint properties - // for the usual path to scale. + const buildingColor = + typeof runtimeParams.buildingColor === "string" + ? runtimeParams.buildingColor + : undefined; + // Match 2D layer-bar opacity semantics for the custom Three.js layer. const baseBuildingOpacity = typeof runtimeParams.buildingOpacity === "number" ? runtimeParams.buildingOpacity : config.buildingOpacity ?? DEFAULT_BUILDING_OPACITY; - const buildingOpacity = - baseBuildingOpacity * (config.layerOpacity ?? 1); - - // Where the buildings take their colours from. - // - // Naming any of the four says the features decide, whether they carry a - // colour outright or a category that is mapped to one. None of them: a - // building keeps the colour it gets from being public or not, which is what - // it has always had. - // - // `buildingColor` overrides either, since it repaints every vertex uniformly - // after the rebuild. + const buildingOpacity = baseBuildingOpacity * (config.layerOpacity ?? 1); + const buildingColors: BuildingColors | undefined = config.fields?.roofColorField || config.fields?.wallColorField || @@ -991,13 +995,20 @@ export function ThreeLayerManager({ ? featureBuildingColors : undefined; - // Keep refs in sync so syncBuildings can re-apply after geometry rebuild - buildingAppearanceRef.current = { color: buildingColor, opacity: buildingOpacity }; + buildingAppearanceRef.current = { + color: buildingColor, + opacity: buildingOpacity, + }; buildingColorsRef.current = buildingColors; useEffect(() => { - if (!map || (config.renderMode !== "extrusion" && config.renderMode !== "lod2")) return; + if ( + !map || + (config.renderMode !== "extrusion" && config.renderMode !== "lod2") + ) + return; applyBuildingAppearance(layerRef.current, buildingColor, buildingOpacity); + notifyGenericThreeLayerContentChanged(map); map.triggerRepaint(); }, [map, config.renderMode, buildingColor, buildingOpacity]); diff --git a/libraries/mapping/engines/maplibre/src/components/Tiles3dLayerManager.spec.ts b/libraries/mapping/engines/maplibre/src/components/Tiles3dLayerManager.spec.ts new file mode 100644 index 0000000000..d7c29d79e4 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/components/Tiles3dLayerManager.spec.ts @@ -0,0 +1,199 @@ +// @vitest-environment jsdom + +import { cleanup, render } from "@testing-library/react"; +import { createElement } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + resolveTiles3dErrorTarget, + Tiles3dLayerManager, +} from "./Tiles3dLayerManager"; +import type { Tiles3dConfig } from "./Tiles3dLayerManager"; + +vi.hoisted(() => { + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: () => "blob:vitest-maplibre-worker", + }); +}); + +const mocks = vi.hoisted(() => ({ + map: { + style: {}, + _removed: false, + getCenter: () => ({ lng: 7.15, lat: 51.25 }), + getTerrain: () => null, + getSource: () => ({}), + setTerrain: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }, + buildRuntime: vi.fn(), + removeRuntime: vi.fn(), +})); + +vi.mock("../contexts/LibreContext", () => ({ + useLibreContext: () => ({ map: mocks.map }), +})); +vi.mock("../lib/runtime/integrations/three-tiles-runtime", () => ({ + buildThreeTilesRuntime: mocks.buildRuntime, + TILES_ERROR_TARGET_DEFAULT_PIXELS: 4, + THREE_TILES_DEFAULT_REQUEST_CONCURRENCY: 64, +})); +vi.mock("../lib/runtime/integrations/shared-three-scene-registry", () => ({ + acquireSharedThreeScene: () => ({ + layer: { + addRuntime: vi.fn(), + hasRuntime: () => true, + removeRuntime: mocks.removeRuntime, + }, + setLocationLabelColor: vi.fn(), + release: vi.fn(), + }), +})); +vi.mock( + "../lib/runtime/integrations/shared-three-scene-content-registry", + () => ({ + notifySharedThreeSceneContentChanged: vi.fn(), + notifySharedThreeSceneRequestStateChanged: vi.fn(), + registerSharedThreeSceneRuntime: () => () => undefined, + }) +); +vi.mock("../utils/threeDPresence", () => ({ + add3dPresence: vi.fn(), + remove3dPresence: vi.fn(), +})); + +const buildFakeRuntime = (id: string) => ({ + id, + setErrorTarget: vi.fn(), + setOpacity: vi.fn(), + setOutlineVisible: vi.fn(), + setOutlineStyle: vi.fn(), + setCacheBudget: vi.fn(), +}); + +const baseConfig: Tiles3dConfig = { + renderMode: "tiles3d", + tilesetUrl: "https://tiles.test/mesh/tileset.json", + errorTarget: 4, + providesTerrain: true, +}; + +const renderManager = (config: Tiles3dConfig, layerOpacity?: number) => + createElement(Tiles3dLayerManager, { config, layerOpacity }); + +describe("resolveTiles3dErrorTarget", () => { + it("uses a 4 px target for a regular 3D tiles mesh", () => { + expect(resolveTiles3dErrorTarget({})).toBe(4); + }); + + it("keeps an explicit style target", () => { + expect(resolveTiles3dErrorTarget({ errorTarget: 1.25 })).toBe(1.25); + }); +}); + +describe("Tiles3dLayerManager", () => { + beforeEach(() => { + mocks.buildRuntime.mockReset(); + mocks.removeRuntime.mockReset(); + mocks.map.setTerrain.mockReset(); + mocks.buildRuntime.mockImplementation((id: string) => buildFakeRuntime(id)); + }); + afterEach(() => { + cleanup(); + }); + + it("applies target, opacity, outline and cache changes through the setters without a rebuild", () => { + const { rerender } = render(renderManager(baseConfig, 1)); + expect(mocks.buildRuntime).toHaveBeenCalledOnce(); + const runtime = mocks.buildRuntime.mock.results[0]?.value as ReturnType< + typeof buildFakeRuntime + >; + expect(runtime.setErrorTarget).toHaveBeenLastCalledWith(4); + + rerender(renderManager({ ...baseConfig, errorTarget: 1 }, 1)); + expect(mocks.buildRuntime).toHaveBeenCalledOnce(); + expect(runtime.setErrorTarget).toHaveBeenLastCalledWith(1); + + rerender( + renderManager({ ...baseConfig, errorTarget: 1, opacity: 0.5 }, 0.5) + ); + expect(mocks.buildRuntime).toHaveBeenCalledOnce(); + expect(runtime.setOpacity).toHaveBeenLastCalledWith(0.25); + + rerender( + renderManager( + { + ...baseConfig, + errorTarget: 1, + opacity: 0.5, + cacheBudgetBytes: 256 * 1024 ** 2, + cacheOverflowBytes: 64 * 1024 ** 2, + }, + 0.5 + ) + ); + expect(mocks.buildRuntime).toHaveBeenCalledOnce(); + expect(runtime.setCacheBudget).toHaveBeenLastCalledWith(256 * 1024 ** 2, { + overflowBytes: 64 * 1024 ** 2, + }); + + rerender( + renderManager( + { + ...baseConfig, + errorTarget: 1, + opacity: 0.5, + outline: false, + outlineColor: "#ff0000", + outlineOpacity: 0.3, + }, + 0.5 + ) + ); + expect(mocks.buildRuntime).toHaveBeenCalledOnce(); + expect(runtime.setOutlineVisible).toHaveBeenLastCalledWith(false); + expect(runtime.setOutlineStyle).toHaveBeenLastCalledWith({ + color: "#ff0000", + opacity: 0.3, + }); + expect(mocks.removeRuntime).not.toHaveBeenCalled(); + }); + + it("rebuilds the runtime for another tileset or terrain role", () => { + const { rerender } = render(renderManager(baseConfig)); + const first = mocks.buildRuntime.mock.results[0]?.value as ReturnType< + typeof buildFakeRuntime + >; + + rerender( + renderManager({ + ...baseConfig, + tilesetUrl: "https://tiles.test/other/tileset.json", + }) + ); + expect(mocks.buildRuntime).toHaveBeenCalledTimes(2); + expect(mocks.removeRuntime).toHaveBeenCalledWith(first.id); + + rerender( + renderManager({ + ...baseConfig, + tilesetUrl: "https://tiles.test/other/tileset.json", + providesTerrain: false, + }) + ); + expect(mocks.buildRuntime).toHaveBeenCalledTimes(3); + }); + + it("keeps MapLibre terrain active while Three owns the visible ground", () => { + const { unmount } = render(renderManager(baseConfig)); + + expect(mocks.map.setTerrain).toHaveBeenCalledWith({ + source: expect.any(String), + exaggeration: 1, + }); + unmount(); + expect(mocks.map.setTerrain).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/components/Tiles3dLayerManager.tsx b/libraries/mapping/engines/maplibre/src/components/Tiles3dLayerManager.tsx index d5bc9b4f97..d080c58965 100644 --- a/libraries/mapping/engines/maplibre/src/components/Tiles3dLayerManager.tsx +++ b/libraries/mapping/engines/maplibre/src/components/Tiles3dLayerManager.tsx @@ -1,11 +1,20 @@ import { useEffect, useRef } from "react"; -import { buildTiles3dLayer } from "@carma-mapping/engines/threejs"; -import type { Tiles3dCustomLayer } from "@carma-mapping/engines/threejs"; - import { useLibreContext } from "../contexts/LibreContext"; import { WUPPERTAL_TERRAIN_SOURCE_ID } from "../constants/wuppertalDefaultStyle"; import { add3dPresence, remove3dPresence } from "../utils/threeDPresence"; +import { + notifySharedThreeSceneContentChanged, + notifySharedThreeSceneRequestStateChanged, + registerSharedThreeSceneRuntime, +} from "../lib/runtime/integrations/shared-three-scene-content-registry"; +import { acquireSharedThreeScene } from "../lib/runtime/integrations/shared-three-scene-registry"; +import { + buildThreeTilesRuntime, + THREE_TILES_DEFAULT_REQUEST_CONCURRENCY, + TILES_ERROR_TARGET_DEFAULT_PIXELS, + type ThreeTilesRuntime, +} from "../lib/runtime/integrations/three-tiles-runtime"; // ───────────────────────────────────────────────────────────── // Tiles3dLayerManager: mounts a 3D Tiles tileset named by a style. @@ -59,6 +68,8 @@ export interface Tiles3dConfig { * being drawn wrong. */ terrainMandatory?: boolean; + /** The tileset itself supplies terrain, so separate Three.js terrain is redundant. */ + providesTerrain?: boolean; } export interface Tiles3dLayerManagerProps { @@ -67,6 +78,10 @@ export interface Tiles3dLayerManagerProps { layerOpacity?: number; } +export const resolveTiles3dErrorTarget = ( + config: Pick +): number => config.errorTarget ?? TILES_ERROR_TARGET_DEFAULT_PIXELS; + /** Whether the map can still be asked about its layers, see ThreeLayerManager. */ function mapIsUsable(map: unknown): boolean { const candidate = map as { _removed?: boolean; style?: unknown } | null; @@ -78,91 +93,82 @@ export function Tiles3dLayerManager({ layerOpacity, }: Tiles3dLayerManagerProps) { const { map } = useLibreContext(); - const layerRef = useRef(null); + const runtimeRef = useRef(null); // Whether the terrain demand has been answered for this mount, see below. const terrainSettledRef = useRef(false); - // Read while building a layer, which happens outside the effect that follows - // the slider, so the first frame after a rebuild is already at the right - // opacity instead of flashing opaque. + // Read while building a layer, which happens outside the effects that follow + // the sliders and the style, so the first frame after a rebuild is already + // at the right settings instead of flashing opaque or coarse. const layerOpacityRef = useRef(layerOpacity); layerOpacityRef.current = layerOpacity; + const configRef = useRef(config); + configRef.current = config; - // The origin is fixed when the layer is built, so it is deliberately not a - // dependency: re-anchoring it on every pan would tear the tileset down and - // load it again. A local metre frame is good enough across a city. + // Native style-declared tilesets use the shared Three.js scene as well. This + // is what lets the shadow add-on's directional light and shadow map reach + // them; without the add-on, the shared scene keeps its regular ambient-only + // rendering and no shadow light exists. + // + // The runtime is rebuilt only for another map, tileset or terrain role; + // everything else reaches it through its setters below, so a slider does not + // drop the tile cache. useEffect(() => { - if (!map || !config.tilesetUrl) return; + if (!map || !config.tilesetUrl || !mapIsUsable(map)) return; - const layerId = `3d-tiles-${config.tilesetUrl}`; + const initialConfig = configRef.current; const center = map.getCenter(); const origin: [number, number] = [center.lng, center.lat]; - - // Adding the layer is a repeated affair, not a one-off. MapLibre cannot - // diff a style while a custom layer is attached, so every change to the - // layer list rebuilds the style from scratch and takes this layer off - // again. The style can also still be loading when the config first - // arrives, and `addLayer` refuses outright while it is. Both are answered - // by trying again on the events that mark the style usable; the layer - // object survives in between, so a re-attach costs no downloads. - const attach = () => { - if (!mapIsUsable(map) || !map.isStyleLoaded()) return; - if (map.getLayer(layerId)) return; - - const layer = - layerRef.current ?? - buildTiles3dLayer(layerId, config.tilesetUrl, origin, { - errorTarget: config.errorTarget, - cacheBudgetBytes: config.cacheBudgetBytes, - cacheOverflowBytes: config.cacheOverflowBytes, - opacity: (config.opacity ?? 1) * (layerOpacityRef.current ?? 1), - outline: config.outline, - outlineColor: config.outlineColor, - outlineOpacity: config.outlineOpacity, - }); - layerRef.current = layer; - - try { - map.addLayer(layer); - // What lets the camera restriction know the map has become three - // dimensional. A tileset stays out of the raycast registry, which - // holds layers that answer `raycast`, and this one does not. - add3dPresence(map, layerId); - } catch (err) { - // The layer is kept: the next styledata or idle tries again. - console.warn("[3D-TILES] addLayer failed:", err); + const runtimeId = `three-tiles-${config.tilesetUrl.replace( + /[^a-zA-Z0-9_-]+/g, + "-" + )}`; + const lease = acquireSharedThreeScene(map); + const runtime = buildThreeTilesRuntime( + runtimeId, + config.tilesetUrl, + origin, + { + requestConcurrency: THREE_TILES_DEFAULT_REQUEST_CONCURRENCY, + cacheBudgetBytes: initialConfig.cacheBudgetBytes, + cacheOverflowBytes: initialConfig.cacheOverflowBytes, + outline: initialConfig.outline, + outlineColor: initialConfig.outlineColor, + outlineOpacity: initialConfig.outlineOpacity, + providesTerrain: config.providesTerrain, + shadowBuildingStyle: true, + onContentChanged: () => notifySharedThreeSceneContentChanged(map), + onRequestStateChange: () => + notifySharedThreeSceneRequestStateChanged(map), } - }; - - attach(); - map.on("styledata", attach); - map.on("idle", attach); + ); + runtime.setErrorTarget(resolveTiles3dErrorTarget(initialConfig)); + runtime.setOpacity( + (initialConfig.opacity ?? 1) * (layerOpacityRef.current ?? 1) + ); + runtime.setOutlineVisible(initialConfig.outline ?? true); + runtimeRef.current = runtime; + lease.layer.addRuntime(runtime); + // What lets the camera restriction know the map has become three + // dimensional. A tileset stays out of the raycast registry, which + // holds layers that answer `raycast`, and this one does not. + add3dPresence(map, runtimeId); + const unregisterRuntime = registerSharedThreeSceneRuntime(map, runtime); return () => { - map.off("styledata", attach); - map.off("idle", attach); - const layer = layerRef.current; - layerRef.current = null; - if (!layer) return; - remove3dPresence(map, layerId); - // A panel that goes away destroys its map before this runs, and it took - // its layers with it. - if (mapIsUsable(map) && map.getLayer(layerId)) { - map.removeLayer(layerId); + runtimeRef.current = null; + remove3dPresence(map, runtimeId); + unregisterRuntime(); + if (lease.layer.hasRuntime(runtime.id)) { + lease.layer.removeRuntime(runtime.id); } - layer.dispose(); + lease.release(); }; - }, [ - map, - config.tilesetUrl, - config.errorTarget, - config.opacity, - config.outline, - config.outlineColor, - config.outlineOpacity, - ]); + }, [map, config.tilesetUrl, config.providesTerrain]); // Terrain is only ever switched on here, never off again: the way back // belongs to the terrain control, and so does the setting it persists. + // Terrain-providing meshes still need MapLibre terrain so the host style's + // raster and vector content remains draped at the correct elevation. // // It is answered once per mount. The style can still be loading when the // config arrives, which is why this listens on `styledata` at all, but @@ -170,7 +176,7 @@ export function Tiles3dLayerManager({ // change is never a reason to switch it on a second time. Without that guard // the next change to the layer list would undo a deliberate switch-off. useEffect(() => { - if (!map || !config.terrainMandatory) return; + if (!map || (!config.terrainMandatory && !config.providesTerrain)) return; terrainSettledRef.current = false; @@ -192,19 +198,38 @@ export function Tiles3dLayerManager({ return () => { map.off("styledata", demandTerrain); }; - }, [map, config.terrainMandatory]); + }, [map, config.terrainMandatory, config.providesTerrain]); useEffect(() => { - layerRef.current?.setOutlineVisible(config.outline ?? true); + runtimeRef.current?.setErrorTarget( + resolveTiles3dErrorTarget({ errorTarget: config.errorTarget }) + ); + }, [config.errorTarget]); + + useEffect(() => { + runtimeRef.current?.setCacheBudget(config.cacheBudgetBytes, { + overflowBytes: config.cacheOverflowBytes, + }); + }, [config.cacheBudgetBytes, config.cacheOverflowBytes]); + + useEffect(() => { + runtimeRef.current?.setOutlineVisible(config.outline ?? true); }, [config.outline]); + useEffect(() => { + runtimeRef.current?.setOutlineStyle({ + color: config.outlineColor ?? 0x000000, + opacity: config.outlineOpacity ?? 1, + }); + }, [config.outlineColor, config.outlineOpacity]); + // The layer bar's slider reaches a 2D layer as paint properties, which a // custom layer has none of, so it is multiplied in here the way the three.js // building layers do it. useEffect(() => { - const layer = layerRef.current; - if (!layer) return; - layer.setOpacity((config.opacity ?? 1) * (layerOpacity ?? 1)); + const runtime = runtimeRef.current; + if (!runtime) return; + runtime.setOpacity((config.opacity ?? 1) * (layerOpacity ?? 1)); }, [config.opacity, layerOpacity]); return null; diff --git a/libraries/mapping/engines/maplibre/src/components/building-group-cache.ts b/libraries/mapping/engines/maplibre/src/components/building-group-cache.ts new file mode 100644 index 0000000000..84a7945f5e --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/components/building-group-cache.ts @@ -0,0 +1,94 @@ +import { MercatorCoordinate } from "maplibre-gl"; + +import { + geographicBoundsIntersect, + padGeographicBounds, + type GeographicBounds, + unionGeographicBounds, +} from "@carma-geo/helpers"; +import type { Lod2RoofFace } from "@carma-mapping/engines/threejs"; + +type BuildingSourceSnapshot = Readonly<{ + id: string | number | undefined; + properties: Record; + source: string; + sourceLayer: string; +}>; + +export type CachedBuildingGroup = { + fragments: number[][][]; + height: number; + zGround: number; + roofFaces?: Lod2RoofFace[]; + isPublic: boolean; + roofColor: string | null; + wallColor: string | null; + sourceFeature: BuildingSourceSnapshot; + bounds: GeographicBounds; +}; + +const BUILDING_CACHE_VIEWPORT_PADDING = 0.1; + +export const getFootprintRadiusMeters = ( + ring: number[][], + longitude: number, + latitude: number +): number => { + const center = MercatorCoordinate.fromLngLat([longitude, latitude], 0); + const mercatorUnitsPerMeter = center.meterInMercatorCoordinateUnits(); + let radiusMeters = 0; + + for (const [pointLongitude, pointLatitude] of ring) { + const point = MercatorCoordinate.fromLngLat( + [pointLongitude, pointLatitude], + 0 + ); + radiusMeters = Math.max( + radiusMeters, + Math.hypot(point.x - center.x, point.y - center.y) / mercatorUnitsPerMeter + ); + } + + return radiusMeters; +}; + +export const retainBuildingGroupsInView = ( + cache: Map, + queried: ReadonlyMap, + viewportBounds: GeographicBounds, + padding = BUILDING_CACHE_VIEWPORT_PADDING +): void => { + for (const [id, next] of queried) { + const previous = cache.get(id); + if (!previous) { + cache.set(id, next); + continue; + } + const fragmentKeys = new Set( + previous.fragments.map((fragment) => JSON.stringify(fragment)) + ); + const fragments = [...previous.fragments]; + for (const fragment of next.fragments) { + const key = JSON.stringify(fragment); + if (fragmentKeys.has(key)) continue; + fragmentKeys.add(key); + fragments.push(fragment); + } + cache.set(id, { + ...next, + fragments, + bounds: unionGeographicBounds(previous.bounds, next.bounds), + roofFaces: next.roofFaces?.length ? next.roofFaces : previous.roofFaces, + }); + } + + const retainedBounds = padGeographicBounds( + viewportBounds, + Math.max(0, padding) + ); + for (const [id, group] of cache) { + if (!geographicBoundsIntersect(group.bounds, retainedBounds)) { + cache.delete(id); + } + } +}; diff --git a/libraries/mapping/engines/maplibre/src/constants/mapEvents.ts b/libraries/mapping/engines/maplibre/src/constants/mapEvents.ts new file mode 100644 index 0000000000..00f8726541 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/constants/mapEvents.ts @@ -0,0 +1,15 @@ +export const MAPLIBRE_EVENT = { + IDLE: "idle", + MOVE: "move", + MOVE_END: "moveend", + MOVE_START: "movestart", + RENDER: "render", + RESIZE: "resize", + STYLE_DATA: "styledata", + STYLE_DATA_LOADING: "styledataloading", + STYLE_LOAD: "style.load", + TERRAIN: "terrain", +} as const; + +export type MapLibreEventName = + (typeof MAPLIBRE_EVENT)[keyof typeof MAPLIBRE_EVENT]; diff --git a/libraries/mapping/engines/maplibre/src/constants/wuppertalDefaultStyle.ts b/libraries/mapping/engines/maplibre/src/constants/wuppertalDefaultStyle.ts index 559fc7f949..bcee577100 100644 --- a/libraries/mapping/engines/maplibre/src/constants/wuppertalDefaultStyle.ts +++ b/libraries/mapping/engines/maplibre/src/constants/wuppertalDefaultStyle.ts @@ -1,6 +1,7 @@ import type { StyleSpecification } from "maplibre-gl"; import { slugifyUrl } from "../utils/styleComposer"; +import { withRetryTileProtocol } from "../utils/retryTileProtocol"; /** * Configuration for creating a city-specific MapLibre default style. * Other cities can define their own config and use createDefaultStyle(). @@ -45,7 +46,9 @@ export function createTerrainSources( return { [slugifyUrl(config.terrain.url)]: { type: "raster-dem", - tiles: [config.terrain.url], + // A DEM tile that drops out leaves the terrain flat there for good; + // fetch it through the retrying protocol instead. + tiles: [withRetryTileProtocol(config.terrain.url)], tileSize: config.terrain.tileSize ?? 512, maxzoom: config.terrain.maxzoom ?? 15, }, diff --git a/libraries/mapping/engines/maplibre/src/hooks/useImperativeStyle.ts b/libraries/mapping/engines/maplibre/src/hooks/useImperativeStyle.ts index 06d3b63f7f..8ae16cfe9d 100644 --- a/libraries/mapping/engines/maplibre/src/hooks/useImperativeStyle.ts +++ b/libraries/mapping/engines/maplibre/src/hooks/useImperativeStyle.ts @@ -17,6 +17,14 @@ import { } from "../utils/styleComposer"; import type { LibreLayer } from "../components/LibreMap"; import { getVectorMapping } from "../utils/styleBuilder"; +import { + notifyMapLibreStyleCompositionReady, + notifyMapLibreStyleCompositionStarted, +} from "../lib/runtime/integrations/map-style-layer-suppression"; +import { + getLibreLayerCompositionKey, + getLibreLayerSubStyleId, +} from "../lib/style-composition/libre-layer-identity"; export interface UseImperativeStyleOptions { /** When false the hook is inert (merged mode is active). */ @@ -40,52 +48,6 @@ export interface UseImperativeStyleOptions { onHidingManagerRefresh: () => void; } -/** Compute a stable key for a LibreLayer entry for diff purposes. */ -function layerKey(layer: LibreLayer, index: number): string { - switch (layer.type) { - case "vector": - return `vector::${layer.name}::${ - typeof layer.style === "string" ? layer.style : "inline" - }`; - case "geojson": - return `geojson::${layer.name}::${layer.data}`; - case "wms": - case "wmts": - return `${layer.type}::${layer.url}::${layer.layers}::${ - layer.nonTiled ? "nt" : "tiled" - }`; - case "tiles": - return `tiles::${layer.name}::${layer.url}`; - case "cog": - return `cog::${layer.name}::${layer.url}`; - default: - return `unknown::${index}`; - } -} - -/** Derive a sub-style ID that matches the key used by StyleComposer.managed. */ -function subStyleId(layer: LibreLayer, index: number): string { - switch (layer.type) { - case "vector": { - // Must match the layerId used in StyleComposer.addVectorSubStyle - return typeof layer.style === "string" - ? slugifyUrl(layer.style) - : layer.name; - } - case "geojson": - return `geojson-${layer.name}-${index}`; - case "wms": - case "wmts": - return `raster-${layer.layers.replace(/[^a-zA-Z0-9]/g, "-")}-${index}`; - case "tiles": - return `tiles-${layer.name.replace(/[^a-zA-Z0-9]/g, "-")}-${index}`; - case "cog": - return `cog-${layer.name}-${index}`; - default: - return `layer-${index}`; - } -} - export function useImperativeStyle({ enabled, map, @@ -128,8 +90,8 @@ export function useImperativeStyle({ for (let i = 0; i < effectiveLayers.length; i++) { const layer = effectiveLayers[i]; - const id = subStyleId(layer, i); - const key = layerKey(layer, i); + const id = getLibreLayerSubStyleId(layer, i); + const key = getLibreLayerCompositionKey(layer, i); newKeys.push(key); newIds.push(id); @@ -197,7 +159,10 @@ export function useImperativeStyle({ l.infoboxMapping && l.infoboxMapping.length > 0 ) { - const geoId = subStyleId(l, vectorBackgroundLayers.length + idx); + const geoId = getLibreLayerSubStyleId( + l, + vectorBackgroundLayers.length + idx + ); mapping[l.name] = l.infoboxMapping; mapping[`${geoId}::geojson`] = l.infoboxMapping; } @@ -219,6 +184,7 @@ export function useImperativeStyle({ if (filterFunction) { filterFunction(mapInst, layers); } + notifyMapLibreStyleCompositionReady(mapInst); } finally { isApplyingRef.current = false; } @@ -274,6 +240,7 @@ export function useImperativeStyle({ ...(overrideGlyphs ? { glyphs: overrideGlyphs } : {}), }; + notifyMapLibreStyleCompositionStarted(map); map.setStyle(baseStyle); // Wait for style to load, then apply all layers @@ -315,8 +282,8 @@ export function useImperativeStyle({ const effectiveLayers = [...vectorBackgroundLayers, ...(layers || [])]; - const newKeys = effectiveLayers.map((l, i) => layerKey(l, i)); - const newIds = effectiveLayers.map((l, i) => subStyleId(l, i)); + const newKeys = effectiveLayers.map(getLibreLayerCompositionKey); + const newIds = effectiveLayers.map(getLibreLayerSubStyleId); const oldKeys = prevKeysRef.current; const oldIds = prevIdsRef.current; @@ -325,6 +292,7 @@ export function useImperativeStyle({ newKeys.length === oldKeys.length && newKeys.every((k, i) => k === oldKeys[i]) ) { + let opacityChanged = false; for (let i = 0; i < effectiveLayers.length; i++) { const layer = effectiveLayers[i]; const id = newIds[i]; @@ -334,6 +302,7 @@ export function useImperativeStyle({ "opacityTransition" in layer ? layer.opacityTransition : undefined; const prevOpacity = prevOpacitiesRef.current.get(id) ?? 1; if (newOpacity !== prevOpacity) { + opacityChanged = true; if (layer.type === "vector") { composer.updateVectorOpacity(id, newOpacity, transition); } else if ( @@ -347,6 +316,7 @@ export function useImperativeStyle({ prevOpacitiesRef.current.set(id, newOpacity); } } + if (opacityChanged) notifyMapLibreStyleCompositionReady(map); return; } @@ -440,7 +410,10 @@ export function useImperativeStyle({ l.infoboxMapping && l.infoboxMapping.length > 0 ) { - const geoId = subStyleId(l, vectorBackgroundLayers.length + idx); + const geoId = getLibreLayerSubStyleId( + l, + vectorBackgroundLayers.length + idx + ); mapping[l.name] = l.infoboxMapping; mapping[`${geoId}::geojson`] = l.infoboxMapping; } @@ -452,6 +425,7 @@ export function useImperativeStyle({ if (filterFunction) { filterFunction(map, layers); } + notifyMapLibreStyleCompositionReady(map); }; void diffAndApply(); diff --git a/libraries/mapping/engines/maplibre/src/index.ts b/libraries/mapping/engines/maplibre/src/index.ts index 13610b1650..098864f1cb 100644 --- a/libraries/mapping/engines/maplibre/src/index.ts +++ b/libraries/mapping/engines/maplibre/src/index.ts @@ -225,11 +225,72 @@ export { DEFAULT_MAPLIBRE_PITCH_MAX_DEG, DEFAULT_MAPLIBRE_PITCH_MIN_DEG, } from "./constants/cameraDefaults"; +export { + MAPLIBRE_EVENT, + type MapLibreEventName, +} from "./constants/mapEvents"; // Three.js layer management -export { ThreeLayerManager, get3dLayers } from "./components/ThreeLayerManager"; +export { ThreeLayerManager } from "./components/ThreeLayerManager"; +export { getGenericThreeLayers as get3dLayers } from "./lib/runtime/integrations/generic-three-layer-registry"; export { has3dLayers } from "./utils/threeDPresence"; export type { ThreeLayerManagerProps } from "./components/ThreeLayerManager"; +export { + buildSharedThreeSceneLayer, + getSharedThreeShadowViewSignature, +} from "./lib/runtime/integrations/shared-three-scene-layer"; +export type { + SharedThreeSceneFrame, + SharedThreeSceneLayer, + SharedThreeSceneRuntime, + SharedThreeSceneShadowView, + SharedThreeSceneTileVolume, +} from "./lib/runtime/integrations/shared-three-scene-layer"; +export { + createSharedThreeSceneCameraPreview, + type SharedThreeSceneCameraPreview, +} from "./lib/runtime/integrations/shared-three-scene-camera-preview"; +export { acquireSharedThreeScene } from "./lib/runtime/integrations/shared-three-scene-registry"; +export { + getSharedThreeSceneRuntimes, + notifySharedThreeSceneContentChanged, + notifySharedThreeSceneRequestStateChanged, + registerSharedThreeSceneRuntime, + subscribeSharedThreeSceneContent, + subscribeSharedThreeSceneRequestState, +} from "./lib/runtime/integrations/shared-three-scene-content-registry"; +export { + getGenericThreeLayers, + notifyGenericThreeLayerContentChanged, + registerGenericThreeLayer, + subscribeGenericThreeLayers, + unregisterGenericThreeLayer, +} from "./lib/runtime/integrations/generic-three-layer-registry"; +export type { ThreeTilesLayer } from "./lib/runtime/integrations/three-tiles-layer"; +export { + THREE_TILES_LAYER_TYPE, + THREE_TILES_SHADER_KIND, +} from "./lib/runtime/integrations/three-tiles-layer"; +export { + buildThreeTilesRuntime, + TILES_ERROR_TARGET_DEFAULT_PIXELS, + TILES_ERROR_TARGET_MAX_PIXELS, + TILES_ERROR_TARGET_MIN_PIXELS, +} from "./lib/runtime/integrations/three-tiles-runtime"; +export type { + ImageProjector, + ThreeTilesRuntime, +} from "./lib/runtime/integrations/three-tiles-runtime"; +export { buildCesiumTerrainRuntime } from "./lib/runtime/integrations/cesium-terrain-tile-runtime"; +export type { CesiumTerrainRuntimeOptions } from "./lib/runtime/integrations/cesium-terrain-tile-runtime"; +export { isSharedThreeTerrainLoading } from "./lib/runtime/integrations/shared-three-terrain-registry"; +export { + acquireMapLibreTerrainMeshComposition, + isMapStyleContourLineLayer, + MAPLIBRE_TERRAIN_MESH_BASE_OPACITY, + suppressMapLibreRegularStyleLayers, +} from "./lib/runtime/integrations/map-style-layer-suppression"; + // Styles (CSS should be imported by consumers) // import '@carma-mapping/engines/maplibre/styles/map.css'; diff --git a/libraries/mapping/engines/maplibre/src/lib/contracts/maplibre-style.d.ts b/libraries/mapping/engines/maplibre/src/lib/contracts/maplibre-style.d.ts index 298e14a044..4527e4213a 100644 --- a/libraries/mapping/engines/maplibre/src/lib/contracts/maplibre-style.d.ts +++ b/libraries/mapping/engines/maplibre/src/lib/contracts/maplibre-style.d.ts @@ -53,6 +53,7 @@ export type CarmaMapLibreStyleMetadata = { header?: string; accentColor?: string; keywords?: string[]; + tags?: string[]; }; }; }; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-runtime.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-runtime.spec.ts new file mode 100644 index 0000000000..4e4197f929 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-runtime.spec.ts @@ -0,0 +1,1258 @@ +import { MercatorCoordinate } from "maplibre-gl"; +import { + BufferGeometry, + Camera, + Float32BufferAttribute, + Group, + Mesh, + OrthographicCamera, + PerspectiveCamera, + FrontSide, + MeshLambertMaterial, + Vector2, + Vector3, +} from "three"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + acquireCesiumTerrainTileSource, + createProjectedTerrainTileGeometry, + notifySharedThreeTerrainChanged, + registerSharedThreeTerrainSampler, + setSharedThreeTerrainLoading, +} = vi.hoisted(() => ({ + acquireCesiumTerrainTileSource: vi.fn(), + createProjectedTerrainTileGeometry: vi.fn(), + notifySharedThreeTerrainChanged: vi.fn(), + registerSharedThreeTerrainSampler: vi.fn(() => vi.fn()), + setSharedThreeTerrainLoading: vi.fn(), +})); + +vi.mock("@carma-mapping/engines/three/primitives", () => ({ + createProjectedTerrainTileGeometry, +})); + +vi.mock("@carma-mapping/engines/cesium/terrain", () => ({ + acquireCesiumTerrainTileSource, + cesiumTerrainTileKey: ({ level, x, y }: Record) => + `${level}/${x}/${y}`, +})); + +vi.mock("./shared-three-terrain-registry", () => ({ + notifySharedThreeTerrainChanged, + registerSharedThreeTerrainSampler, + setSharedThreeTerrainLoading, +})); + +import { buildCesiumTerrainRuntime } from "./cesium-terrain-tile-runtime"; + +describe("buildCesiumTerrainRuntime", () => { + let fineBoundaryNormalBeforeSmoothing: Vector3 | null; + + beforeEach(() => { + vi.clearAllMocks(); + fineBoundaryNormalBeforeSmoothing = null; + createProjectedTerrainTileGeometry.mockImplementation(({ tile }) => { + const geometry = new BufferGeometry(); + const isSyntheticFlat = + tile.heightMeters.length === 4 && + tile.heightMeters.every((height) => height === 0); + const tileX = tile.id?.x; + const west = tileX === 531 ? -1 : tileX === 533 ? 1 : 0; + const nearHeight = tile.heightMeters[0] === 123 ? 1 : 0; + const farHeight = !isSyntheticFlat && tileX === 533 ? 1 : 0; + if (tile.heightMeters[0] === 456) { + geometry.setAttribute( + "position", + new Float32BufferAttribute( + new Float32Array([ + 1, 0, 0, 1, 0, -0.5, 1, 0, -1, 2, 1, 0, 2, 1, -0.5, 2, 1, -1, + ]), + 3 + ) + ); + geometry.setIndex([0, 3, 1, 1, 3, 4, 1, 4, 2, 2, 4, 5]); + geometry.computeVertexNormals(); + const normal = geometry.getAttribute("normal"); + fineBoundaryNormalBeforeSmoothing = new Vector3( + normal.getX(1), + normal.getY(1), + normal.getZ(1) + ); + return geometry; + } + geometry.setAttribute( + "position", + new Float32BufferAttribute( + new Float32Array([ + west, + nearHeight, + 0, + west, + nearHeight, + -1, + west + 1, + farHeight, + 0, + west + 1, + farHeight, + -1, + ]), + 3 + ) + ); + geometry.setIndex([0, 2, 1, 1, 2, 3]); + geometry.computeVertexNormals(); + return geometry; + }); + }); + + it("caps an excessive configured request concurrency per terrain origin", async () => { + const tileIds = Array.from({ length: 30 }, (_, x) => ({ + level: 10, + x, + y: 0, + })); + const requestTile = vi.fn(() => new Promise(() => undefined)); + const source = { + requestTile, + getTileGridIdsForBounds: vi.fn(() => tileIds), + getTileBounds: vi.fn(() => ({ + west: 7, + south: 51, + east: 7.4, + north: 51.3, + })), + getLevelMaximumGeometricError: vi.fn(() => 0.00001), + getTileDataAvailable: vi.fn(() => true), + sampleHeight: vi.fn(() => 150), + trimCache: vi.fn(), + }; + acquireCesiumTerrainTileSource.mockResolvedValue(source); + const runtime = buildCesiumTerrainRuntime( + "bounded-terrain", + "https://example.test/bounded-terrain", + [7.15, 51.256], + { + minimumLevel: 10, + maximumLevel: 10, + maxSelectionTiles: tileIds.length, + requestConcurrency: 256, + } + ); + const map = { + getBounds: vi.fn(() => ({ + getWest: () => 7, + getSouth: () => 51, + getEast: () => 7.4, + getNorth: () => 51.3, + })), + triggerRepaint: vi.fn(), + }; + runtime.onAdd?.(map as never); + await vi.waitFor(() => { + expect(registerSharedThreeTerrainSampler).toHaveBeenCalled(); + }); + const lodCamera = new PerspectiveCamera(60, 1, 1, 10_000); + lodCamera.position.set(0, 1_000, 0); + + runtime.update({ + map: map as never, + renderCamera: new Camera(), + lodCamera, + lookTarget: new Vector3(), + viewport: new Vector2(1_000, 1_000), + }); + + await vi.waitFor(() => expect(requestTile).toHaveBeenCalledTimes(24)); + runtime.dispose(); + }); + + it("interpolates coarse neighbor normals for finer LOD edge vertices", async () => { + const coarseId = { level: 10, x: 532, y: 218 }; + const fineId = { level: 11, x: 533, y: 218 }; + const source = { + requestTile: vi.fn(async (id) => ({ + id, + heightMeters: + id === fineId ? new Float32Array([456]) : new Float32Array([100]), + westIndices: + id === fineId ? new Uint32Array([0, 1, 2]) : new Uint32Array(), + southIndices: new Uint32Array(), + eastIndices: + id === coarseId ? new Uint32Array([2, 3]) : new Uint32Array(), + northIndices: new Uint32Array(), + })), + getTileGridIdsForBounds: vi.fn(() => [coarseId, fineId]), + getTileBounds: vi.fn((id) => + id === fineId + ? { west: 7.2, south: 51, east: 7.4, north: 51.3 } + : { west: 7, south: 51, east: 7.2, north: 51.3 } + ), + getLevelMaximumGeometricError: vi.fn(() => 0.00001), + getTileDataAvailable: vi.fn(() => true), + sampleHeight: vi.fn(() => 150), + trimCache: vi.fn(), + }; + acquireCesiumTerrainTileSource.mockResolvedValue(source); + const runtime = buildCesiumTerrainRuntime( + "mixed-lod-terrain", + "https://example.test/mixed-lod-terrain", + [7.15, 51.256], + { minimumLevel: 10, maximumLevel: 11 } + ); + const map = { + getBounds: vi.fn(() => ({ + getWest: () => 7, + getSouth: () => 51, + getEast: () => 7.4, + getNorth: () => 51.3, + })), + triggerRepaint: vi.fn(), + }; + runtime.onAdd?.(map as never); + await vi.waitFor(() => { + expect(registerSharedThreeTerrainSampler).toHaveBeenCalled(); + }); + const lodCamera = new PerspectiveCamera(60, 1, 1, 10_000); + lodCamera.position.set(0, 1_000, 0); + runtime.update({ + map: map as never, + renderCamera: new Camera(), + lodCamera, + lookTarget: new Vector3(), + viewport: new Vector2(1_000, 1_000), + }); + + await expect(runtime.ready).resolves.toBe(true); + const fineMesh = ( + runtime.root.children.find((child) => + child.name.endsWith("11/533/218") + ) as Group + ).children[0] as Mesh; + const smoothedNormal = fineMesh.geometry.getAttribute("normal"); + + expect(fineBoundaryNormalBeforeSmoothing).not.toBeNull(); + expect(smoothedNormal.getY(1)).toBeGreaterThan( + fineBoundaryNormalBeforeSmoothing!.y + 0.1 + ); + + runtime.dispose(); + }); + + it("adds a shadeable terrain mesh in the shared local-meter frame", async () => { + const tileId = { level: 10, x: 532, y: 218 }; + const sunTileId = { level: 10, x: 533, y: 218 }; + const source = { + requestTile: vi.fn(async (id) => ({ + id, + heightMeters: new Float32Array([100]), + westIndices: + id.x === sunTileId.x ? new Uint32Array([0, 1]) : new Uint32Array(), + southIndices: new Uint32Array(), + eastIndices: + id.x === tileId.x ? new Uint32Array([2, 3]) : new Uint32Array(), + northIndices: new Uint32Array(), + })), + getTileGridIdsForBounds: vi.fn((bounds) => + bounds.east > 7.25 ? [tileId, sunTileId] : [tileId] + ), + getTileBounds: vi.fn((id) => + id.x === sunTileId.x + ? { west: 7.4, south: 51, east: 7.5, north: 51.3 } + : { west: 7, south: 51, east: 7.2, north: 51.3 } + ), + getLevelMaximumGeometricError: vi.fn(() => 0.01), + getTileDataAvailable: vi.fn(() => true), + sampleHeight: vi.fn(() => 150), + trimCache: vi.fn(), + }; + acquireCesiumTerrainTileSource.mockResolvedValue(source); + const onContentChanged = vi.fn(); + const runtime = buildCesiumTerrainRuntime( + "terrain", + "https://example.test/terrain", + [7.15, 51.256], + { + minimumLevel: 10, + maximumLevel: 10, + shadowLevelOffset: 0, + onContentChanged, + } + ); + const map = { + getBounds: vi.fn(() => ({ + getWest: () => 7, + getSouth: () => 51, + getEast: () => 7.2, + getNorth: () => 51.3, + })), + triggerRepaint: vi.fn(), + }; + runtime.onAdd?.(map as never); + await vi.waitFor(() => { + expect(registerSharedThreeTerrainSampler).toHaveBeenCalled(); + }); + const lodCamera = new PerspectiveCamera(60, 1, 1, 10_000); + lodCamera.position.set(0, 1_000, 0); + runtime.update({ + map: map as never, + renderCamera: new Camera(), + lodCamera, + lookTarget: new Vector3(), + viewport: new Vector2(1_000, 1_000), + }); + + await expect(runtime.ready).resolves.toBe(true); + expect(runtime.root.children).toHaveLength(1); + expect( + runtime.root.children.some((child) => + child.name.endsWith("-viewport-coverage") + ) + ).toBe(false); + const tileNode = runtime.root.children.find((child) => + child.name.includes("source:") + ) as Group; + expect(tileNode.children).toHaveLength(1); + const mesh = tileNode.children[0] as Mesh & { + castShadow: boolean; + receiveShadow: boolean; + material: { side: number; shadowSide: number | null }; + customDepthMaterial?: unknown; + geometry: { getAttribute: (name: string) => { count: number } }; + }; + expect(mesh.castShadow).toBe(true); + expect(mesh.receiveShadow).toBe(true); + expect(mesh.material).toBeInstanceOf(MeshLambertMaterial); + expect(mesh.material.side).toBe(FrontSide); + expect(mesh.material.shadowSide).toBe(FrontSide); + // Standard depth pass: acne control lives in the light's texel-scaled + // normal bias, not in a per-mesh depth material. + expect(mesh.customDepthMaterial).toBeUndefined(); + expect(mesh.geometry.getAttribute("position").count).toBe(4); + expect(source.requestTile).toHaveBeenCalledWith(tileId); + expect(registerSharedThreeTerrainSampler).toHaveBeenCalled(); + expect(notifySharedThreeTerrainChanged).toHaveBeenCalledWith(map); + expect(onContentChanged).toHaveBeenCalledOnce(); + const debugVolumes = runtime.getActiveTileVolumes(); + expect(debugVolumes).toHaveLength(1); + expect(debugVolumes[0]).toMatchObject({ + id: "terrain:source:10/532/218", + kind: "terrain-tile", + }); + expect(debugVolumes[0]?.minimum.every(Number.isFinite)).toBe(true); + expect(debugVolumes[0]?.maximum.every(Number.isFinite)).toBe(true); + expect(debugVolumes[0]?.minimum[1]).toBeGreaterThan(99); + expect(debugVolumes[0]?.maximum[1]).toBeLessThan(101); + + const shadowCamera = new OrthographicCamera( + -1_000, + 1_000, + 1_000, + -1_000, + 1, + 5_000 + ); + shadowCamera.position.set(20_000, 1_000, 0); + shadowCamera.lookAt(20_000, 0, 0); + shadowCamera.updateProjectionMatrix(); + shadowCamera.updateMatrixWorld(true); + runtime.setShadowView({ + camera: shadowCamera, + shadowMapSize: { width: 1_000, height: 1_000 }, + }); + runtime.update({ + map: map as never, + renderCamera: new Camera(), + lodCamera, + lookTarget: new Vector3(), + viewport: new Vector2(1_000, 1_000), + }); + await vi.waitFor(() => { + expect(source.requestTile).toHaveBeenCalledWith(sunTileId); + expect(runtime.root.children).toHaveLength(2); + }); + expect(onContentChanged).toHaveBeenCalledTimes(2); + const viewportNormal = ( + ( + runtime.root.children.find((child) => + child.name.endsWith("10/532/218") + ) as Group + ).children[0] as Mesh + ).geometry.getAttribute("normal"); + const occluderNormal = ( + ( + runtime.root.children.find((child) => + child.name.endsWith("10/533/218") + ) as Group + ).children[0] as Mesh + ).geometry.getAttribute("normal"); + expect(viewportNormal.getX(2)).toBeCloseTo(occluderNormal.getX(0)); + expect(viewportNormal.getY(2)).toBeCloseTo(occluderNormal.getY(0)); + + runtime.dispose(); + expect(runtime.root.children).toHaveLength(0); + }); + + it("loads the viewport on the first update without an interaction gate", async () => { + const tileId = { level: 10, x: 532, y: 218 }; + const source = { + requestTile: vi.fn(async (id) => ({ + id, + heightMeters: new Float32Array([100]), + westIndices: new Uint32Array(), + southIndices: new Uint32Array(), + eastIndices: new Uint32Array(), + northIndices: new Uint32Array(), + })), + getTileGridIdsForBounds: vi.fn(() => [tileId]), + getTileBounds: vi.fn(() => ({ + west: 7, + south: 51, + east: 7.2, + north: 51.3, + })), + getLevelMaximumGeometricError: vi.fn(() => 0.01), + getTileDataAvailable: vi.fn(() => true), + sampleHeight: vi.fn(() => 150), + trimCache: vi.fn(), + }; + acquireCesiumTerrainTileSource.mockResolvedValue(source); + const runtime = buildCesiumTerrainRuntime( + "initial-viewport-terrain", + "https://example.test/initial-viewport-terrain", + [7.15, 51.256], + { minimumLevel: 10, maximumLevel: 10 } + ); + const map = { + getBounds: vi.fn(() => ({ + getWest: () => 7, + getSouth: () => 51, + getEast: () => 7.2, + getNorth: () => 51.3, + })), + triggerRepaint: vi.fn(), + }; + runtime.onAdd?.(map as never); + expect(setSharedThreeTerrainLoading).toHaveBeenCalledWith( + map, + "initial-viewport-terrain", + true + ); + await vi.waitFor(() => { + expect(registerSharedThreeTerrainSampler).toHaveBeenCalled(); + }); + + runtime.update({ + map: map as never, + renderCamera: new Camera(), + lodCamera: new PerspectiveCamera(60, 1, 1, 10_000), + lookTarget: new Vector3(), + viewport: new Vector2(1_000, 1_000), + }); + + await expect(runtime.ready).resolves.toBe(true); + expect(source.requestTile).toHaveBeenCalledWith(tileId); + expect(setSharedThreeTerrainLoading).toHaveBeenLastCalledWith( + map, + "initial-viewport-terrain", + false + ); + runtime.dispose(); + }); + + it("uses orthographic shadow resolution to refine offscreen occluders", async () => { + const parentId = { level: 10, x: 532, y: 218 }; + const source = { + requestTile: vi.fn(async (id) => ({ + id, + heightMeters: new Float32Array([100]), + westIndices: new Uint32Array(), + southIndices: new Uint32Array(), + eastIndices: new Uint32Array(), + northIndices: new Uint32Array(), + })), + getTileGridIdsForBounds: vi.fn(() => [parentId]), + getTileBounds: vi.fn((id) => { + if (id.level === parentId.level) { + return { west: 7.4, south: 51.24, east: 7.46, north: 51.27 }; + } + const west = id.x % 2 === 0 ? 7.4 : 7.43; + const north = id.y % 2 === 0 ? 51.27 : 51.255; + return { west, south: north - 0.015, east: west + 0.03, north }; + }), + getLevelMaximumGeometricError: vi.fn(() => 100), + getTileDataAvailable: vi.fn(() => true), + sampleHeight: vi.fn(), + trimCache: vi.fn(), + }; + acquireCesiumTerrainTileSource.mockResolvedValue(source); + const originLngLat: [number, number] = [7.15, 51.256]; + const runtime = buildCesiumTerrainRuntime( + "orthographic-shadow-terrain", + "https://example.test/orthographic-shadow-terrain", + originLngLat, + { + minimumLevel: 10, + maximumLevel: 11, + errorTargetPixels: 2.5, + shadowLevelOffset: 2, + } + ); + const map = { + getBounds: vi.fn(() => ({ + getWest: () => 7.1, + getSouth: () => 51.24, + getEast: () => 7.2, + getNorth: () => 51.27, + })), + triggerRepaint: vi.fn(), + }; + runtime.onAdd?.(map as never); + await vi.waitFor(() => { + expect(registerSharedThreeTerrainSampler).toHaveBeenCalled(); + }); + const origin = MercatorCoordinate.fromLngLat(originLngLat, 0); + const coordinate = MercatorCoordinate.fromLngLat( + [7.43, originLngLat[1]], + 0 + ); + const meterScale = origin.meterInMercatorCoordinateUnits(); + const x = (coordinate.x - origin.x) / meterScale; + const z = (coordinate.y - origin.y) / meterScale; + const shadowCamera = new OrthographicCamera(-500, 500, 500, -500, 1, 5_000); + shadowCamera.position.set(x, 1_000, z); + shadowCamera.lookAt(x, 0, z); + shadowCamera.updateProjectionMatrix(); + shadowCamera.updateMatrixWorld(true); + runtime.setShadowView({ + camera: shadowCamera, + shadowMapSize: { width: 1_000, height: 1_000 }, + }); + const lodCamera = new PerspectiveCamera(60, 1, 1, 100_000); + lodCamera.position.set(0, 1_000, 0); + runtime.update({ + map: map as never, + renderCamera: new Camera(), + lodCamera, + lookTarget: new Vector3(), + // Deliberately unrelated to the 1,000 px shadow map: terrain shadow LOD + // must follow the raster it is rendered into, not the browser viewport. + viewport: new Vector2(1, 1), + }); + + await expect(runtime.ready).resolves.toBe(true); + expect(source.requestTile.mock.calls.some(([id]) => id.level === 11)).toBe( + true + ); + expect(source.requestTile).not.toHaveBeenCalledWith(parentId); + runtime.dispose(); + }); + + it("shows a viewport parent while its children refine", async () => { + const parentId = { level: 10, x: 532, y: 218 }; + let resolveChildren = () => undefined; + const childrenReady = new Promise((resolve) => { + resolveChildren = resolve; + }); + const createTile = (id: typeof parentId) => ({ + id, + bounds: { west: 7, south: 51, east: 7.4, north: 51.4 }, + heightMeters: new Float32Array([100]), + westIndices: new Uint32Array(), + southIndices: new Uint32Array(), + eastIndices: new Uint32Array(), + northIndices: new Uint32Array(), + }); + const source = { + requestTile: vi.fn(async (id: typeof parentId) => { + if (id.level > parentId.level) await childrenReady; + return createTile(id); + }), + getTileGridIdsForBounds: vi.fn(() => [parentId]), + getTileBounds: vi.fn((id: typeof parentId) => { + if (id.level === parentId.level) { + return { west: 7, south: 51, east: 7.4, north: 51.4 }; + } + const west = id.x % 2 === 0 ? 7 : 7.2; + const north = id.y % 2 === 0 ? 51.4 : 51.2; + return { west, south: north - 0.2, east: west + 0.2, north }; + }), + getLevelMaximumGeometricError: vi.fn(() => 1_000_000), + getTileDataAvailable: vi.fn(() => true), + sampleHeight: vi.fn(), + trimCache: vi.fn(), + }; + acquireCesiumTerrainTileSource.mockResolvedValue(source); + const runtime = buildCesiumTerrainRuntime( + "progressive-terrain", + "https://example.test/progressive-terrain", + [7.2, 51.2], + { minimumLevel: 10, maximumLevel: 11, errorTargetPixels: 0.1 } + ); + const map = { + getBounds: vi.fn(() => ({ + getWest: () => 7, + getSouth: () => 51, + getEast: () => 7.4, + getNorth: () => 51.4, + })), + triggerRepaint: vi.fn(), + }; + runtime.onAdd?.(map as never); + await vi.waitFor(() => { + expect(registerSharedThreeTerrainSampler).toHaveBeenCalled(); + }); + const lodCamera = new PerspectiveCamera(60, 1, 1, 100_000); + lodCamera.position.set(0, 1_000, 0); + runtime.update({ + map: map as never, + renderCamera: new Camera(), + lodCamera, + lookTarget: new Vector3(), + viewport: new Vector2(1_000, 1_000), + }); + + await expect(runtime.ready).resolves.toBe(true); + const parentNode = runtime.root.children.find((child) => + child.name.includes("source:10/532/218") + ); + expect(parentNode?.visible).toBe(true); + + resolveChildren(); + await vi.waitFor(() => { + expect(parentNode?.visible).toBe(false); + expect( + runtime.root.children.filter( + (child) => child.visible && child.name.includes("source:11/") + ) + ).toHaveLength(4); + }); + + runtime.dispose(); + }); + + it("publishes an independent viewport root without waiting for its neighbor", async () => { + const westId = { level: 10, x: 532, y: 218 }; + const eastId = { level: 10, x: 533, y: 218 }; + let resolveEast = () => undefined; + const eastReady = new Promise((resolve) => { + resolveEast = resolve; + }); + const source = { + requestTile: vi.fn(async (id: typeof westId) => { + if (id.x === eastId.x) await eastReady; + return { + id, + heightMeters: new Float32Array([100]), + westIndices: new Uint32Array(), + southIndices: new Uint32Array(), + eastIndices: new Uint32Array(), + northIndices: new Uint32Array(), + }; + }), + getTileGridIdsForBounds: vi.fn(() => [westId, eastId]), + getTileBounds: vi.fn((id: typeof westId) => ({ + west: id.x === westId.x ? 7 : 7.2, + south: 51, + east: id.x === westId.x ? 7.2 : 7.4, + north: 51.4, + })), + getLevelMaximumGeometricError: vi.fn(() => 0.01), + getTileDataAvailable: vi.fn(() => true), + sampleHeight: vi.fn(), + trimCache: vi.fn(), + }; + acquireCesiumTerrainTileSource.mockResolvedValue(source); + const runtime = buildCesiumTerrainRuntime( + "partial-root-terrain", + "https://example.test/partial-root-terrain", + [7.2, 51.2], + { minimumLevel: 10, maximumLevel: 10 } + ); + const map = { + getBounds: vi.fn(() => ({ + getWest: () => 7, + getSouth: () => 51, + getEast: () => 7.4, + getNorth: () => 51.4, + })), + triggerRepaint: vi.fn(), + }; + runtime.onAdd?.(map as never); + await vi.waitFor(() => { + expect(registerSharedThreeTerrainSampler).toHaveBeenCalled(); + }); + const lodCamera = new PerspectiveCamera(60, 1, 1, 100_000); + lodCamera.position.set(0, 1_000, 0); + runtime.update({ + map: map as never, + renderCamera: new Camera(), + lodCamera, + lookTarget: new Vector3(), + viewport: new Vector2(1_000, 1_000), + }); + + await expect(runtime.ready).resolves.toBe(true); + expect( + runtime.root.children.find((child) => + child.name.includes("source:10/532/218") + )?.visible + ).toBe(true); + expect( + runtime.root.children.find((child) => + child.name.includes("source:10/533/218") + ) + ).toBeUndefined(); + + resolveEast(); + await vi.waitFor(() => { + expect( + runtime.root.children.find((child) => + child.name.includes("source:10/533/218") + )?.visible + ).toBe(true); + }); + runtime.dispose(); + }); + + it("leaves unavailable and no-data terrain transparent", async () => { + const zeroSourceId = { level: 10, x: 531, y: 218 }; + const sourceId = { level: 10, x: 532, y: 218 }; + const flatId = { level: 10, x: 533, y: 218 }; + const source = { + requestTile: vi.fn(async (id) => ({ + id, + bounds: + id.x === zeroSourceId.x + ? { west: 6.8, south: 51, east: 7, north: 51.3 } + : { west: 7, south: 51, east: 7.2, north: 51.3 }, + u: new Float32Array([0, 0, 1, 1]), + v: new Float32Array([0, 1, 0, 1]), + heightMeters: + id.x === zeroSourceId.x + ? new Float32Array([0, 0, 0, 0]) + : new Float32Array([123, 123, 123, 0]), + indices: new Uint32Array([0, 2, 1, 1, 2, 3]), + westIndices: new Uint32Array(), + southIndices: new Uint32Array(), + eastIndices: + id.x === sourceId.x ? new Uint32Array([2, 3]) : new Uint32Array(), + northIndices: new Uint32Array(), + })), + getTileGridIdsForBounds: vi.fn(() => [zeroSourceId, sourceId, flatId]), + getTileBounds: vi.fn((id) => + id.x === flatId.x + ? { west: 7.2, south: 51, east: 7.4, north: 51.3 } + : id.x === zeroSourceId.x + ? { west: 6.8, south: 51, east: 7, north: 51.3 } + : { west: 7, south: 51, east: 7.2, north: 51.3 } + ), + getLevelMaximumGeometricError: vi.fn(() => 0.01), + getTileDataAvailable: vi.fn((id) => id.x !== flatId.x), + sampleHeight: vi.fn(), + trimCache: vi.fn(), + }; + acquireCesiumTerrainTileSource.mockResolvedValue(source); + const runtime = buildCesiumTerrainRuntime( + "terrain-with-flat-coverage", + "https://example.test/terrain-with-flat-coverage", + [7.15, 51.256], + { minimumLevel: 10, maximumLevel: 10, noDataHeightMeters: 0 } + ); + const map = { + getBounds: vi.fn(() => ({ + getWest: () => 7, + getSouth: () => 51, + getEast: () => 7.4, + getNorth: () => 51.3, + })), + triggerRepaint: vi.fn(), + }; + runtime.onAdd?.(map as never); + await vi.waitFor(() => { + expect(registerSharedThreeTerrainSampler).toHaveBeenCalled(); + }); + const lodCamera = new PerspectiveCamera(60, 1, 1, 10_000); + lodCamera.position.set(0, 1_000, 0); + runtime.update({ + map: map as never, + renderCamera: new Camera(), + lodCamera, + lookTarget: new Vector3(), + viewport: new Vector2(1_000, 1_000), + }); + + await expect(runtime.ready).resolves.toBe(true); + expect(source.requestTile).toHaveBeenCalledTimes(2); + expect(source.requestTile).toHaveBeenCalledWith(zeroSourceId); + expect(source.requestTile).toHaveBeenCalledWith(sourceId); + expect(runtime.root.children).toHaveLength(2); + expect( + runtime.root.children.some((child) => child.name.includes("flat:")) + ).toBe(false); + const zeroSourceNode = runtime.root.children.find((child) => + child.name.includes("source:10/531/218") + ) as Group; + expect(zeroSourceNode.children).toHaveLength(0); + const mixedSourceNode = runtime.root.children.find((child) => + child.name.includes("source:10/532/218") + ) as Group; + expect(mixedSourceNode.children).toHaveLength(1); + expect( + mixedSourceNode.children.some((child) => child.name.endsWith("-base")) + ).toBe(false); + const reliefSourceMesh = mixedSourceNode.children.find((child) => + child.name.endsWith("-relief") + ) as Mesh; + expect(reliefSourceMesh.castShadow).toBe(true); + expect(reliefSourceMesh.receiveShadow).toBe(true); + expect(reliefSourceMesh.customDepthMaterial).toBeUndefined(); + expect(reliefSourceMesh.geometry.getAttribute("position").count).toBe(4); + expect(Array.from(reliefSourceMesh.geometry.getIndex()!.array)).toEqual([ + 0, 2, 1, + ]); + expect( + createProjectedTerrainTileGeometry.mock.calls.some( + ([{ tile }]) => tile.id?.x === flatId.x + ) + ).toBe(false); + + runtime.dispose(); + }); + + it("keeps a coarse parent whole when a child quadrant has no data", async () => { + const parentId = { level: 10, x: 532, y: 218 }; + const sourceChildId = { level: 11, x: 1_064, y: 436 }; + const source = { + requestTile: vi.fn(async (id) => ({ + id, + heightMeters: new Float32Array([100]), + westIndices: new Uint32Array(), + southIndices: new Uint32Array(), + eastIndices: new Uint32Array(), + northIndices: new Uint32Array(), + })), + getTileGridIdsForBounds: vi.fn(() => [parentId]), + getTileBounds: vi.fn((id) => { + if (id.level === parentId.level) { + return { west: 7, south: 51, east: 7.4, north: 51.4 }; + } + const west = id.x % 2 === 0 ? 7 : 7.2; + const north = id.y % 2 === 0 ? 51.4 : 51.2; + return { west, south: north - 0.2, east: west + 0.2, north }; + }), + getLevelMaximumGeometricError: vi.fn(() => 1_000_000), + getTileDataAvailable: vi.fn( + (id) => id.level === parentId.level || id.x === sourceChildId.x + ), + sampleHeight: vi.fn(), + trimCache: vi.fn(), + }; + acquireCesiumTerrainTileSource.mockResolvedValue(source); + const runtime = buildCesiumTerrainRuntime( + "terrain-mixed-children", + "https://example.test/terrain-mixed-children", + [7.2, 51.2], + { minimumLevel: 10, maximumLevel: 11, errorTargetPixels: 0.1 } + ); + const map = { + getBounds: vi.fn(() => ({ + getWest: () => 7, + getSouth: () => 51, + getEast: () => 7.4, + getNorth: () => 51.4, + })), + triggerRepaint: vi.fn(), + }; + runtime.onAdd?.(map as never); + await vi.waitFor(() => { + expect(registerSharedThreeTerrainSampler).toHaveBeenCalled(); + }); + const lodCamera = new PerspectiveCamera(60, 1, 1, 100_000); + lodCamera.position.set(0, 1_000, 0); + const lowResolutionShadowCamera = new OrthographicCamera( + -50_000, + 50_000, + 50_000, + -50_000, + 1, + 5_000 + ); + lowResolutionShadowCamera.position.set(0, 1_000, 0); + lowResolutionShadowCamera.lookAt(0, 0, 0); + lowResolutionShadowCamera.updateProjectionMatrix(); + lowResolutionShadowCamera.updateMatrixWorld(true); + runtime.setShadowView({ + camera: lowResolutionShadowCamera, + shadowMapSize: { width: 1_000, height: 1_000 }, + }); + runtime.update({ + map: map as never, + renderCamera: new Camera(), + lodCamera, + lookTarget: new Vector3(), + viewport: new Vector2(1_000, 1_000), + }); + + await expect(runtime.ready).resolves.toBe(true); + // Splitting would trade the parent's real ground for sea-level plates in + // the quadrants without data - a hole in the view, and up-sun a hole in + // the shadow. The parent stays whole; refinement ends at the + // availability boundary. + expect(source.requestTile).toHaveBeenCalledTimes(1); + expect(source.requestTile).toHaveBeenCalledWith(parentId); + expect( + runtime.root.children.some((child) => child.name.includes("source:10/")) + ).toBe(true); + expect( + runtime.root.children.some((child) => child.name.includes("flat:11/")) + ).toBe(false); + + runtime.dispose(); + }); + + it("refines the viewport to its error target before the sun coverage", async () => { + // One viewport tile and three sun-coverage tiles west of it compete for a + // budget that only fits the viewport split. The view must reach its own + // pixel-error target regardless of how demanding the sun coverage is, and + // its tiles must be first in the download order. + const viewportId = { level: 10, x: 532, y: 218 }; + // A one-tile gap to the viewport keeps edge-touching out of the picture. + const westIds = [ + { level: 10, x: 528, y: 218 }, + { level: 10, x: 529, y: 218 }, + { level: 10, x: 530, y: 218 }, + ]; + const boundsOf = (id: { level: number; x: number; y: number }) => { + const scale = 2 ** (id.level - 10); + const width = 0.1 / scale; + const west = 7.1 + (id.x - 532 * scale) * width; + const north = 51.3 - (id.y - 218 * scale) * width; + return { west, south: north - width, east: west + width, north }; + }; + const intersects = ( + a: { west: number; south: number; east: number; north: number }, + b: { west: number; south: number; east: number; north: number } + ) => + a.west < b.east && + a.east > b.west && + a.south < b.north && + a.north > b.south; + const source = { + requestTile: vi.fn(async (id) => ({ + id, + heightMeters: new Float32Array([100]), + westIndices: new Uint32Array(), + southIndices: new Uint32Array(), + eastIndices: new Uint32Array(), + northIndices: new Uint32Array(), + })), + getTileGridIdsForBounds: vi.fn((bounds) => + [...westIds, viewportId].filter((id) => + intersects(boundsOf(id), bounds) + ) + ), + getTileBounds: vi.fn(boundsOf), + getLevelMaximumGeometricError: vi.fn((level) => 200 / 2 ** (level - 10)), + getTileDataAvailable: vi.fn(() => true), + sampleHeight: vi.fn(), + trimCache: vi.fn(), + }; + acquireCesiumTerrainTileSource.mockResolvedValue(source); + const originLngLat: [number, number] = [7.15, 51.25]; + const runtime = buildCesiumTerrainRuntime( + "viewport-priority-terrain", + "https://example.test/viewport-priority-terrain", + originLngLat, + { + minimumLevel: 10, + maximumLevel: 11, + errorTargetPixels: 0.1, + shadowLevelOffset: 0, + // Roots (4) plus the viewport split (net +3) fit; the sun-coverage + // split (net +3 more) must not. + maxSelectionTiles: 7, + } + ); + const map = { + getBounds: vi.fn(() => ({ + getWest: () => 7.1, + getSouth: () => 51.2, + getEast: () => 7.2, + getNorth: () => 51.3, + })), + triggerRepaint: vi.fn(), + }; + runtime.onAdd?.(map as never); + await vi.waitFor(() => { + expect(registerSharedThreeTerrainSampler).toHaveBeenCalled(); + }); + const origin = MercatorCoordinate.fromLngLat(originLngLat, 0); + const meterScale = origin.meterInMercatorCoordinateUnits(); + const westCenter = MercatorCoordinate.fromLngLat([6.85, 51.25], 0); + const shadowCamera = new OrthographicCamera( + -12_000, + 12_000, + 12_000, + -12_000, + 1, + 20_000 + ); + shadowCamera.position.set( + (westCenter.x - origin.x) / meterScale, + 2_000, + (westCenter.y - origin.y) / meterScale + ); + shadowCamera.lookAt( + (westCenter.x - origin.x) / meterScale, + 0, + (westCenter.y - origin.y) / meterScale + ); + shadowCamera.updateProjectionMatrix(); + shadowCamera.updateMatrixWorld(true); + runtime.setShadowView({ + camera: shadowCamera, + shadowMapSize: { width: 1_000, height: 1_000 }, + }); + const lodCamera = new PerspectiveCamera(60, 1, 1, 100_000); + lodCamera.position.set(0, 500, 0); + runtime.update({ + map: map as never, + renderCamera: new Camera(), + lodCamera, + lookTarget: new Vector3(), + viewport: new Vector2(1_000, 1_000), + }); + await expect(runtime.ready).resolves.toBe(true); + + const requestedIds = source.requestTile.mock.calls.map(([id]) => id); + // The viewport root split into its level-11 children ... + expect( + requestedIds.filter((id) => id.level === 11 && id.x >> 1 === 532) + ).toHaveLength(4); + // ... while the sun coverage stayed at its root level: the leftover + // budget cannot fit another split. + expect( + requestedIds.filter((id) => id.level === 11 && id.x >> 1 !== 532) + ).toHaveLength(0); + for (const westId of westIds) { + expect(requestedIds).toContainEqual(westId); + } + // Download order: everything in view comes before the sun coverage. + const viewportIndices = requestedIds + .map((id, index) => ({ id, index })) + .filter(({ id }) => id.level === 11 || (id.level === 10 && id.x === 532)) + .map(({ index }) => index); + const coverageIndices = requestedIds + .map((id, index) => ({ id, index })) + .filter(({ id }) => id.level === 10 && id.x !== 532) + .map(({ index }) => index); + expect(Math.max(...viewportIndices)).toBeLessThan( + Math.min(...coverageIndices) + ); + + runtime.dispose(); + }); + + it("refines elevated neighbor tiles whose 3D bounds enter the camera frustum", async () => { + const centerId = { level: 10, x: 532, y: 218 }; + const foregroundId = { level: 10, x: 532, y: 219 }; + const boundsOf = (id: { level: number; x: number; y: number }) => { + const scale = 2 ** (id.level - 10); + const width = 0.02 / scale; + const west = 7.14 + (id.x - 532 * scale) * width; + const north = 51.26 - (id.y - 218 * scale) * width; + return { west, south: north - width, east: west + width, north }; + }; + const source = { + requestTile: vi.fn(async (id) => ({ + id, + heightMeters: new Float32Array([200, 220]), + westIndices: new Uint32Array(), + southIndices: new Uint32Array(), + eastIndices: new Uint32Array(), + northIndices: new Uint32Array(), + })), + getTileGridIdsForBounds: vi.fn(() => [centerId, foregroundId]), + getTileBounds: vi.fn(boundsOf), + getLevelMaximumGeometricError: vi.fn( + (level) => 1_000 / 2 ** (level - 10) + ), + getTileDataAvailable: vi.fn(() => true), + sampleHeight: vi.fn(), + trimCache: vi.fn(), + }; + acquireCesiumTerrainTileSource.mockResolvedValue(source); + const runtime = buildCesiumTerrainRuntime( + "frustum-volume-terrain", + "https://example.test/frustum-volume-terrain", + [7.15, 51.25], + { + minimumLevel: 10, + maximumLevel: 11, + errorTargetPixels: 50, + maxSelectionTiles: 10, + } + ); + const map = { + getBounds: vi.fn(() => ({ + getWest: () => 7.145, + getSouth: () => 51.245, + getEast: () => 7.155, + getNorth: () => 51.255, + })), + triggerRepaint: vi.fn(), + }; + runtime.onAdd?.(map as never); + await vi.waitFor(() => { + expect(registerSharedThreeTerrainSampler).toHaveBeenCalled(); + }); + const viewport = new Vector2(1_200, 600); + const renderAt = (z: number) => { + const camera = new PerspectiveCamera(80, 2, 1, 100_000); + camera.position.set(0, 210, z); + camera.lookAt(0, 210, 0); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(true); + runtime.update({ + map: map as never, + renderCamera: camera, + lodCamera: camera, + lookTarget: new Vector3(0, 210, 0), + viewport, + }); + }; + + // Load both roots from a distant view. The foreground tile is outside the + // planar map bounds, but its height volume is visible in the real frustum. + renderAt(20_000); + await expect(runtime.ready).resolves.toBe(true); + expect(source.requestTile).toHaveBeenCalledWith(centerId); + expect(source.requestTile).toHaveBeenCalledWith(foregroundId); + + source.requestTile.mockClear(); + renderAt(5_000); + await vi.waitFor(() => { + const requestedIds = source.requestTile.mock.calls.map(([id]) => id); + expect( + requestedIds.filter( + (id) => id.level === 11 && id.y >> 1 === foregroundId.y + ) + ).toHaveLength(4); + expect( + requestedIds.filter((id) => id.level === 11 && id.y >> 1 === centerId.y) + ).toHaveLength(4); + }); + + runtime.dispose(); + }); + + it("keeps visible ground untouched when a superseded batch lands", async () => { + const boundsOf = (id: { level: number; x: number; y: number }) => { + const width = 0.125; + const west = 7.125 + (id.x - 532) * width; + return { west, south: 51.2, east: west + width, north: 51.3 }; + }; + const intersects = ( + a: { west: number; south: number; east: number; north: number }, + b: { west: number; south: number; east: number; north: number } + ) => + a.west < b.east && + a.east > b.west && + a.south < b.north && + a.north > b.south; + const allIds = [ + { level: 10, x: 532, y: 218 }, + { level: 10, x: 533, y: 218 }, + { level: 10, x: 534, y: 218 }, + ]; + const pendingResolvers: Array<() => void> = []; + const source = { + requestTile: vi.fn( + (id) => + new Promise((resolve) => { + pendingResolvers.push(() => + resolve({ + id, + heightMeters: new Float32Array([100]), + westIndices: new Uint32Array(), + southIndices: new Uint32Array(), + eastIndices: new Uint32Array(), + northIndices: new Uint32Array(), + }) + ); + }) + ), + getTileGridIdsForBounds: vi.fn((bounds) => + allIds.filter((id) => intersects(boundsOf(id), bounds)) + ), + getTileBounds: vi.fn(boundsOf), + getLevelMaximumGeometricError: vi.fn(() => 0.0001), + getTileDataAvailable: vi.fn(() => true), + sampleHeight: vi.fn(), + trimCache: vi.fn(), + }; + acquireCesiumTerrainTileSource.mockResolvedValue(source); + const runtime = buildCesiumTerrainRuntime( + "superseded-batch-terrain", + "https://example.test/superseded-batch-terrain", + [7.15, 51.25], + { minimumLevel: 10, maximumLevel: 10 } + ); + let viewEast = 7.2; + const map = { + getBounds: vi.fn(() => ({ + getWest: () => 7.125, + getSouth: () => 51.2, + getEast: () => viewEast, + getNorth: () => 51.3, + })), + triggerRepaint: vi.fn(), + }; + runtime.onAdd?.(map as never); + await vi.waitFor(() => { + expect(registerSharedThreeTerrainSampler).toHaveBeenCalled(); + }); + const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + const renderAt = (x: number) => { + const lodCamera = new PerspectiveCamera(60, 1, 1, 100_000); + lodCamera.position.set(x, 1_000, 0); + lodCamera.updateMatrixWorld(true); + runtime.update({ + map: map as never, + renderCamera: new Camera(), + lodCamera, + lookTarget: new Vector3(), + viewport: new Vector2(1_000, 1_000), + }); + }; + + renderAt(0); + await vi.waitFor(() => expect(pendingResolvers).toHaveLength(1)); + pendingResolvers.splice(0).forEach((resolve) => resolve()); + await flush(); + const visibleNode = () => + runtime.root.children.find((child) => + child.name.includes("source:10/532/") + ); + expect(visibleNode()?.visible).toBe(true); + + viewEast = 7.3; + renderAt(100); + await flush(); + expect(pendingResolvers).toHaveLength(1); + viewEast = 7.45; + renderAt(200); + await flush(); + expect(pendingResolvers).toHaveLength(3); + pendingResolvers.splice(1).forEach((resolve) => resolve()); + await flush(); + expect(visibleNode()?.visible).toBe(true); + pendingResolvers.splice(0).forEach((resolve) => resolve()); + await flush(); + expect(visibleNode()?.visible).toBe(true); + + runtime.dispose(); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts new file mode 100644 index 0000000000..403959e892 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts @@ -0,0 +1,1599 @@ +import { MercatorCoordinate } from "maplibre-gl"; +import type { Map as MaplibreMap } from "maplibre-gl"; +import { + Box3, + Camera, + FrontSide, + Frustum, + Group, + Matrix4, + Mesh, + MeshLambertMaterial, + Vector3, + type BufferGeometry, + type ColorRepresentation, +} from "three"; + +import { clamp, quantize } from "@carma-commons/math"; +import { + geographicBoundsIntersect, + unionGeographicBounds, +} from "@carma-geo/helpers"; +import { + acquireCesiumTerrainTileSource, + cesiumTerrainTileKey, + isConfirmedTerrainServerError, + type CesiumTerrainTile, + type CesiumTerrainTileBounds, + type CesiumTerrainTileId, + type CesiumTerrainTileSource, +} from "@carma-mapping/engines/cesium/terrain"; +import { createProjectedTerrainTileGeometry } from "@carma-mapping/engines/three/primitives"; + +import { + notifySharedThreeTerrainChanged, + registerSharedThreeTerrainSampler, + setSharedThreeTerrainLoading, +} from "./shared-three-terrain-registry"; +import { createProjectedTerrainGeometryCache } from "./projected-terrain-geometry-cache"; +import { + createPayloadAwareRequestConcurrency, + DEFAULT_MAXIMUM_REQUEST_CONCURRENCY, +} from "./payload-aware-request-concurrency"; +import type { + SharedThreeSceneFrame, + SharedThreeSceneTileVolume, + SharedThreeSceneRuntime, + SharedThreeSceneShadowView, +} from "./shared-three-scene-layer"; +import { getSharedThreeShadowViewSignature } from "./shared-three-scene-layer"; + +const DEFAULT_TERRAIN_COLOR = 0xd8d1c4; +const DEFAULT_ERROR_TARGET_PIXELS = 2.5; +const DEFAULT_SHADOW_LEVEL_OFFSET = 2; +const DEFAULT_MINIMUM_LEVEL = 8; +const DEFAULT_MAXIMUM_LEVEL = 17; +const DEFAULT_MAX_SELECTION_TILES = 192; +// Quantized-mesh payloads are small enough that byte-based admission alone can +// open hundreds of HTTP/2 streams. Keep this origin-bound runtime below the +// browser/server stream ceiling while retaining progressive loading. +const MAXIMUM_REQUEST_CONCURRENCY = Math.min( + 24, + DEFAULT_MAXIMUM_REQUEST_CONCURRENCY +); +const DEFAULT_REQUEST_CONCURRENCY = MAXIMUM_REQUEST_CONCURRENCY; +const DEFAULT_MAX_CACHED_MESHES = 256; +const TERRAIN_UPDATE_PRIORITY = 100; +const ZERO_ELEVATION_EPSILON_METERS = 1e-3; +const UNKNOWN_TERRAIN_HEIGHT_RANGE_METERS = [-1_000, 10_000] as const; + +export type CesiumTerrainMaterialOptions = Readonly<{ + color?: ColorRepresentation; +}>; + +export type CesiumTerrainRuntimeOptions = Readonly<{ + errorTargetPixels?: number; + shadowLevelOffset?: number; + minimumLevel?: number; + maximumLevel?: number; + maxSelectionTiles?: number; + requestConcurrency?: number; + maxCacheBytes?: number; + maxCachedMeshes?: number; + /** Source-specific height that denotes missing terrain coverage. */ + noDataHeightMeters?: number; + /** Conservative elevation range used until a tile or ancestor is loaded. */ + heightRangeMeters?: readonly [minimum: number, maximum: number]; + material?: CesiumTerrainMaterialOptions; + /** Project MapLibre ground styling onto this terrain before lighting. */ + receivesMapStyleTexture?: boolean; + /** Called after the active terrain meshes or their normals changed. */ + onContentChanged?: () => void; + onError?: (error: unknown) => void; +}>; + +export interface CesiumTerrainRuntime extends SharedThreeSceneRuntime { + ready: Promise; + setShadowView: (view: SharedThreeSceneShadowView | null) => void; + setMaterialColor: (color: ColorRepresentation) => void; + getElevation: (longitude: number, latitude: number) => number | undefined; + getViewElevationRange: ( + camera: Camera + ) => readonly [minimum: number, maximum: number] | null; + getActiveTileVolumes: () => readonly SharedThreeSceneTileVolume[]; +} + +type TerrainMeshRecord = { + node: Group; + reliefMesh: Mesh | null; + boundaryEdges: TerrainBoundaryEdges; + lastUsed: number; + id: CesiumTerrainTileId; + minimumHeightMeters: number; + maximumHeightMeters: number; +}; + +type TerrainBoundarySide = "west" | "south" | "east" | "north"; + +type TerrainBoundaryEdges = Record; + +type TerrainBoundaryVertex = { + accumulator: TerrainBoundaryNormalAccumulator; + parameter: number; + normal: Vector3; +}; + +type TerrainBoundaryNormalAccumulator = { + record: TerrainMeshRecord; + index: number; + normalSum: Vector3; + contributorCount: number; +}; + +type TerrainBoundaryEdge = { + side: TerrainBoundarySide; + vertices: TerrainBoundaryVertex[]; + minimum: number; + maximum: number; +}; + +type TerrainSelection = { + entries: TerrainSelectionEntry[]; + viewportStages: TerrainSelectionEntry[][]; + loadEntries: TerrainSelectionEntry[]; + signature: string; + viewportElevationSignature: string; +}; + +type TerrainSelectionEntry = { + id: CesiumTerrainTileId; + kind: "source"; +}; + +type TerrainCandidate = { + entry: TerrainSelectionEntry; + /** Error against the view's own pixel target; 0 outside the viewport. */ + viewportErrorRatio: number; + /** Error against the coarser shadow target; 0 outside the sun coverage. */ + shadowErrorRatio: number; + intersectsViewport: boolean; + viewportCenterDistanceSquared: number; +}; + +const TERRAIN_BOUNDARY_KEY_PRECISION = 1_000; +const TERRAIN_BOUNDARY_OVERLAP_EPSILON = 1e-3; + +const oppositeTerrainBoundarySide = ( + side: TerrainBoundarySide +): TerrainBoundarySide => { + switch (side) { + case "west": + return "east"; + case "east": + return "west"; + case "south": + return "north"; + case "north": + return "south"; + } +}; + +const terrainBoundaryAxis = (side: TerrainBoundarySide) => + side === "west" || side === "east" ? "x" : "z"; + +const interpolateTerrainBoundaryNormal = ( + edge: TerrainBoundaryEdge, + parameter: number +): Vector3 | null => { + const { vertices } = edge; + if (vertices.length === 0) return null; + if (vertices.length === 1) return vertices[0].normal.clone(); + if ( + parameter < edge.minimum - TERRAIN_BOUNDARY_OVERLAP_EPSILON || + parameter > edge.maximum + TERRAIN_BOUNDARY_OVERLAP_EPSILON + ) { + return null; + } + for (let index = 1; index < vertices.length; index += 1) { + const before = vertices[index - 1]; + const after = vertices[index]; + if (parameter > after.parameter + TERRAIN_BOUNDARY_OVERLAP_EPSILON) { + continue; + } + const span = after.parameter - before.parameter; + if (Math.abs(span) <= TERRAIN_BOUNDARY_OVERLAP_EPSILON) { + return before.normal.clone(); + } + return before.normal + .clone() + .lerp(after.normal, clamp((parameter - before.parameter) / span, 0, 1)) + .normalize(); + } + return vertices[vertices.length - 1].normal.clone(); +}; + +const partitionNoDataTerrainGeometry = ( + geometry: BufferGeometry, + tile: CesiumTerrainTile, + noDataHeightMeters: number +) => { + const noDataMask = Uint8Array.from(tile.heightMeters, (height) => + Math.abs(height - noDataHeightMeters) <= ZERO_ELEVATION_EPSILON_METERS + ? 1 + : 0 + ); + const hasNoData = noDataMask.some((value) => value === 1); + if (!hasNoData) { + return { + reliefGeometry: geometry, + reliefVertexMask: new Uint8Array( + geometry.getAttribute("position").count + ).fill(1), + hasNoData, + }; + } + + const sourceIndex = geometry.getIndex(); + if (!sourceIndex) { + throw new TypeError("Projected terrain geometry must be indexed"); + } + const reliefIndices: number[] = []; + const reliefVertexMask = new Uint8Array(tile.heightMeters.length); + for (let offset = 0; offset < sourceIndex.count; offset += 3) { + const a = sourceIndex.getX(offset); + const b = sourceIndex.getX(offset + 1); + const c = sourceIndex.getX(offset + 2); + // A configured no-data vertex marks missing coverage. Keeping a mixed + // triangle would create a kilometre-scale ramp whose interpolated normals + // show up as a light-dependent wedge. Render only complete relief faces; + // missing coverage stays transparent and reveals the atmosphere. + if (noDataMask[a] === 1 || noDataMask[b] === 1 || noDataMask[c] === 1) { + continue; + } + reliefIndices.push(a, b, c); + reliefVertexMask[a] = 1; + reliefVertexMask[b] = 1; + reliefVertexMask[c] = 1; + } + if (reliefIndices.length === 0) { + geometry.dispose(); + return { reliefGeometry: null, reliefVertexMask, hasNoData }; + } + geometry.setIndex(reliefIndices); + geometry.computeVertexNormals(); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + return { reliefGeometry: geometry, reliefVertexMask, hasNoData }; +}; + +const terrainSelectionKey = ({ id, kind }: TerrainSelectionEntry) => + `${kind}:${cesiumTerrainTileKey(id)}`; + +const terrainTileContains = ( + ancestor: CesiumTerrainTileId, + descendant: CesiumTerrainTileId +) => { + if (ancestor.level > descendant.level) return false; + const shift = descendant.level - ancestor.level; + return ( + descendant.x >> shift === ancestor.x && descendant.y >> shift === ancestor.y + ); +}; + +const clampInteger = ( + value: number | undefined, + fallback: number, + minimum: number +) => Math.max(minimum, Math.floor(value ?? fallback)); + +const normalizeShadowMapSize = ( + shadowMapSize: SharedThreeSceneShadowView["shadowMapSize"] +): SharedThreeSceneShadowView["shadowMapSize"] => ({ + width: + Number.isFinite(shadowMapSize.width) && shadowMapSize.width > 0 + ? shadowMapSize.width + : 1, + height: + Number.isFinite(shadowMapSize.height) && shadowMapSize.height > 0 + ? shadowMapSize.height + : 1, +}); + +const getFiniteHeightRange = ( + heights: ArrayLike +): readonly [minimum: number, maximum: number] | null => { + let minimum = Number.POSITIVE_INFINITY; + let maximum = Number.NEGATIVE_INFINITY; + for (let index = 0; index < heights.length; index += 1) { + const height = heights[index]; + if (!Number.isFinite(height)) continue; + minimum = Math.min(minimum, height); + maximum = Math.max(maximum, height); + } + return Number.isFinite(minimum) && Number.isFinite(maximum) + ? [minimum, maximum] + : null; +}; + +const getViewportBounds = (map: MaplibreMap): CesiumTerrainTileBounds => { + const bounds = map.getBounds(); + return { + west: bounds.getWest(), + south: bounds.getSouth(), + east: bounds.getEast(), + north: bounds.getNorth(), + }; +}; + +const cameraFrustumBounds = ( + camera: Camera, + root: Group, + origin: MercatorCoordinate, + meterScale: number +): CesiumTerrainTileBounds | null => { + camera.updateMatrixWorld(true); + camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert(); + root.updateMatrixWorld(true); + const localFromWorld = new Matrix4().copy(root.matrixWorld).invert(); + let west = Number.POSITIVE_INFINITY; + let south = Number.POSITIVE_INFINITY; + let east = Number.NEGATIVE_INFINITY; + let north = Number.NEGATIVE_INFINITY; + for (const x of [-1, 1]) { + for (const y of [-1, 1]) { + for (const z of [-1, 1]) { + const local = new Vector3(x, y, z) + .unproject(camera) + .applyMatrix4(localFromWorld); + const lngLat = new MercatorCoordinate( + origin.x + local.x * meterScale, + origin.y + local.z * meterScale, + 0 + ).toLngLat(); + west = Math.min(west, lngLat.lng); + south = Math.min(south, lngLat.lat); + east = Math.max(east, lngLat.lng); + north = Math.max(north, lngLat.lat); + } + } + } + return [west, south, east, north].every(Number.isFinite) + ? { + west: Math.max(-180, west), + south: Math.max(-90, south), + east: Math.min(180, east), + north: Math.min(90, north), + } + : null; +}; + +type ConcurrentLoadFailure = { value: T; error: unknown }; + +/** + * Load every value with bounded concurrency. One failed value does not stop + * the others: the failures come back with the results so the caller can + * retry them without losing what did arrive. + */ +const loadWithConcurrency = async ( + values: readonly T[], + concurrency: number, + load: (value: T) => Promise, + onLoaded?: (value: T, result: R) => void +): Promise<{ + results: Array; + failures: ConcurrentLoadFailure[]; +}> => { + const results = new Array(values.length); + const failures: ConcurrentLoadFailure[] = []; + let cursor = 0; + const worker = async () => { + while (cursor < values.length) { + const index = cursor; + cursor += 1; + const value = values[index]; + try { + const result = await load(value); + results[index] = result; + onLoaded?.(value, result); + } catch (error) { + failures.push({ value, error }); + } + } + }; + await Promise.all( + Array.from({ length: Math.min(concurrency, values.length) }, worker) + ); + return { results, failures }; +}; + +const SELECTION_RETRY_BASE_DELAY_MS = 1_000; +const SELECTION_RETRY_MAX_DELAY_MS = 30_000; + +export const buildCesiumTerrainRuntime = ( + runtimeId: string, + terrainUrl: string, + originLngLat: [number, number], + options: CesiumTerrainRuntimeOptions = {} +): CesiumTerrainRuntime => { + const errorTargetPixels = Math.max( + 0.1, + options.errorTargetPixels ?? DEFAULT_ERROR_TARGET_PIXELS + ); + const minimumLevel = clampInteger( + options.minimumLevel, + DEFAULT_MINIMUM_LEVEL, + 0 + ); + const maximumLevel = Math.max( + minimumLevel, + clampInteger(options.maximumLevel, DEFAULT_MAXIMUM_LEVEL, 0) + ); + const shadowLevelOffset = clampInteger( + options.shadowLevelOffset, + DEFAULT_SHADOW_LEVEL_OFFSET, + 0 + ); + const maxSelectionTiles = clampInteger( + options.maxSelectionTiles, + DEFAULT_MAX_SELECTION_TILES, + 1 + ); + const requestConcurrency = Math.min( + MAXIMUM_REQUEST_CONCURRENCY, + clampInteger(options.requestConcurrency, DEFAULT_REQUEST_CONCURRENCY, 1) + ); + const maxCachedMeshes = clampInteger( + options.maxCachedMeshes, + DEFAULT_MAX_CACHED_MESHES, + 1 + ); + if ( + options.noDataHeightMeters !== undefined && + !Number.isFinite(options.noDataHeightMeters) + ) { + throw new RangeError("Terrain no-data height must be finite"); + } + if ( + options.heightRangeMeters && + (!Number.isFinite(options.heightRangeMeters[0]) || + !Number.isFinite(options.heightRangeMeters[1]) || + options.heightRangeMeters[0] > options.heightRangeMeters[1]) + ) { + throw new RangeError("Terrain height range must be finite and ordered"); + } + const noDataHeightMeters = options.noDataHeightMeters; + const unknownTerrainHeightRange = + options.heightRangeMeters ?? UNKNOWN_TERRAIN_HEIGHT_RANGE_METERS; + const origin = MercatorCoordinate.fromLngLat(originLngLat, 0); + const meterScale = origin.meterInMercatorCoordinateUnits(); + const projectedGeometryCache = createProjectedTerrainGeometryCache( + terrainUrl, + originLngLat + ); + const payloadAwareConcurrency = createPayloadAwareRequestConcurrency(); + const root = new Group(); + root.name = `${runtimeId}-root`; + const material = new MeshLambertMaterial({ + color: options.material?.color ?? DEFAULT_TERRAIN_COLOR, + side: FrontSide, + // The terrain is an open upward-wound surface, unlike closed building + // extrusions. Cast its visible top faces directly instead of Three.js's + // default opposite-side pass, which requires a closed volume. + shadowSide: FrontSide, + }); + let mapStyleProjectionVersion = 0; + const sourcePromise = acquireCesiumTerrainTileSource(terrainUrl, { + maxCacheBytes: options.maxCacheBytes, + }); + const meshes = new Map(); + let source: CesiumTerrainTileSource | null = null; + let map: MaplibreMap | null = null; + let shadowView: SharedThreeSceneShadowView | null = null; + let unregisterSampler: (() => void) | null = null; + let disposed = false; + let terrainLoading = true; + let meshUseClock = 0; + let selectionGeneration = 0; + let requestedSignature = ""; + // Tiles the server refused for good; they are not asked for again. + const unavailableTileKeys = new Set(); + let selectionRetryTimer: ReturnType | null = null; + let failedSelectionRounds = 0; + const clearSelectionRetry = () => { + if (selectionRetryTimer === null) return; + clearTimeout(selectionRetryTimer); + selectionRetryTimer = null; + }; + /** + * A selection whose tiles partly failed is asked for again after a backoff, + * so a transient outage leaves no hole once the host recovers. Nothing + * else re-evaluates a selection while the camera rests. + */ + const scheduleSelectionRetry = () => { + if (disposed || selectionRetryTimer !== null) return; + const retryDelay = Math.min( + SELECTION_RETRY_MAX_DELAY_MS, + SELECTION_RETRY_BASE_DELAY_MS * 2 ** failedSelectionRounds + ); + const delay = Math.max( + retryDelay, + payloadAwareConcurrency.getCooldownRemainingMs() + ); + failedSelectionRounds += 1; + selectionRetryTimer = setTimeout(() => { + selectionRetryTimer = null; + if (disposed) return; + requestedSignature = ""; + selectionInputSignature = ""; + map?.triggerRepaint(); + }, delay * (1 + Math.random() * 0.5)); + }; + let activeViewportElevationSignature = ""; + // Avoid repeating the full selection walk for an unchanged view. + let selectionInputSignature = ""; + // Quantization prevents shadow-fit and terrain-selection feedback. + let shadowViewSignature = ""; + let resolveReady: (loaded: boolean) => void = () => undefined; + let readySettled = false; + const ready = new Promise((resolve) => { + resolveReady = resolve; + }); + + const settleReady = (loaded: boolean) => { + if (readySettled) return; + readySettled = true; + resolveReady(loaded); + }; + + const setTerrainLoading = (loading: boolean) => { + terrainLoading = loading; + if (map) setSharedThreeTerrainLoading(map, runtimeId, loading); + }; + + const projectToLocalWorld = ( + longitude: number, + latitude: number, + height: number, + target: Vector3 + ) => { + const coordinate = MercatorCoordinate.fromLngLat( + [longitude, latitude], + height + ); + return target.set( + (coordinate.x - origin.x) / meterScale, + (coordinate.z - origin.z) / meterScale, + (coordinate.y - origin.y) / meterScale + ); + }; + + const getScreenSpaceError = ( + terrainSource: CesiumTerrainTileSource, + frame: SharedThreeSceneFrame, + id: CesiumTerrainTileId, + localBoundingBox: Box3, + localCameraPosition: Vector3 + ) => { + const distance = Math.max( + 1, + localBoundingBox.distanceToPoint(localCameraPosition) + ); + const focalLengthPixels = + frame.viewport.y / (2 * Math.tan((frame.lodCamera.fov * Math.PI) / 360)); + return ( + (terrainSource.getLevelMaximumGeometricError(id.level) * + focalLengthPixels) / + distance + ); + }; + + /** Pixel density of an orthographic shadow buffer in the terrain frame. */ + const getOrthographicPixelsPerMeter = ( + camera: Camera, + pixelWidth: number, + pixelHeight: number + ) => { + if ( + !(camera as Camera & { isOrthographicCamera?: boolean }) + .isOrthographicCamera + ) { + return 0; + } + camera.updateMatrixWorld(true); + root.updateMatrixWorld(true); + const clipFromRoot = new Matrix4() + .multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse) + .multiply(root.matrixWorld); + const elements = clipFromRoot.elements; + const pixelsPerMeterForAxis = (offset: number) => + Math.hypot( + (elements[offset] * pixelWidth) / 2, + (elements[offset + 1] * pixelHeight) / 2 + ); + return Math.max( + pixelsPerMeterForAxis(0), + pixelsPerMeterForAxis(4), + pixelsPerMeterForAxis(8) + ); + }; + + const getRelevantChildren = ( + terrainSource: CesiumTerrainTileSource, + parent: TerrainSelectionEntry, + intersectsViewport: (entry: TerrainSelectionEntry) => boolean, + intersectsShadow: (entry: TerrainSelectionEntry) => boolean + ) => { + const childLevel = parent.id.level + 1; + const children: TerrainSelectionEntry[] = []; + for (let yOffset = 0; yOffset < 2; yOffset += 1) { + for (let xOffset = 0; xOffset < 2; xOffset += 1) { + const id = { + level: childLevel, + x: parent.id.x * 2 + xOffset, + y: parent.id.y * 2 + yOffset, + }; + const entry = { id, kind: "source" } as const; + const childIntersectsViewport = intersectsViewport(entry); + if (!childIntersectsViewport && !intersectsShadow(entry)) continue; + // Keep the parent whole when any relevant child lacks source data. + if (terrainSource.getTileDataAvailable(id) === false) return []; + children.push(entry); + } + } + return children; + }; + + const createProjectedGeometry = (tile: CesiumTerrainTile) => + createProjectedTerrainTileGeometry({ + tile, + projectToWorld: projectToLocalWorld, + }); + + const loadTerrainEntry = async ( + terrainSource: CesiumTerrainTileSource, + entry: TerrainSelectionEntry + ) => { + const cached = await projectedGeometryCache.get(entry.id); + if (cached) { + return { tile: cached.tile, projectedGeometry: cached.geometry }; + } + let tile: CesiumTerrainTile; + try { + tile = await terrainSource.requestTile(entry.id); + payloadAwareConcurrency.observePayload(tile.byteLength); + } catch (error) { + payloadAwareConcurrency.observeFailure(error); + throw error; + } + const projectedGeometry = createProjectedGeometry(tile); + projectedGeometryCache.set(tile, projectedGeometry); + return { tile, projectedGeometry }; + }; + + const ensureMesh = ( + tile: CesiumTerrainTile, + entry: TerrainSelectionEntry, + projectedGeometry?: BufferGeometry + ) => { + const key = terrainSelectionKey(entry); + const cached = meshes.get(key); + if (cached) { + projectedGeometry?.dispose(); + cached.lastUsed = ++meshUseClock; + return cached.node; + } + let reliefGeometry: BufferGeometry | null = + projectedGeometry ?? createProjectedGeometry(tile); + let reliefVertexMask = new Uint8Array( + reliefGeometry.getAttribute("position").count + ).fill(1); + if (noDataHeightMeters !== undefined) { + const partition = partitionNoDataTerrainGeometry( + reliefGeometry, + tile, + noDataHeightMeters + ); + reliefGeometry = partition.reliefGeometry; + reliefVertexMask = partition.reliefVertexMask; + } + + const node = new Group(); + node.name = `${runtimeId}-${key}`; + let reliefMesh: Mesh | null = null; + if (reliefGeometry) { + reliefMesh = new Mesh(reliefGeometry, material); + reliefMesh.userData.isShadowTerrainSurface = true; + reliefMesh.name = `${node.name}-relief`; + reliefMesh.castShadow = true; + reliefMesh.receiveShadow = true; + node.add(reliefMesh); + } + node.visible = false; + root.add(node); + mapStyleProjectionVersion += 1; + const filterReliefBoundary = (indices: Uint32Array | undefined) => + Uint32Array.from( + [...(indices ?? [])].filter((index) => reliefVertexMask[index] === 1) + ); + const boundaryEdges: TerrainBoundaryEdges = { + west: filterReliefBoundary(tile.westIndices), + south: filterReliefBoundary(tile.southIndices), + east: filterReliefBoundary(tile.eastIndices), + north: filterReliefBoundary(tile.northIndices), + }; + const decodedHeightRange = getFiniteHeightRange(tile.heightMeters) ?? [ + 0, 0, + ]; + const minimumHeightMeters = Number.isFinite(tile.minimumHeightMeters) + ? tile.minimumHeightMeters + : decodedHeightRange[0]; + const maximumHeightMeters = Number.isFinite(tile.maximumHeightMeters) + ? tile.maximumHeightMeters + : decodedHeightRange[1]; + meshes.set(key, { + node, + reliefMesh, + boundaryEdges, + lastUsed: ++meshUseClock, + id: entry.id, + minimumHeightMeters, + maximumHeightMeters, + }); + return node; + }; + + const smoothActiveBoundaryNormals = (activeKeys: ReadonlySet) => { + const boundaries = new Map(); + const normalAccumulators = new Map< + string, + TerrainBoundaryNormalAccumulator + >(); + for (const [key, record] of meshes) { + if (!activeKeys.has(key)) continue; + const { reliefMesh } = record; + if (!reliefMesh) continue; + reliefMesh.geometry.computeVertexNormals(); + const normal = reliefMesh.geometry.getAttribute("normal"); + const position = reliefMesh.geometry.getAttribute("position"); + for (const side of ["west", "south", "east", "north"] as const) { + const indices = record.boundaryEdges[side]; + const axis = terrainBoundaryAxis(side); + const edgeVertices: TerrainBoundaryVertex[] = []; + for (const index of indices) { + const vertexNormal = new Vector3( + normal.getX(index), + normal.getY(index), + normal.getZ(index) + ); + if (vertexNormal.lengthSq() <= Number.EPSILON) continue; + const vertexKey = `${key}/${index}`; + const accumulator = + normalAccumulators.get(vertexKey) ?? + ({ + record, + index, + normalSum: vertexNormal.clone(), + contributorCount: 0, + } satisfies TerrainBoundaryNormalAccumulator); + normalAccumulators.set(vertexKey, accumulator); + edgeVertices.push({ + accumulator, + parameter: + axis === "x" ? position.getZ(index) : position.getX(index), + normal: vertexNormal, + }); + } + edgeVertices.sort((left, right) => left.parameter - right.parameter); + if (edgeVertices.length === 0) continue; + const lineCoordinate = + axis === "x" + ? position.getX(edgeVertices[0].accumulator.index) + : position.getZ(edgeVertices[0].accumulator.index); + const lineKey = `${axis}/${Math.round( + lineCoordinate * TERRAIN_BOUNDARY_KEY_PRECISION + )}`; + const edge: TerrainBoundaryEdge = { + side, + vertices: edgeVertices, + minimum: edgeVertices[0].parameter, + maximum: edgeVertices[edgeVertices.length - 1].parameter, + }; + const lineEdges = boundaries.get(lineKey) ?? []; + lineEdges.push(edge); + boundaries.set(lineKey, lineEdges); + } + } + + for (const edges of boundaries.values()) { + for (let leftIndex = 0; leftIndex < edges.length; leftIndex += 1) { + const left = edges[leftIndex]; + for ( + let rightIndex = leftIndex + 1; + rightIndex < edges.length; + rightIndex += 1 + ) { + const right = edges[rightIndex]; + if (oppositeTerrainBoundarySide(left.side) !== right.side) continue; + const overlapMinimum = Math.max(left.minimum, right.minimum); + const overlapMaximum = Math.min(left.maximum, right.maximum); + if ( + overlapMaximum - overlapMinimum <= + TERRAIN_BOUNDARY_OVERLAP_EPSILON + ) { + continue; + } + for (const vertex of left.vertices) { + const neighborNormal = interpolateTerrainBoundaryNormal( + right, + vertex.parameter + ); + if (!neighborNormal) continue; + vertex.accumulator.normalSum.add(neighborNormal); + vertex.accumulator.contributorCount += 1; + } + for (const vertex of right.vertices) { + const neighborNormal = interpolateTerrainBoundaryNormal( + left, + vertex.parameter + ); + if (!neighborNormal) continue; + vertex.accumulator.normalSum.add(neighborNormal); + vertex.accumulator.contributorCount += 1; + } + } + } + } + + const updatedAttributes = new Set< + ReturnType + >(); + for (const accumulator of normalAccumulators.values()) { + if ( + accumulator.contributorCount === 0 || + accumulator.normalSum.lengthSq() === 0 + ) { + continue; + } + accumulator.normalSum.normalize(); + const attribute = + accumulator.record.reliefMesh!.geometry.getAttribute("normal"); + attribute.setXYZ( + accumulator.index, + accumulator.normalSum.x, + accumulator.normalSum.y, + accumulator.normalSum.z + ); + updatedAttributes.add(attribute); + } + for (const attribute of updatedAttributes) attribute.needsUpdate = true; + }; + + let activeMeshKeys: ReadonlySet = new Set(); + const terrainBoundsCorner = new Vector3(); + + const getTerrainMeshWorldBounds = ( + record: TerrainMeshRecord, + target: Box3 + ): Box3 => { + const geographicBounds = source!.getTileBounds(record.id); + target.makeEmpty(); + for (const longitude of [geographicBounds.west, geographicBounds.east]) { + for (const latitude of [geographicBounds.south, geographicBounds.north]) { + target.expandByPoint( + projectToLocalWorld( + longitude, + latitude, + record.minimumHeightMeters, + terrainBoundsCorner + ) + ); + target.expandByPoint( + projectToLocalWorld( + longitude, + latitude, + record.maximumHeightMeters, + terrainBoundsCorner + ) + ); + } + } + return target.applyMatrix4(root.matrixWorld); + }; + + const getViewElevationRange = ( + camera: Camera + ): readonly [number, number] | null => { + if (!source || activeMeshKeys.size === 0) return null; + camera.updateMatrixWorld(true); + root.updateMatrixWorld(true); + const viewProjection = new Matrix4().multiplyMatrices( + camera.projectionMatrix, + camera.matrixWorldInverse + ); + const viewFrustum = new Frustum().setFromProjectionMatrix( + viewProjection, + camera.coordinateSystem, + camera.reversedDepth + ); + const localBounds = new Box3(); + let minimum = Number.POSITIVE_INFINITY; + let maximum = Number.NEGATIVE_INFINITY; + for (const key of activeMeshKeys) { + const record = meshes.get(key); + if (!record?.node.visible) continue; + getTerrainMeshWorldBounds(record, localBounds); + if (!viewFrustum.intersectsBox(localBounds)) continue; + minimum = Math.min(minimum, localBounds.min.y); + maximum = Math.max(maximum, localBounds.max.y); + } + return Number.isFinite(minimum) && Number.isFinite(maximum) + ? [minimum, maximum] + : null; + }; + + const getActiveTileVolumes = (): readonly SharedThreeSceneTileVolume[] => { + if (!source || activeMeshKeys.size === 0) return []; + root.updateMatrixWorld(true); + const bounds = new Box3(); + const volumes: SharedThreeSceneTileVolume[] = []; + for (const key of activeMeshKeys) { + const record = meshes.get(key); + if (!record?.node.visible) continue; + getTerrainMeshWorldBounds(record, bounds); + volumes.push({ + id: `${runtimeId}:${key}`, + kind: "terrain-tile", + minimum: [bounds.min.x, bounds.min.y, bounds.min.z], + maximum: [bounds.max.x, bounds.max.y, bounds.max.z], + }); + } + return volumes; + }; + + const applyMeshVisibility = () => { + for (const [key, record] of meshes) { + record.node.visible = root.visible && activeMeshKeys.has(key); + record.node.position.y = 0; + record.node.updateMatrixWorld(); + } + }; + + const trimMeshCache = (activeKeys: ReadonlySet) => { + let excess = meshes.size - maxCachedMeshes; + if (excess <= 0) return; + const candidates = [...meshes.entries()] + .filter(([key]) => !activeKeys.has(key)) + .sort(([, left], [, right]) => left.lastUsed - right.lastUsed); + for (const [key, record] of candidates) { + if (excess <= 0) break; + root.remove(record.node); + record.reliefMesh?.geometry.dispose(); + meshes.delete(key); + excess -= 1; + } + }; + + const buildSelection = ( + terrainSource: CesiumTerrainTileSource, + frame: SharedThreeSceneFrame + ): TerrainSelection => { + const viewportBounds = getViewportBounds(frame.map); + const shadowBounds = shadowView + ? cameraFrustumBounds(shadowView.camera, root, origin, meterScale) + : null; + const shadowPixelsPerMeter = + shadowView && shadowBounds + ? getOrthographicPixelsPerMeter( + shadowView.camera, + shadowView.shadowMapSize.width, + shadowView.shadowMapSize.height + ) + : 0; + const coverageBounds = shadowBounds + ? unionGeographicBounds(viewportBounds, shadowBounds) + : viewportBounds; + frame.renderCamera.updateMatrixWorld(true); + root.updateWorldMatrix(true, false); + const clipFromWorld = new Matrix4().multiplyMatrices( + frame.renderCamera.projectionMatrix, + frame.renderCamera.matrixWorldInverse + ); + const viewportFrustum = new Frustum().setFromProjectionMatrix( + clipFromWorld + ); + const shadowFrustum = shadowView + ? new Frustum().setFromProjectionMatrix( + new Matrix4().multiplyMatrices( + shadowView.camera.projectionMatrix, + shadowView.camera.matrixWorldInverse + ), + shadowView.camera.coordinateSystem, + shadowView.camera.reversedDepth + ) + : null; + const localFromWorld = new Matrix4().copy(root.matrixWorld).invert(); + const localCameraPosition = frame.lodCamera.position + .clone() + .applyMatrix4(localFromWorld); + const viewMetrics = new Map< + string, + Readonly<{ + intersectsViewport: boolean; + intersectsShadow: boolean; + localBoundingBox: Box3; + viewportCenterDistanceSquared: number; + }> + >(); + const getKnownHeightRange = ( + entry: TerrainSelectionEntry + ): readonly [minimum: number, maximum: number] => { + let ancestor = entry.id; + while (ancestor.level >= 0) { + const record = meshes.get( + terrainSelectionKey({ id: ancestor, kind: "source" }) + ); + if (record) { + const uncertainty = + ancestor.level === entry.id.level + ? 0 + : terrainSource.getLevelMaximumGeometricError(ancestor.level); + return [ + record.minimumHeightMeters - uncertainty, + record.maximumHeightMeters + uncertainty, + ]; + } + if (ancestor.level === 0) break; + ancestor = { + level: ancestor.level - 1, + x: ancestor.x >> 1, + y: ancestor.y >> 1, + }; + } + return unknownTerrainHeightRange; + }; + const getViewMetrics = (entry: TerrainSelectionEntry) => { + const key = terrainSelectionKey(entry); + const cached = viewMetrics.get(key); + if (cached) return cached; + const bounds = terrainSource.getTileBounds(entry.id); + const [minimumHeight, maximumHeight] = getKnownHeightRange(entry); + const localBoundingBox = new Box3(); + for (const longitude of [bounds.west, bounds.east]) { + for (const latitude of [bounds.south, bounds.north]) { + localBoundingBox.expandByPoint( + projectToLocalWorld( + longitude, + latitude, + minimumHeight, + new Vector3() + ) + ); + localBoundingBox.expandByPoint( + projectToLocalWorld( + longitude, + latitude, + maximumHeight, + new Vector3() + ) + ); + } + } + const worldBoundingBox = localBoundingBox + .clone() + .applyMatrix4(root.matrixWorld); + const projectedCenter = worldBoundingBox + .getCenter(new Vector3()) + .project(frame.renderCamera); + const metrics = { + intersectsViewport: + geographicBoundsIntersect(bounds, viewportBounds) || + viewportFrustum.intersectsBox(worldBoundingBox), + intersectsShadow: + shadowFrustum?.intersectsBox(worldBoundingBox) ?? false, + localBoundingBox, + viewportCenterDistanceSquared: + projectedCenter.x ** 2 + projectedCenter.y ** 2, + }; + viewMetrics.set(key, metrics); + return metrics; + }; + const intersectsViewport = (entry: TerrainSelectionEntry) => + getViewMetrics(entry).intersectsViewport; + const intersectsShadow = (entry: TerrainSelectionEntry) => + getViewMetrics(entry).intersectsShadow; + const getRootSearchBounds = (level: number) => { + const viewportIds = terrainSource.getTileGridIdsForBounds( + viewportBounds, + level + ); + let longitudePadding = 0; + let latitudePadding = 0; + for (const id of viewportIds) { + const bounds = terrainSource.getTileBounds(id); + longitudePadding = Math.max( + longitudePadding, + bounds.east - bounds.west + ); + latitudePadding = Math.max( + latitudePadding, + bounds.north - bounds.south + ); + } + const viewportAndNeighbors = { + west: viewportBounds.west - longitudePadding, + south: Math.max(-90, viewportBounds.south - latitudePadding), + east: viewportBounds.east + longitudePadding, + north: Math.min(90, viewportBounds.north + latitudePadding), + }; + return unionGeographicBounds(coverageBounds, viewportAndNeighbors); + }; + const getRootEntries = (level: number): TerrainSelectionEntry[] => + terrainSource + .getTileGridIdsForBounds(getRootSearchBounds(level), level) + .flatMap((id) => { + if (terrainSource.getTileDataAvailable(id) === false) return []; + const entry = { id, kind: "source" } as TerrainSelectionEntry; + const rootIntersectsViewport = intersectsViewport(entry); + const rootIntersectsShadow = intersectsShadow(entry); + if (!rootIntersectsViewport && !rootIntersectsShadow) return []; + return [entry]; + }); + let rootLevel = minimumLevel; + let rootEntries = getRootEntries(rootLevel); + while (rootEntries.length > maxSelectionTiles && rootLevel > 0) { + rootLevel -= 1; + rootEntries = getRootEntries(rootLevel); + } + const selected = new Map( + rootEntries.map((entry) => [terrainSelectionKey(entry), entry]) + ); + const toCandidate = (entry: TerrainSelectionEntry): TerrainCandidate => { + const metrics = getViewMetrics(entry); + const viewportErrorRatio = metrics.intersectsViewport + ? getScreenSpaceError( + terrainSource, + frame, + entry.id, + metrics.localBoundingBox, + localCameraPosition + ) / errorTargetPixels + : 0; + const shadowTargetPixels = errorTargetPixels * 2 ** shadowLevelOffset; + const levelErrorMeters = terrainSource.getLevelMaximumGeometricError( + entry.id.level + ); + const shadowErrorRatio = metrics.intersectsShadow + ? (levelErrorMeters * shadowPixelsPerMeter) / shadowTargetPixels + : 0; + return { + entry, + viewportErrorRatio, + shadowErrorRatio, + intersectsViewport: metrics.intersectsViewport, + viewportCenterDistanceSquared: metrics.viewportCenterDistanceSquared, + }; + }; + // Refine the viewport before spending the shared budget on sun coverage. + const makeHeap = (ratioOf: (candidate: TerrainCandidate) => number) => { + const heap: TerrainCandidate[] = []; + const swap = (a: number, b: number) => { + const held = heap[a]; + heap[a] = heap[b]; + heap[b] = held; + }; + const siftDown = (from: number) => { + let index = from; + for (;;) { + const left = 2 * index + 1; + const right = left + 1; + let largest = index; + if ( + left < heap.length && + ratioOf(heap[left]) > ratioOf(heap[largest]) + ) { + largest = left; + } + if ( + right < heap.length && + ratioOf(heap[right]) > ratioOf(heap[largest]) + ) { + largest = right; + } + if (largest === index) break; + swap(largest, index); + index = largest; + } + }; + return { + get size() { + return heap.length; + }, + push(candidate: TerrainCandidate) { + heap.push(candidate); + let index = heap.length - 1; + while (index > 0) { + const parent = (index - 1) >> 1; + if (ratioOf(heap[parent]) >= ratioOf(heap[index])) break; + swap(parent, index); + index = parent; + } + }, + pop(): TerrainCandidate { + const top = heap[0]; + const last = heap.pop()!; + if (heap.length > 0) { + heap[0] = last; + siftDown(0); + } + return top; + }, + }; + }; + + const viewportHeap = makeHeap((candidate) => candidate.viewportErrorRatio); + const shadowHeap = makeHeap((candidate) => candidate.shadowErrorRatio); + for (const entry of rootEntries) { + const candidate = toCandidate(entry); + if (candidate.intersectsViewport) viewportHeap.push(candidate); + else shadowHeap.push(candidate); + } + + const refine = ( + heap: ReturnType, + ratioOf: (candidate: TerrainCandidate) => number + ) => { + while (heap.size > 0) { + const candidate = heap.pop(); + if (ratioOf(candidate) <= 1) break; + if (candidate.entry.id.level >= maximumLevel) continue; + const children = getRelevantChildren( + terrainSource, + candidate.entry, + intersectsViewport, + intersectsShadow + ); + if (!children.length) continue; + if (selected.size + children.length - 1 > maxSelectionTiles) continue; + selected.delete(terrainSelectionKey(candidate.entry)); + for (const child of children) { + selected.set(terrainSelectionKey(child), child); + const childCandidate = toCandidate(child); + if (childCandidate.intersectsViewport) { + viewportHeap.push(childCandidate); + } else { + shadowHeap.push(childCandidate); + } + } + } + }; + + refine(viewportHeap, (candidate) => candidate.viewportErrorRatio); + refine(shadowHeap, (candidate) => candidate.shadowErrorRatio); + + // Viewport tiles download before offscreen shadow casters. + const entries = [...selected.values()].sort((left, right) => { + const leftInView = intersectsViewport(left) ? 0 : 1; + const rightInView = intersectsViewport(right) ? 0 : 1; + if (leftInView !== rightInView) return leftInView - rightInView; + return ( + getViewMetrics(left).viewportCenterDistanceSquared - + getViewMetrics(right).viewportCenterDistanceSquared + ); + }); + const viewportEntries = entries.filter(intersectsViewport); + const getAncestorEntry = ( + entry: TerrainSelectionEntry, + level: number + ): TerrainSelectionEntry => { + if (level >= entry.id.level) return entry; + const shift = entry.id.level - level; + const id = { + level, + x: entry.id.x >> shift, + y: entry.id.y >> shift, + }; + return { id, kind: "source" }; + }; + const maximumViewportLevel = viewportEntries.reduce( + (maximum, entry) => Math.max(maximum, entry.id.level), + rootLevel + ); + const viewportStages: TerrainSelectionEntry[][] = []; + let previousStageSignature = ""; + for (let level = rootLevel; level <= maximumViewportLevel; level += 1) { + const stageByKey = new Map(); + for (const entry of viewportEntries) { + const stageEntry = getAncestorEntry(entry, level); + stageByKey.set(terrainSelectionKey(stageEntry), stageEntry); + } + const stage = [...stageByKey.values()].sort( + (left, right) => + getViewMetrics(left).viewportCenterDistanceSquared - + getViewMetrics(right).viewportCenterDistanceSquared + ); + const stageSignature = stage.map(terrainSelectionKey).sort().join("|"); + if (stageSignature !== previousStageSignature) { + viewportStages.push(stage); + previousStageSignature = stageSignature; + } + } + const loadEntriesByKey = new Map(); + for (const stage of viewportStages) { + for (const entry of stage) { + loadEntriesByKey.set(terrainSelectionKey(entry), entry); + } + } + for (const entry of entries) { + loadEntriesByKey.set(terrainSelectionKey(entry), entry); + } + return { + entries, + viewportStages, + loadEntries: [...loadEntriesByKey.values()], + signature: entries.map(terrainSelectionKey).sort().join("|"), + viewportElevationSignature: entries + .filter(intersectsViewport) + .map(terrainSelectionKey) + .sort() + .join("|"), + }; + }; + + const computeSelectionInputSignature = ( + frame: SharedThreeSceneFrame + ): string => { + // Quantize the synthesized LoD pose to limit selection churn. + const { position, quaternion } = frame.lodCamera; + return [ + quantize(position.x, 5), + quantize(position.y, 5), + quantize(position.z, 5), + quantize(quaternion.x, 0.005), + quantize(quaternion.y, 0.005), + quantize(quaternion.z, 0.005), + quantize(quaternion.w, 0.005), + `${frame.viewport.x}x${frame.viewport.y}`, + shadowViewSignature, + ].join(";"); + }; + + const loadSelection = ( + terrainSource: CesiumTerrainTileSource, + selection: TerrainSelection + ) => { + setTerrainLoading(true); + const generation = ++selectionGeneration; + let publishedViewportStage = -1; + const rootViewportKeys = new Set( + (selection.viewportStages[0] ?? []).map(terrainSelectionKey) + ); + const activateEntries = (entries: readonly TerrainSelectionEntry[]) => { + const activeKeys = new Set(entries.map(terrainSelectionKey)); + smoothActiveBoundaryNormals(activeKeys); + activeMeshKeys = activeKeys; + applyMeshVisibility(); + settleReady(true); + map?.triggerRepaint(); + }; + const publishViewportRoot = (entry: TerrainSelectionEntry) => { + const key = terrainSelectionKey(entry); + if ( + disposed || + generation !== selectionGeneration || + !rootViewportKeys.has(key) || + activeMeshKeys.has(key) + ) { + return; + } + const overlapsActiveHierarchy = [...activeMeshKeys].some((activeKey) => { + const active = meshes.get(activeKey); + return ( + active && + (terrainTileContains(active.id, entry.id) || + terrainTileContains(entry.id, active.id)) + ); + }); + if (overlapsActiveHierarchy) return; + activeMeshKeys = new Set([...activeMeshKeys, key]); + applyMeshVisibility(); + settleReady(true); + map?.triggerRepaint(); + }; + const publishReadyViewportStage = () => { + if (disposed || generation !== selectionGeneration) return; + const previousStage = publishedViewportStage; + let nextStage = publishedViewportStage + 1; + while ( + nextStage < selection.viewportStages.length && + selection.viewportStages[nextStage].every((entry) => + meshes.has(terrainSelectionKey(entry)) + ) + ) { + publishedViewportStage = nextStage; + nextStage += 1; + } + if (publishedViewportStage > previousStage) { + activateEntries(selection.viewportStages[publishedViewportStage]); + } + }; + const entriesToLoad = selection.loadEntries.filter( + (entry) => + !meshes.has(terrainSelectionKey(entry)) && + !unavailableTileKeys.has(cesiumTerrainTileKey(entry.id)) + ); + for (const entry of selection.viewportStages[0] ?? []) { + if (meshes.has(terrainSelectionKey(entry))) publishViewportRoot(entry); + } + publishReadyViewportStage(); + void loadWithConcurrency( + entriesToLoad, + Math.max(1, payloadAwareConcurrency.getConcurrency(requestConcurrency)), + async (entry) => { + if (disposed || generation !== selectionGeneration) { + throw new Error("Stale terrain selection"); + } + return loadTerrainEntry(terrainSource, entry); + }, + (entry, { tile, projectedGeometry }) => { + if (disposed) { + projectedGeometry.dispose(); + return; + } + ensureMesh(tile, entry, projectedGeometry); + publishViewportRoot(entry); + publishReadyViewportStage(); + } + ) + .then(({ failures }) => { + if (disposed || generation !== selectionGeneration) return; + let transientFailure: unknown = null; + for (const { value, error } of failures) { + if (isConfirmedTerrainServerError(error)) { + unavailableTileKeys.add(cesiumTerrainTileKey(value.id)); + } else { + transientFailure ??= error; + } + } + if (transientFailure !== null) { + options.onError?.(transientFailure); + scheduleSelectionRetry(); + } else { + failedSelectionRounds = 0; + } + const activeKeys = new Set(); + const retainedSourceKeys = new Set(); + for (const entry of selection.entries) { + const key = terrainSelectionKey(entry); + activeKeys.add(key); + retainedSourceKeys.add(cesiumTerrainTileKey(entry.id)); + } + activateEntries(selection.entries); + terrainSource.trimCache(retainedSourceKeys); + trimMeshCache(activeKeys); + setTerrainLoading(false); + options.onContentChanged?.(); + if ( + map && + selection.viewportElevationSignature !== + activeViewportElevationSignature + ) { + activeViewportElevationSignature = + selection.viewportElevationSignature; + notifySharedThreeTerrainChanged(map); + // Re-evaluate screen-space error after source heights become known. + selectionInputSignature = ""; + } + map?.triggerRepaint(); + }) + .catch((error) => { + if (disposed || generation !== selectionGeneration) return; + setTerrainLoading(false); + options.onError?.(error); + settleReady(false); + }); + }; + + void sourcePromise + .then((terrainSource) => { + if (disposed) return; + source = terrainSource; + if (map) { + unregisterSampler = registerSharedThreeTerrainSampler( + map, + runtimeId, + terrainSource.sampleHeight + ); + map.triggerRepaint(); + } + }) + .catch((error) => { + if (disposed) return; + setTerrainLoading(false); + options.onError?.(error); + settleReady(false); + }); + + return { + id: runtimeId, + originLngLat, + root, + providesTerrain: true, + receivesMapStyleTexture: options.receivesMapStyleTexture === true, + mapStyleProjectionVersion: () => mapStyleProjectionVersion, + updatePriority: TERRAIN_UPDATE_PRIORITY, + ready, + onAdd(mapInstance) { + map = mapInstance; + setSharedThreeTerrainLoading(mapInstance, runtimeId, terrainLoading); + if (source && !unregisterSampler) { + unregisterSampler = registerSharedThreeTerrainSampler( + mapInstance, + runtimeId, + source.sampleHeight + ); + } + map.triggerRepaint(); + }, + update(frame) { + if (disposed || !root.visible) return; + if (!source) return; + const inputSignature = computeSelectionInputSignature(frame); + if (inputSignature === selectionInputSignature) return; + const selection = buildSelection(source, frame); + selectionInputSignature = inputSignature; + if (selection.signature === requestedSignature) { + if (selectionGeneration === 0) { + setTerrainLoading(false); + settleReady(true); + } + return; + } + requestedSignature = selection.signature; + loadSelection(source, selection); + }, + setShadowView(view) { + shadowView = view + ? { + camera: view.camera, + shadowMapSize: normalizeShadowMapSize(view.shadowMapSize), + } + : null; + const nextSignature = getSharedThreeShadowViewSignature(shadowView); + if (nextSignature !== shadowViewSignature) { + shadowViewSignature = nextSignature; + selectionInputSignature = ""; + } + }, + setMaterialColor(color) { + material.color.set(color); + map?.triggerRepaint(); + }, + getElevation(longitude, latitude) { + const height = source?.sampleHeight(longitude, latitude); + return height !== undefined && + noDataHeightMeters !== undefined && + Math.abs(height - noDataHeightMeters) <= ZERO_ELEVATION_EPSILON_METERS + ? undefined + : height; + }, + getViewElevationRange, + getActiveTileVolumes, + dispose() { + if (disposed) return; + disposed = true; + clearSelectionRetry(); + selectionGeneration += 1; + unregisterSampler?.(); + unregisterSampler = null; + if (map) setSharedThreeTerrainLoading(map, runtimeId, false); + for (const record of meshes.values()) { + record.reliefMesh?.geometry.dispose(); + } + meshes.clear(); + material.dispose(); + root.clear(); + map = null; + settleReady(false); + }, + }; +}; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/generic-three-layer-registry.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/generic-three-layer-registry.spec.ts new file mode 100644 index 0000000000..9e031d4d53 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/generic-three-layer-registry.spec.ts @@ -0,0 +1,31 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from "vitest"; + +import { + getGenericThreeLayers, + notifyGenericThreeLayerContentChanged, + registerGenericThreeLayer, + subscribeGenericThreeLayers, + unregisterGenericThreeLayer, +} from "./generic-three-layer-registry"; + +describe("generic Three.js layer registry", () => { + it("publishes layer lifecycle and content changes", () => { + const map = {} as never; + const layer = {} as never; + const listener = vi.fn(); + const unsubscribe = subscribeGenericThreeLayers(map, listener); + + registerGenericThreeLayer(map, layer); + notifyGenericThreeLayerContentChanged(map); + expect(getGenericThreeLayers(map)).toEqual([layer]); + expect(listener).toHaveBeenCalledTimes(2); + + unregisterGenericThreeLayer(map, layer); + expect(getGenericThreeLayers(map)).toEqual([]); + expect(listener).toHaveBeenCalledTimes(3); + + unsubscribe(); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/generic-three-layer-registry.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/generic-three-layer-registry.ts new file mode 100644 index 0000000000..f84856fab9 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/generic-three-layer-registry.ts @@ -0,0 +1,57 @@ +import type { Map as MaplibreMap } from "maplibre-gl"; + +import type { GenericCustomLayer } from "@carma-mapping/engines/threejs"; + +import { add3dPresence, remove3dPresence } from "../../../utils/threeDPresence"; + +const layersByMap = new WeakMap(); +const listenersByMap = new WeakMap void>>(); + +const emitGenericThreeLayerChange = (map: MaplibreMap) => { + for (const listener of listenersByMap.get(map) ?? []) listener(); +}; + +export const getGenericThreeLayers = (map: MaplibreMap): GenericCustomLayer[] => + layersByMap.get(map) ?? []; + +export const registerGenericThreeLayer = ( + map: MaplibreMap, + layer: GenericCustomLayer +): void => { + const layers = layersByMap.get(map) ?? []; + if (layers.includes(layer)) return; + layersByMap.set(map, [...layers, layer]); + // What lets the camera restriction and terrain button know the map has + // become three dimensional, see utils/threeDPresence. + add3dPresence(map, layer.id); + emitGenericThreeLayerChange(map); +}; + +export const unregisterGenericThreeLayer = ( + map: MaplibreMap, + layer: GenericCustomLayer +): void => { + const layers = layersByMap.get(map) ?? []; + const nextLayers = layers.filter((candidate) => candidate !== layer); + if (nextLayers.length === layers.length) return; + remove3dPresence(map, layer.id); + if (nextLayers.length > 0) layersByMap.set(map, nextLayers); + else layersByMap.delete(map); + emitGenericThreeLayerChange(map); +}; + +export const notifyGenericThreeLayerContentChanged = (map: MaplibreMap): void => + emitGenericThreeLayerChange(map); + +export const subscribeGenericThreeLayers = ( + map: MaplibreMap, + listener: () => void +): (() => void) => { + const listeners = listenersByMap.get(map) ?? new Set<() => void>(); + listeners.add(listener); + listenersByMap.set(map, listeners); + return () => { + listeners.delete(listener); + if (listeners.size === 0) listenersByMap.delete(map); + }; +}; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/gltf1-upgrade-plugin.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/gltf1-upgrade-plugin.spec.ts new file mode 100644 index 0000000000..184e7f98db --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/gltf1-upgrade-plugin.spec.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { Gltf1UpgradePlugin, upgradeB3dmGltf1 } from "./gltf1-upgrade-plugin"; + +const encoder = new TextEncoder(); + +const buildB3dmWithGltf1 = () => { + const gltf = { + asset: { version: "1.0" }, + buffers: { binary_glTF: { byteLength: 4 } }, + bufferViews: { + vertices: { buffer: "binary_glTF", byteOffset: 0, byteLength: 4 }, + }, + accessors: { + position: { + bufferView: "vertices", + componentType: 5126, + count: 1, + type: "SCALAR", + min: [0], + max: [1], + }, + }, + images: { + image: { + extensions: { + KHR_binary_glTF: { + bufferView: "vertices", + mimeType: "image/png", + }, + }, + }, + }, + samplers: { sampler: { wrapS: 10497, wrapT: 10497 } }, + textures: { texture: { sampler: "sampler", source: "image" } }, + materials: { material: { values: { diffuse: "texture" } } }, + meshes: { + mesh: { + primitives: [ + { + attributes: { POSITION: "position" }, + material: "material", + }, + ], + }, + }, + nodes: { node: { meshes: ["mesh"] } }, + scenes: { scene: { nodes: ["node"] } }, + scene: "scene", + extensions: { CESIUM_RTC: { center: [1, 2, 3] } }, + }; + const rawJson = encoder.encode(JSON.stringify(gltf)); + const jsonLength = rawJson.length + ((4 - (rawJson.length % 4)) % 4); + const glb = new Uint8Array(20 + jsonLength + 4); + const glbView = new DataView(glb.buffer); + glbView.setUint32(0, 0x46546c67, true); + glbView.setUint32(4, 1, true); + glbView.setUint32(8, glb.length, true); + glbView.setUint32(12, jsonLength, true); + glbView.setUint32(16, 0, true); + glb.fill(0x20, 20, 20 + jsonLength); + glb.set(rawJson, 20); + glb.set([1, 2, 3, 4], 20 + jsonLength); + + const b3dm = new Uint8Array(28 + glb.length); + const b3dmView = new DataView(b3dm.buffer); + b3dmView.setUint32(0, 0x6d643362, true); + b3dmView.setUint32(4, 1, true); + b3dmView.setUint32(8, b3dm.length, true); + b3dm.set(glb, 28); + return b3dm.buffer; +}; + +const readGltf2Json = (b3dm: ArrayBuffer) => { + const bytes = new Uint8Array(b3dm); + const view = new DataView(b3dm); + const jsonLength = view.getUint32(28 + 12, true); + return JSON.parse( + new TextDecoder().decode(bytes.subarray(28 + 20, 28 + 20 + jsonLength)) + ) as Record; +}; + +describe("glTF 1 b3dm upgrade", () => { + afterEach(() => vi.restoreAllMocks()); + + it("converts dictionaries, binary images and scene references to glTF 2", () => { + const upgraded = upgradeB3dmGltf1(buildB3dmWithGltf1()); + + expect(upgraded).not.toBeNull(); + const json = readGltf2Json(upgraded!); + expect(json.asset).toMatchObject({ version: "2.0" }); + expect(json.buffers).toEqual([{ byteLength: 4 }]); + expect(json.images).toEqual([{ bufferView: 0, mimeType: "image/png" }]); + expect(json.extensionsUsed).toEqual([ + "KHR_materials_unlit", + "CESIUM_RTC", + ]); + expect(json.scene).toBe(0); + }); + + it("ignores non-b3dm data", () => { + expect(upgradeB3dmGltf1(new ArrayBuffer(32))).toBeNull(); + }); + + it("upgrades successful b3dm fetches and passes other responses through", async () => { + const source = buildB3dmWithGltf1(); + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response(source, { status: 200 })) + .mockResolvedValueOnce(new Response("plain", { status: 200 })); + const plugin = new Gltf1UpgradePlugin(); + + const upgraded = await plugin.fetchData("tile.b3dm", {}); + const plain = await plugin.fetchData("tile.json", {}); + + expect(new DataView(await upgraded.arrayBuffer()).getUint32(28 + 4, true)).toBe( + 2 + ); + expect(await plain.text()).toBe("plain"); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/playgrounds/ng-topicmap-playground/src/app/pointcloud/gltf1UpgradePlugin.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/gltf1-upgrade-plugin.ts similarity index 82% rename from playgrounds/ng-topicmap-playground/src/app/pointcloud/gltf1UpgradePlugin.ts rename to libraries/mapping/engines/maplibre/src/lib/runtime/integrations/gltf1-upgrade-plugin.ts index db09b41a9a..1b5f44f7c5 100644 --- a/playgrounds/ng-topicmap-playground/src/app/pointcloud/gltf1UpgradePlugin.ts +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/gltf1-upgrade-plugin.ts @@ -1,21 +1,4 @@ -// ───────────────────────────────────────────────────────────── -// On-the-fly glTF 1.0 → 2.0 upgrade for b3dm tiles (WUPP Mesh -// 2020). three's GLTFLoader only parses glTF 2 — this plugin -// intercepts tile downloads via the 3d-tiles-renderer fetchData -// hook, sniffs the embedded GLB version and rewrites v1 payloads -// in place (pure JS, no WASM): -// -// - dictionaries → arrays (ids become indices) -// - technique/shader materials → unlit pbr with baseColorTexture -// - KHR_binary_glTF images → native bufferView images -// - CESIUM_RTC is passed through (handled by GLTFExtensionsPlugin) -// - GLB1 container (20 B header) → GLB2 (JSON + BIN chunks); the -// binary body stays byte-identical -// -// Scope: the minimal feature set the 2020 tiles actually use -// (one textured mesh per tile). Written from scratch against the -// glTF 1.0/2.0 specs — no GPL code involved. -// ───────────────────────────────────────────────────────────── +// Converts the glTF 1 payloads in the 2020 b3dm mesh to glTF 2 for GLTFLoader. interface Gltf1Json { asset?: Record; @@ -106,7 +89,6 @@ const indexMap = (record: Record | undefined) => { return map; }; -/** Rewrite a glTF 1.0 JSON (KHR_binary_glTF flavor) as glTF 2.0 */ const upgradeGltf1Json = (gltf1: Gltf1Json): Record => { const bufferViewIds = indexMap(gltf1.bufferViews); const accessorIds = indexMap(gltf1.accessors); @@ -261,7 +243,6 @@ const upgradeGltf1Json = (gltf1: Gltf1Json): Record => { const textDecoder = new TextDecoder(); const textEncoder = new TextEncoder(); -/** GLB1 (20 B header) → GLB2 (JSON + BIN chunks), body unchanged */ const glb1ToGlb2 = (glb: Uint8Array): Uint8Array | null => { const view = new DataView(glb.buffer, glb.byteOffset, glb.byteLength); if (view.getUint32(0, true) !== 0x46546c67) return null; // "glTF" @@ -301,7 +282,6 @@ const glb1ToGlb2 = (glb: Uint8Array): Uint8Array | null => { return out; }; -/** Rewrite the GLB inside a b3dm when it is glTF 1 (else null) */ export const upgradeB3dmGltf1 = (buffer: ArrayBuffer): ArrayBuffer | null => { const bytes = new Uint8Array(buffer); const view = new DataView(buffer); @@ -327,36 +307,26 @@ export const upgradeB3dmGltf1 = (buffer: ArrayBuffer): ArrayBuffer | null => { return out.buffer; }; -/** - * 3d-tiles-renderer plugin: intercepts .b3dm downloads and upgrades - * embedded glTF 1.0 payloads so three's GLTFLoader can parse them. - */ +export interface Gltf1UpgradePluginOptions { + /** Observes every raw response before its body is consumed. */ + onResponse?: (url: string, response: Response) => void; +} + export class Gltf1UpgradePlugin { name = "GLTF1_UPGRADE_PLUGIN"; - stats = { tiles: 0, upgraded: 0, totalMs: 0, bytesIn: 0 }; + private readonly onResponse: Gltf1UpgradePluginOptions["onResponse"]; - constructor() { - if (import.meta.env.DEV) { - // dev-only introspection for measuring upgrade cost - (window as unknown as Record).__gltf1UpgradeStats = - this.stats; - } + constructor(options: Gltf1UpgradePluginOptions = {}) { + this.onResponse = options.onResponse; } async fetchData(url: string | URL, options: RequestInit): Promise { const response = await fetch(url, options); + this.onResponse?.(String(url), response); if (!/\.b3dm(\?|$)/.test(String(url)) || !response.ok) return response; const buffer = await response.arrayBuffer(); - this.stats.tiles++; - const started = performance.now(); const upgraded = upgradeB3dmGltf1(buffer); - if (upgraded) { - this.stats.upgraded++; - this.stats.totalMs += performance.now() - started; - this.stats.bytesIn += buffer.byteLength; - } - // body was consumed either way — hand back a fresh Response return new Response(upgraded ?? buffer, { status: 200 }); } } diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/map-style-layer-suppression.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/map-style-layer-suppression.spec.ts new file mode 100644 index 0000000000..050035d1d5 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/map-style-layer-suppression.spec.ts @@ -0,0 +1,497 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + acquireMapLibreTerrainMeshComposition, + getMapStyleLocationLabelFlatOffset, + getMapLibreLayerOpacityProperties, + isMapStyleLocationLabelLayer, + isMapStyleHouseNumberLabelLayer, + isMapStylePointLabelLayer, + isMapStyleWaterLabelLayer, + isMapStyleRoadLabelLayer, + MAPLIBRE_TERRAIN_MESH_BASE_OPACITY, + notifyMapLibreStyleCompositionReady, + notifyMapLibreStyleCompositionStarted, + suppressMapLibreRegularStyleLayers, +} from "./map-style-layer-suppression"; + +type TestLayer = { + id: string; + type: string; + source?: string; + "source-layer"?: string; + layout?: Record; +}; + +const createMap = (initialLayers: TestLayer[]) => { + let layers = initialLayers; + const paint = new Map(); + const layout = new Map(); + const handlers = new Map void>>(); + const propertyKey = (layerId: string, property: string) => + `${layerId}:${property}`; + const map = { + getStyle: vi.fn(() => ({ version: 8, sources: {}, layers })), + getLayer: vi.fn((layerId: string) => { + const layer = layers.find((candidate) => candidate.id === layerId); + if (!layer) return undefined; + const { "source-layer": sourceLayer, ...runtimeLayer } = layer; + return { ...runtimeLayer, sourceLayer }; + }), + getPaintProperty: vi.fn((layerId: string, property: string) => + paint.get(propertyKey(layerId, property)) + ), + setPaintProperty: vi.fn( + (layerId: string, property: string, value: unknown) => { + const key = propertyKey(layerId, property); + if (value == null) paint.delete(key); + else paint.set(key, value); + } + ), + getLayoutProperty: vi.fn((layerId: string, property: string) => + layout.get(propertyKey(layerId, property)) + ), + setLayoutProperty: vi.fn( + (layerId: string, property: string, value: unknown) => { + const key = propertyKey(layerId, property); + if (value == null) layout.delete(key); + else layout.set(key, value); + } + ), + on: vi.fn((event: string, handler: () => void) => { + const eventHandlers = handlers.get(event) ?? new Set(); + eventHandlers.add(handler); + handlers.set(event, eventHandlers); + }), + off: vi.fn((event: string, handler: () => void) => { + handlers.get(event)?.delete(handler); + }), + }; + + return { + map, + paint, + layout, + setLayers(nextLayers: TestLayer[]) { + layers = nextLayers; + }, + emit(event: string) { + for (const handler of handlers.get(event) ?? []) handler(); + }, + getPaint(layerId: string, property: string) { + return paint.get(propertyKey(layerId, property)); + }, + setPaint(layerId: string, property: string, value: unknown) { + paint.set(propertyKey(layerId, property), value); + }, + getVisibility(layerId: string) { + return layout.get(propertyKey(layerId, "visibility")); + }, + setVisibility(layerId: string, value: unknown) { + layout.set(propertyKey(layerId, "visibility"), value); + }, + }; +}; + +describe("MapLibre regular style layer suppression", () => { + it("keeps only point-based location labels above Three", () => { + expect(isMapStyleLocationLabelLayer({ id: "roads", type: "line" })).toBe( + false + ); + expect( + isMapStyleLocationLabelLayer({ + id: "place-city", + type: "symbol", + "source-layer": "place", + }) + ).toBe(true); + expect( + isMapStyleLocationLabelLayer({ + id: "road-labels", + type: "symbol", + "source-layer": "transportation_name", + layout: { "symbol-placement": "line" }, + }) + ).toBe(false); + expect( + isMapStyleLocationLabelLayer({ + id: "raster-dop-overlay-1-raster", + type: "raster", + source: "rvrSchriftNT", + }) + ).toBe(false); + expect( + isMapStyleLocationLabelLayer({ + id: "bg-basemap_relief::Name_Stadtgemeinde_bis_500000", + type: "symbol", + "source-layer": "Name_Punkt", + }) + ).toBe(true); + expect( + isMapStyleLocationLabelLayer({ + id: "bg-basemap_relief::Name_Staatsgrenze", + type: "symbol", + "source-layer": "Name_Linie", + layout: { "symbol-placement": "line" }, + }) + ).toBe(false); + expect( + isMapStyleLocationLabelLayer({ + id: "bg-basemap_relief::Name_Wald", + type: "symbol", + "source-layer": "Name_Punkt", + }) + ).toBe(false); + }); + + it("classifies every point symbol as an overlay label", () => { + expect( + isMapStylePointLabelLayer({ + id: "house-numbers", + type: "symbol", + "source-layer": "Hausnummer", + layout: { "text-field": ["get", "hausnummer"] }, + }) + ).toBe(true); + expect( + isMapStylePointLabelLayer({ + id: "road-labels", + type: "symbol", + layout: { "symbol-placement": "line" }, + }) + ).toBe(false); + expect( + isMapStylePointLabelLayer({ + id: "autobahn-route-shields", + type: "symbol", + layout: { "symbol-placement": "point" }, + }) + ).toBe(true); + expect( + isMapStyleRoadLabelLayer({ + id: "Verkehr_Strasse_Fernverkehr_Nummer", + type: "symbol", + layout: { "symbol-placement": "point" }, + }) + ).toBe(true); + expect( + isMapStylePointLabelLayer({ + id: "boundary-labels", + type: "symbol", + layout: { "symbol-placement": "line-center" }, + }) + ).toBe(false); + // basemap.de street names switch between line placements per zoom. + expect( + isMapStylePointLabelLayer({ + id: "Name_Kreis_Gemeindestr", + type: "symbol", + layout: { + "symbol-placement": { + stops: [ + [13, "line"], + [16, "line-center"], + ], + }, + }, + }) + ).toBe(false); + expect( + isMapStylePointLabelLayer({ + id: "Name_Landesstr", + type: "symbol", + layout: { + "symbol-placement": ["step", ["zoom"], "line", 16, "line-center"], + }, + }) + ).toBe(false); + }); + + it("recognizes water and house-number labels for the mesh drape", () => { + expect( + isMapStyleWaterLabelLayer({ + id: "bg-basemap_relief-Name_GewaesserL_Fluss", + type: "symbol", + "source-layer": "Gewaesserlinie", + }) + ).toBe(true); + expect( + isMapStyleWaterLabelLayer({ + id: "bg-basemap_relief-Name_Kreis_Gemeindestr", + type: "symbol", + "source-layer": "Verkehrslinie", + }) + ).toBe(false); + expect( + isMapStyleHouseNumberLabelLayer({ + id: "bg-basemap_relief-Hauskoordinate", + type: "symbol", + "source-layer": "Hauskoordinate", + }) + ).toBe(true); + expect( + isMapStyleHouseNumberLabelLayer({ + id: "place-city", + type: "symbol", + "source-layer": "place", + }) + ).toBe(false); + }); + + it("lifts place labels by their encoded basemap.de prominence", () => { + const layer = (id: string): TestLayer => ({ + id: `bg-basemap_relief::${id}`, + type: "symbol", + "source-layer": "Name_Punkt", + }); + + expect( + getMapStyleLocationLabelFlatOffset(layer("Name_Landeshauptstadt")) + ).toEqual([0, -3]); + expect( + getMapStyleLocationLabelFlatOffset(layer("Name_Stadtgemeinde_bis_500000")) + ).toEqual([0, -2.5]); + expect( + getMapStyleLocationLabelFlatOffset(layer("Name_Stadtgemeinde_bis_50000")) + ).toEqual([0, -2]); + expect( + getMapStyleLocationLabelFlatOffset( + layer("Name_Landgemeinde_groesser_10000") + ) + ).toEqual([0, -1.5]); + expect( + getMapStyleLocationLabelFlatOffset( + layer("Name_Ortsteil_Stadtteil_bis_1000") + ) + ).toEqual([0, -1]); + expect( + getMapStyleLocationLabelFlatOffset(layer("Name_Wohnplatz_bis_20")) + ).toEqual([0, -0.65]); + }); + + it("makes every opacity-capable regular layer transparent and restores exact values", () => { + const layerTypes = [ + "background", + "circle", + "color-relief", + "fill", + "fill-extrusion", + "heatmap", + "line", + "raster", + "symbol", + ]; + const testMap = createMap([ + ...layerTypes.map((type) => ({ + id: type, + type, + source: type, + "source-layer": `${type}-features`, + })), + { id: "hillshade", type: "hillshade", source: "terrain" }, + { id: "three", type: "custom" }, + ]); + const originals = new Map(); + for (const type of layerTypes) { + for (const property of getMapLibreLayerOpacityProperties(type)) { + const value = property === "fill-opacity" ? ["get", "opacity"] : 0.4; + originals.set(`${type}:${property}`, value); + testMap.setPaint(type, property, value); + } + } + testMap.setVisibility("hillshade", undefined); + testMap.setPaint("three", "custom-opacity", 0.8); + + const restore = suppressMapLibreRegularStyleLayers(testMap.map as never); + + for (const type of layerTypes) { + for (const property of getMapLibreLayerOpacityProperties(type)) { + expect(testMap.getPaint(type, property)).toBe(0); + } + } + expect(testMap.getVisibility("hillshade")).toBe("none"); + expect(testMap.getPaint("three", "custom-opacity")).toBe(0.8); + expect(testMap.map.setPaintProperty).not.toHaveBeenCalledWith( + "three", + expect.anything(), + expect.anything() + ); + + restore(); + + for (const [key, value] of originals) { + const separator = key.indexOf(":"); + expect( + testMap.getPaint(key.slice(0, separator), key.slice(separator + 1)) + ).toEqual(value); + } + expect(testMap.getVisibility("hillshade")).toBeUndefined(); + expect(testMap.map.setLayoutProperty).toHaveBeenCalledWith( + "hillshade", + "visibility", + null + ); + expect(testMap.map.off).toHaveBeenCalledWith( + "styledata", + expect.any(Function) + ); + }); + + it("suppresses layers added later and adopts a reloaded style's values", () => { + const testMap = createMap([ + { id: "basemap", type: "raster", source: "base" }, + ]); + testMap.setPaint("basemap", "raster-opacity", 0.5); + const restore = suppressMapLibreRegularStyleLayers(testMap.map as never); + + testMap.setLayers([ + { id: "basemap", type: "raster", source: "base" }, + { id: "overlay", type: "fill", source: "overlay" }, + ]); + testMap.setPaint("overlay", "fill-opacity", 0.7); + testMap.emit("styledata"); + expect(testMap.getPaint("overlay", "fill-opacity")).toBe(0); + + testMap.emit("styledataloading"); + testMap.setLayers([ + { id: "basemap", type: "raster", source: "replacement" }, + { id: "labels", type: "symbol", source: "replacement" }, + ]); + testMap.setPaint("basemap", "raster-opacity", 0.85); + testMap.setPaint("labels", "icon-opacity", 0.25); + testMap.setPaint("labels", "text-opacity", undefined); + testMap.emit("styledata"); + + expect(testMap.getPaint("basemap", "raster-opacity")).toBe(0); + expect(testMap.getPaint("labels", "icon-opacity")).toBe(0); + expect(testMap.getPaint("labels", "text-opacity")).toBe(0); + + restore(); + expect(testMap.getPaint("basemap", "raster-opacity")).toBe(0.85); + expect(testMap.getPaint("labels", "icon-opacity")).toBe(0.25); + expect(testMap.getPaint("labels", "text-opacity")).toBeUndefined(); + expect(testMap.map.setPaintProperty).toHaveBeenCalledWith( + "labels", + "text-opacity", + null + ); + }); + + it("keeps suppression until the final idempotent release", () => { + const testMap = createMap([ + { id: "basemap", type: "raster", source: "base" }, + ]); + testMap.setPaint("basemap", "raster-opacity", 0.6); + + const releaseFirst = suppressMapLibreRegularStyleLayers( + testMap.map as never + ); + const releaseSecond = suppressMapLibreRegularStyleLayers( + testMap.map as never + ); + releaseFirst(); + releaseFirst(); + expect(testMap.getPaint("basemap", "raster-opacity")).toBe(0); + + releaseSecond(); + expect(testMap.getPaint("basemap", "raster-opacity")).toBe(0.6); + }); + + it("retains the current restore snapshot if a style load never completes", () => { + const testMap = createMap([ + { id: "basemap", type: "raster", source: "base" }, + ]); + testMap.setPaint("basemap", "raster-opacity", 0.45); + const restore = suppressMapLibreRegularStyleLayers(testMap.map as never); + + testMap.emit("styledataloading"); + restore(); + + expect(testMap.getPaint("basemap", "raster-opacity")).toBe(0.45); + }); +}); + +describe("MapLibre terrain mesh composition", () => { + it("waits for a full style replacement to finish before applying once", () => { + const testMap = createMap([ + { id: "city-map", type: "raster", source: "amtlich" }, + ]); + testMap.setPaint("city-map", "raster-opacity", 0.9); + notifyMapLibreStyleCompositionReady(testMap.map as never); + notifyMapLibreStyleCompositionStarted(testMap.map as never); + + const release = acquireMapLibreTerrainMeshComposition(testMap.map as never); + expect(testMap.getPaint("city-map", "raster-opacity")).toBe(0.9); + + notifyMapLibreStyleCompositionReady(testMap.map as never); + expect(testMap.getPaint("city-map", "raster-opacity")).toBe( + MAPLIBRE_TERRAIN_MESH_BASE_OPACITY + ); + expect(testMap.map.setPaintProperty).toHaveBeenCalledTimes(1); + release(); + }); + + it("fades base surfaces and preserves roads and label-only overlays", () => { + const testMap = createMap([ + { id: "background", type: "background" }, + { id: "city-map", type: "raster", source: "amtlich" }, + { id: "landcover", type: "fill", source: "landcover" }, + { id: "road-surface", type: "fill", source: "transport" }, + { + id: "spw2-light-grundriss-raster", + type: "raster", + source: "spw2-light-grundriss", + }, + { id: "dop-overlay-raster", type: "raster", source: "dop-overlay" }, + { id: "roads", type: "line", source: "transport" }, + { id: "labels", type: "symbol", source: "labels" }, + { id: "three", type: "custom" }, + { id: "---boundary:first---", type: "background" }, + ]); + testMap.setPaint("background", "background-opacity", 1); + testMap.setPaint("city-map", "raster-opacity", 0.9); + testMap.setPaint("landcover", "fill-opacity", ["get", "opacity"]); + testMap.setPaint("road-surface", "fill-opacity", 0.75); + testMap.setPaint("spw2-light-grundriss-raster", "raster-opacity", 0.8); + testMap.setPaint("dop-overlay-raster", "raster-opacity", 0.85); + testMap.setPaint("roads", "line-opacity", 0.7); + testMap.setPaint("labels", "text-opacity", 1); + testMap.setPaint("---boundary:first---", "background-opacity", 0); + + const release = acquireMapLibreTerrainMeshComposition(testMap.map as never); + + // One coherent StyleComposer completion signal applies the composition; + // intermediate styledata events are deliberately ignored. + expect(testMap.getPaint("city-map", "raster-opacity")).toBe(0.9); + notifyMapLibreStyleCompositionReady(testMap.map as never); + + expect(testMap.getPaint("background", "background-opacity")).toBe( + MAPLIBRE_TERRAIN_MESH_BASE_OPACITY + ); + expect(testMap.getPaint("city-map", "raster-opacity")).toBe( + MAPLIBRE_TERRAIN_MESH_BASE_OPACITY + ); + expect(testMap.getPaint("landcover", "fill-opacity")).toBe( + MAPLIBRE_TERRAIN_MESH_BASE_OPACITY + ); + expect(testMap.getPaint("road-surface", "fill-opacity")).toBe(0.75); + expect( + testMap.getPaint("spw2-light-grundriss-raster", "raster-opacity") + ).toBe(MAPLIBRE_TERRAIN_MESH_BASE_OPACITY); + expect(testMap.getPaint("dop-overlay-raster", "raster-opacity")).toBe(0.85); + expect(testMap.getPaint("roads", "line-opacity")).toBe(0.7); + expect(testMap.getPaint("labels", "text-opacity")).toBe(1); + expect(testMap.getPaint("---boundary:first---", "background-opacity")).toBe( + 0 + ); + + release(); + + expect(testMap.getPaint("background", "background-opacity")).toBe(1); + expect(testMap.getPaint("city-map", "raster-opacity")).toBe(0.9); + expect(testMap.getPaint("landcover", "fill-opacity")).toEqual([ + "get", + "opacity", + ]); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/map-style-layer-suppression.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/map-style-layer-suppression.ts new file mode 100644 index 0000000000..c49a470f60 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/map-style-layer-suppression.ts @@ -0,0 +1,620 @@ +import type { Map as MaplibreMap } from "maplibre-gl"; + +import { MAPLIBRE_EVENT } from "../../../constants/mapEvents"; + +const MAPLIBRE_LAYER_OPACITY_PROPERTIES: Readonly< + Record +> = { + background: ["background-opacity"], + circle: ["circle-opacity", "circle-stroke-opacity"], + "color-relief": ["color-relief-opacity"], + fill: ["fill-opacity"], + "fill-extrusion": ["fill-extrusion-opacity"], + heatmap: ["heatmap-opacity"], + line: ["line-opacity"], + raster: ["raster-opacity"], + symbol: ["icon-opacity", "text-opacity"], +}; + +type SavedLayerRendering = + | { + mode: "paint"; + signature: string; + properties: Array<[name: string, value: unknown]>; + } + | { + mode: "layout"; + signature: string; + visibility: unknown; + }; + +type SuppressedStyleLayersEntry = { + references: number; + savedLayers: Map; + suppressLayers: () => void; + prepareForStyleLoad: () => void; +}; + +export type RuntimeStyleLayer = { + id: string; + type: string; + source?: unknown; + "source-layer"?: unknown; + sourceLayer?: unknown; + layout?: Record; + metadata?: Record; +}; + +const MAPLIBRE_BASE_SURFACE_LAYER_TYPES = new Set([ + "background", + "color-relief", + "fill", + "fill-extrusion", + "hillshade", + "raster", +]); + +const MAPLIBRE_LIVE_OVERLAY_LAYER_TYPES = new Set([ + "circle", + "heatmap", + "line", + "symbol", +]); + +const OVERLAY_LAYER_HINT = + /(?:dop[-_:]?overlay|label|road|route|schrift|street|transport)/i; + +const LOCATION_LABEL_HINT = + /(?:^|[-_:])(place|settlement|locality|city|town|village|municipality|district|borough|suburb|neighbou?rhood|quarter|ort|stadt|gemeinde|bezirk)(?:$|[-_:])/i; + +// The layer signature joins id, source and source-layer with ":", so a name +// that ends a layer id is followed by ":" rather than by "_" or the end. +const BASEMAP_DE_LOCATION_LABEL_HINT = + /(?:^|[-_:])Name_(?:Staat(?:_DE)?|Bundesland|Landeshauptstadt|Stadtgemeinde(?:[-_:]|$)|Landgemeinde(?:[-_:]|$)|Ortsteil_(?:Gemeindeteil|Stadtteil)(?:[-_:]|$)|Wohnplatz(?:[-_:]|$))/i; + +const ROAD_LABEL_HINT = + /(?:road|street|strasse|stra(?:ss|ß)e|highway|motorway|transport|route|autobahn|verkehr|fahrbahn|fernverkehr|bundesstra)/i; + +/** + * Point-anchored MapLibre symbols can be redrawn after the shared Three layer + * without duplicating the draped line labels. This includes place names, + * house numbers, POIs, and point road shields. Only a literal or omitted + * `symbol-placement` counts as point-anchored: a zoom function or expression + * (basemap.de street names switch between `line` and `line-center`) follows + * line features at some zoom and stays in the draped style. + */ +export const isMapStylePointLabelLayer = ( + layer: RuntimeStyleLayer +): boolean => { + if (layer.type !== "symbol") return false; + const placement = layer.layout?.["symbol-placement"]; + return placement === undefined || placement === "point"; +}; + +/** Identify road and route shields whose authored text/icon colors stay intact. */ +export const isMapStyleRoadLabelLayer = (layer: RuntimeStyleLayer): boolean => { + if (layer.type !== "symbol") return false; + const signature = [ + layer.id, + layer.source, + layer.sourceLayer ?? layer["source-layer"], + layer.metadata?.["layer-id"], + ].join(":"); + return ROAD_LABEL_HINT.test(signature); +}; + +const WATER_LABEL_HINT = + /(?:gewaesser|gewässer|wasser|water|river|fluss|bach|see(?:n|$|[-_:])|lake|teich|kanal|hafen)/i; + +const HOUSE_NUMBER_LABEL_HINT = + /(?:hausnummer|hauskoordinate|house[-_ ]?number|housenumber|housenum|addr)/i; + +const getLayerHintSignature = (layer: RuntimeStyleLayer): string => + [ + layer.id, + layer.source, + layer.sourceLayer ?? layer["source-layer"], + layer.metadata?.["layer-id"], + ].join(":"); + +const ROAD_SHIELD_HINT = /(?:^|[-_:])(?:nummer|shield|route[-_ ]?ref)/i; + +/** + * Road shields: point-anchored route numbers on their own icon backdrop. + * Their authored text and fill stay untouched in every mode. Station and + * stop names also live in `Verkehrspunkt`, but they are plain labels. + */ +export const isMapStyleRoadShieldLayer = (layer: RuntimeStyleLayer): boolean => + isMapStylePointLabelLayer(layer) && + ROAD_SHIELD_HINT.test(getLayerHintSignature(layer)); + +const ELEVATION_HINT = + /(?:hoehenlinie|hoehenzahl|hoehenpunkt|höhenlinie|contour|isohypse|elevation)/i; + +/** Contour lines stay in the mesh drape; every other line or fill is hidden. */ +export const isMapStyleContourLineLayer = (layer: RuntimeStyleLayer): boolean => + layer.type === "line" && ELEVATION_HINT.test(getLayerHintSignature(layer)); + +/** Contour and spot-height labels: sun colored without a halo on the mesh. */ +export const isMapStyleElevationLabelLayer = ( + layer: RuntimeStyleLayer +): boolean => + layer.type === "symbol" && ELEVATION_HINT.test(getLayerHintSignature(layer)); + +/** Water names keep their authored blue and lose their halo on the mesh drape. */ +export const isMapStyleWaterLabelLayer = (layer: RuntimeStyleLayer): boolean => + layer.type === "symbol" && + WATER_LABEL_HINT.test(getLayerHintSignature(layer)); + +/** House numbers are styled like street names on the mesh drape. */ +export const isMapStyleHouseNumberLabelLayer = ( + layer: RuntimeStyleLayer +): boolean => + layer.type === "symbol" && + HOUSE_NUMBER_LABEL_HINT.test(getLayerHintSignature(layer)); + +/** Point-based place names that may remain crisp above the shaded scene. */ +export const isMapStyleLocationLabelLayer = ( + layer: RuntimeStyleLayer +): boolean => { + if (!isMapStylePointLabelLayer(layer)) return false; + const signature = [ + layer.id, + layer.source, + layer.sourceLayer ?? layer["source-layer"], + layer.metadata?.["layer-id"], + ].join(":"); + if (ROAD_LABEL_HINT.test(signature)) return false; + return ( + BASEMAP_DE_LOCATION_LABEL_HINT.test(signature) || + LOCATION_LABEL_HINT.test(signature) + ); +}; + +export type MapStyleLocationLabelFlatOffset = readonly [ + horizontalEm: number, + verticalEm: number +]; + +/** Flat screen-space lift for point labels, ranked by place prominence. */ +export const getMapStyleLocationLabelFlatOffset = ( + layer: RuntimeStyleLayer +): MapStyleLocationLabelFlatOffset | null => { + if (!isMapStyleLocationLabelLayer(layer)) return null; + const signature = [layer.id, layer.metadata?.["layer-id"]].join(":"); + + if ( + /Name_(?:Staat(?:_DE)?|Bundesland|Landeshauptstadt|Stadtgemeinde_(?:groesser_1Mio|bis_1Mio))/i.test( + signature + ) + ) { + return [0, -3]; + } + if (/Name_Stadtgemeinde_(?:bis_500000|bis_200000)/i.test(signature)) { + return [0, -2.5]; + } + if (/Name_Stadtgemeinde_/i.test(signature)) return [0, -2]; + if (/Name_Landgemeinde_/i.test(signature)) return [0, -1.5]; + if (/Name_Ortsteil_(?:Gemeindeteil|Stadtteil)_/i.test(signature)) { + return [0, -1]; + } + if (/Name_Wohnplatz_/i.test(signature)) return [0, -0.65]; + return [0, -1.5]; +}; + +/** + * Height above the ground at which a place name floats over the 3D scene, + * ranked by place prominence. `null` for anything but place names. + */ +export const getMapStyleLocationLabelLiftMeters = ( + layer: RuntimeStyleLayer +): number | null => { + if (!isMapStyleLocationLabelLayer(layer)) return null; + const signature = [layer.id, layer.metadata?.["layer-id"]].join(":"); + if ( + /Name_(?:Staat(?:_DE)?|Bundesland|Landeshauptstadt|Stadtgemeinde_(?:groesser_1Mio|bis_1Mio))/i.test( + signature + ) + ) { + return 600; + } + if (/Name_Stadtgemeinde_(?:bis_500000|bis_200000)/i.test(signature)) { + return 500; + } + if (/Name_Stadtgemeinde_/i.test(signature)) return 400; + if (/Name_Landgemeinde_/i.test(signature)) return 350; + if (/Name_Wohnplatz_/i.test(signature)) return 150; + return 300; +}; + +/** Small lift that keeps house numbers and POI names just above the mesh. */ +export const POINT_LABEL_LIFT_METERS = 10; + +/** + * Height above the ground for any point-anchored label: place names by + * prominence, shields not at all, everything else (house numbers, POIs, + * stations) a few meters so the text clears the mesh surface. + */ +export const getMapStylePointLabelLiftMeters = ( + layer: RuntimeStyleLayer +): number | null => { + if (!isMapStylePointLabelLayer(layer)) return null; + if (isMapStyleRoadShieldLayer(layer)) return null; + return getMapStyleLocationLabelLiftMeters(layer) ?? POINT_LABEL_LIFT_METERS; +}; + +export const isMapStyleOverlayLayer = (layer: RuntimeStyleLayer): boolean => { + if (layer.type === "custom") return false; + if (MAPLIBRE_LIVE_OVERLAY_LAYER_TYPES.has(layer.type)) return true; + if (!MAPLIBRE_BASE_SURFACE_LAYER_TYPES.has(layer.type)) return true; + return OVERLAY_LAYER_HINT.test( + [ + layer.id, + layer.source, + layer.sourceLayer ?? layer["source-layer"], + layer.metadata?.["layer-id"], + ].join(":") + ); +}; + +const suppressedStyleLayers = new WeakMap< + MaplibreMap, + SuppressedStyleLayersEntry +>(); + +export const MAPLIBRE_TERRAIN_MESH_BASE_OPACITY = 0; + +type MeshCompositionProperty = { + original: unknown; + applied: unknown; +}; + +type MeshCompositionLayer = { + signature: string; + properties: Map; +}; + +type MeshCompositionEntry = { + references: number; + savedLayers: Map; + apply: () => void; + unsubscribeStyleReady: () => void; +}; + +const meshCompositions = new WeakMap(); + +type StyleReadyEntry = { + revision: number; + composing: boolean; + listeners: Set<() => void>; +}; + +const styleReadyEntries = new WeakMap(); + +const getStyleReadyEntry = (map: MaplibreMap): StyleReadyEntry => { + let entry = styleReadyEntries.get(map); + if (!entry) { + entry = { revision: 0, composing: false, listeners: new Set() }; + styleReadyEntries.set(map, entry); + } + return entry; +}; + +/** Mark the interval in which StyleComposer replaces and rebuilds the style. */ +export const notifyMapLibreStyleCompositionStarted = ( + map: MaplibreMap +): void => { + getStyleReadyEntry(map).composing = true; +}; + +/** Notify integrations after StyleComposer has finished one coherent update. */ +export const notifyMapLibreStyleCompositionReady = (map: MaplibreMap): void => { + const entry = getStyleReadyEntry(map); + entry.composing = false; + entry.revision += 1; + for (const listener of entry.listeners) listener(); +}; + +const getTerrainMeshBaseOpacity = (value: unknown): number => + typeof value === "number" && Number.isFinite(value) + ? Math.min(value, MAPLIBRE_TERRAIN_MESH_BASE_OPACITY) + : MAPLIBRE_TERRAIN_MESH_BASE_OPACITY; + +export const getMapLibreLayerOpacityProperties = ( + layerType: string +): readonly string[] => MAPLIBRE_LAYER_OPACITY_PROPERTIES[layerType] ?? []; + +const getLayerSignature = (layer: { + type: string; + source?: unknown; + "source-layer"?: unknown; + sourceLayer?: unknown; +}) => + `${layer.type}:${String(layer.source)}:${String( + layer.sourceLayer ?? layer["source-layer"] + )}`; + +/** + * Keep MapLibre terrain enabled as the draping surface for the host style, + * while a Three.js terrain mesh supplies the visible ground. Base rasters and + * land-cover fills become fully transparent; roads, linework, labels, + * and explicitly named overlay rasters retain their authored opacity. + */ +export const acquireMapLibreTerrainMeshComposition = ( + map: MaplibreMap +): (() => void) => { + const existing = meshCompositions.get(map); + if (existing) { + existing.references += 1; + } else { + const savedLayers = new Map(); + let applying = false; + + const apply = () => { + if (applying) return; + applying = true; + try { + let layers: RuntimeStyleLayer[]; + try { + layers = (map.getStyle()?.layers ?? []) as RuntimeStyleLayer[]; + } catch { + return; + } + + const currentLayerIds = new Set(layers.map(({ id }) => id)); + for (const layerId of savedLayers.keys()) { + if (!currentLayerIds.has(layerId)) savedLayers.delete(layerId); + } + + for (const layer of layers) { + if (layer.type === "custom" || isMapStyleOverlayLayer(layer)) { + continue; + } + const opacityProperties = getMapLibreLayerOpacityProperties( + layer.type + ); + if (opacityProperties.length === 0) continue; + + const signature = getLayerSignature(layer); + let savedLayer = savedLayers.get(layer.id); + if (!savedLayer || savedLayer.signature !== signature) { + savedLayer = { signature, properties: new Map() }; + savedLayers.set(layer.id, savedLayer); + } + + for (const property of opacityProperties) { + try { + const current = map.getPaintProperty(layer.id, property); + let savedProperty = savedLayer.properties.get(property); + // A style composer or opacity slider may legitimately replace + // the authored value while the mesh is mounted. Adopt it as the + // new restore value, then immediately re-apply composition. + if (!savedProperty || current !== savedProperty.applied) { + savedProperty = { + original: current, + applied: getTerrainMeshBaseOpacity(current), + }; + savedLayer.properties.set(property, savedProperty); + } + if (current !== savedProperty.applied) { + map.setPaintProperty(layer.id, property, savedProperty.applied); + } + } catch { + // A style rebuild can remove a layer between inspection and set. + } + } + } + } finally { + applying = false; + } + }; + + const styleReadyEntry = getStyleReadyEntry(map); + const onStyleReady = () => apply(); + styleReadyEntry.listeners.add(onStyleReady); + const entry = { + references: 1, + savedLayers, + apply, + unsubscribeStyleReady: () => + styleReadyEntry.listeners.delete(onStyleReady), + }; + meshCompositions.set(map, entry); + // If the base style was already fully composed before this mesh mounted, + // one application is sufficient. Otherwise StyleComposer notifies us at + // the end of its current update. + if (styleReadyEntry.revision > 0 && !styleReadyEntry.composing) apply(); + } + + let released = false; + return () => { + if (released) return; + released = true; + const entry = meshCompositions.get(map); + if (!entry) return; + entry.references -= 1; + if (entry.references > 0) return; + + entry.unsubscribeStyleReady(); + meshCompositions.delete(map); + for (const [layerId, savedLayer] of entry.savedLayers) { + let runtimeLayer: unknown; + try { + runtimeLayer = map.getLayer(layerId); + } catch { + continue; + } + if ( + !runtimeLayer || + getLayerSignature(runtimeLayer as RuntimeStyleLayer) !== + savedLayer.signature + ) { + continue; + } + for (const [property, savedProperty] of savedLayer.properties) { + try { + if ( + map.getPaintProperty(layerId, property) === savedProperty.applied + ) { + map.setPaintProperty( + layerId, + property, + savedProperty.original === undefined + ? null + : savedProperty.original + ); + } + } catch { + // The map or style may already be gone during React cleanup. + } + } + } + }; +}; + +/** + * Temporarily hides regular MapLibre style rendering without hiding custom + * layers. Paint opacity is preferred because source layers must remain + * logically visible for custom Three.js layers that consume their features. + */ +export const suppressMapLibreRegularStyleLayers = ( + map: MaplibreMap +): (() => void) => { + const existing = suppressedStyleLayers.get(map); + if (existing) { + existing.references += 1; + } else { + const savedLayers = new Map(); + let resetOnNextStyleData = false; + + const suppressLayers = () => { + let layers: RuntimeStyleLayer[]; + try { + layers = (map.getStyle()?.layers ?? []) as RuntimeStyleLayer[]; + } catch { + return; + } + if (resetOnNextStyleData) { + savedLayers.clear(); + resetOnNextStyleData = false; + } + + const currentLayerIds = new Set(layers.map((layer) => layer.id)); + for (const layerId of savedLayers.keys()) { + if (!currentLayerIds.has(layerId)) savedLayers.delete(layerId); + } + + for (const layer of layers) { + if (layer.type === "custom") continue; + const signature = getLayerSignature(layer); + const saved = savedLayers.get(layer.id); + if (saved && saved.signature !== signature) + savedLayers.delete(layer.id); + + const opacityProperties = getMapLibreLayerOpacityProperties(layer.type); + if (opacityProperties.length > 0) { + let paintSaved = savedLayers.get(layer.id); + if (!paintSaved) { + try { + paintSaved = { + mode: "paint", + signature, + properties: opacityProperties.map((property) => [ + property, + map.getPaintProperty(layer.id, property), + ]), + }; + savedLayers.set(layer.id, paintSaved); + } catch { + continue; + } + } + if (paintSaved.mode !== "paint") continue; + for (const [property] of paintSaved.properties) { + try { + if (map.getPaintProperty(layer.id, property) !== 0) { + map.setPaintProperty(layer.id, property, 0); + } + } catch { + // The layer may disappear during a style rebuild. + } + } + continue; + } + + let layoutSaved = savedLayers.get(layer.id); + if (!layoutSaved) { + try { + layoutSaved = { + mode: "layout", + signature, + visibility: map.getLayoutProperty(layer.id, "visibility"), + }; + savedLayers.set(layer.id, layoutSaved); + } catch { + continue; + } + } + if (layoutSaved.mode !== "layout") continue; + try { + if (map.getLayoutProperty(layer.id, "visibility") !== "none") { + map.setLayoutProperty(layer.id, "visibility", "none"); + } + } catch { + // The layer may disappear during a style rebuild. + } + } + }; + + const prepareForStyleLoad = () => { + resetOnNextStyleData = true; + }; + const entry = { + references: 1, + savedLayers, + suppressLayers, + prepareForStyleLoad, + }; + suppressedStyleLayers.set(map, entry); + map.on(MAPLIBRE_EVENT.STYLE_DATA_LOADING, prepareForStyleLoad); + map.on(MAPLIBRE_EVENT.STYLE_DATA, suppressLayers); + suppressLayers(); + } + + let restored = false; + return () => { + if (restored) return; + restored = true; + const entry = suppressedStyleLayers.get(map); + if (!entry) return; + entry.references -= 1; + if (entry.references > 0) return; + + map.off(MAPLIBRE_EVENT.STYLE_DATA_LOADING, entry.prepareForStyleLoad); + map.off(MAPLIBRE_EVENT.STYLE_DATA, entry.suppressLayers); + suppressedStyleLayers.delete(map); + for (const [layerId, saved] of entry.savedLayers) { + try { + const layer = map.getLayer(layerId); + if (!layer || getLayerSignature(layer) !== saved.signature) continue; + if (saved.mode === "paint") { + for (const [property, value] of saved.properties) { + map.setPaintProperty( + layerId, + property, + value === undefined ? null : value + ); + } + } else { + map.setLayoutProperty( + layerId, + "visibility", + saved.visibility === undefined ? null : saved.visibility + ); + } + } catch { + // Nothing remains to restore after map/style teardown. + } + } + }; +}; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/payload-aware-request-concurrency.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/payload-aware-request-concurrency.spec.ts new file mode 100644 index 0000000000..29b88b5a5a --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/payload-aware-request-concurrency.spec.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + createPayloadAwareRequestConcurrency, + isPermanentTileRequestFailure, + isTransientTileRequestFailure, +} from "./payload-aware-request-concurrency"; + +describe("payload-aware request concurrency", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("uses hundreds of parallel requests for small payloads", () => { + const concurrency = createPayloadAwareRequestConcurrency( + 64 * 1024 ** 2, + 32 * 1024 + ); + + expect(concurrency.getConcurrency()).toBe(256); + }); + + it("reduces concurrency for larger payloads", () => { + const concurrency = createPayloadAwareRequestConcurrency( + 64 * 1024 ** 2, + 256 * 1024 + ); + + for (let index = 0; index < 20; index += 1) { + concurrency.observePayload(2 * 1024 ** 2); + } + + expect(concurrency.getConcurrency()).toBeGreaterThanOrEqual(16); + expect(concurrency.getConcurrency()).toBeLessThan(40); + }); + + it("respects a caller-provided maximum", () => { + const concurrency = createPayloadAwareRequestConcurrency(); + + expect(concurrency.getConcurrency(12)).toBe(12); + expect(concurrency.getConcurrency(0)).toBe(0); + }); + + it("backs off after failures and recovers after successful payloads", () => { + const concurrency = createPayloadAwareRequestConcurrency(); + + expect(concurrency.getConcurrency()).toBe(64); + concurrency.observeFailure(); + expect(concurrency.getConcurrency()).toBe(32); + concurrency.observeFailure(); + expect(concurrency.getConcurrency()).toBe(16); + + for (let index = 0; index < 20; index += 1) { + concurrency.observePayload(1024 ** 2); + } + expect(concurrency.getConcurrency()).toBeGreaterThan(50); + }); + + it("pauses an origin after overload responses without extending a burst", () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + const concurrency = createPayloadAwareRequestConcurrency(); + const overload = new Error("Failed with status 429: Too Many Requests"); + + expect(concurrency.observeFailure(overload)).toBe(1_000); + expect(concurrency.getConcurrency()).toBe(0); + vi.advanceTimersByTime(200); + expect(concurrency.observeFailure(overload)).toBe(800); + + vi.advanceTimersByTime(800); + expect(concurrency.getConcurrency()).toBeGreaterThan(0); + expect(concurrency.observeFailure(new Error("request timed out"))).toBe( + 2_000 + ); + expect(concurrency.getConcurrency()).toBe(0); + }); + + it("does not globally pause for a permanent missing tile", () => { + const concurrency = createPayloadAwareRequestConcurrency(); + + expect(concurrency.observeFailure(new Error("status 404"))).toBe(0); + expect(concurrency.getConcurrency()).toBeGreaterThan(0); + }); + + it("classifies missing or forbidden resources as permanent failures", () => { + expect(isPermanentTileRequestFailure(new Error("status 404"))).toBe(true); + expect(isPermanentTileRequestFailure({ status: 403 })).toBe(true); + expect( + isPermanentTileRequestFailure(new Error("Failed with status 410: Gone")) + ).toBe(true); + expect(isPermanentTileRequestFailure(new Error("status 503"))).toBe(false); + expect(isPermanentTileRequestFailure(new Error("request timed out"))).toBe( + false + ); + expect(isPermanentTileRequestFailure(undefined)).toBe(false); + expect(isTransientTileRequestFailure(new Error("status 404"))).toBe(false); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/payload-aware-request-concurrency.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/payload-aware-request-concurrency.ts new file mode 100644 index 0000000000..c0b1d8f4be --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/payload-aware-request-concurrency.ts @@ -0,0 +1,112 @@ +import { clamp } from "@carma-commons/math"; + +const DEFAULT_TARGET_IN_FLIGHT_BYTES = 64 * 1024 ** 2; +const DEFAULT_INITIAL_PAYLOAD_BYTES = 1024 ** 2; +const DEFAULT_MINIMUM_CONCURRENCY = 8; +export const DEFAULT_MAXIMUM_REQUEST_CONCURRENCY = 256; +const PAYLOAD_AVERAGE_WEIGHT = 0.2; +const FAILURE_BACKOFF_FACTOR = 0.5; +const SUCCESS_RECOVERY_WEIGHT = 0.1; +const MINIMUM_PRESSURE_FACTOR = 0.125; +const REQUEST_BACKOFF_BASE_DELAY_MS = 1_000; +const REQUEST_BACKOFF_MAX_DELAY_MS = 16_000; + +export type PayloadAwareRequestConcurrency = Readonly<{ + getConcurrency: (maximum?: number) => number; + observePayload: (byteLength: number) => void; + observeFailure: (error?: unknown) => number; + observeSuccess: () => void; + getCooldownRemainingMs: () => number; +}>; + +const getHttpStatus = (error: unknown): number | null => { + if (typeof error === "object" && error !== null && "status" in error) { + const status = Number((error as { status?: unknown }).status); + if (Number.isInteger(status)) return status; + } + + const message = error instanceof Error ? error.message : String(error ?? ""); + const match = message.match(/(?:status|error code)\s*:?[\s"]*(\d{3})/i); + return match ? Number(match[1]) : null; +}; + +const PERMANENT_HTTP_STATUSES = new Set([403, 404, 410]); + +/** The resource is missing or forbidden; retrying cannot change that. */ +export const isPermanentTileRequestFailure = (error: unknown): boolean => { + const status = getHttpStatus(error); + return status !== null && PERMANENT_HTTP_STATUSES.has(status); +}; + +export const isTransientTileRequestFailure = (error: unknown): boolean => { + const status = getHttpStatus(error); + if (status !== null) { + return status === 408 || status === 425 || status === 429 || status >= 500; + } + + if (!(error instanceof Error)) return false; + return ( + error.name === "TimeoutError" || + /timeout|timed out|networkerror|failed to fetch|network request failed/i.test( + error.message + ) + ); +}; + +export const createPayloadAwareRequestConcurrency = ( + targetInFlightBytes = DEFAULT_TARGET_IN_FLIGHT_BYTES, + initialPayloadBytes = DEFAULT_INITIAL_PAYLOAD_BYTES +): PayloadAwareRequestConcurrency => { + const targetBytes = Math.max(1, targetInFlightBytes); + let averagePayloadBytes = Math.max(1, initialPayloadBytes); + let pressureFactor = 1; + let cooldownUntil = 0; + let backoffAttempt = 0; + + const observeSuccess = () => { + pressureFactor += (1 - pressureFactor) * SUCCESS_RECOVERY_WEIGHT; + if (Date.now() >= cooldownUntil && backoffAttempt > 0) { + backoffAttempt -= 1; + } + }; + + return { + getConcurrency(maximum = DEFAULT_MAXIMUM_REQUEST_CONCURRENCY) { + if (!Number.isFinite(maximum) || maximum <= 0) return 0; + if (Date.now() < cooldownUntil) return 0; + const upperBound = Math.max(1, Math.floor(maximum)); + const lowerBound = Math.min(DEFAULT_MINIMUM_CONCURRENCY, upperBound); + return Math.round( + clamp( + (targetBytes / averagePayloadBytes) * pressureFactor, + lowerBound, + upperBound + ) + ); + }, + observePayload(byteLength) { + if (!Number.isFinite(byteLength) || byteLength <= 0) return; + averagePayloadBytes += + (byteLength - averagePayloadBytes) * PAYLOAD_AVERAGE_WEIGHT; + observeSuccess(); + }, + observeFailure(error) { + pressureFactor = Math.max( + MINIMUM_PRESSURE_FACTOR, + pressureFactor * FAILURE_BACKOFF_FACTOR + ); + const now = Date.now(); + if (isTransientTileRequestFailure(error) && now >= cooldownUntil) { + backoffAttempt += 1; + const delay = Math.min( + REQUEST_BACKOFF_MAX_DELAY_MS, + REQUEST_BACKOFF_BASE_DELAY_MS * 2 ** Math.max(0, backoffAttempt - 1) + ); + cooldownUntil = now + delay; + } + return Math.max(0, cooldownUntil - now); + }, + observeSuccess, + getCooldownRemainingMs: () => Math.max(0, cooldownUntil - Date.now()), + }; +}; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/projected-terrain-geometry-cache.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/projected-terrain-geometry-cache.spec.ts new file mode 100644 index 0000000000..d7b2f33604 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/projected-terrain-geometry-cache.spec.ts @@ -0,0 +1,100 @@ +import { BufferGeometry, Float32BufferAttribute, Vector3 } from "three"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { stored, storage } = vi.hoisted(() => { + const stored = new Map(); + return { + stored, + storage: { + clear: vi.fn(async () => stored.clear()), + getItem: vi.fn(async (key: string) => stored.get(key) ?? null), + setItem: vi.fn(async (key: string, value: unknown) => { + stored.set(key, value); + return value; + }), + }, + }; +}); + +vi.mock("localforage", () => ({ + default: { createInstance: vi.fn(() => storage) }, +})); + +import { + createProjectedTerrainGeometryCache, + PROJECTED_TERRAIN_GEOMETRY_CACHE_REVISION, +} from "./projected-terrain-geometry-cache"; + +const tile = { + id: { level: 10, x: 532, y: 218 }, + bounds: { west: 7.1, south: 51.2, east: 7.2, north: 51.3 }, + u: new Float32Array([0, 0, 1]), + v: new Float32Array([0, 1, 0]), + heightMeters: new Float32Array([100, 110, 120]), + minimumHeightMeters: 100, + maximumHeightMeters: 120, + indices: new Uint32Array([0, 1, 2]), + westIndices: new Uint32Array(), + southIndices: new Uint32Array(), + eastIndices: new Uint32Array(), + northIndices: new Uint32Array(), + childTileMask: 15, + geometricErrorMeters: 10, + byteLength: 60, +}; + +const createGeometry = () => { + const geometry = new BufferGeometry(); + geometry.setAttribute( + "position", + new Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 0, -1], 3) + ); + geometry.setIndex([0, 1, 2]); + geometry.computeVertexNormals(); + return geometry; +}; + +describe("projected terrain geometry cache", () => { + beforeEach(() => { + stored.clear(); + vi.clearAllMocks(); + }); + + it("restores a transformed tile before its source must be requested", async () => { + stored.set("__conversion_revision__", "older-conversion"); + stored.set("stale-tile", { positions: new Float32Array() }); + const cache = createProjectedTerrainGeometryCache( + "https://example.test/terrain", + [7.15, 51.25] + ); + const geometry = createGeometry(); + + expect(await cache.get(tile.id)).toBeNull(); + cache.set(tile, geometry); + const restored = await cache.get(tile.id); + + expect(storage.clear).toHaveBeenCalledOnce(); + expect(stored.get("__conversion_revision__")).toBe( + PROJECTED_TERRAIN_GEOMETRY_CACHE_REVISION + ); + expect(stored.has("stale-tile")).toBe(false); + expect(restored?.tile).not.toBe(tile); + expect(restored?.tile.id).toEqual(tile.id); + expect(restored?.tile.heightMeters).toEqual(tile.heightMeters); + expect( + new Vector3().fromBufferAttribute( + restored!.geometry.getAttribute("normal"), + 0 + ).y + ).toBeGreaterThan(0); + + const otherSource = createProjectedTerrainGeometryCache( + "https://example.test/other-terrain", + [7.15, 51.25] + ); + expect(await otherSource.get(tile.id)).toBeNull(); + + geometry.dispose(); + restored?.geometry.dispose(); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/projected-terrain-geometry-cache.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/projected-terrain-geometry-cache.ts new file mode 100644 index 0000000000..7c93abde44 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/projected-terrain-geometry-cache.ts @@ -0,0 +1,242 @@ +import localforage from "localforage"; +import md5 from "md5"; +import { + BufferGeometry, + Float32BufferAttribute, + Uint32BufferAttribute, +} from "three"; + +import { + cesiumTerrainTileKey, + type CesiumTerrainTile, + type CesiumTerrainTileId, +} from "@carma-mapping/engines/cesium/terrain"; + +// Bump the revision whenever projection, winding, generated attributes, or the +// persisted tile metadata change. +export const PROJECTED_TERRAIN_GEOMETRY_CACHE_REVISION = + "projected-quantized-mesh-v2"; + +const CACHE_REVISION_KEY = "__conversion_revision__"; +const storage = localforage.createInstance({ + name: "carma-terrain-geometry-cache", + storeName: "projected_tiles", +}); + +type CachedProjectedTerrainGeometry = Readonly<{ + positions: Float32Array; + normals: Float32Array; + indices: Uint32Array; +}>; + +type CachedProjectedTerrainTile = Readonly<{ + tile: CesiumTerrainTile; + geometry: CachedProjectedTerrainGeometry; +}>; + +export type ProjectedTerrainCacheEntry = Readonly<{ + tile: CesiumTerrainTile; + geometry: BufferGeometry; +}>; + +type ProjectedTerrainGeometryCache = Readonly<{ + get: (id: CesiumTerrainTileId) => Promise; + set: (tile: CesiumTerrainTile, geometry: BufferGeometry) => void; +}>; + +let cacheAvailable = true; +let revisionReady: Promise | null = null; +const pendingWrites = new Map>(); + +const prepareStorage = () => { + revisionReady ??= (async () => { + try { + const revision = await storage.getItem(CACHE_REVISION_KEY); + if (revision !== PROJECTED_TERRAIN_GEOMETRY_CACHE_REVISION) { + await storage.clear(); + await storage.setItem( + CACHE_REVISION_KEY, + PROJECTED_TERRAIN_GEOMETRY_CACHE_REVISION + ); + } + return true; + } catch { + cacheAvailable = false; + return false; + } + })(); + return revisionReady; +}; + +const hash = md5 as unknown as (message: string | Uint8Array) => string; + +const isTypedArray = ( + value: unknown, + constructor: { new (array: ArrayLike): T } +): value is T => value instanceof constructor; + +const isCachedTile = (value: unknown): value is CesiumTerrainTile => { + if (!value || typeof value !== "object") return false; + const tile = value as Partial; + const id = tile.id; + const bounds = tile.bounds; + return Boolean( + id && + [id.level, id.x, id.y].every(Number.isInteger) && + bounds && + [bounds.west, bounds.south, bounds.east, bounds.north].every( + Number.isFinite + ) && + isTypedArray(tile.u, Float32Array) && + isTypedArray(tile.v, Float32Array) && + isTypedArray(tile.heightMeters, Float32Array) && + tile.u.length === tile.v.length && + tile.u.length === tile.heightMeters.length && + isTypedArray(tile.indices, Uint32Array) && + isTypedArray(tile.westIndices, Uint32Array) && + isTypedArray(tile.southIndices, Uint32Array) && + isTypedArray(tile.eastIndices, Uint32Array) && + isTypedArray(tile.northIndices, Uint32Array) && + Number.isFinite(tile.minimumHeightMeters) && + Number.isFinite(tile.maximumHeightMeters) && + Number.isFinite(tile.childTileMask) && + Number.isFinite(tile.geometricErrorMeters) && + Number.isFinite(tile.byteLength) + ); +}; + +const isCachedGeometry = ( + value: unknown +): value is CachedProjectedTerrainGeometry => { + if (!value || typeof value !== "object") return false; + const record = value as Partial; + if ( + !(record.positions instanceof Float32Array) || + !(record.normals instanceof Float32Array) || + !(record.indices instanceof Uint32Array) || + record.positions.length === 0 || + record.positions.length % 3 !== 0 || + record.normals.length !== record.positions.length || + record.indices.length === 0 || + record.indices.length % 3 !== 0 + ) { + return false; + } + const vertexCount = record.positions.length / 3; + for (const index of record.indices) { + if (index >= vertexCount) return false; + } + return true; +}; + +const isCachedEntry = (value: unknown): value is CachedProjectedTerrainTile => { + if (!value || typeof value !== "object") return false; + const entry = value as Partial; + return isCachedTile(entry.tile) && isCachedGeometry(entry.geometry); +}; + +const cloneTile = (tile: CesiumTerrainTile): CesiumTerrainTile => ({ + ...tile, + id: { ...tile.id }, + bounds: { ...tile.bounds }, + u: Float32Array.from(tile.u), + v: Float32Array.from(tile.v), + heightMeters: Float32Array.from(tile.heightMeters), + indices: Uint32Array.from(tile.indices), + westIndices: Uint32Array.from(tile.westIndices), + southIndices: Uint32Array.from(tile.southIndices), + eastIndices: Uint32Array.from(tile.eastIndices), + northIndices: Uint32Array.from(tile.northIndices), +}); + +const restoreGeometry = (record: CachedProjectedTerrainGeometry) => { + const geometry = new BufferGeometry(); + geometry.setAttribute( + "position", + new Float32BufferAttribute(Float32Array.from(record.positions), 3) + ); + geometry.setAttribute( + "normal", + new Float32BufferAttribute(Float32Array.from(record.normals), 3) + ); + geometry.setIndex( + new Uint32BufferAttribute(Uint32Array.from(record.indices), 1) + ); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + return geometry; +}; + +const snapshotGeometry = ( + geometry: BufferGeometry +): CachedProjectedTerrainGeometry | null => { + const position = geometry.getAttribute("position"); + const normal = geometry.getAttribute("normal"); + const index = geometry.getIndex(); + if (!position || !normal || !index) return null; + return { + positions: Float32Array.from(position.array), + normals: Float32Array.from(normal.array), + indices: Uint32Array.from(index.array), + }; +}; + +export const createProjectedTerrainGeometryCache = ( + terrainUrl: string, + originLngLat: readonly [longitude: number, latitude: number] +): ProjectedTerrainGeometryCache => { + const namespace = hash( + [ + terrainUrl.trim().replace(/\/+$/, ""), + originLngLat[0], + originLngLat[1], + PROJECTED_TERRAIN_GEOMETRY_CACHE_REVISION, + ].join("|") + ); + const getKey = (id: CesiumTerrainTileId) => + `${namespace}:${cesiumTerrainTileKey(id)}`; + + return { + async get(id) { + if (!(await prepareStorage())) return null; + const key = getKey(id); + await pendingWrites.get(key); + try { + const cached = await storage.getItem(key); + if ( + isCachedEntry(cached) && + cached.tile.id.level === id.level && + cached.tile.id.x === id.x && + cached.tile.id.y === id.y + ) { + return { + tile: cloneTile(cached.tile), + geometry: restoreGeometry(cached.geometry), + }; + } + } catch { + cacheAvailable = false; + } + return null; + }, + + set(tile, geometry) { + if (!cacheAvailable) return; + const snapshot = snapshotGeometry(geometry); + if (!snapshot) return; + const key = getKey(tile.id); + const entry: CachedProjectedTerrainTile = { + tile, + geometry: snapshot, + }; + const write = prepareStorage() + .then((ready) => { + if (!ready) return; + return storage.setItem(key, entry).then(() => undefined); + }) + .catch(() => undefined) + .finally(() => pendingWrites.delete(key)); + pendingWrites.set(key, write); + }, + }; +}; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-scene-accumulator.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-scene-accumulator.spec.ts new file mode 100644 index 0000000000..52affdbe0d --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-scene-accumulator.spec.ts @@ -0,0 +1,107 @@ +import type { WebGLRenderTarget, WebGLRenderer } from "three"; +import { describe, expect, it, vi } from "vitest"; + +import { + buildSharedSceneAccumulator, + fitRenderTargetSizeToPixelBudget, +} from "./shared-scene-accumulator"; + +const buildRenderer = (broken = false) => { + let target: WebGLRenderTarget | null = null; + const renderer = { + getRenderTarget: vi.fn(() => target), + setRenderTarget: vi.fn((next: WebGLRenderTarget | null) => { + target = next; + }), + setClearColor: vi.fn(), + clear: vi.fn(), + render: vi.fn(), + readRenderTargetPixels: vi.fn( + ( + _target: WebGLRenderTarget, + _x: number, + _y: number, + _width: number, + _height: number, + pixels: Uint8Array | Float32Array + ) => { + if (pixels instanceof Uint8Array) pixels[3] = 255; + else if (!broken) pixels[0] = 1; + } + ), + }; + return renderer as unknown as WebGLRenderer; +}; + +describe("buildSharedSceneAccumulator", () => { + it("preserves aspect ratio while fitting a pixel budget", () => { + const size = fitRenderTargetSizeToPixelBudget(1_170, 2_532, 1_000_000); + + expect(size.width * size.height).toBeLessThanOrEqual(1_000_000); + expect(size.width / size.height).toBeCloseTo(1_170 / 2_532, 2); + expect(fitRenderTargetSizeToPixelBudget(800, 600, 1_000_000)).toEqual({ + width: 800, + height: 600, + }); + }); + + it("accumulates the configured rounds and resets on state changes", () => { + const renderer = buildRenderer(); + const accumulator = buildSharedSceneAccumulator(2); + const renderScene = vi.fn(); + + accumulator.ensureState("first"); + expect(accumulator.jitterFor(0).x).toBeGreaterThanOrEqual(-0.5); + accumulator.renderRound(renderer, 8, 4, renderScene); + expect(accumulator.composite(renderer)).toBe(false); + accumulator.renderRound(renderer, 8, 4, renderScene); + expect(accumulator.composite(renderer)).toBe(true); + + expect(renderScene).toHaveBeenCalledTimes(2); + expect( + vi.mocked(renderer.readRenderTargetPixels).mock.calls[1]?.[5] + ).toBeInstanceOf(Uint16Array); + expect(accumulator.converged).toBe(true); + expect(accumulator.hasSettledFrame).toBe(true); + expect(accumulator.nextRound).toBe(2); + accumulator.ensureState("second"); + expect(accumulator.nextRound).toBe(0); + expect(accumulator.hasSettledFrame).toBe(true); + expect(accumulator.composite(renderer, true)).toBe(true); + expect(accumulator.composite(renderer)).toBe(false); + accumulator.dispose(); + }); + + it("marks an unusable blend pipeline as broken", () => { + const error = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const accumulator = buildSharedSceneAccumulator(1); + + accumulator.renderRound(buildRenderer(true), 4, 4, () => undefined); + + expect(accumulator.broken).toBe(true); + expect(error).toHaveBeenCalledOnce(); + accumulator.dispose(); + error.mockRestore(); + }); + + it("falls back when render target setup throws", () => { + const error = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const renderer = buildRenderer(); + vi.mocked(renderer.setRenderTarget).mockImplementationOnce(() => { + throw new Error("allocation failed"); + }); + const accumulator = buildSharedSceneAccumulator(1); + + expect(() => + accumulator.renderRound(renderer, 4_096, 4_096, () => undefined) + ).not.toThrow(); + expect(accumulator.broken).toBe(true); + expect(error).toHaveBeenCalledOnce(); + accumulator.dispose(); + error.mockRestore(); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-scene-accumulator.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-scene-accumulator.ts new file mode 100644 index 0000000000..7a737fefb5 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-scene-accumulator.ts @@ -0,0 +1,336 @@ +import { + AlwaysDepth, + DepthTexture, + GLSL3, + HalfFloatType, + LinearFilter, + Mesh, + NormalBlending, + OrthographicCamera, + PlaneGeometry, + Scene, + ShaderMaterial, + Texture, + Vector2, + WebGLRenderTarget, + type WebGLRenderer, +} from "three"; + +// Accumulates sun-disc samples and preserves the final scene depth. + +/** Halton(2,3) low-discrepancy points, centred, for sub-pixel camera jitter. */ +const halton = (index: number, base: number): number => { + let fraction = 1; + let result = 0; + let i = index + 1; + while (i > 0) { + fraction /= base; + result += fraction * (i % base); + i = Math.floor(i / base); + } + return result - 0.5; +}; + +const COMPOSITE_VERTEX = /* glsl */ ` + out vec2 vUv; + void main() { + vUv = uv; + gl_Position = vec4(position.xy, 0.0, 1.0); + } +`; + +// Three injects a pc_fragColor output only for shaders it upgrades itself; +// an explicitly GLSL3 ShaderMaterial declares its own fragment output. +const BLEND_FRAGMENT = /* glsl */ ` + layout(location = 0) out highp vec4 outColor; + in vec2 vUv; + uniform sampler2D tPrevious; + uniform sampler2D tRound; + uniform float uRoundWeight; + void main() { + outColor = mix( + texture(tPrevious, vUv), + texture(tRound, vUv), + uRoundWeight + ); + } +`; + +const COMPOSITE_FRAGMENT = /* glsl */ ` + layout(location = 0) out highp vec4 outColor; + in vec2 vUv; + uniform sampler2D tColor; + uniform sampler2D tDepth; + void main() { + vec4 color = texture(tColor, vUv); + if (color.a < 0.004) discard; + outColor = linearToOutputTexel(color); + gl_FragDepth = texture(tDepth, vUv).r; + } +`; + +export type SharedSceneAccumulator = { + /** Set when the self-check failed; callers must render directly instead. */ + readonly broken: boolean; + /** Whether every configured round has been blended in. */ + readonly converged: boolean; + /** Whether a complete accumulation is available for reuse. */ + readonly hasSettledFrame: boolean; + /** The round the next renderRound call will produce, 0-based. */ + readonly nextRound: number; + /** + * Restart when the state the rounds sample from has changed. Cheap to call + * with the same key every frame. + */ + ensureState: (stateKey: string) => void; + /** Sub-pixel NDC jitter for the round about to render, x/y in [-0.5,0.5]. */ + jitterFor: (round: number) => Vector2; + /** Render one round of `renderScene` into the accumulation buffers. */ + renderRound: ( + renderer: WebGLRenderer, + width: number, + height: number, + renderScene: () => void + ) => void; + /** + * Draw an accumulated average plus scene depth into the current target. + * Returns false when no suitable frame exists yet. + */ + composite: (renderer: WebGLRenderer, preferSettled?: boolean) => boolean; + dispose: () => void; +}; + +export const fitRenderTargetSizeToPixelBudget = ( + width: number, + height: number, + maxPixels: number +): Readonly<{ width: number; height: number }> => { + const requestedWidth = Math.max(1, Math.floor(width)); + const requestedHeight = Math.max(1, Math.floor(height)); + if ( + !Number.isFinite(maxPixels) || + requestedWidth * requestedHeight <= maxPixels + ) { + return { width: requestedWidth, height: requestedHeight }; + } + const scale = Math.sqrt( + Math.max(1, Math.floor(maxPixels)) / (requestedWidth * requestedHeight) + ); + return { + width: Math.max(1, Math.floor(requestedWidth * scale)), + height: Math.max(1, Math.floor(requestedHeight * scale)), + }; +}; + +export const buildSharedSceneAccumulator = ( + rounds: number +): SharedSceneAccumulator => { + let sceneTarget: WebGLRenderTarget | null = null; + let settledSceneTarget: WebGLRenderTarget | null = null; + let accumRead: WebGLRenderTarget | null = null; + let accumWrite: WebGLRenderTarget | null = null; + let settledAccum: WebGLRenderTarget | null = null; + let width = 0; + let height = 0; + let round = 0; + let stateKey = ""; + let selfChecked = false; + let broken = false; + let hasSettledFrame = false; + + const fullscreenScene = new Scene(); + const fullscreenCamera = new OrthographicCamera(-1, 1, 1, -1, 0, 1); + const blendMaterial = new ShaderMaterial({ + glslVersion: GLSL3, + vertexShader: COMPOSITE_VERTEX, + fragmentShader: BLEND_FRAGMENT, + uniforms: { + tPrevious: { value: null as Texture | null }, + tRound: { value: null as Texture | null }, + uRoundWeight: { value: 1 }, + }, + depthTest: false, + depthWrite: false, + }); + const compositeMaterial = new ShaderMaterial({ + glslVersion: GLSL3, + vertexShader: COMPOSITE_VERTEX, + fragmentShader: COMPOSITE_FRAGMENT, + uniforms: { + tColor: { value: null as Texture | null }, + tDepth: { value: null as Texture | null }, + }, + transparent: true, + blending: NormalBlending, + // Depth is written from the stored scene depth; the test must always + // pass for the write to happen at all. + depthTest: true, + depthFunc: AlwaysDepth, + depthWrite: true, + }); + const fullscreenMesh = new Mesh(new PlaneGeometry(2, 2), blendMaterial); + fullscreenMesh.frustumCulled = false; + fullscreenScene.add(fullscreenMesh); + + const disposeTargets = () => { + sceneTarget?.depthTexture?.dispose(); + sceneTarget?.dispose(); + settledSceneTarget?.depthTexture?.dispose(); + settledSceneTarget?.dispose(); + accumRead?.dispose(); + accumWrite?.dispose(); + settledAccum?.dispose(); + sceneTarget = null; + settledSceneTarget = null; + accumRead = null; + accumWrite = null; + settledAccum = null; + hasSettledFrame = false; + }; + + const ensureTargets = (nextWidth: number, nextHeight: number) => { + if (sceneTarget && width === nextWidth && height === nextHeight) return; + disposeTargets(); + width = nextWidth; + height = nextHeight; + const buildSceneTarget = () => + new WebGLRenderTarget(width, height, { + depthBuffer: true, + depthTexture: new DepthTexture(width, height), + samples: 0, + }); + sceneTarget = buildSceneTarget(); + settledSceneTarget = buildSceneTarget(); + const accumOptions = { + type: HalfFloatType, + minFilter: LinearFilter, + magFilter: LinearFilter, + depthBuffer: false, + } as const; + accumRead = new WebGLRenderTarget(width, height, accumOptions); + accumWrite = new WebGLRenderTarget(width, height, accumOptions); + settledAccum = new WebGLRenderTarget(width, height, accumOptions); + round = 0; + }; + + return { + get broken() { + return broken; + }, + get converged() { + return round >= rounds; + }, + get hasSettledFrame() { + return hasSettledFrame; + }, + get nextRound() { + return round; + }, + ensureState(nextKey) { + if (stateKey === nextKey) return; + stateKey = nextKey; + round = 0; + }, + jitterFor(index) { + return new Vector2(halton(index, 2), halton(index, 3)); + }, + renderRound(renderer, nextWidth, nextHeight, renderScene) { + const previousTarget = renderer.getRenderTarget(); + try { + ensureTargets(nextWidth, nextHeight); + if (!sceneTarget || !accumRead || !accumWrite) return; + renderer.setRenderTarget(sceneTarget); + renderer.setClearColor(0x000000, 0); + renderer.clear(true, true, false); + renderScene(); + renderer.setRenderTarget(accumWrite); + fullscreenMesh.material = blendMaterial; + blendMaterial.uniforms.tPrevious.value = accumRead.texture; + blendMaterial.uniforms.tRound.value = sceneTarget.texture; + blendMaterial.uniforms.uRoundWeight.value = 1 / (round + 1); + renderer.render(fullscreenScene, fullscreenCamera); + renderer.setRenderTarget(previousTarget); + // Fall back to direct rendering if the first blend pass produces no data. + if (!selfChecked && round === 0) { + selfChecked = true; + const scenePixel = new Uint8Array(4); + renderer.readRenderTargetPixels( + sceneTarget, + Math.floor(width / 2), + Math.floor(height / 2), + 1, + 1, + scenePixel + ); + const accumPixel = new Uint16Array(4); + renderer.readRenderTargetPixels( + accumWrite, + Math.floor(width / 2), + Math.floor(height / 2), + 1, + 1, + accumPixel + ); + if ( + scenePixel[3] > 0 && + accumPixel[0] === 0 && + accumPixel[1] === 0 && + accumPixel[2] === 0 && + accumPixel[3] === 0 + ) { + broken = true; + console.error( + "[shadow-simulation] accumulation self-check failed; falling back to direct rendering" + ); + } + } + const swap = accumRead; + accumRead = accumWrite; + accumWrite = swap; + round += 1; + if (round >= rounds && settledAccum && settledSceneTarget) { + const previousSettled = settledAccum; + settledAccum = accumRead; + accumRead = previousSettled; + const previousSettledScene = settledSceneTarget; + settledSceneTarget = sceneTarget; + sceneTarget = previousSettledScene; + hasSettledFrame = true; + } + } catch (error) { + broken = true; + disposeTargets(); + try { + renderer.setRenderTarget(previousTarget); + } catch { + // The host renderer owns WebGL context restoration. + } + console.error( + "[shadow-simulation] accumulation render target failed; falling back to direct rendering", + error + ); + } + }, + composite(renderer, preferSettled = false) { + if ( + !hasSettledFrame || + (!preferSettled && round < rounds) || + !settledAccum || + !settledSceneTarget + ) { + return false; + } + fullscreenMesh.material = compositeMaterial; + compositeMaterial.uniforms.tColor.value = settledAccum.texture; + compositeMaterial.uniforms.tDepth.value = settledSceneTarget.depthTexture; + renderer.render(fullscreenScene, fullscreenCamera); + return true; + }, + dispose() { + disposeTargets(); + blendMaterial.dispose(); + compositeMaterial.dispose(); + fullscreenMesh.geometry.dispose(); + }, + }; +}; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-camera-preview.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-camera-preview.spec.ts new file mode 100644 index 0000000000..9608e7a655 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-camera-preview.spec.ts @@ -0,0 +1,100 @@ +import * as THREE from "three"; +import { describe, expect, it, vi } from "vitest"; + +import { createSharedThreeSceneCameraPreview } from "./shared-three-scene-camera-preview"; +import type { SharedThreeSceneLayer } from "./shared-three-scene-layer"; + +describe("shared Three.js camera preview", () => { + it("renders offscreen and restores the shared renderer state", () => { + const scene = new THREE.Scene(); + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10); + const hostFramebuffer = {} as WebGLFramebuffer; + const depthRange = new Float32Array([0, 0.985]); + const viewport = new THREE.Vector4(4, 5, 640, 360); + const scissor = new THREE.Vector4(8, 9, 620, 340); + const clearColor = new THREE.Color(0x123456); + let clearAlpha = 0.4; + let renderTarget: THREE.WebGLRenderTarget | null = null; + let scissorTest = true; + const gl = { + FRAMEBUFFER: 0x8d40, + FRAMEBUFFER_BINDING: 0x8ca6, + DEPTH_RANGE: 0x0b70, + getParameter: vi.fn((parameter: number) => + parameter === 0x8ca6 ? hostFramebuffer : depthRange + ), + bindFramebuffer: vi.fn(), + depthRange: vi.fn(), + }; + const renderer = { + getContext: vi.fn(() => gl), + getRenderTarget: vi.fn(() => renderTarget), + setRenderTarget: vi.fn((next: THREE.WebGLRenderTarget | null) => { + renderTarget = next; + }), + getViewport: vi.fn((target: THREE.Vector4) => target.copy(viewport)), + setViewport: vi.fn((value: THREE.Vector4 | number, ...rest: number[]) => { + if (value instanceof THREE.Vector4) viewport.copy(value); + else viewport.set(value, rest[0], rest[1], rest[2]); + }), + getScissor: vi.fn((target: THREE.Vector4) => target.copy(scissor)), + setScissor: vi.fn((value: THREE.Vector4 | number, ...rest: number[]) => { + if (value instanceof THREE.Vector4) scissor.copy(value); + else scissor.set(value, rest[0], rest[1], rest[2]); + }), + getScissorTest: vi.fn(() => scissorTest), + setScissorTest: vi.fn((next: boolean) => { + scissorTest = next; + }), + getClearColor: vi.fn((target: THREE.Color) => target.copy(clearColor)), + getClearAlpha: vi.fn(() => clearAlpha), + setClearColor: vi.fn( + (next: THREE.ColorRepresentation, nextAlpha: number) => { + clearColor.set(next); + clearAlpha = nextAlpha; + } + ), + resetState: vi.fn(), + clear: vi.fn(), + render: vi.fn(), + readRenderTargetPixels: vi.fn( + ( + _target: THREE.WebGLRenderTarget, + _x: number, + _y: number, + _width: number, + _height: number, + pixels: Uint8Array + ) => pixels.fill(17) + ), + } as unknown as THREE.WebGLRenderer; + const layer = { + getRenderer: () => renderer, + getScene: () => scene, + } as unknown as SharedThreeSceneLayer; + const preview = createSharedThreeSceneCameraPreview(layer); + const onFrame = vi.fn(); + + expect(preview.render(camera, 48, 24, onFrame)).toBe(true); + + expect(renderer.render).toHaveBeenCalledWith(scene, camera); + expect(onFrame).toHaveBeenCalledOnce(); + expect(onFrame.mock.calls[0][0]).toHaveLength(48 * 24 * 4); + expect(onFrame.mock.calls[0][0][0]).toBe(17); + expect(renderTarget).toBeNull(); + expect(viewport.toArray()).toEqual([4, 5, 640, 360]); + expect(scissor.toArray()).toEqual([8, 9, 620, 340]); + expect(scissorTest).toBe(true); + expect(clearColor.getHex()).toBe(0x123456); + expect(clearAlpha).toBe(0.4); + expect(gl.bindFramebuffer).toHaveBeenLastCalledWith( + gl.FRAMEBUFFER, + hostFramebuffer + ); + const restoredDepthRange = gl.depthRange.mock.lastCall; + expect(restoredDepthRange?.[0]).toBe(0); + expect(restoredDepthRange?.[1]).toBeCloseTo(0.985); + + preview.dispose(); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-camera-preview.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-camera-preview.ts new file mode 100644 index 0000000000..f248bfde5b --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-camera-preview.ts @@ -0,0 +1,95 @@ +import * as THREE from "three"; + +import type { SharedThreeSceneLayer } from "./shared-three-scene-layer"; + +export type SharedThreeSceneCameraPreview = Readonly<{ + render: ( + camera: THREE.Camera, + width: number, + height: number, + onFrame: (pixels: Uint8Array, width: number, height: number) => void + ) => boolean; + dispose: () => void; +}>; + +/** + * Render a diagnostic camera with the mounted shared renderer and scene. + * The render target keeps the preview off MapLibre's framebuffer while the + * explicit state restoration leaves the host renderer untouched. + */ +export const createSharedThreeSceneCameraPreview = ( + layer: SharedThreeSceneLayer +): SharedThreeSceneCameraPreview => { + let target: THREE.WebGLRenderTarget | null = null; + let pixels = new Uint8Array(0); + const previousViewport = new THREE.Vector4(); + const previousScissor = new THREE.Vector4(); + const previousClearColor = new THREE.Color(); + + const ensureTarget = (width: number, height: number) => { + if (!target) { + target = new THREE.WebGLRenderTarget(width, height, { + depthBuffer: true, + stencilBuffer: false, + format: THREE.RGBAFormat, + type: THREE.UnsignedByteType, + }); + target.texture.colorSpace = THREE.SRGBColorSpace; + } else if (target.width !== width || target.height !== height) { + target.setSize(width, height); + } + const pixelCount = width * height * 4; + if (pixels.length !== pixelCount) pixels = new Uint8Array(pixelCount); + }; + + return { + render(camera, requestedWidth, requestedHeight, onFrame) { + const renderer = layer.getRenderer(); + if (!renderer) return false; + const width = Math.max(1, Math.floor(requestedWidth)); + const height = Math.max(1, Math.floor(requestedHeight)); + ensureTarget(width, height); + if (!target) return false; + + const gl = renderer.getContext(); + const hostFramebuffer = gl.getParameter( + gl.FRAMEBUFFER_BINDING + ) as WebGLFramebuffer | null; + const hostDepthRange = gl.getParameter(gl.DEPTH_RANGE) as Float32Array; + const previousTarget = renderer.getRenderTarget(); + renderer.getViewport(previousViewport); + renderer.getScissor(previousScissor); + const previousScissorTest = renderer.getScissorTest(); + renderer.getClearColor(previousClearColor); + const previousClearAlpha = renderer.getClearAlpha(); + + try { + renderer.resetState(); + renderer.setRenderTarget(target); + renderer.setViewport(0, 0, width, height); + renderer.setScissorTest(false); + renderer.setClearColor(0x0f172a, 1); + gl.depthRange(0, 1); + renderer.clear(true, true, false); + renderer.render(layer.getScene(), camera); + renderer.readRenderTargetPixels(target, 0, 0, width, height, pixels); + onFrame(pixels, width, height); + return true; + } finally { + renderer.setRenderTarget(previousTarget); + renderer.setViewport(previousViewport); + renderer.setScissor(previousScissor); + renderer.setScissorTest(previousScissorTest); + renderer.setClearColor(previousClearColor, previousClearAlpha); + renderer.resetState(); + gl.bindFramebuffer(gl.FRAMEBUFFER, hostFramebuffer); + gl.depthRange(hostDepthRange[0], hostDepthRange[1]); + } + }, + dispose() { + target?.dispose(); + target = null; + pixels = new Uint8Array(0); + }, + }; +}; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-content-registry.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-content-registry.spec.ts new file mode 100644 index 0000000000..51ca827ba9 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-content-registry.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + getSharedThreeSceneRuntimes, + notifySharedThreeSceneContentChanged, + notifySharedThreeSceneRequestStateChanged, + registerSharedThreeSceneRuntime, + subscribeSharedThreeSceneContent, + subscribeSharedThreeSceneRequestState, +} from "./shared-three-scene-content-registry"; + +describe("shared Three.js scene content registry", () => { + it("notifies only subscribers of the affected map", () => { + const firstMap = {} as never; + const secondMap = {} as never; + const firstListener = vi.fn(); + const secondListener = vi.fn(); + const unsubscribe = subscribeSharedThreeSceneContent( + firstMap, + firstListener + ); + subscribeSharedThreeSceneContent(secondMap, secondListener); + + notifySharedThreeSceneContentChanged(firstMap); + expect(firstListener).toHaveBeenCalledOnce(); + expect(secondListener).not.toHaveBeenCalled(); + + unsubscribe(); + notifySharedThreeSceneContentChanged(firstMap); + expect(firstListener).toHaveBeenCalledOnce(); + }); + + it("keeps request-state notifications separate from content changes", () => { + const map = {} as never; + const contentListener = vi.fn(); + const requestListener = vi.fn(); + subscribeSharedThreeSceneContent(map, contentListener); + const unsubscribe = subscribeSharedThreeSceneRequestState( + map, + requestListener + ); + + notifySharedThreeSceneRequestStateChanged(map); + expect(requestListener).toHaveBeenCalledOnce(); + expect(contentListener).not.toHaveBeenCalled(); + + unsubscribe(); + notifySharedThreeSceneRequestStateChanged(map); + expect(requestListener).toHaveBeenCalledOnce(); + }); + + it("registers runtimes for shadow-mode styling and unregisters them", () => { + const map = {} as never; + const runtime = { id: "lod2" } as never; + + const unregister = registerSharedThreeSceneRuntime(map, runtime); + expect(getSharedThreeSceneRuntimes(map)).toEqual([runtime]); + + unregister(); + expect(getSharedThreeSceneRuntimes(map)).toEqual([]); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-content-registry.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-content-registry.ts new file mode 100644 index 0000000000..f229f22734 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-content-registry.ts @@ -0,0 +1,72 @@ +import type { Map as MaplibreMap } from "maplibre-gl"; + +import type { SharedThreeSceneRuntime } from "./shared-three-scene-layer"; + +const listeners = new WeakMap void>>(); +const requestStateListeners = new WeakMap void>>(); +const runtimes = new WeakMap>(); + +const notifyListeners = ( + registry: WeakMap void>>, + map: MaplibreMap +) => { + for (const listener of registry.get(map) ?? []) listener(); +}; + +const subscribeListeners = ( + registry: WeakMap void>>, + map: MaplibreMap, + listener: () => void +): (() => void) => { + const mapListeners = registry.get(map) ?? new Set<() => void>(); + mapListeners.add(listener); + registry.set(map, mapListeners); + return () => { + mapListeners.delete(listener); + if (mapListeners.size === 0) registry.delete(map); + }; +}; + +/** Notify consumers such as the shadow simulation after streamed scene data changes. */ +export const notifySharedThreeSceneContentChanged = (map: MaplibreMap) => { + notifyListeners(listeners, map); +}; + +export const subscribeSharedThreeSceneContent = ( + map: MaplibreMap, + listener: () => void +): (() => void) => { + return subscribeListeners(listeners, map, listener); +}; + +export const notifySharedThreeSceneRequestStateChanged = ( + map: MaplibreMap +) => { + notifyListeners(requestStateListeners, map); +}; + +export const subscribeSharedThreeSceneRequestState = ( + map: MaplibreMap, + listener: () => void +): (() => void) => { + return subscribeListeners(requestStateListeners, map, listener); +}; + +export const getSharedThreeSceneRuntimes = ( + map: MaplibreMap +): readonly SharedThreeSceneRuntime[] => [...(runtimes.get(map) ?? [])]; + +export const registerSharedThreeSceneRuntime = ( + map: MaplibreMap, + runtime: SharedThreeSceneRuntime +): (() => void) => { + const mapRuntimes = runtimes.get(map) ?? new Set(); + mapRuntimes.add(runtime); + runtimes.set(map, mapRuntimes); + notifySharedThreeSceneContentChanged(map); + return () => { + mapRuntimes.delete(runtime); + if (mapRuntimes.size === 0) runtimes.delete(map); + notifySharedThreeSceneContentChanged(map); + }; +}; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.spec.ts new file mode 100644 index 0000000000..82bb9d05a3 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.spec.ts @@ -0,0 +1,290 @@ +import * as THREE from "three"; +import { describe, expect, it, vi } from "vitest"; + +import { + buildSharedThreeSceneLayer, + clearDepthForMapStyleOverlays, + clearMapStyleGroundBeforeThreeTerrain, + configureMapStyleProjectedMaterial, + configureSharedRenderCamera, + installRenderTargetDepthRangeBridge, + syncSharedCanvasViewport, +} from "./shared-three-scene-layer"; + +const expectMatrixToBeCloseTo = ( + actual: THREE.Matrix4, + expected: THREE.Matrix4 +): void => { + actual.elements.forEach((value, index) => { + expect(value).toBeCloseTo(expected.elements[index], 10); + }); +}; + +describe("shared Three.js scene layer", () => { + it("projects the captured MapLibre ground pass before terrain lighting", () => { + const material = new THREE.MeshLambertMaterial(); + const texture = new THREE.Texture(); + const sceneToClip = new THREE.Matrix4().makeTranslation(1, 2, 3); + const uniforms = { + texture: { value: texture }, + sceneToClip: { value: sceneToClip }, + enabled: { value: 1 }, + depthTexture: { value: null }, + depthEnabled: { value: 0 }, + depthNearFar: { value: new THREE.Vector2(1, 1000) }, + }; + const shader = { + uniforms: {}, + vertexShader: "#include \n#include ", + fragmentShader: + "#include \n#include \n#include ", + }; + + configureMapStyleProjectedMaterial(material, uniforms); + material.onBeforeCompile(shader as never, {} as never); + + expect(shader.uniforms).toMatchObject({ + carmaMapStyleTexture: uniforms.texture, + carmaMapStyleSceneToClip: uniforms.sceneToClip, + carmaMapStyleEnabled: uniforms.enabled, + }); + expect(shader.vertexShader).toContain( + "carmaMapStyleSceneToClip * modelMatrix" + ); + expect(shader.fragmentShader).toContain( + "diffuseColor.rgb = carmaMapStyleSRGBToLinear" + ); + expect(shader.fragmentShader).toContain("diffuseColor.a = 1.0"); + expect(material.customProgramCacheKey()).toContain( + "carma-map-style-projection-v2" + ); + expect(material.defines?.CARMA_MAP_STYLE_OVERLAY).toBeUndefined(); + }); + + it("composites the captured pass over a textured receiver in overlay mode", () => { + const material = new THREE.MeshStandardMaterial(); + const uniforms = { + texture: { value: new THREE.Texture() }, + sceneToClip: { value: new THREE.Matrix4() }, + enabled: { value: 1 }, + depthTexture: { value: new THREE.Texture() }, + depthEnabled: { value: 1 }, + depthNearFar: { value: new THREE.Vector2(1, 1000) }, + }; + const shader = { + uniforms: {}, + vertexShader: "#include \n#include ", + fragmentShader: + "#include \n#include \n#include ", + }; + + configureMapStyleProjectedMaterial(material, uniforms, "overlay"); + material.onBeforeCompile(shader as never, {} as never); + + expect(material.defines?.CARMA_MAP_STYLE_OVERLAY).toBe(""); + expect(shader.fragmentShader).toContain("#ifdef CARMA_MAP_STYLE_OVERLAY"); + expect(shader.fragmentShader).toContain("carmaMapStyleOccludedByMesh"); + expect(shader.fragmentShader).toContain("carmaMapStyleLabelCoverage"); + expect(shader.fragmentShader.indexOf("carmaShade")).toBeLessThan( + shader.fragmentShader.indexOf("#include ") + ); + expect(shader.uniforms).toMatchObject({ + carmaMapStyleDepthTexture: uniforms.depthTexture, + carmaMapStyleDepthEnabled: uniforms.depthEnabled, + carmaMapStyleDepthNearFar: uniforms.depthNearFar, + }); + expect(shader.fragmentShader).toContain("carmaMapStyleSample.a"); + expect(material.customProgramCacheKey()).toContain("|overlay"); + + configureMapStyleProjectedMaterial(material, uniforms, "replace"); + expect(material.defines?.CARMA_MAP_STYLE_OVERLAY).toBeUndefined(); + expect(material.customProgramCacheKey()).toContain("|replace"); + }); + + it("clears mesh depth before MapLibre draws retained place labels", () => { + const gl = { + DEPTH_BUFFER_BIT: 0x00000100, + clear: vi.fn(), + clearDepth: vi.fn(), + depthMask: vi.fn(), + depthRange: vi.fn(), + }; + + clearDepthForMapStyleOverlays(gl, [0, 0.985]); + + expect(gl.depthMask).toHaveBeenCalledWith(true); + expect(gl.depthRange.mock.calls).toEqual([ + [0, 1], + [0, 0.985], + ]); + expect(gl.clearDepth).toHaveBeenCalledWith(1); + expect(gl.clear).toHaveBeenCalledWith(gl.DEPTH_BUFFER_BIT); + }); + + it("clears MapLibre ground color and depth before Three replaces it", () => { + const previousClearColor = new Float32Array([0.2, 0.3, 0.4, 1]); + const gl = { + COLOR_BUFFER_BIT: 0x00004000, + DEPTH_BUFFER_BIT: 0x00000100, + COLOR_CLEAR_VALUE: 0x0c22, + clear: vi.fn(), + clearColor: vi.fn(), + clearDepth: vi.fn(), + depthMask: vi.fn(), + depthRange: vi.fn(), + getParameter: vi.fn(() => previousClearColor), + }; + + clearMapStyleGroundBeforeThreeTerrain(gl, [0, 0.985]); + + expect(gl.clear).toHaveBeenCalledWith( + gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT + ); + expect(gl.clearColor.mock.calls).toEqual([ + [0, 0, 0, 0], + [...previousClearColor], + ]); + expect(gl.depthRange.mock.calls).toEqual([ + [0, 1], + [0, 0.985], + ]); + }); + + it("uses a real camera view without changing MapLibre's scene-to-clip matrix", () => { + const lodCamera = new THREE.PerspectiveCamera(52, 16 / 9, 2, 1_000_000); + lodCamera.position.set(1_250, 840, -430); + lodCamera.up.set(0, 1, 0); + lodCamera.lookAt(new THREE.Vector3(140, 210, 380)); + lodCamera.updateMatrixWorld(true); + + const sceneToClipMatrix = new THREE.Matrix4() + .makePerspective(-0.7, 0.9, 0.6, -0.5, 0.5, 2_000) + .multiply(new THREE.Matrix4().makeTranslation(0.15, -0.25, 0.4)); + const renderCamera = new THREE.PerspectiveCamera(); + + configureSharedRenderCamera(renderCamera, lodCamera, sceneToClipMatrix); + + expectMatrixToBeCloseTo(renderCamera.matrixWorld, lodCamera.matrixWorld); + expectMatrixToBeCloseTo( + renderCamera.matrixWorldInverse, + lodCamera.matrixWorldInverse + ); + expectMatrixToBeCloseTo( + new THREE.Matrix4().multiplyMatrices( + renderCamera.projectionMatrix, + renderCamera.matrixWorldInverse + ), + sceneToClipMatrix + ); + + renderCamera.updateMatrixWorld(true); + expectMatrixToBeCloseTo( + new THREE.Matrix4().multiplyMatrices( + renderCamera.projectionMatrix, + renderCamera.matrixWorldInverse + ), + sceneToClipMatrix + ); + }); + + it("tracks MapLibre canvas resizes in Three's main framebuffer viewport", () => { + const renderer = { + setViewport: vi.fn(), + } as unknown as Pick; + const canvas = { width: 1_280, height: 720 }; + const viewport = new THREE.Vector2(1, 1); + + syncSharedCanvasViewport(renderer, canvas, viewport); + + expect(viewport.toArray()).toEqual([1_280, 720]); + expect(renderer.setViewport).toHaveBeenLastCalledWith(0, 0, 1_280, 720); + + canvas.width = 1_400; + canvas.height = 500; + syncSharedCanvasViewport(renderer, canvas, viewport); + + expect(viewport.toArray()).toEqual([1_400, 500]); + expect(renderer.setViewport).toHaveBeenLastCalledWith(0, 0, 1_400, 500); + + syncSharedCanvasViewport(renderer, canvas, viewport); + expect(renderer.setViewport).toHaveBeenCalledTimes(2); + }); + + it("uses canonical depth for offscreen targets and MapLibre depth on main", () => { + const events: string[] = []; + const hostFramebuffer = {} as WebGLFramebuffer; + let activeFramebuffer: WebGLFramebuffer | null = hostFramebuffer; + const originalSetRenderTarget = vi.fn((target: unknown) => { + events.push(target === null ? "target:main" : "target:offscreen"); + activeFramebuffer = target === null ? null : (target as WebGLFramebuffer); + }); + const renderer = { + setRenderTarget: originalSetRenderTarget, + } as unknown as Pick; + const gl = { + FRAMEBUFFER: 0x8d40, + FRAMEBUFFER_BINDING: 0x8ca6, + getParameter: vi.fn(() => activeFramebuffer), + bindFramebuffer: vi.fn( + (_target: number, framebuffer: WebGLFramebuffer | null) => { + activeFramebuffer = framebuffer; + events.push( + framebuffer === hostFramebuffer + ? "framebuffer:host" + : "framebuffer:other" + ); + } + ), + depthRange: vi.fn((near: number, far: number) => { + events.push(`depth:${near}:${far}`); + }), + }; + const bridge = installRenderTargetDepthRangeBridge(renderer, gl); + + bridge.render([0, 0.985], () => { + renderer.setRenderTarget({} as THREE.WebGLRenderTarget); + renderer.setRenderTarget(null); + }); + + expect(events).toEqual([ + "target:offscreen", + "depth:0:1", + "target:main", + "depth:0:0.985", + "framebuffer:host", + "depth:0:0.985", + ]); + expect(activeFramebuffer).toBe(hostFramebuffer); + + bridge.dispose(); + renderer.setRenderTarget(null); + expect(originalSetRenderTarget).toHaveBeenCalledTimes(3); + expect(gl.depthRange).toHaveBeenCalledTimes(3); + }); + + it("exposes attached runtime roots", () => { + const layer = buildSharedThreeSceneLayer("shared-three-scene"); + const root = new THREE.Group(); + const dispose = vi.fn(); + + layer.addRuntime({ + id: "mesh-runtime", + originLngLat: [7.15, 51.25], + root, + update: vi.fn(), + dispose, + }); + + expect(layer.getScene().children).toContain(root); + expect(layer.getRuntimes()).toEqual([ + expect.objectContaining({ id: "mesh-runtime" }), + ]); + expect(layer.hasRuntime("mesh-runtime")).toBe(true); + + layer.removeRuntime("mesh-runtime"); + + expect(layer.getScene().children).not.toContain(root); + expect(layer.getRuntimes()).toEqual([]); + expect(dispose).toHaveBeenCalledOnce(); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.ts new file mode 100644 index 0000000000..74c5624de2 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.ts @@ -0,0 +1,1125 @@ +import { synthesizeLodCamera } from "@carma-mapping/engines/threejs"; +import { MercatorCoordinate } from "maplibre-gl"; +import type { + CustomLayerInterface, + CustomRenderMethodInput, + Map as MaplibreMap, +} from "maplibre-gl"; +import * as THREE from "three"; + +import { quantize } from "@carma-commons/math"; +import { + buildSharedSceneAccumulator, + fitRenderTargetSizeToPixelBudget, + type SharedSceneAccumulator, +} from "./shared-scene-accumulator"; + +export interface SharedThreeSceneFrame { + map: MaplibreMap; + renderCamera: THREE.Camera; + lodCamera: THREE.PerspectiveCamera; + lookTarget: THREE.Vector3; + viewport: THREE.Vector2; +} + +export type SharedThreeSceneShadowView = Readonly<{ + camera: THREE.Camera; + shadowMapSize: Readonly<{ + width: number; + height: number; + }>; +}>; + +export type SharedThreeSceneTileVolume = Readonly<{ + id: string; + kind: string; + loadReason?: "viewport" | "shadow"; + minimum: readonly [number, number, number]; + maximum: readonly [number, number, number]; +}>; + +export interface SharedThreeSceneRuntime { + id: string; + originLngLat: [number, number]; + root: THREE.Object3D; + /** This runtime already supplies the visible ground surface. */ + providesTerrain?: boolean; + /** Project preceding MapLibre ground styling onto selected runtime materials. */ + receivesMapStyleTexture?: boolean | ((material: THREE.Material) => boolean); + /** + * How the projected style meets the receiver's own color: `replace` paints + * the captured pass as the surface color (bare terrain), `overlay` + * composites it by alpha over the receiver's own texture (a textured mesh + * that only takes draped labels). + */ + mapStyleProjectionBlend?: MapStyleProjectionBlend; + /** Changes whenever streamed content replaces or adds render materials. */ + mapStyleProjectionVersion?: () => number; + /** Whether terrain-supplying content is ready to replace fallback terrain. */ + hasRenderableContent?: () => boolean; + updatePriority?: number; + onAdd?: (map: MaplibreMap) => void; + update: (frame: SharedThreeSceneFrame) => void; + setShadowSimulationStyle?: ( + style: SharedThreeSceneShadowStyle | null + ) => void; + setShadowView?: (view: SharedThreeSceneShadowView | null) => void; + /** Requested screen-space error in pixels; lower loads finer tiles. */ + setErrorTarget?: (errorTarget: number) => void; + /** World-space elevation span of loaded content intersecting this camera. */ + getViewElevationRange?: ( + camera: THREE.Camera + ) => readonly [minimum: number, maximum: number] | null; + /** World-space bounds of active tiles used for coverage and diagnostics. */ + getActiveTileVolumes?: () => readonly SharedThreeSceneTileVolume[]; + /** Show runtime-owned tile bounds and identifiers for diagnostics. */ + setTileBoundsVisible?: (visible: boolean) => void; + /** Outstanding work required before a fixed-state render can converge. */ + getRequestDemand?: () => number; + dispose: () => void; +} + +export const getSharedThreeShadowViewSignature = ( + view: SharedThreeSceneShadowView | null +): string => { + if (!view) return ""; + const { camera, shadowMapSize } = view; + camera.updateMatrixWorld(true); + return [ + quantize(camera.position.x, 0.25), + quantize(camera.position.y, 0.25), + quantize(camera.position.z, 0.25), + quantize(camera.quaternion.x, 0.0001), + quantize(camera.quaternion.y, 0.0001), + quantize(camera.quaternion.z, 0.0001), + quantize(camera.quaternion.w, 0.0001), + ...camera.projectionMatrix.elements.map((value) => quantize(value, 0.0001)), + `${shadowMapSize.width}x${shadowMapSize.height}`, + ].join(","); +}; + +export type SharedThreeSceneShadowStyle = Readonly<{ + fullOpacity: boolean; + uniformColor: string | null; + /** 0 keeps the source texture, 1 shows only uniformColor. */ + uniformColorMix?: number; + /** 0 removes all source-texture saturation, 1 preserves it. */ + textureSaturation?: number; +}>; + +export type SharedSceneAccumulationController = { + /** Changes whenever the shadow/lighting state the rounds sample changed. */ + epoch: () => number; + /** Changes only when an already displayed result is visually obsolete. */ + visualEpoch: () => number; + /** Whether accumulating is worthwhile right now (soft sun, camera at rest). */ + active: () => boolean; + /** Whether a settled result may cover a temporary content-loading gap. */ + retainSettledFrame: () => boolean; + /** Re-aim the scene's lights for the given accumulation round. */ + prepareRound: (round: number) => void; + /** Restore the non-jittered scene state before drawing the visible frame. */ + finishRound?: () => void; + rounds: number; + /** Maximum offscreen pixels used by each accumulation target. */ + maxRenderTargetPixels?: number; +}; + +export interface SharedThreeSceneLayer extends CustomLayerInterface { + addRuntime: (runtime: SharedThreeSceneRuntime) => void; + removeRuntime: (runtimeId: string) => void; + hasRuntime: (runtimeId: string) => boolean; + getScene: () => THREE.Scene; + /** Runtimes currently attached to the shared scene, including local terrain. */ + getRuntimes: () => readonly SharedThreeSceneRuntime[]; + /** Renderer owned by the mounted MapLibre custom layer, if it is active. */ + getRenderer: () => THREE.WebGLRenderer | null; + /** + * Progressive refinement at rest: while the controller reports itself + * active and its epoch and the camera hold still, the layer renders one + * jittered round per frame into an accumulation buffer and composites the + * running average; after the configured rounds, frames become a blit. + * Pass null to return to direct rendering. + */ + setAccumulationController: ( + controller: SharedSceneAccumulationController | null + ) => void; + /** Enable capture and projection of the preceding MapLibre style pass. */ + setMapStyleProjectionVisible?: (visible: boolean) => void; + /** Diagnostics: what the map-style projection did in the last frame. */ + getMapStyleProjectionState?: () => MapStyleProjectionState; + projectLngLatToScene: ( + lngLat: [number, number], + altitudeMeters?: number, + target?: THREE.Vector3 + ) => THREE.Vector3 | null; + /** Inverse of projectLngLatToScene for a shared-scene world position. */ + projectSceneToLngLat: ( + position: THREE.Vector3 | readonly [number, number, number] + ) => [longitude: number, latitude: number] | null; + /** Detach the custom layer without destroying runtimes preserved across HMR. */ + detach: () => void; + dispose: () => void; +} + +export interface SharedThreeSceneLayerOptions { + ambientLightIntensity?: number; +} + +type DepthRange = readonly [near: number, far: number]; + +type RenderTargetDepthRangeBridge = { + render: (depthRange: DepthRange, callback: () => void) => void; + dispose: () => void; +}; + +type SharedCanvasViewportRenderer = Pick; + +type MapStyleProjectionUniforms = Readonly<{ + texture: { value: THREE.Texture | null }; + sceneToClip: { value: THREE.Matrix4 }; + enabled: { value: number }; + /** MapLibre's packed terrain depth of the same frame, for overlay occlusion. */ + depthTexture: { value: THREE.Texture | null }; + depthEnabled: { value: number }; + /** Near and far plane of the MapLibre camera that wrote that depth. */ + depthNearFar: { value: THREE.Vector2 }; +}>; + +/** + * The internal pieces of MapLibre's terrain that hold the DEM depth pass: + * `Terrain.getFramebuffer("depth")` renders the DEM with `terrainDepth`, which + * packs `gl_Position.z / gl_Position.w` into RGBA8 (see terrain_depth.fragment). + */ +type MapLibreTerrainDepthHost = { + terrain?: { + _fboDepthTexture?: { texture?: WebGLTexture | null } | null; + } | null; + transform?: { nearZ?: number; farZ?: number }; +}; + +export type MapStyleProjectionBlend = "replace" | "overlay"; + +export type MapStyleProjectionState = Readonly<{ + visible: boolean; + enabled: boolean; + depthEnabled: boolean; + depthNearFar: readonly [number, number]; + receivers: Readonly>; + frames: number; +}>; + +type MapStyleProjectionMaterialState = { + uniforms: MapStyleProjectionUniforms; + blend: MapStyleProjectionBlend; +}; + +const MAP_STYLE_PROJECTION_STATE = "carmaMapStyleProjectionState"; +const MAP_STYLE_PROJECTION_SHADER_KEY = "|carma-map-style-projection-v2"; +const MAP_STYLE_PROJECTION_OVERLAY_DEFINE = "CARMA_MAP_STYLE_OVERLAY"; + +const MAP_STYLE_PROJECTION_VERTEX_HEADER = /* glsl */ ` +uniform mat4 carmaMapStyleSceneToClip; +varying vec4 vCarmaMapStyleClip; +`; + +const MAP_STYLE_PROJECTION_VERTEX_BODY = /* glsl */ ` +#include +vCarmaMapStyleClip = carmaMapStyleSceneToClip * modelMatrix * vec4( transformed, 1.0 ); +`; + +const MAP_STYLE_PROJECTION_FRAGMENT_HEADER = /* glsl */ ` +uniform sampler2D carmaMapStyleTexture; +uniform float carmaMapStyleEnabled; +uniform sampler2D carmaMapStyleDepthTexture; +uniform float carmaMapStyleDepthEnabled; +uniform vec2 carmaMapStyleDepthNearFar; +varying vec4 vCarmaMapStyleClip; +#ifdef CARMA_MAP_STYLE_OVERLAY +// Draped label picked up in map_fragment, composited after lighting. +float carmaMapStyleLabelCoverage = 0.0; +vec3 carmaMapStyleLabelColor = vec3( 0.0 ); +#endif + +// MapLibre packs the DEM depth (clip z / w) into RGBA8, see terrain_depth.fragment. +float carmaMapStyleUnpackDepth( vec4 packed ) { + return dot( packed, vec4( 1.0 / 16777216.0, 1.0 / 65536.0, 1.0 / 256.0, 1.0 ) ); +} + +float carmaMapStyleLinearDepth( float ndcZ ) { + float near = carmaMapStyleDepthNearFar.x; + float far = carmaMapStyleDepthNearFar.y; + return 2.0 * near * far / ( far + near - ndcZ * ( far - near ) ); +} + +// The screen projection paints every surface along the view ray. A label +// drawn on the DEM ground belongs only to mesh surfaces at that ground: a +// roof or facade nearer to the camera than the DEM at the same pixel stays +// clean, so the label reads as baked on the street and occluded by buildings. +bool carmaMapStyleOccludedByMesh( vec2 uv ) { + if ( carmaMapStyleDepthEnabled < 0.5 ) return false; + float groundZ = carmaMapStyleUnpackDepth( texture2D( carmaMapStyleDepthTexture, uv ) ); + if ( groundZ <= 0.0 ) return false; + float fragmentZ = vCarmaMapStyleClip.z / vCarmaMapStyleClip.w; + float groundDistance = carmaMapStyleLinearDepth( groundZ ); + float fragmentDistance = carmaMapStyleLinearDepth( fragmentZ ); + float tolerance = max( 1.5, 0.02 * groundDistance ); + return fragmentDistance < groundDistance - tolerance; +} + +vec3 carmaMapStyleSRGBToLinear( vec3 value ) { + return mix( + pow( value * 0.9478672986 + vec3( 0.0521327014 ), vec3( 2.4 ) ), + value * 0.0773993808, + vec3( lessThanEqual( value, vec3( 0.04045 ) ) ) + ); +} +`; + +const MAP_STYLE_PROJECTION_FRAGMENT_OUTPUT = /* glsl */ ` +#ifdef CARMA_MAP_STYLE_OVERLAY +if ( carmaMapStyleLabelCoverage > 0.0 ) { + // Keep the surface's own light and shadow ratio (how much brighter or + // darker lighting made the albedo) and apply it to the label color, so + // the text stays the sun color in the light and darkens in shadow + // without blowing out. + const vec3 carmaLuma = vec3( 0.2126, 0.7152, 0.0722 ); + float carmaAlbedo = max( dot( diffuseColor.rgb, carmaLuma ), 1e-3 ); + float carmaLit = dot( outgoingLight, carmaLuma ); + float carmaShade = clamp( carmaLit / carmaAlbedo, 0.35, 1.0 ); + outgoingLight = mix( + outgoingLight, + carmaMapStyleLabelColor * carmaShade, + carmaMapStyleLabelCoverage + ); +} +#endif +#include +`; + +const MAP_STYLE_PROJECTION_FRAGMENT_BODY = /* glsl */ ` +#include +if ( carmaMapStyleEnabled > 0.5 && vCarmaMapStyleClip.w > 0.0 ) { + vec2 carmaMapStyleUv = vCarmaMapStyleClip.xy / vCarmaMapStyleClip.w * 0.5 + 0.5; + if ( + all( greaterThanEqual( carmaMapStyleUv, vec2( 0.0 ) ) ) && + all( lessThanEqual( carmaMapStyleUv, vec2( 1.0 ) ) ) + ) { + vec4 carmaMapStyleSample = texture2D( + carmaMapStyleTexture, + carmaMapStyleUv + ); +#ifdef CARMA_MAP_STYLE_OVERLAY + // MapLibre leaves premultiplied color in the framebuffer. Straighten it, + // linearize and composite it over the receiver's own texture so a + // label-only capture keeps the mesh texture visible in between. + if ( carmaMapStyleSample.a > 0.0 && !carmaMapStyleOccludedByMesh( carmaMapStyleUv ) ) { + vec3 carmaMapStyleStraight = carmaMapStyleSRGBToLinear( + clamp( carmaMapStyleSample.rgb / carmaMapStyleSample.a, 0.0, 1.0 ) + ); + // Glyph and halo bodies land at full coverage; only the antialiased + // rim keeps a partial blend, so the draped text reads solid. The color + // is applied after lighting (see the opaque stage below): fed in as + // albedo it would clip to white under direct sun. + carmaMapStyleLabelCoverage = smoothstep( 0.15, 0.55, carmaMapStyleSample.a ); + carmaMapStyleLabelColor = carmaMapStyleStraight; + } +#else + diffuseColor.rgb = carmaMapStyleSRGBToLinear( + carmaMapStyleSample.rgb + ); + diffuseColor.a = 1.0; +#endif + } +} +`; + +/** + * Add a stable screen projection of MapLibre's preceding ground pass to a + * terrain material. The projected color enters before Lambert lighting, so + * terrain and the style draped onto it receive the same Three.js shadows. + */ +const applyMapStyleProjectionBlend = ( + material: THREE.Material, + blend: MapStyleProjectionBlend +): void => { + const defines = (material.defines ??= {}); + if (blend === "overlay") { + defines[MAP_STYLE_PROJECTION_OVERLAY_DEFINE] = ""; + } else { + delete defines[MAP_STYLE_PROJECTION_OVERLAY_DEFINE]; + } +}; + +export const configureMapStyleProjectedMaterial = ( + material: THREE.Material, + uniforms: MapStyleProjectionUniforms, + blend: MapStyleProjectionBlend = "replace" +): void => { + const userData = material.userData as Record; + const existing = userData[MAP_STYLE_PROJECTION_STATE] as + | MapStyleProjectionMaterialState + | undefined; + if (existing) { + if (existing.uniforms !== uniforms) { + existing.uniforms = uniforms; + material.needsUpdate = true; + } + if (existing.blend !== blend) { + existing.blend = blend; + applyMapStyleProjectionBlend(material, blend); + material.needsUpdate = true; + } + return; + } + + const state: MapStyleProjectionMaterialState = { uniforms, blend }; + userData[MAP_STYLE_PROJECTION_STATE] = state; + applyMapStyleProjectionBlend(material, blend); + const previousOnBeforeCompile = material.onBeforeCompile.bind(material); + const previousProgramCacheKey = material.customProgramCacheKey.bind(material); + material.onBeforeCompile = (shader, renderer) => { + previousOnBeforeCompile(shader, renderer); + shader.uniforms["carmaMapStyleTexture"] = state.uniforms.texture; + shader.uniforms["carmaMapStyleSceneToClip"] = state.uniforms.sceneToClip; + shader.uniforms["carmaMapStyleEnabled"] = state.uniforms.enabled; + shader.uniforms["carmaMapStyleDepthTexture"] = state.uniforms.depthTexture; + shader.uniforms["carmaMapStyleDepthEnabled"] = state.uniforms.depthEnabled; + shader.uniforms["carmaMapStyleDepthNearFar"] = state.uniforms.depthNearFar; + shader.vertexShader = shader.vertexShader + .replace( + "#include ", + `#include ${MAP_STYLE_PROJECTION_VERTEX_HEADER}` + ) + .replace("#include ", MAP_STYLE_PROJECTION_VERTEX_BODY); + shader.fragmentShader = shader.fragmentShader + .replace( + "#include ", + `#include ${MAP_STYLE_PROJECTION_FRAGMENT_HEADER}` + ) + .replace("#include ", MAP_STYLE_PROJECTION_FRAGMENT_BODY) + .replace( + "#include ", + MAP_STYLE_PROJECTION_FRAGMENT_OUTPUT + ); + }; + material.customProgramCacheKey = () => + `${previousProgramCacheKey()}${MAP_STYLE_PROJECTION_SHADER_KEY}|${ + state.blend + }`; + material.needsUpdate = true; +}; + +type OverlayDepthContext = Pick< + WebGLRenderingContext, + "DEPTH_BUFFER_BIT" | "clear" | "clearDepth" | "depthMask" | "depthRange" +>; + +type GroundClearContext = OverlayDepthContext & + Pick< + WebGLRenderingContext, + "COLOR_BUFFER_BIT" | "COLOR_CLEAR_VALUE" | "clearColor" | "getParameter" + >; + +/** Clear the shared framebuffer depth without disturbing MapLibre's range. */ +const clearSharedDepthBuffer = ( + gl: OverlayDepthContext, + mapLibreDepthRange: DepthRange +): void => { + gl.depthMask(true); + gl.depthRange(0, 1); + gl.clearDepth(1); + gl.clear(gl.DEPTH_BUFFER_BIT); + gl.depthRange(mapLibreDepthRange[0], mapLibreDepthRange[1]); +}; + +/** + * Remove MapLibre's captured ground pass from the shared framebuffer before + * Three draws the actual terrain. The color remains available through the + * framebuffer texture, but MapLibre's flat fill, DEM surface and skirts must + * not survive as a second visible ground surface. + */ +export const clearMapStyleGroundBeforeThreeTerrain = ( + gl: GroundClearContext, + mapLibreDepthRange: DepthRange +): void => { + const clearColor = gl.getParameter(gl.COLOR_CLEAR_VALUE) as Float32Array; + gl.depthMask(true); + gl.depthRange(0, 1); + gl.clearDepth(1); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + gl.clearColor(clearColor[0], clearColor[1], clearColor[2], clearColor[3]); + gl.depthRange(mapLibreDepthRange[0], mapLibreDepthRange[1]); +}; + +/** Let the explicitly retained place-label layers render above Three. */ +export const clearDepthForMapStyleOverlays = clearSharedDepthBuffer; + +/** + * Give the render camera the real local-scene pose while retaining MapLibre's + * exact scene-to-clip transform. + * + * MapLibre supplies the complete scene-to-clip matrix, whereas Three expects + * separate projection and view matrices. Compensating the projection by the + * camera world matrix keeps `projection * view` unchanged and makes Three's + * view-space shader inputs describe the synthesized map camera correctly. + */ +export const configureSharedRenderCamera = ( + renderCamera: THREE.PerspectiveCamera, + lodCamera: THREE.PerspectiveCamera, + sceneToClipMatrix: THREE.Matrix4 +): void => { + renderCamera.position.copy(lodCamera.position); + renderCamera.quaternion.copy(lodCamera.quaternion); + renderCamera.scale.copy(lodCamera.scale); + renderCamera.up.copy(lodCamera.up); + renderCamera.fov = lodCamera.fov; + renderCamera.aspect = lodCamera.aspect; + renderCamera.near = lodCamera.near; + renderCamera.far = lodCamera.far; + renderCamera.zoom = lodCamera.zoom; + renderCamera.focus = lodCamera.focus; + renderCamera.filmGauge = lodCamera.filmGauge; + renderCamera.filmOffset = lodCamera.filmOffset; + renderCamera.matrix.copy(lodCamera.matrix); + renderCamera.matrixWorld.copy(lodCamera.matrixWorld); + renderCamera.matrixWorldInverse.copy(lodCamera.matrixWorldInverse); + renderCamera.projectionMatrix + .copy(sceneToClipMatrix) + .multiply(renderCamera.matrixWorld); + renderCamera.projectionMatrixInverse + .copy(renderCamera.projectionMatrix) + .invert(); +}; + +/** + * Keep Three's main-framebuffer viewport in sync with the canvas MapLibre owns. + * + * WebGLRenderer snapshots the canvas dimensions when it is constructed. A + * later MapLibre resize changes the shared canvas drawing buffer without + * updating Three's private main viewport. After rendering a shadow map, Three + * would therefore restore that stale viewport and stretch or clip the scene. + * Updating only the viewport avoids calling `setSize`, which would write back + * to a canvas whose size lifecycle belongs to MapLibre. + */ +export const syncSharedCanvasViewport = ( + renderer: SharedCanvasViewportRenderer, + canvas: Pick, + viewport: THREE.Vector2 +): void => { + const width = Math.max(1, canvas.width); + const height = Math.max(1, canvas.height); + if (viewport.x === width && viewport.y === height) return; + viewport.set(width, height); + renderer.setViewport(0, 0, width, height); +}; + +/** + * Three.js does not track `gl.depthRange`. MapLibre intentionally compresses + * the main 3D depth range to leave room for later style layers, but that range + * must not leak into Three's offscreen shadow maps: their lookup coordinates + * are always normalized to [0, 1]. Route offscreen targets to the canonical + * range while preserving MapLibre's range for the shared main framebuffer. + */ +export const installRenderTargetDepthRangeBridge = ( + renderer: Pick, + gl: Pick< + WebGLRenderingContext, + | "depthRange" + | "getParameter" + | "bindFramebuffer" + | "FRAMEBUFFER" + | "FRAMEBUFFER_BINDING" + > +): RenderTargetDepthRangeBridge => { + const originalSetRenderTarget = renderer.setRenderTarget; + let activeDepthRange: DepthRange | null = null; + + renderer.setRenderTarget = function (...args) { + originalSetRenderTarget.apply(renderer, args); + if (!activeDepthRange) return; + if (args[0] === null) { + gl.depthRange(activeDepthRange[0], activeDepthRange[1]); + } else { + gl.depthRange(0, 1); + } + }; + + return { + render(depthRange, callback) { + // MapLibre may render custom layers into an internal framebuffer. Three + // does not know about it and setRenderTarget(null) binds the browser's + // default framebuffer after an offscreen shadow/accumulation pass. + const hostFramebuffer = gl.getParameter( + gl.FRAMEBUFFER_BINDING + ) as WebGLFramebuffer | null; + activeDepthRange = depthRange; + try { + callback(); + } finally { + activeDepthRange = null; + gl.bindFramebuffer(gl.FRAMEBUFFER, hostFramebuffer); + gl.depthRange(depthRange[0], depthRange[1]); + } + }, + dispose() { + activeDepthRange = null; + renderer.setRenderTarget = originalSetRenderTarget; + }, + }; +}; + +const rotationX = new THREE.Matrix4().makeRotationAxis( + new THREE.Vector3(1, 0, 0), + Math.PI / 2 +); + +/** + * One MapLibre custom layer and one Three.js scene for all streamed point and + * mesh content. Opaque meshes and transparent splats therefore share Three's + * render ordering and MapLibre's existing depth buffer in a single draw. + */ +export const buildSharedThreeSceneLayer = ( + layerId: string, + options: SharedThreeSceneLayerOptions = {} +): SharedThreeSceneLayer => { + const scene = new THREE.Scene(); + scene.add( + new THREE.AmbientLight(0xffffff, options.ambientLightIntensity ?? 2.4) + ); + const renderCamera = new THREE.PerspectiveCamera(); + const lodCamera = new THREE.PerspectiveCamera(); + let accumulationController: SharedSceneAccumulationController | null = null; + let accumulator: SharedSceneAccumulator | null = null; + let accumulatorRounds = 0; + let settledAccumulatorVisualKey = ""; + const jitterMatrix = new THREE.Matrix4(); + const unjitteredProjectionMatrix = new THREE.Matrix4(); + const viewport = new THREE.Vector2(1, 1); + const lookTarget = new THREE.Vector3(); + const mapStyleProjectionUniforms: MapStyleProjectionUniforms = { + texture: { value: null }, + sceneToClip: { value: new THREE.Matrix4() }, + enabled: { value: 0 }, + depthTexture: { value: null }, + depthEnabled: { value: 0 }, + depthNearFar: { value: new THREE.Vector2(1, 1000) }, + }; + let mapStyleFramebufferTexture: THREE.FramebufferTexture | null = null; + // A Three texture handle that borrows MapLibre's terrain depth WebGLTexture + // for the frame instead of copying it. + let mapStyleDepthTexture: THREE.Texture | null = null; + let mapStyleDepthGlTexture: WebGLTexture | null = null; + const mapStyleProjectionVersions = new Map(); + const mapStyleProjectionReceivers = new Map(); + const runtimes = new Map(); + let runtimeUpdateOrder: SharedThreeSceneRuntime[] = []; + let map: MaplibreMap | null = null; + let renderer: THREE.WebGLRenderer | null = null; + let depthRangeBridge: RenderTargetDepthRangeBridge | null = null; + let originMerc: MercatorCoordinate | null = null; + let meterScale = 0; + let mapStyleProjectionVisible = true; + let mapStyleProjectionEpoch = 0; + let renderedFrames = 0; + let disposed = false; + + const placeRuntime = (runtime: SharedThreeSceneRuntime) => { + if (!originMerc || meterScale <= 0) return; + const runtimeOrigin = MercatorCoordinate.fromLngLat( + runtime.originLngLat, + 0 + ); + const runtimeScale = runtimeOrigin.meterInMercatorCoordinateUnits(); + runtime.root.position.set( + (runtimeOrigin.x - originMerc.x) / meterScale, + (runtimeOrigin.z - originMerc.z) / meterScale, + (runtimeOrigin.y - originMerc.y) / meterScale + ); + runtime.root.scale.setScalar(runtimeScale / meterScale); + runtime.root.updateMatrixWorld(true); + }; + + const configureMapStyleProjection = (): boolean => { + for (const runtime of runtimes.values()) { + const receiver = runtime.receivesMapStyleTexture; + if (!receiver) continue; + const version = runtime.mapStyleProjectionVersion?.() ?? 0; + if (mapStyleProjectionVersions.get(runtime.id) === version) continue; + let configured = false; + runtime.root.traverse((object) => { + if (!(object instanceof THREE.Mesh)) return; + const materials = Array.isArray(object.material) + ? object.material + : [object.material]; + for (const material of materials) { + if (typeof receiver === "function" && !receiver(material)) continue; + configureMapStyleProjectedMaterial( + material, + mapStyleProjectionUniforms, + runtime.mapStyleProjectionBlend ?? "replace" + ); + configured = true; + } + }); + mapStyleProjectionVersions.set(runtime.id, version); + mapStyleProjectionReceivers.set(runtime.id, configured); + } + return [...mapStyleProjectionReceivers.values()].some(Boolean); + }; + + const captureMapStyleFramebuffer = () => { + if (!renderer || viewport.x < 1 || viewport.y < 1) return; + const width = Math.floor(viewport.x); + const height = Math.floor(viewport.y); + if ( + !mapStyleFramebufferTexture || + mapStyleFramebufferTexture.image.width !== width || + mapStyleFramebufferTexture.image.height !== height + ) { + mapStyleFramebufferTexture?.dispose(); + mapStyleFramebufferTexture = new THREE.FramebufferTexture(width, height); + mapStyleFramebufferTexture.minFilter = THREE.LinearFilter; + mapStyleFramebufferTexture.magFilter = THREE.LinearFilter; + mapStyleProjectionUniforms.texture.value = mapStyleFramebufferTexture; + } + renderer.copyFramebufferToTexture(mapStyleFramebufferTexture); + mapStyleProjectionUniforms.enabled.value = 1; + }; + + /** + * Borrow MapLibre's terrain depth pass of this frame. It is re-rendered + * whenever the camera moves or terrain tiles arrive, so it describes the + * same DEM ground that the captured labels were drawn on. + */ + const bindMapStyleDepth = () => { + const host = map as unknown as MapLibreTerrainDepthHost | null; + const glTexture = host?.terrain?._fboDepthTexture?.texture ?? null; + const near = host?.transform?.nearZ; + const far = host?.transform?.farZ; + if ( + !renderer || + !glTexture || + typeof near !== "number" || + typeof far !== "number" || + !(far > near && near > 0) + ) { + mapStyleProjectionUniforms.depthEnabled.value = 0; + return; + } + if (!mapStyleDepthTexture) { + mapStyleDepthTexture = new THREE.Texture(); + mapStyleDepthTexture.minFilter = THREE.NearestFilter; + mapStyleDepthTexture.magFilter = THREE.NearestFilter; + mapStyleDepthTexture.generateMipmaps = false; + mapStyleDepthTexture.flipY = false; + mapStyleProjectionUniforms.depthTexture.value = mapStyleDepthTexture; + } + if (mapStyleDepthGlTexture !== glTexture) { + mapStyleDepthGlTexture = glTexture; + const properties = renderer.properties.get(mapStyleDepthTexture) as { + __webglTexture?: WebGLTexture; + __webglInit?: boolean; + __version?: number; + }; + properties.__webglTexture = glTexture; + properties.__webglInit = true; + properties.__version = mapStyleDepthTexture.version; + } + mapStyleProjectionUniforms.depthNearFar.value.set(near, far); + mapStyleProjectionUniforms.depthEnabled.value = 1; + }; + + const releaseMapStyleDepth = () => { + if (mapStyleDepthTexture && renderer) { + // The WebGLTexture belongs to MapLibre; drop the handle without + // letting Three delete it. + const properties = renderer.properties.get(mapStyleDepthTexture) as { + __webglTexture?: WebGLTexture; + }; + delete properties.__webglTexture; + renderer.properties.remove(mapStyleDepthTexture); + } + mapStyleDepthTexture = null; + mapStyleDepthGlTexture = null; + mapStyleProjectionUniforms.depthTexture.value = null; + mapStyleProjectionUniforms.depthEnabled.value = 0; + }; + + const layer: SharedThreeSceneLayer = { + id: layerId, + type: "custom", + renderingMode: "3d", + + addRuntime(runtime) { + if (disposed) return; + const existing = runtimes.get(runtime.id); + if (existing === runtime) return; + if (existing) layer.removeRuntime(existing.id); + runtimes.set(runtime.id, runtime); + mapStyleProjectionVersions.delete(runtime.id); + mapStyleProjectionReceivers.delete(runtime.id); + runtimeUpdateOrder = [...runtimes.values()].sort( + (a, b) => (b.updatePriority ?? 0) - (a.updatePriority ?? 0) + ); + scene.add(runtime.root); + placeRuntime(runtime); + if (map) runtime.onAdd?.(map); + map?.triggerRepaint(); + }, + + removeRuntime(runtimeId) { + const runtime = runtimes.get(runtimeId); + if (!runtime) return; + runtimes.delete(runtimeId); + mapStyleProjectionVersions.delete(runtimeId); + mapStyleProjectionReceivers.delete(runtimeId); + runtimeUpdateOrder = runtimeUpdateOrder.filter( + (candidate) => candidate !== runtime + ); + scene.remove(runtime.root); + runtime.dispose(); + map?.triggerRepaint(); + }, + + hasRuntime(runtimeId) { + return runtimes.has(runtimeId); + }, + + getScene() { + return scene; + }, + + getRuntimes() { + return [...runtimes.values()]; + }, + + getRenderer() { + return renderer; + }, + + setAccumulationController(controller) { + accumulationController = controller; + if (!controller) { + accumulator?.dispose(); + accumulator = null; + } + }, + + getMapStyleProjectionState() { + return { + visible: mapStyleProjectionVisible, + enabled: mapStyleProjectionUniforms.enabled.value === 1, + depthEnabled: mapStyleProjectionUniforms.depthEnabled.value === 1, + depthNearFar: [ + mapStyleProjectionUniforms.depthNearFar.value.x, + mapStyleProjectionUniforms.depthNearFar.value.y, + ], + receivers: Object.fromEntries(mapStyleProjectionReceivers), + frames: renderedFrames, + }; + }, + + setMapStyleProjectionVisible(visible) { + if (mapStyleProjectionVisible === visible) return; + mapStyleProjectionVisible = visible; + mapStyleProjectionEpoch += 1; + if (!visible) mapStyleProjectionUniforms.enabled.value = 0; + map?.triggerRepaint(); + }, + + projectLngLatToScene( + lngLat, + altitudeMeters = 0, + target = new THREE.Vector3() + ) { + if (!originMerc || meterScale <= 0) return null; + const coordinate = MercatorCoordinate.fromLngLat(lngLat, altitudeMeters); + return target.set( + (coordinate.x - originMerc.x) / meterScale, + (coordinate.z - originMerc.z) / meterScale, + (coordinate.y - originMerc.y) / meterScale + ); + }, + + projectSceneToLngLat(position) { + if (!originMerc || meterScale <= 0) return null; + const [x, y, z] = + position instanceof THREE.Vector3 + ? [position.x, position.y, position.z] + : position; + const coordinate = new MercatorCoordinate( + originMerc.x + x * meterScale, + originMerc.y + z * meterScale, + originMerc.z + y * meterScale + ); + const lngLat = coordinate.toLngLat(); + return [lngLat.lng, lngLat.lat]; + }, + + detach() { + for (const runtime of runtimes.values()) scene.remove(runtime.root); + depthRangeBridge?.dispose(); + depthRangeBridge = null; + releaseMapStyleDepth(); + renderer?.dispose(); + renderer = null; + mapStyleFramebufferTexture?.dispose(); + mapStyleFramebufferTexture = null; + mapStyleProjectionUniforms.texture.value = null; + mapStyleProjectionUniforms.enabled.value = 0; + mapStyleProjectionVersions.clear(); + mapStyleProjectionReceivers.clear(); + map = null; + originMerc = null; + meterScale = 0; + }, + + onAdd(mapInstance, gl) { + map = mapInstance; + const center = mapInstance.getCenter(); + originMerc = MercatorCoordinate.fromLngLat([center.lng, center.lat], 0); + meterScale = originMerc.meterInMercatorCoordinateUnits(); + renderer = new THREE.WebGLRenderer({ + canvas: mapInstance.getCanvas(), + context: gl, + }); + depthRangeBridge = installRenderTargetDepthRangeBridge(renderer, gl); + renderer.autoClear = false; + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFShadowMap; + for (const runtime of runtimes.values()) { + if (runtime.root.parent !== scene) scene.add(runtime.root); + placeRuntime(runtime); + runtime.onAdd?.(mapInstance); + } + }, + + render(gl, options: CustomRenderMethodInput) { + if (!map || !renderer || !originMerc || meterScale <= 0) return; + renderedFrames += 1; + + const mainMatrix = new THREE.Matrix4().fromArray( + options.defaultProjectionData.mainMatrix as unknown as number[] + ); + const localFromScene = new THREE.Matrix4() + .makeTranslation(originMerc.x, originMerc.y, originMerc.z) + .scale(new THREE.Vector3(meterScale, -meterScale, meterScale)) + .multiply(rotationX); + const sceneToClipMatrix = mainMatrix.multiply(localFromScene); + + syncSharedCanvasViewport(renderer, map.getCanvas(), viewport); + // Same pose the MapLibre 3D Tiles layer works out for itself, so it + // lives in the engine rather than here, see synthesizeLodCamera. + const centerLngLat = map.getCenter(); + const mapLibreTerrainElevation = map.getTerrain() + ? map.queryTerrainElevation(centerLngLat) ?? + map.getCameraTargetElevation() + : 0; + if ( + !synthesizeLodCamera( + lodCamera, + map, + { + originMerc, + meterScale, + viewport, + centerElevationMeters: mapLibreTerrainElevation, + }, + lookTarget + ) + ) { + return; + } + configureSharedRenderCamera(renderCamera, lodCamera, sceneToClipMatrix); + + const frame: SharedThreeSceneFrame = { + map, + renderCamera, + lodCamera, + lookTarget, + viewport, + }; + scene.updateMatrixWorld(true); + for (const runtime of runtimeUpdateOrder) { + runtime.update(frame); + } + scene.updateMatrixWorld(true); + + const currentDepthRange = gl.getParameter(gl.DEPTH_RANGE) as Float32Array; + const savedDepthRange: DepthRange = [ + currentDepthRange[0], + currentDepthRange[1], + ]; + renderer.resetState(); + gl.depthRange(savedDepthRange[0], savedDepthRange[1]); + mapStyleProjectionUniforms.sceneToClip.value.copy(sceneToClipMatrix); + if (mapStyleProjectionVisible && configureMapStyleProjection()) { + try { + captureMapStyleFramebuffer(); + bindMapStyleDepth(); + } catch (error) { + mapStyleProjectionUniforms.enabled.value = 0; + mapStyleProjectionUniforms.depthEnabled.value = 0; + console.warn("[shared-three-scene] map-style capture failed", error); + } + } else { + mapStyleProjectionUniforms.enabled.value = 0; + } + if ( + runtimeUpdateOrder.some( + (runtime) => + runtime.providesTerrain === true || + Boolean(runtime.receivesMapStyleTexture) + ) + ) { + // The visible ground now belongs to Three. Keep MapLibre's color only + // in the captured texture; discard its competing fill, DEM and skirts. + clearMapStyleGroundBeforeThreeTerrain(gl, savedDepthRange); + } + + const accumulation = accumulationController; + const poseKey = [ + ...renderCamera.matrixWorld.elements, + ...renderCamera.projectionMatrix.elements, + ] + .map((value) => value.toPrecision(6)) + .join(","); + const visualKey = accumulation + ? `${accumulation.visualEpoch()}|${mapStyleProjectionEpoch}|${poseKey}` + : ""; + if (accumulation?.active() && renderer && !accumulator?.broken) { + if (accumulator && accumulatorRounds !== accumulation.rounds) { + accumulator.dispose(); + accumulator = null; + settledAccumulatorVisualKey = ""; + } + if (!accumulator) { + accumulator = buildSharedSceneAccumulator(accumulation.rounds); + accumulatorRounds = accumulation.rounds; + } + accumulator.ensureState( + `${accumulation.epoch()}|${mapStyleProjectionEpoch}|${poseKey}` + ); + const retainSettled = + accumulator.hasSettledFrame && + settledAccumulatorVisualKey === visualKey; + const drawingBuffer = renderer.getDrawingBufferSize( + new THREE.Vector2() + ); + const accumulationSize = fitRenderTargetSizeToPixelBudget( + drawingBuffer.x, + drawingBuffer.y, + accumulation.maxRenderTargetPixels ?? Number.POSITIVE_INFINITY + ); + if (!accumulator.converged) { + const round = accumulator.nextRound; + accumulation.prepareRound(round); + // Sub-pixel camera jitter per round: with the geometry at rest the + // average is straight supersampling, which also wins back the + // antialiasing the offscreen target lacks. + const jitter = accumulator.jitterFor(round); + jitterMatrix.makeTranslation( + (jitter.x * 2) / accumulationSize.width, + (jitter.y * 2) / accumulationSize.height, + 0 + ); + const activeRenderer = renderer; + unjitteredProjectionMatrix.copy(renderCamera.projectionMatrix); + renderCamera.projectionMatrix.premultiply(jitterMatrix); + renderCamera.projectionMatrixInverse + .copy(renderCamera.projectionMatrix) + .invert(); + try { + depthRangeBridge?.render(savedDepthRange, () => { + accumulator?.renderRound( + activeRenderer, + accumulationSize.width, + accumulationSize.height, + () => activeRenderer.render(scene, renderCamera) + ); + }); + } finally { + renderCamera.projectionMatrix.copy(unjitteredProjectionMatrix); + renderCamera.projectionMatrixInverse + .copy(unjitteredProjectionMatrix) + .invert(); + accumulation.finishRound?.(); + } + if (accumulator.converged) { + settledAccumulatorVisualKey = visualKey; + } + } + let composited = false; + depthRangeBridge?.render(savedDepthRange, () => { + if (renderer) { + composited = + accumulator?.composite(renderer, retainSettled) ?? false; + } + }); + if (!composited) { + depthRangeBridge?.render(savedDepthRange, () => { + renderer?.render(scene, renderCamera); + }); + } + if (!accumulator.converged) map.triggerRepaint(); + } else { + const retainSettled = + accumulation?.retainSettledFrame() === true && + accumulator?.hasSettledFrame === true && + settledAccumulatorVisualKey === visualKey; + let composited = false; + depthRangeBridge?.render(savedDepthRange, () => { + if (retainSettled && renderer) { + composited = accumulator?.composite(renderer, true) ?? false; + } + }); + if (!composited) { + depthRangeBridge?.render(savedDepthRange, () => { + renderer?.render(scene, renderCamera); + }); + } + } + clearDepthForMapStyleOverlays(gl, savedDepthRange); + }, + + onRemove() { + depthRangeBridge?.dispose(); + depthRangeBridge = null; + releaseMapStyleDepth(); + renderer?.dispose(); + renderer = null; + mapStyleFramebufferTexture?.dispose(); + mapStyleFramebufferTexture = null; + mapStyleProjectionUniforms.texture.value = null; + mapStyleProjectionUniforms.enabled.value = 0; + mapStyleProjectionVersions.clear(); + mapStyleProjectionReceivers.clear(); + map = null; + }, + + dispose() { + if (disposed) return; + disposed = true; + accumulator?.dispose(); + accumulator = null; + mapStyleFramebufferTexture?.dispose(); + mapStyleFramebufferTexture = null; + mapStyleProjectionUniforms.texture.value = null; + mapStyleProjectionUniforms.enabled.value = 0; + mapStyleProjectionVersions.clear(); + mapStyleProjectionReceivers.clear(); + for (const runtime of runtimes.values()) runtime.dispose(); + runtimes.clear(); + scene.clear(); + depthRangeBridge?.dispose(); + depthRangeBridge = null; + renderer?.dispose(); + renderer = null; + map = null; + }, + }; + + return layer; +}; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-registry.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-registry.spec.ts new file mode 100644 index 0000000000..66c1cfb9a2 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-registry.spec.ts @@ -0,0 +1,1152 @@ +// @vitest-environment node + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { MAPLIBRE_EVENT } from "../../../constants/mapEvents"; + +vi.mock("./shared-three-scene-layer", () => ({ + buildSharedThreeSceneLayer: vi.fn(), +})); + +import { buildSharedThreeSceneLayer } from "./shared-three-scene-layer"; +import { acquireSharedThreeScene } from "./shared-three-scene-registry"; + +describe("shared Three.js scene registry", () => { + const dispose = vi.fn(); + const sharedLayer = { + id: "carma-shared-three-scene", + addRuntime: vi.fn(), + removeRuntime: vi.fn(), + getScene: vi.fn(), + getRuntimes: vi.fn(() => []), + getRenderer: vi.fn(), + projectSceneToLngLat: vi.fn( + (position: readonly [number, number, number]) => + [position[0], position[2]] as [number, number] + ), + dispose, + }; + + beforeEach(() => { + vi.clearAllMocks(); + // Label overlay maintenance is rate limited; drive its trailing pass + // deterministically. + vi.useFakeTimers(); + sharedLayer.getRuntimes.mockReturnValue([]); + vi.mocked(buildSharedThreeSceneLayer).mockReturnValue(sharedLayer as never); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("shares one layer and disposes it after the final lease", () => { + const listeners = new Map void>(); + const addLayer = vi.fn(); + const removeLayer = vi.fn(); + let attached = false; + addLayer.mockImplementation(() => { + attached = true; + }); + removeLayer.mockImplementation(() => { + attached = false; + }); + const map = { + isStyleLoaded: vi.fn(() => true), + getStyle: vi.fn(() => ({ + layers: [ + { id: "basemap", type: "raster" }, + { id: "roads", type: "line" }, + { id: "labels", type: "symbol" }, + ], + })), + getLayer: vi.fn(() => (attached ? sharedLayer : undefined)), + addLayer, + removeLayer, + on: vi.fn((event: string, handler: () => void) => { + listeners.set(event, handler); + }), + off: vi.fn((event: string) => { + listeners.delete(event); + }), + }; + + const first = acquireSharedThreeScene(map as never); + const second = acquireSharedThreeScene(map as never); + + expect(first.layer).toBe(second.layer); + expect(buildSharedThreeSceneLayer).toHaveBeenCalledOnce(); + expect(addLayer).toHaveBeenCalledWith(sharedLayer); + + first.release(); + expect(dispose).not.toHaveBeenCalled(); + + second.release(); + expect(removeLayer).toHaveBeenCalledWith(sharedLayer.id); + expect(dispose).toHaveBeenCalledOnce(); + expect(listeners.has("styledata")).toBe(false); + expect(listeners.has(MAPLIBRE_EVENT.STYLE_LOAD)).toBe(false); + expect(listeners.has("idle")).toBe(false); + }); + + it("adds the layer while sources keep the style in a loading state", () => { + const addLayer = vi.fn(); + let attached = false; + addLayer.mockImplementation(() => { + attached = true; + }); + const map = { + isStyleLoaded: vi.fn(() => false), + getStyle: vi.fn(() => ({ layers: [] })), + getLayer: vi.fn(() => (attached ? sharedLayer : undefined)), + addLayer, + removeLayer: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }; + + const lease = acquireSharedThreeScene(map as never); + + expect(addLayer).toHaveBeenCalledWith(sharedLayer); + lease.release(); + }); + + it("moves the shared layer after the full style and keeps point labels above it", () => { + const layers = [ + { id: "basemap", type: "raster" }, + { id: sharedLayer.id, type: "custom" }, + { id: "landcover", type: "fill" }, + { id: "roads", type: "line" }, + { + id: "road-labels", + type: "symbol", + "source-layer": "transportation_name", + layout: { "symbol-placement": "line" }, + }, + { + id: "autobahn-route-shields", + type: "symbol", + layout: { "symbol-placement": "point" }, + }, + { id: "place-city", type: "symbol", "source-layer": "place" }, + { + id: "house-numbers", + type: "symbol", + "source-layer": "Hausnummer", + }, + ]; + const moveLayer = vi.fn((id: string, beforeId?: string) => { + const currentIndex = layers.findIndex((layer) => layer.id === id); + const [current] = layers.splice(currentIndex, 1); + const beforeIndex = beforeId + ? layers.findIndex((layer) => layer.id === beforeId) + : layers.length; + layers.splice(beforeIndex, 0, current); + }); + const layout = new Map([ + ["place-city:text-offset", [0, 0]], + ]); + const paint = new Map([ + ["place-city:text-halo-width", 1.25], + ["place-city:text-halo-color", "rgba(255, 255, 255, 0.8)"], + ["place-city:text-color", "#223344"], + ["house-numbers:text-halo-color", "rgba(255, 255, 255, 0.8)"], + ["house-numbers:text-color", "#112233"], + ["autobahn-route-shields:text-color", "#ffffff"], + ["autobahn-route-shields:text-halo-color", "#003399"], + ]); + const map = { + getStyle: vi.fn(() => ({ + layers: layers.filter(({ id }) => id !== sharedLayer.id), + })), + getLayersOrder: vi.fn(() => layers.map(({ id }) => id)), + getLayer: vi.fn((id: string) => + id === sharedLayer.id + ? { implementation: sharedLayer } + : layers.find((layer) => layer.id === id) + ), + addLayer: vi.fn(), + moveLayer, + getLayoutProperty: vi.fn((id: string, property: string) => + layout.get(`${id}:${property}`) + ), + setLayoutProperty: vi.fn( + (id: string, property: string, value: unknown) => { + const key = `${id}:${property}`; + if (value == null) layout.delete(key); + else layout.set(key, value); + } + ), + getPaintProperty: vi.fn((id: string, property: string) => + paint.get(`${id}:${property}`) + ), + setPaintProperty: vi.fn( + (id: string, property: string, value: unknown) => { + const key = `${id}:${property}`; + if (value == null) paint.delete(key); + else paint.set(key, value); + } + ), + removeLayer: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }; + + const lease = acquireSharedThreeScene(map as never); + + expect(moveLayer).toHaveBeenCalledWith(sharedLayer.id); + expect(moveLayer).toHaveBeenCalledWith("place-city"); + expect(moveLayer).toHaveBeenCalledWith("house-numbers"); + expect(layers.map(({ id }) => id)).toEqual([ + "basemap", + "landcover", + "roads", + "road-labels", + sharedLayer.id, + "autobahn-route-shields", + "place-city", + "house-numbers", + ]); + expect(layout.get("place-city:text-offset")).toEqual([0, 0]); + expect(paint.get("place-city:text-translate-anchor")).toBe("viewport"); + expect(paint.get("place-city:text-halo-width")).toBe(1.25); + lease.setLocationLabelColor("#ffe0aa"); + vi.advanceTimersByTime(1000); + expect(paint.get("place-city:text-halo-width")).toBe(1.25); + expect(paint.get("place-city:text-halo-color")).toBe("#ffe0aa"); + expect(paint.get("place-city:text-color")).toBe("#223344"); + expect(paint.has("house-numbers:text-halo-width")).toBe(false); + expect(paint.get("house-numbers:text-halo-color")).toBe("#ffe0aa"); + expect(paint.get("house-numbers:text-color")).toBe("#112233"); + expect(paint.get("autobahn-route-shields:text-color")).toBe("#ffffff"); + expect(paint.get("autobahn-route-shields:text-halo-color")).toBe("#003399"); + lease.setPointLabelOverlayVisible(false); + expect(layout.get("place-city:visibility")).toBe("none"); + expect(layout.get("house-numbers:visibility")).toBe("none"); + expect(layout.get("autobahn-route-shields:visibility")).toBe("none"); + expect(paint.get("house-numbers:text-color")).toBe("#112233"); + lease.setPointLabelOverlayVisible(true); + expect(layout.has("place-city:visibility")).toBe(false); + expect(layout.has("house-numbers:visibility")).toBe(false); + expect(layout.has("autobahn-route-shields:visibility")).toBe(false); + expect(layers.slice(-4).map(({ id }) => id)).toEqual([ + sharedLayer.id, + "autobahn-route-shields", + "place-city", + "house-numbers", + ]); + lease.release(); + expect(layout.get("place-city:text-offset")).toEqual([0, 0]); + expect(paint.get("place-city:text-halo-width")).toBe(1.25); + expect(paint.get("place-city:text-halo-color")).toBe( + "rgba(255, 255, 255, 0.8)" + ); + expect(paint.get("place-city:text-color")).toBe("#223344"); + }); + + it("lifts basemap.de place names without lifting line labels", () => { + const layers = [ + { id: "basemap", type: "fill" }, + { id: sharedLayer.id, type: "custom" }, + { + id: "bg-basemap_relief::Name_Stadtgemeinde_bis_500000", + type: "symbol", + source: "bg-basemap_relief::basemap", + "source-layer": "Name_Punkt", + }, + { + id: "bg-basemap_relief::Name_Staatsgrenze", + type: "symbol", + source: "bg-basemap_relief::basemap", + "source-layer": "Name_Linie", + layout: { "symbol-placement": "line" }, + }, + ]; + const layout = new Map(); + const paint = new Map([ + [ + "bg-basemap_relief::Name_Stadtgemeinde_bis_500000:text-halo-color", + "rgba(255, 255, 255, 0.8)", + ], + [ + "bg-basemap_relief::Name_Stadtgemeinde_bis_500000:text-color", + "#334455", + ], + ]); + const map = { + getStyle: vi.fn(() => ({ + layers: layers.filter(({ id }) => id !== sharedLayer.id), + })), + getLayersOrder: vi.fn(() => layers.map(({ id }) => id)), + getLayer: vi.fn((id: string) => + id === sharedLayer.id + ? { implementation: sharedLayer } + : layers.find((layer) => layer.id === id) + ), + getLayoutProperty: vi.fn((id: string, property: string) => + layout.get(`${id}:${property}`) + ), + setLayoutProperty: vi.fn( + (id: string, property: string, value: unknown) => { + const key = `${id}:${property}`; + if (value == null) layout.delete(key); + else layout.set(key, value); + } + ), + getPaintProperty: vi.fn((id: string, property: string) => + paint.get(`${id}:${property}`) + ), + setPaintProperty: vi.fn( + (id: string, property: string, value: unknown) => { + const key = `${id}:${property}`; + if (value == null) paint.delete(key); + else paint.set(key, value); + } + ), + addLayer: vi.fn(), + moveLayer: vi.fn(), + removeLayer: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }; + + const lease = acquireSharedThreeScene(map as never); + lease.setLocationLabelColor("#fff2d8"); + vi.advanceTimersByTime(1000); + + expect( + layout.has("bg-basemap_relief::Name_Stadtgemeinde_bis_500000:text-offset") + ).toBe(false); + expect( + paint.get( + "bg-basemap_relief::Name_Stadtgemeinde_bis_500000:text-translate-anchor" + ) + ).toBe("viewport"); + expect( + paint.has( + "bg-basemap_relief::Name_Stadtgemeinde_bis_500000:text-halo-width" + ) + ).toBe(false); + expect( + paint.get( + "bg-basemap_relief::Name_Stadtgemeinde_bis_500000:text-halo-color" + ) + ).toBe("#fff2d8"); + expect( + paint.get("bg-basemap_relief::Name_Stadtgemeinde_bis_500000:text-color") + ).toBe("#334455"); + expect(layout.has("bg-basemap_relief::Name_Staatsgrenze:text-offset")).toBe( + false + ); + lease.release(); + }); + + it("styles street names, house numbers and water names for a textured mesh", () => { + const streetLayer = { + id: "bg-basemap_relief-Name_Kreis_Gemeindestr", + type: "symbol", + source: "bg-basemap_relief::basemap", + "source-layer": "Verkehrslinie", + layout: { "symbol-placement": { stops: [[13, "line"]] } }, + }; + const houseLayer = { + id: "bg-basemap_relief-Hauskoordinate", + type: "symbol", + source: "bg-basemap_relief::basemap", + "source-layer": "Hauskoordinate", + }; + const waterLayer = { + id: "bg-basemap_relief-Name_GewaesserF_See_klein", + type: "symbol", + source: "bg-basemap_relief::basemap", + "source-layer": "Gewaesserflaeche", + }; + const poiLayer = { + id: "bg-basemap_relief-Name_Gebaeude_oeffentlich", + type: "symbol", + source: "bg-basemap_relief::basemap", + "source-layer": "Gebaeudepunkt", + }; + const contourLabelLayer = { + id: "bg-basemap_relief-NameHL_Hoehenlinie_10er", + type: "symbol", + source: "bg-basemap_relief::basemap", + "source-layer": "Hoehenlinie", + layout: { "symbol-placement": "line" }, + }; + const shieldLayer = { + id: "bg-basemap_relief-Nummer_Bundesstr", + type: "symbol", + source: "bg-basemap_relief::basemap", + "source-layer": "Verkehrslinie", + layout: { "symbol-placement": "point" }, + }; + const contourLineLayer = { + id: "bg-basemap_relief-Hoehenlinie_10er", + type: "line", + source: "bg-basemap_relief::basemap", + "source-layer": "Hoehenlinie", + }; + const layers = [ + { id: "basemap", type: "fill" }, + contourLineLayer, + streetLayer, + { id: sharedLayer.id, type: "custom" }, + houseLayer, + waterLayer, + poiLayer, + shieldLayer, + contourLabelLayer, + ]; + const layout = new Map([ + [`${streetLayer.id}:text-size`, 13], + ]); + const paint = new Map([ + [`${streetLayer.id}:text-color`, "#333333"], + [`${streetLayer.id}:text-halo-color`, "#ffffff"], + [`${streetLayer.id}:text-halo-width`, 2], + [`${streetLayer.id}:text-halo-blur`, 0.5], + [`${houseLayer.id}:text-color`, "#222222"], + [`${houseLayer.id}:text-halo-color`, "rgba(255, 255, 255, 0.8)"], + [`${waterLayer.id}:text-color`, "#1f6fb2"], + [`${waterLayer.id}:text-halo-color`, "#ffffff"], + [`${waterLayer.id}:text-halo-width`, 1.5], + [`${poiLayer.id}:text-color`, "#444444"], + [`${poiLayer.id}:text-halo-color`, "#cccccc"], + [`${poiLayer.id}:icon-color`, "#ffffff"], + [`${shieldLayer.id}:text-color`, "#000000"], + [`${shieldLayer.id}:text-halo-color`, "#ffffff"], + [`${contourLabelLayer.id}:text-color`, "#666666"], + [`${contourLabelLayer.id}:text-halo-width`, 1], + [`${contourLineLayer.id}:line-opacity`, 0.8], + ]); + const map = { + getStyle: vi.fn(() => ({ + layers: layers.filter(({ id }) => id !== sharedLayer.id), + })), + getLayersOrder: vi.fn(() => layers.map(({ id }) => id)), + getLayer: vi.fn((id: string) => + id === sharedLayer.id + ? { implementation: sharedLayer } + : layers.find((layer) => layer.id === id) + ), + getLayoutProperty: vi.fn((id: string, property: string) => + layout.get(`${id}:${property}`) + ), + setLayoutProperty: vi.fn( + (id: string, property: string, value: unknown) => { + const key = `${id}:${property}`; + if (value == null) layout.delete(key); + else layout.set(key, value); + } + ), + getPaintProperty: vi.fn((id: string, property: string) => + paint.get(`${id}:${property}`) + ), + setPaintProperty: vi.fn( + (id: string, property: string, value: unknown) => { + const key = `${id}:${property}`; + if (value == null) paint.delete(key); + else paint.set(key, value); + } + ), + addLayer: vi.fn(), + moveLayer: vi.fn(), + removeLayer: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }; + + const lease = acquireSharedThreeScene(map as never); + lease.setLocationLabelColor("#fff2d8"); + vi.advanceTimersByTime(1000); + + // Bare terrain: the default rules only tint white halos with the sun. + expect(paint.get(`${streetLayer.id}:text-color`)).toBe("#333333"); + expect(paint.get(`${houseLayer.id}:text-halo-color`)).toBe("#fff2d8"); + expect(paint.get(`${waterLayer.id}:text-halo-width`)).toBe(1.5); + + lease.setMeshLabelStyle(true); + + expect(paint.get(`${streetLayer.id}:text-color`)).toBe("#ffffff"); + expect(paint.get(`${streetLayer.id}:text-halo-color`)).toBe("#808080"); + expect(paint.get(`${streetLayer.id}:text-halo-width`)).toBe(1.5); + expect(paint.get(`${streetLayer.id}:text-halo-blur`)).toBe(0); + expect(layout.get(`${streetLayer.id}:text-size`)).toBe(18.2); + expect(paint.get(`${houseLayer.id}:text-color`)).toBe("#fff2d8"); + expect(paint.get(`${houseLayer.id}:text-halo-color`)).toBe("#808080"); + expect(paint.get(`${waterLayer.id}:text-color`)).toBe("#1f6fb2"); + expect(paint.get(`${waterLayer.id}:text-halo-width`)).toBe(0); + expect(paint.get(`${poiLayer.id}:text-color`)).toBe("#fff2d8"); + expect(paint.get(`${poiLayer.id}:text-halo-color`)).toBe("#808080"); + expect(paint.get(`${poiLayer.id}:icon-color`)).toBe("#fff2d8"); + // Shields keep their authored text and halo in every mode. + expect(paint.get(`${shieldLayer.id}:text-color`)).toBe("#000000"); + expect(paint.get(`${shieldLayer.id}:text-halo-color`)).toBe("#ffffff"); + expect(paint.get(`${contourLabelLayer.id}:text-color`)).toBe("#fff2d8"); + expect(paint.get(`${contourLabelLayer.id}:text-halo-width`)).toBe(0); + // The drape below Three keeps symbols and contour lines only. + expect(layout.get("basemap:visibility")).toBe("none"); + expect(layout.has(`${contourLineLayer.id}:visibility`)).toBe(false); + expect(paint.get(`${contourLineLayer.id}:line-opacity`)).toBe(0.5); + expect(layout.has(`${streetLayer.id}:visibility`)).toBe(false); + + lease.setMeshLabelStyle(false); + + expect(paint.get(`${streetLayer.id}:text-color`)).toBe("#333333"); + expect(paint.get(`${streetLayer.id}:text-halo-color`)).toBe("#ffffff"); + expect(paint.get(`${streetLayer.id}:text-halo-width`)).toBe(2); + expect(paint.get(`${streetLayer.id}:text-halo-blur`)).toBe(0.5); + expect(layout.get(`${streetLayer.id}:text-size`)).toBe(13); + expect(paint.get(`${houseLayer.id}:text-color`)).toBe("#222222"); + expect(paint.get(`${houseLayer.id}:text-halo-color`)).toBe("#fff2d8"); + expect(paint.get(`${waterLayer.id}:text-halo-width`)).toBe(1.5); + expect(paint.get(`${poiLayer.id}:text-color`)).toBe("#444444"); + expect(paint.get(`${poiLayer.id}:text-halo-color`)).toBe("#cccccc"); + expect(paint.get(`${poiLayer.id}:icon-color`)).toBe("#ffffff"); + expect(paint.get(`${contourLabelLayer.id}:text-color`)).toBe("#666666"); + expect(paint.get(`${contourLabelLayer.id}:text-halo-width`)).toBe(1); + expect(layout.has("basemap:visibility")).toBe(false); + expect(paint.get(`${contourLineLayer.id}:line-opacity`)).toBe(0.8); + + lease.release(); + expect(paint.get(`${houseLayer.id}:text-halo-color`)).toBe( + "rgba(255, 255, 255, 0.8)" + ); + }); + + it("lifts place names by meters above the map center and tints white sprites", () => { + const placeLayer = { + id: "bg-basemap_relief::Name_Ortsteil_Stadtteil", + type: "symbol", + source: "bg-basemap_relief::basemap", + "source-layer": "Name_Punkt", + }; + const poiLayer = { + id: "bg-basemap_relief::Name_Gebaeude_oeffentlich", + type: "symbol", + source: "bg-basemap_relief::basemap", + "source-layer": "Gebaeudepunkt", + }; + const layers = [ + { id: sharedLayer.id, type: "custom" }, + placeLayer, + poiLayer, + ]; + const paint = new Map(); + const listeners = new Map void>(); + const images = new Map< + string, + { width: number; height: number; data: Uint8Array } + >([ + [ + "church", + { + width: 2, + height: 1, + data: new Uint8Array([255, 255, 255, 255, 20, 20, 20, 255]), + }, + ], + [ + "school", + { + width: 2, + height: 1, + data: new Uint8Array([120, 60, 20, 255, 250, 250, 250, 255]), + }, + ], + [ + "shield", + { + width: 2, + height: 1, + data: new Uint8Array([255, 220, 0, 255, 255, 255, 255, 255]), + }, + ], + ]); + let zoom = 16; + const map = { + getStyle: vi.fn(() => ({ layers: [placeLayer, poiLayer] })), + getLayersOrder: vi.fn(() => layers.map(({ id }) => id)), + getLayer: vi.fn((id: string) => + id === sharedLayer.id + ? { implementation: sharedLayer } + : layers.find((layer) => layer.id === id) + ), + getZoom: vi.fn(() => zoom), + getPitch: vi.fn(() => 0), + getCenter: vi.fn(() => ({ lng: 0, lat: 0 })), + getCanvas: vi.fn(() => ({ clientHeight: 2000 })), + getLayoutProperty: vi.fn(), + setLayoutProperty: vi.fn(), + getPaintProperty: vi.fn((id: string, property: string) => + paint.get(`${id}:${property}`) + ), + setPaintProperty: vi.fn( + (id: string, property: string, value: unknown) => { + const key = `${id}:${property}`; + if (value == null) paint.delete(key); + else paint.set(key, value); + } + ), + listImages: vi.fn(() => [...images.keys()]), + hasImage: vi.fn((id: string) => images.has(id)), + updateImage: vi.fn((id: string, image: { data: Uint8Array }) => { + images.set(id, { + ...images.get(id)!, + data: new Uint8Array(image.data), + }); + }), + style: { + imageManager: { + getImage: (id: string) => ({ data: images.get(id), sdf: false }), + }, + }, + addLayer: vi.fn(), + moveLayer: vi.fn(), + removeLayer: vi.fn(), + on: vi.fn((event: string, handler: () => void) => { + listeners.set(event, handler); + }), + off: vi.fn(), + }; + + const lease = acquireSharedThreeScene(map as never); + + // 300 m at zoom 16 on the equator: 512 * 2^16 / 40075016.686 px per meter. + const expectedPixels = (300 * 512 * 2 ** 16) / 40075016.686; + expect(paint.get(`${placeLayer.id}:text-translate-anchor`)).toBe( + "viewport" + ); + const [, lifted] = paint.get(`${placeLayer.id}:text-translate`) as [ + number, + number + ]; + expect(-lifted).toBeCloseTo(expectedPixels, 3); + const [, liftedPoi] = paint.get(`${poiLayer.id}:text-translate`) as [ + number, + number + ]; + expect(-liftedPoi).toBeCloseTo((expectedPixels * 10) / 300, 3); + + zoom = 17; + listeners.get("move")?.(); + const [, liftedCloser] = paint.get(`${placeLayer.id}:text-translate`) as [ + number, + number + ]; + expect(-liftedCloser).toBeCloseTo(expectedPixels * 2, 3); + + // Sprites follow the sun color only with the mesh label style, and only + // the flat white ones. + lease.setLocationLabelColor("#ff8000"); + vi.advanceTimersByTime(1000); + expect([...images.get("church")!.data]).toEqual([ + 255, 255, 255, 255, 20, 20, 20, 255, + ]); + lease.setMeshLabelStyle(true); + // Every sprite is lit by the sun color: albedo times light per channel. + expect([...images.get("church")!.data]).toEqual([ + 255, 128, 0, 255, 20, 10, 0, 255, + ]); + expect([...images.get("school")!.data]).toEqual([ + 120, 30, 0, 255, 250, 125, 0, 255, + ]); + expect([...images.get("shield")!.data]).toEqual([ + 255, 110, 0, 255, 255, 128, 0, 255, + ]); + lease.setMeshLabelStyle(false); + expect([...images.get("church")!.data]).toEqual([ + 255, 255, 255, 255, 20, 20, 20, 255, + ]); + expect([...images.get("school")!.data]).toEqual([ + 120, 60, 20, 255, 250, 250, 250, 255, + ]); + expect([...images.get("shield")!.data]).toEqual([ + 255, 220, 0, 255, 255, 255, 255, 255, + ]); + + lease.release(); + expect(paint.has(`${placeLayer.id}:text-translate`)).toBe(false); + expect(paint.has(`${placeLayer.id}:text-translate-anchor`)).toBe(false); + }); + + it("drapes a textured terrain mesh without the shadow simulation", () => { + sharedLayer.getRuntimes.mockReturnValue([ + { + id: "mesh", + providesTerrain: true, + mapStyleProjectionBlend: "overlay", + getActiveTileVolumes: () => [], + } as never, + ]); + const streetLayer = { + id: "bg-basemap_relief-Name_Kreis_Gemeindestr", + type: "symbol", + source: "bg-basemap_relief::basemap", + "source-layer": "Verkehrslinie", + layout: { "symbol-placement": "line" }, + }; + const poiLayer = { + id: "bg-basemap_relief-Name_Gebaeude_oeffentlich", + type: "symbol", + source: "bg-basemap_relief::basemap", + "source-layer": "Gebaeudepunkt", + }; + const layers = [ + { id: "basemap", type: "fill" }, + streetLayer, + { id: sharedLayer.id, type: "custom" }, + poiLayer, + ]; + const layout = new Map([ + [`${streetLayer.id}:text-size`, 13], + ]); + const paint = new Map([ + [`${streetLayer.id}:text-color`, "#333333"], + [`${streetLayer.id}:text-halo-color`, "#ffffff"], + [`${poiLayer.id}:text-color`, "#444444"], + [`${poiLayer.id}:text-halo-color`, "#ffffff"], + ]); + const terrain = Object.create({ getMeshFrameDelta: () => 42 }) as { + getMeshFrameDelta: (zoom: number) => number; + }; + const map = { + terrain, + getStyle: vi.fn(() => ({ + layers: layers.filter(({ id }) => id !== sharedLayer.id), + })), + getLayersOrder: vi.fn(() => layers.map(({ id }) => id)), + getLayer: vi.fn((id: string) => + id === sharedLayer.id + ? { implementation: sharedLayer } + : layers.find((layer) => layer.id === id) + ), + getLayoutProperty: vi.fn((id: string, property: string) => + layout.get(`${id}:${property}`) + ), + setLayoutProperty: vi.fn( + (id: string, property: string, value: unknown) => { + const key = `${id}:${property}`; + if (value == null) layout.delete(key); + else layout.set(key, value); + } + ), + getPaintProperty: vi.fn((id: string, property: string) => + paint.get(`${id}:${property}`) + ), + setPaintProperty: vi.fn( + (id: string, property: string, value: unknown) => { + const key = `${id}:${property}`; + if (value == null) paint.delete(key); + else paint.set(key, value); + } + ), + getFilter: vi.fn(), + setFilter: vi.fn(), + addLayer: vi.fn(), + moveLayer: vi.fn(), + removeLayer: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }; + + const lease = acquireSharedThreeScene(map as never); + + // Drape and street styling apply on their own; without a sun color the + // point labels keep their authored paint. + expect(layout.get("basemap:visibility")).toBe("none"); + expect(paint.get(`${streetLayer.id}:text-color`)).toBe("#ffffff"); + expect(paint.get(`${streetLayer.id}:text-halo-color`)).toBe("#808080"); + expect(layout.get(`${streetLayer.id}:text-size`)).toBe(18.2); + expect(paint.get(`${poiLayer.id}:text-color`)).toBe("#444444"); + expect(paint.get(`${poiLayer.id}:text-halo-color`)).toBe("#ffffff"); + // MapLibre's tile skirts stay out of the captured pass. + expect(terrain.getMeshFrameDelta(15)).toBe(0); + + lease.release(); + expect(terrain.getMeshFrameDelta(15)).toBe(42); + expect(Object.hasOwn(terrain, "getMeshFrameDelta")).toBe(false); + expect(layout.has("basemap:visibility")).toBe(false); + expect(paint.get(`${streetLayer.id}:text-color`)).toBe("#333333"); + expect(layout.get(`${streetLayer.id}:text-size`)).toBe(13); + }); + + it("shows place names only inside active Three terrain tile footprints", () => { + const placeLayer = { + id: "place-city", + type: "symbol", + source: "basemap", + "source-layer": "place", + }; + const layers = [{ id: sharedLayer.id, type: "custom" }, placeLayer]; + const originalFilter = ["==", "class", "city"]; + let currentFilter: unknown = originalFilter; + sharedLayer.getRuntimes.mockReturnValue([ + { + id: "terrain", + providesTerrain: true, + getActiveTileVolumes: () => [ + { + id: "12/34/56", + kind: "terrain-tile", + minimum: [-10, 100, -20] as const, + maximum: [30, 200, 40] as const, + }, + ], + } as never, + ]); + const map = { + getStyle: vi.fn(() => ({ layers: [placeLayer] })), + getLayersOrder: vi.fn(() => layers.map(({ id }) => id)), + getLayer: vi.fn((id: string) => + id === sharedLayer.id + ? { implementation: sharedLayer } + : layers.find((layer) => layer.id === id) + ), + getFilter: vi.fn(() => currentFilter), + setFilter: vi.fn((_id: string, filter: unknown) => { + currentFilter = filter; + }), + getLayoutProperty: vi.fn(), + setLayoutProperty: vi.fn(), + getPaintProperty: vi.fn(), + setPaintProperty: vi.fn(), + addLayer: vi.fn(), + moveLayer: vi.fn(), + removeLayer: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }; + + const lease = acquireSharedThreeScene(map as never); + + expect(currentFilter).toEqual([ + "all", + ["==", ["get", "class"], "city"], + [ + "within", + { + type: "MultiPolygon", + coordinates: [ + [ + [ + [-10.5, -20.5], + [30.5, -20.5], + [30.5, 40.5], + [-10.5, 40.5], + [-10.5, -20.5], + ], + ], + ], + }, + ], + ]); + + lease.release(); + expect(currentFilter).toEqual(originalFilter); + }); + + it("merges adjacent terrain tiles into one coverage polygon", () => { + const placeLayer = { + id: "place-city", + type: "symbol", + source: "basemap", + "source-layer": "place", + }; + const layers = [{ id: sharedLayer.id, type: "custom" }, placeLayer]; + let currentFilter: unknown = null; + const tile = (id: string, x: number, z: number) => ({ + id, + kind: "terrain-tile", + minimum: [x, 100, z] as const, + maximum: [x + 10, 200, z + 10] as const, + }); + sharedLayer.getRuntimes.mockReturnValue([ + { + id: "terrain", + providesTerrain: true, + getActiveTileVolumes: () => [ + tile("0/0", 0, 0), + tile("1/0", 10, 0), + tile("0/1", 0, 10), + tile("1/1", 10, 10), + ], + } as never, + ]); + const map = { + getStyle: vi.fn(() => ({ layers: [placeLayer] })), + getLayersOrder: vi.fn(() => layers.map(({ id }) => id)), + getLayer: vi.fn((id: string) => + id === sharedLayer.id + ? { implementation: sharedLayer } + : layers.find((layer) => layer.id === id) + ), + getFilter: vi.fn(() => currentFilter), + setFilter: vi.fn((_id: string, filter: unknown) => { + currentFilter = filter; + }), + getLayoutProperty: vi.fn(), + setLayoutProperty: vi.fn(), + getPaintProperty: vi.fn(), + setPaintProperty: vi.fn(), + addLayer: vi.fn(), + moveLayer: vi.fn(), + removeLayer: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }; + + const lease = acquireSharedThreeScene(map as never); + + expect(map.setFilter).toHaveBeenCalledOnce(); + expect(currentFilter).toEqual([ + "within", + { + type: "MultiPolygon", + coordinates: [ + [ + [ + [-0.5, -0.5], + [20.5, -0.5], + [20.5, 20.5], + [-0.5, 20.5], + [-0.5, -0.5], + ], + ], + ], + }, + ]); + + // Unchanged coverage and an untouched filter skip the rewrite entirely. + vi.advanceTimersByTime(1000); + lease.setLocationLabelColor("#fff2d8"); + vi.advanceTimersByTime(1000); + expect(map.setFilter).toHaveBeenCalledOnce(); + lease.release(); + }); + + it("reuses terrain coverage until the active tile footprints change", () => { + const listeners = new Map void>(); + const placeLayer = { + id: "place-city", + type: "symbol", + source: "basemap", + "source-layer": "place", + }; + const layers = [{ id: sharedLayer.id, type: "custom" }, placeLayer]; + let currentFilter: unknown = null; + let volumes = [ + { + id: "12/34/56", + kind: "terrain-tile", + minimum: [-10, 100, -20] as const, + maximum: [30, 200, 40] as const, + }, + ]; + const getActiveTileVolumes = vi.fn(() => volumes); + sharedLayer.getRuntimes.mockReturnValue([ + { + id: "terrain", + providesTerrain: true, + getActiveTileVolumes, + } as never, + ]); + const map = { + getStyle: vi.fn(() => ({ layers: [placeLayer] })), + getLayersOrder: vi.fn(() => layers.map(({ id }) => id)), + getLayer: vi.fn((id: string) => + id === sharedLayer.id + ? { implementation: sharedLayer } + : layers.find((layer) => layer.id === id) + ), + getFilter: vi.fn(() => currentFilter), + setFilter: vi.fn((_id: string, filter: unknown) => { + currentFilter = filter; + }), + getLayoutProperty: vi.fn(), + setLayoutProperty: vi.fn(), + getPaintProperty: vi.fn(), + setPaintProperty: vi.fn(), + addLayer: vi.fn(), + moveLayer: vi.fn(), + removeLayer: vi.fn(), + on: vi.fn((event: string, handler: () => void) => { + listeners.set(event, handler); + }), + off: vi.fn(), + }; + + const lease = acquireSharedThreeScene(map as never); + + expect(getActiveTileVolumes).toHaveBeenCalledOnce(); + expect(sharedLayer.projectSceneToLngLat).toHaveBeenCalledTimes(4); + + listeners.get("styledata")?.(); + listeners.get("idle")?.(); + lease.setLocationLabelColor("#fff2d8"); + + // Events inside the maintenance interval collapse into one trailing pass. + expect(getActiveTileVolumes).toHaveBeenCalledOnce(); + vi.advanceTimersByTime(1000); + expect(getActiveTileVolumes).toHaveBeenCalledTimes(2); + expect(sharedLayer.projectSceneToLngLat).toHaveBeenCalledTimes(4); + + volumes = [ + { + id: "12/34/56", + kind: "terrain-tile", + minimum: [-20, 100, -30] as const, + maximum: [40, 200, 50] as const, + }, + ]; + listeners.get("styledata")?.(); + vi.advanceTimersByTime(1000); + + expect(getActiveTileVolumes).toHaveBeenCalledTimes(3); + expect(sharedLayer.projectSceneToLngLat).toHaveBeenCalledTimes(8); + lease.release(); + }); + + it("does not redraw raster label overlays above Three", () => { + const addLayer = vi.fn(); + const map = { + getStyle: vi.fn(() => ({ + layers: [ + { id: "basemap", type: "raster" }, + { + id: "---raster-spw2-light-grundriss-0:first---", + type: "background", + }, + { + id: "raster-spw2-light-grundriss-0-raster", + type: "raster", + }, + { id: "raster-dop-overlay-1-raster", type: "raster" }, + ], + })), + getLayer: vi.fn(() => undefined), + addLayer, + removeLayer: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }; + + const lease = acquireSharedThreeScene(map as never); + + expect(addLayer).toHaveBeenCalledWith(sharedLayer); + lease.release(); + }); + + it("retries after the host style becomes writable", () => { + const listeners = new Map void>(); + let attached = false; + const addLayer = vi + .fn() + .mockImplementationOnce(() => { + throw new Error("Style is not done loading"); + }) + .mockImplementation(() => { + attached = true; + }); + const map = { + getStyle: vi.fn(() => ({ layers: [] })), + getLayer: vi.fn(() => (attached ? sharedLayer : undefined)), + addLayer, + removeLayer: vi.fn(), + on: vi.fn((event: string, handler: () => void) => { + listeners.set(event, handler); + }), + off: vi.fn(), + }; + + const lease = acquireSharedThreeScene(map as never); + expect(attached).toBe(false); + + listeners.get(MAPLIBRE_EVENT.STYLE_LOAD)?.(); + + expect(attached).toBe(true); + expect(addLayer).toHaveBeenCalledTimes(2); + lease.release(); + }); + + it("reuses a mounted shared layer after the module registry was replaced", () => { + const mountedLayer = { + ...sharedLayer, + addRuntime: vi.fn(), + removeRuntime: vi.fn(), + getScene: vi.fn(), + }; + const addLayer = vi.fn(); + const removeLayer = vi.fn(); + const map = { + getStyle: vi.fn(() => ({ layers: [] })), + getLayer: vi.fn(() => ({ implementation: mountedLayer })), + addLayer, + removeLayer, + on: vi.fn(), + off: vi.fn(), + }; + + const lease = acquireSharedThreeScene(map as never); + + expect(lease.layer).toBe(mountedLayer); + expect(buildSharedThreeSceneLayer).not.toHaveBeenCalled(); + expect(addLayer).not.toHaveBeenCalled(); + + lease.release(); + expect(removeLayer).toHaveBeenCalledWith(mountedLayer.id); + expect(mountedLayer.dispose).toHaveBeenCalledOnce(); + }); + + it("does not remove a newer shared layer when an old lease releases", () => { + const replacementLayer = { + ...sharedLayer, + addRuntime: vi.fn(), + removeRuntime: vi.fn(), + getScene: vi.fn(), + }; + const removeLayer = vi.fn(); + let mountedLayer: unknown = sharedLayer; + const map = { + getStyle: vi.fn(() => ({ layers: [] })), + getLayer: vi.fn(() => ({ implementation: mountedLayer })), + addLayer: vi.fn(), + removeLayer, + on: vi.fn(), + off: vi.fn(), + }; + + const lease = acquireSharedThreeScene(map as never); + mountedLayer = replacementLayer; + lease.release(); + + expect(removeLayer).not.toHaveBeenCalled(); + expect(dispose).toHaveBeenCalledOnce(); + }); + + it("tolerates MapLibre style teardown during HMR", () => { + const addLayer = vi.fn(() => { + throw new Error("style is gone"); + }); + const map = { + getStyle: vi.fn(() => { + throw new Error("style is gone"); + }), + getLayer: vi.fn(() => { + throw new Error("style is gone"); + }), + addLayer, + removeLayer: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }; + + const lease = acquireSharedThreeScene(map as never); + + expect(lease.layer).toBe(sharedLayer); + expect(addLayer).toHaveBeenCalledWith(sharedLayer); + expect(() => lease.release()).not.toThrow(); + expect(dispose).toHaveBeenCalledOnce(); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-registry.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-registry.ts new file mode 100644 index 0000000000..65948be4d9 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-registry.ts @@ -0,0 +1,1842 @@ +import { + convertFilter, + type FilterSpecification, +} from "@maplibre/maplibre-gl-style-spec"; +import type { Map as MaplibreMap } from "maplibre-gl"; + +import { MAPLIBRE_EVENT } from "../../../constants/mapEvents"; +import { buildSharedThreeSceneLayer } from "./shared-three-scene-layer"; +import type { SharedThreeSceneLayer } from "./shared-three-scene-layer"; +import { + getMapStylePointLabelLiftMeters, + isMapStyleContourLineLayer, + isMapStyleElevationLabelLayer, + isMapStylePointLabelLayer, + isMapStyleRoadLabelLayer, + isMapStyleRoadShieldLayer, + isMapStyleWaterLabelLayer, + type RuntimeStyleLayer, +} from "./map-style-layer-suppression"; + +const SHARED_SCENE_LAYER_ID = "carma-shared-three-scene"; +const SHARED_SCENE_ENTRY_VERSION = 15; +/** Contour lines stay in the mesh drape at half strength. */ +const MESH_DRAPE_CONTOUR_OPACITY = 0.5; +const EARTH_CIRCUMFERENCE_METERS = 40_075_016.686; +const MAPLIBRE_TILE_SIZE = 512; +/** Keep a lifted place name inside the view at high zoom. */ +const MAX_LABEL_LIFT_VIEWPORT_FRACTION = 0.35; +/** Halo behind street names and house numbers on the mesh drape. */ +const MESH_LABEL_HALO_COLOR = "#808080"; +/** Draped street names are read off a textured surface; give them more body. */ +const MESH_STREET_LABEL_SIZE_FACTOR = 1.4; +const MESH_STREET_LABEL_HALO_WIDTH = 1.5; +const TERRAIN_COVERAGE_MARGIN_METERS = 0.5; +/** Neighbouring tile boxes overlap by twice the margin; merge anything closer. */ +const TERRAIN_COVERAGE_MERGE_TOLERANCE_METERS = 0.01; +/** + * Label overlay maintenance rewrites the filter and paint of every point-label + * layer. MapLibre schedules a repaint per write and that repaint's `idle` + * lands here again, so the maintenance is rate limited instead of running on + * every style or idle event. + */ +const LABEL_OVERLAY_MAINTENANCE_INTERVAL_MS = 1000; + +type PointLabelLayersCache = { + orderSignature: string; + layers: RuntimeStyleLayer[]; +}; + +type SharedSceneEntry = { + version: number; + layer: SharedThreeSceneLayer; + references: number; + disposed: boolean; + /** All style layers, reused until the layer list changes. */ + styleLayersCache: PointLabelLayersCache | null; + labelMaintenanceTimer: ReturnType | null; + lastLabelMaintenanceMs: number; + savedLocationLabelOffsets: Map; + savedLocationLabelHaloWidths: Map; + savedLocationLabelHaloColors: Map; + savedLocationLabelTextColors: Map; + savedLocationLabelFilters: Map; + savedPointLabelVisibilities: Map; + locationLabelColorRequests: Map; + pointLabelOverlayVisibilityRequests: Map; + /** Street names and house numbers in sun color on a textured mesh. */ + meshLabelStyleRequests: Map; + savedMeshLabelPaint: Map; + /** Fills, strokes and rasters hidden below Three while a mesh is draped. */ + savedMeshDrapeVisibilities: Map; + savedContourOpacities: Map; + /** MapLibre terrain whose tile skirts are switched off while Three draws the ground. */ + terrainFramePatch: TerrainFramePatch | null; + /** Place names floating above the scene, with their saved translate paint. */ + liftLayers: LabelLiftLayer[]; + savedLabelLifts: Map; + /** Recolored sprite copies while the mesh label style is on. */ + tintedImages: Map; + /** Per-frame lift update; registered as a MapLibre `move` listener. */ + updateLabelLift: () => void; + /** Rate-limited maintenance; safe to register as a MapLibre listener. */ + ensureLayer: (event?: unknown) => void; + /** Immediate maintenance for user-driven changes. */ + ensureLayerNow: () => void; +}; + +type SavedLocationLabelOffset = { + signature: string; + original: unknown; + applied: readonly [number, number]; +}; + +type SavedLocationLabelHaloWidth = { + signature: string; + original: unknown; + applied: number; +}; + +type SavedLocationLabelHaloColor = { + signature: string; + original: unknown; + applied: string; +}; + +type SavedLocationLabelTextColor = { + signature: string; + original: unknown; + applied: string; +}; + +type SavedLocationLabelFilter = { + signature: string; + original: unknown; + appliedSignature: string; + /** The runtime filter object MapLibre handed back after the last write. */ + appliedFilter?: unknown; + /** The coverage the applied filter was built from. */ + coverage?: TerrainCoverage; +}; + +type SavedPointLabelVisibility = { + signature: string; + original: unknown; +}; + +type SavedMeshLabelPaint = { + layerId: string; + property: MeshLabelPaintProperty; + signature: string; + original: unknown; + applied: unknown; +}; + +type LabelLiftLayer = { id: string; signature: string; meters: number }; + +type SavedLabelLift = { + signature: string; + originalTranslate: unknown; + originalAnchor: unknown; + appliedPixels: number; +}; + +type MapLibreTerrainWithFrame = { + getMeshFrameDelta?: (zoom: number) => number; +}; + +type TerrainFramePatch = { + terrain: MapLibreTerrainWithFrame; + inherited: boolean; + original: MapLibreTerrainWithFrame["getMeshFrameDelta"]; +}; + +type SpriteImageData = { + width: number; + height: number; + data: Uint8Array | Uint8ClampedArray; +}; + +type TintedSpriteImage = { + original: SpriteImageData; + color: string; +}; + +type MeshLabelPaintProperty = + | "text-color" + | "text-halo-color" + | "text-halo-width" + | "text-halo-blur" + | "icon-color" + | "text-size"; + +/** `text-size` is a layout property; the rest is paint. */ +const MESH_LABEL_LAYOUT_PROPERTIES = new Set([ + "text-size", +]); + +const getMeshLabelProperty = ( + map: MaplibreMap, + layerId: string, + property: MeshLabelPaintProperty +): unknown => + MESH_LABEL_LAYOUT_PROPERTIES.has(property) + ? map.getLayoutProperty(layerId, property) + : map.getPaintProperty(layerId, property); + +const setMeshLabelProperty = ( + map: MaplibreMap, + layerId: string, + property: MeshLabelPaintProperty, + value: unknown +): void => { + if (MESH_LABEL_LAYOUT_PROPERTIES.has(property)) { + map.setLayoutProperty(layerId, property, value); + } else { + map.setPaintProperty(layerId, property, value); + } +}; + +/** Scale an authored `text-size`; legacy stop functions are left alone. */ +const scaleTextSize = (authored: unknown, factor: number): unknown => { + if (typeof authored === "number") + return Math.round(authored * factor * 10) / 10; + if (Array.isArray(authored)) return ["*", factor, authored]; + return undefined; +}; + +type SharedSceneHotData = { + sharedThreeSceneEntries?: WeakMap; +}; + +export type SharedThreeSceneLease = { + layer: SharedThreeSceneLayer; + setLocationLabelColor: (color: string | null) => void; + setPointLabelOverlayVisible: (visible: boolean) => void; + /** + * Restyle the draped and overlaid labels for a textured mesh: street names + * and house numbers take the sun color with a grey halo, water names keep + * their blue and drop their halo. Off for bare terrain. + */ + setMeshLabelStyle: (enabled: boolean) => void; + release: () => void; +}; + +const hotData = import.meta.hot?.data as SharedSceneHotData | undefined; +const entries = + hotData?.sharedThreeSceneEntries ?? + new WeakMap(); +if (hotData) hotData.sharedThreeSceneEntries = entries; + +const isSharedThreeSceneLayer = ( + value: unknown +): value is SharedThreeSceneLayer => { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + return ( + candidate.id === SHARED_SCENE_LAYER_ID && + typeof candidate.addRuntime === "function" && + typeof candidate.removeRuntime === "function" && + typeof candidate.getScene === "function" && + typeof candidate.getRenderer === "function" + ); +}; + +const getMountedSharedThreeSceneLayer = ( + map: MaplibreMap +): SharedThreeSceneLayer | undefined => { + try { + const styleLayer = map.getLayer(SHARED_SCENE_LAYER_ID) as + | { implementation?: unknown } + | SharedThreeSceneLayer + | undefined; + if (isSharedThreeSceneLayer(styleLayer)) return styleLayer; + return isSharedThreeSceneLayer(styleLayer?.implementation) + ? styleLayer.implementation + : undefined; + } catch { + return undefined; + } +}; + +const getCachedStyleLayers = ( + map: MaplibreMap, + entry: SharedSceneEntry, + layerOrder: readonly string[] +): RuntimeStyleLayer[] => { + // `getStyle()` serializes every layer including the coverage filters, so + // the list is reused until the layer list itself changes. + const orderSignature = layerOrder.join("\n"); + const cached = entry.styleLayersCache; + if (cached?.orderSignature === orderSignature) return cached.layers; + try { + const layers = [ + ...((map.getStyle().layers as RuntimeStyleLayer[] | undefined) ?? []), + ]; + entry.styleLayersCache = { orderSignature, layers }; + return layers; + } catch { + return []; + } +}; + +const getMapStylePointLabelLayers = ( + map: MaplibreMap, + entry: SharedSceneEntry, + layerOrder: readonly string[] +): RuntimeStyleLayer[] => + getCachedStyleLayers(map, entry, layerOrder).filter( + isMapStylePointLabelLayer + ); + +/** Line-placed symbols stay below Three; the mesh drape restyles them in place. */ +const getMapStyleLineLabelLayers = ( + map: MaplibreMap, + entry: SharedSceneEntry, + layerOrder: readonly string[] +): RuntimeStyleLayer[] => + getCachedStyleLayers(map, entry, layerOrder).filter( + (layer) => layer.type === "symbol" && !isMapStylePointLabelLayer(layer) + ); + +const getLayerSignature = (layer: RuntimeStyleLayer): string => + `${layer.type}:${String(layer.source)}:${String( + layer.sourceLayer ?? layer["source-layer"] + )}`; + +const isAppliedOffset = ( + value: unknown, + applied: readonly [number, number] +): boolean => + Array.isArray(value) && + value.length === 2 && + value[0] === applied[0] && + value[1] === applied[1]; + +const getStyleValueSignature = (value: unknown): string => { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +}; + +const restoreLocationLabelFilters = ( + map: MaplibreMap, + savedFilters: Map +): void => { + for (const [layerId, saved] of savedFilters) { + try { + const runtimeLayer = map.getLayer(layerId) as + | RuntimeStyleLayer + | undefined; + if ( + runtimeLayer && + getLayerSignature(runtimeLayer) === saved.signature && + getStyleValueSignature(map.getFilter(layerId)) === + saved.appliedSignature + ) { + map.setFilter( + layerId, + saved.original == null ? null : (saved.original as never) + ); + } + } catch { + // The host may already have disposed or replaced its style. + } + } + savedFilters.clear(); +}; + +type TerrainCoverageBox = Readonly<{ + key: string; + minimumX: number; + minimumZ: number; + maximumX: number; + maximumZ: number; +}>; + +type TerrainCoverage = Readonly<{ predicate: unknown }>; + +type TerrainCoverageCacheEntry = Readonly<{ + sourceSignature: string; + coverage: TerrainCoverage; +}>; + +const terrainCoverageCache = new WeakMap< + SharedThreeSceneLayer, + TerrainCoverageCacheEntry +>(); + +const containsCoverageBox = ( + outer: TerrainCoverageBox, + inner: TerrainCoverageBox +): boolean => + outer.minimumX <= inner.minimumX && + outer.minimumZ <= inner.minimumZ && + outer.maximumX >= inner.maximumX && + outer.maximumZ >= inner.maximumZ; + +const nearlyEqual = (a: number, b: number): boolean => + Math.abs(a - b) <= TERRAIN_COVERAGE_MERGE_TOLERANCE_METERS; + +/** + * Union grid-aligned tile boxes along one axis: boxes that share their extent + * on the other axis and touch or overlap along `axis` collapse into one. + */ +const mergeCoverageBoxesAlong = ( + boxes: readonly TerrainCoverageBox[], + axis: "x" | "z" +): TerrainCoverageBox[] => { + const [minimum, maximum, otherMinimum, otherMaximum] = + axis === "x" + ? (["minimumX", "maximumX", "minimumZ", "maximumZ"] as const) + : (["minimumZ", "maximumZ", "minimumX", "maximumX"] as const); + const sorted = [...boxes].sort( + (a, b) => + a[otherMinimum] - b[otherMinimum] || + a[otherMaximum] - b[otherMaximum] || + a[minimum] - b[minimum] + ); + const merged: TerrainCoverageBox[] = []; + for (const box of sorted) { + const previous = merged[merged.length - 1]; + if ( + previous && + nearlyEqual(previous[otherMinimum], box[otherMinimum]) && + nearlyEqual(previous[otherMaximum], box[otherMaximum]) && + box[minimum] <= + previous[maximum] + TERRAIN_COVERAGE_MERGE_TOLERANCE_METERS + ) { + const extent = Math.max(previous[maximum], box[maximum]); + merged[merged.length - 1] = + axis === "x" + ? { ...previous, maximumX: extent } + : { ...previous, maximumZ: extent }; + continue; + } + merged.push(box); + } + return merged; +}; + +/** + * Collapse a quadtree tile selection into far fewer rectangles. Hundreds of + * per-tile polygons in a `within` filter make every symbol layout and every + * style serialization pay for the polygon count. + */ +const mergeCoverageBoxes = ( + boxes: readonly TerrainCoverageBox[] +): TerrainCoverageBox[] => + mergeCoverageBoxesAlong(mergeCoverageBoxesAlong(boxes, "x"), "z"); + +const getTerrainCoverageFilter = ( + layer: SharedThreeSceneLayer +): TerrainCoverage | null => { + const terrainRuntimes = (layer.getRuntimes?.() ?? []).filter( + (runtime) => runtime.providesTerrain === true + ); + if (terrainRuntimes.length === 0) return null; + + const boxes: TerrainCoverageBox[] = []; + for (const runtime of terrainRuntimes) { + for (const volume of runtime.getActiveTileVolumes?.() ?? []) { + const [minimumX, , minimumZ] = volume.minimum; + const [maximumX, , maximumZ] = volume.maximum; + if ( + ![minimumX, minimumZ, maximumX, maximumZ].every(Number.isFinite) || + minimumX > maximumX || + minimumZ > maximumZ + ) { + continue; + } + boxes.push({ + key: `${runtime.id}:${volume.id}`, + minimumX: minimumX - TERRAIN_COVERAGE_MARGIN_METERS, + minimumZ: minimumZ - TERRAIN_COVERAGE_MARGIN_METERS, + maximumX: maximumX + TERRAIN_COVERAGE_MARGIN_METERS, + maximumZ: maximumZ + TERRAIN_COVERAGE_MARGIN_METERS, + }); + } + } + + // Style and label updates can call this repeatedly while the terrain + // selection is unchanged. Keep the signature in the runtime's stable tile + // order so the hot path remains linear; an order-only change merely causes + // one harmless cache miss. + const sourceSignature = JSON.stringify( + boxes.map(({ key, minimumX, minimumZ, maximumX, maximumZ }) => [ + key, + minimumX, + minimumZ, + maximumX, + maximumZ, + ]) + ); + const cached = terrainCoverageCache.get(layer); + if (cached?.sourceSignature === sourceSignature) return cached.coverage; + + boxes.sort((a, b) => { + const areaA = (a.maximumX - a.minimumX) * (a.maximumZ - a.minimumZ); + const areaB = (b.maximumX - b.minimumX) * (b.maximumZ - b.minimumZ); + return areaB - areaA || a.key.localeCompare(b.key); + }); + const coverageBoxes: TerrainCoverageBox[] = []; + for (const box of boxes) { + if ( + coverageBoxes.some((candidate) => containsCoverageBox(candidate, box)) + ) { + continue; + } + coverageBoxes.push(box); + } + + const coordinates = mergeCoverageBoxes(coverageBoxes).flatMap((box) => { + const southWest = layer.projectSceneToLngLat?.([ + box.minimumX, + 0, + box.minimumZ, + ]); + const southEast = layer.projectSceneToLngLat?.([ + box.maximumX, + 0, + box.minimumZ, + ]); + const northEast = layer.projectSceneToLngLat?.([ + box.maximumX, + 0, + box.maximumZ, + ]); + const northWest = layer.projectSceneToLngLat?.([ + box.minimumX, + 0, + box.maximumZ, + ]); + if (!southWest || !southEast || !northEast || !northWest) return []; + return [[[southWest, southEast, northEast, northWest, southWest]]]; + }); + const predicate = + coordinates.length === 0 + ? false + : ["within", { type: "MultiPolygon", coordinates }]; + const coverage = { predicate }; + terrainCoverageCache.set(layer, { sourceSignature, coverage }); + return coverage; +}; + +const applyLocationLabelCoverageFilters = ( + map: MaplibreMap, + sceneLayer: SharedThreeSceneLayer, + layers: RuntimeStyleLayer[], + savedFilters: Map +): void => { + const coverage = getTerrainCoverageFilter(sceneLayer); + if (!coverage) { + restoreLocationLabelFilters(map, savedFilters); + return; + } + + const currentIds = new Set(layers.map(({ id }) => id)); + for (const id of savedFilters.keys()) { + if (!currentIds.has(id)) savedFilters.delete(id); + } + for (const layer of layers) { + const signature = getLayerSignature(layer); + try { + const current = map.getFilter(layer.id); + let saved = savedFilters.get(layer.id); + if ( + saved && + saved.signature === signature && + saved.coverage === coverage && + saved.appliedFilter !== undefined && + current === saved.appliedFilter + ) { + // MapLibre still holds the object of the last write, so nothing else + // touched this filter; skip serializing the coverage polygon again. + continue; + } + const currentSignature = getStyleValueSignature(current); + if ( + !saved || + saved.signature !== signature || + currentSignature !== saved.appliedSignature + ) { + saved = { signature, original: current, appliedSignature: "" }; + savedFilters.set(layer.id, saved); + } + const originalExpression = + saved.original == null + ? null + : convertFilter(saved.original as FilterSpecification); + const applied = + originalExpression != null + ? ["all", originalExpression, coverage.predicate] + : coverage.predicate; + const appliedSignature = getStyleValueSignature(applied); + saved.appliedSignature = appliedSignature; + saved.coverage = coverage; + if (currentSignature !== appliedSignature) { + // The expression is assembled here from validated parts; skipping the + // style-spec validation saves a full walk of the coverage polygon. + map.setFilter(layer.id, applied as never, { validate: false }); + saved.appliedFilter = map.getFilter(layer.id); + } else { + saved.appliedFilter = current; + } + } catch { + // A style rebuild can remove a layer between inspection and update. + } + } +}; + +const restoreLocationLabelPaint = ( + map: MaplibreMap, + property: "text-color" | "text-halo-color" | "text-halo-width", + savedValues: Map< + string, + | SavedLocationLabelHaloWidth + | SavedLocationLabelHaloColor + | SavedLocationLabelTextColor + > +): void => { + for (const [layerId, saved] of savedValues) { + try { + const runtimeLayer = map.getLayer(layerId) as + | RuntimeStyleLayer + | undefined; + if ( + runtimeLayer && + getLayerSignature(runtimeLayer) === saved.signature && + map.getPaintProperty(layerId, property) === saved.applied + ) { + map.setPaintProperty( + layerId, + property, + saved.original === undefined ? null : saved.original + ); + } + } catch { + // The host may already have disposed or replaced its style. + } + } + savedValues.clear(); +}; + +/** White and near-white halos (basemap.de uses rgb(255,253,238)) count as white. */ +const isWhiteLabelHalo = (value: unknown): boolean => { + if (typeof value !== "string") return false; + const compact = value.toLowerCase().replace(/\s+/g, ""); + if (compact === "white") return true; + const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(compact); + if (hex) { + const digits = + hex[1].length === 3 + ? hex[1].split("").map((digit) => digit + digit) + : [hex[1].slice(0, 2), hex[1].slice(2, 4), hex[1].slice(4, 6)]; + return digits.every((digit) => Number.parseInt(digit, 16) >= 235); + } + const rgb = /^rgba?\((\d+),(\d+),(\d+)(?:,[\d.]+)?\)$/.exec(compact); + return ( + rgb !== null && rgb.slice(1, 4).every((channel) => Number(channel) >= 235) + ); +}; + +const restoreLocationLabelOffsets = ( + map: MaplibreMap, + savedOffsets: Map +): void => { + for (const [layerId, saved] of savedOffsets) { + try { + const runtimeLayer = map.getLayer(layerId) as + | RuntimeStyleLayer + | undefined; + if ( + runtimeLayer && + getLayerSignature(runtimeLayer) === saved.signature && + isAppliedOffset( + map.getLayoutProperty(layerId, "text-offset"), + saved.applied + ) + ) { + map.setLayoutProperty( + layerId, + "text-offset", + saved.original === undefined ? null : saved.original + ); + } + } catch { + // The host may already have disposed or replaced its style. + } + } + savedOffsets.clear(); +}; + +const setPointLabelLayersHidden = ( + map: MaplibreMap, + layers: RuntimeStyleLayer[], + savedVisibilities: Map, + hidden: boolean +): void => { + if (!hidden) { + for (const [layerId, saved] of savedVisibilities) { + try { + const runtimeLayer = map.getLayer(layerId) as + | RuntimeStyleLayer + | undefined; + if ( + runtimeLayer && + getLayerSignature(runtimeLayer) === saved.signature && + map.getLayoutProperty(layerId, "visibility") === "none" + ) { + map.setLayoutProperty( + layerId, + "visibility", + saved.original === undefined ? null : saved.original + ); + } + } catch { + // The host may already have disposed or replaced its style. + } + } + savedVisibilities.clear(); + return; + } + + const currentIds = new Set(layers.map(({ id }) => id)); + for (const id of savedVisibilities.keys()) { + if (!currentIds.has(id)) savedVisibilities.delete(id); + } + for (const layer of layers) { + try { + const signature = getLayerSignature(layer); + const current = map.getLayoutProperty(layer.id, "visibility"); + const saved = savedVisibilities.get(layer.id); + if (!saved || saved.signature !== signature) { + savedVisibilities.set(layer.id, { signature, original: current }); + } + if (current !== "none") { + map.setLayoutProperty(layer.id, "visibility", "none"); + } + } catch { + // A style rebuild can remove a layer between inspection and update. + } + } +}; + +const applyLocationLabelOffsets = ( + map: MaplibreMap, + layers: RuntimeStyleLayer[], + savedOffsets: Map, + savedHaloWidths: Map, + savedHaloColors: Map, + savedTextColors: Map, + textColor: string | null, + /** Layers whose paint another rule owns; offsets still apply. */ + skipPaint: (layer: RuntimeStyleLayer) => boolean = () => false +): void => { + const currentIds = new Set(layers.map(({ id }) => id)); + for (const id of savedOffsets.keys()) { + if (!currentIds.has(id)) savedOffsets.delete(id); + } + for (const id of savedHaloWidths.keys()) { + if (!currentIds.has(id)) savedHaloWidths.delete(id); + } + for (const id of savedHaloColors.keys()) { + if (!currentIds.has(id)) savedHaloColors.delete(id); + } + for (const id of savedTextColors.keys()) { + if (!currentIds.has(id)) savedTextColors.delete(id); + } + if (textColor === null) { + restoreLocationLabelPaint(map, "text-halo-width", savedHaloWidths); + restoreLocationLabelPaint(map, "text-halo-color", savedHaloColors); + restoreLocationLabelPaint(map, "text-color", savedTextColors); + } + + // Place names are lifted in meters (see configureLabelLifts) instead of a + // flat em offset; earlier offsets are handed back through savedOffsets. + restoreLocationLabelOffsets(map, savedOffsets); + for (const layer of layers) { + const signature = getLayerSignature(layer); + // Road shields keep their authored text and fill in every mode. + if (isMapStyleRoadShieldLayer(layer)) continue; + if (textColor === null || skipPaint(layer)) continue; + let authoredHaloColor: unknown; + try { + authoredHaloColor = + savedHaloColors.get(layer.id)?.original ?? + map.getPaintProperty(layer.id, "text-halo-color"); + } catch { + continue; + } + if ( + authoredHaloColor == null || + (!isMapStyleRoadLabelLayer(layer) && !isWhiteLabelHalo(authoredHaloColor)) + ) { + continue; + } + try { + const current = map.getPaintProperty(layer.id, "text-halo-color"); + let saved = savedHaloColors.get(layer.id); + if ( + !saved || + saved.signature !== signature || + current !== saved.applied + ) { + saved = { + signature, + original: current, + applied: textColor, + }; + savedHaloColors.set(layer.id, saved); + } else { + saved.applied = textColor; + } + if (current !== textColor) { + map.setPaintProperty(layer.id, "text-halo-color", textColor); + } + } catch { + // A style rebuild can remove a layer between inspection and update. + } + } +}; + +const getLocationLabelColor = (entry: SharedSceneEntry): string | null => { + let color: string | null = null; + for (const requestedColor of entry.locationLabelColorRequests.values()) { + color = requestedColor; + } + return color; +}; + +/** + * A textured mesh that supplies the terrain composites the captured style + * over its own texture; that is the case the mesh label rules exist for. + */ +const hasMeshDrapeProvider = (entry: SharedSceneEntry): boolean => { + const runtimes = entry.layer.getRuntimes?.() ?? []; + const providers = runtimes.filter( + (runtime) => runtime.providesTerrain === true + ); + return ( + providers.length > 0 && + providers.every((runtime) => runtime.mapStyleProjectionBlend === "overlay") + ); +}; + +const hasTerrainProvider = (entry: SharedSceneEntry): boolean => + (entry.layer.getRuntimes?.() ?? []).some( + (runtime) => runtime.providesTerrain === true + ); + +const restoreTerrainFrame = (entry: SharedSceneEntry): void => { + const patch = entry.terrainFramePatch; + if (!patch) return; + if (patch.inherited) { + delete patch.terrain.getMeshFrameDelta; + } else { + patch.terrain.getMeshFrameDelta = patch.original; + } + entry.terrainFramePatch = null; +}; + +/** + * MapLibre extends every terrain tile with a skirt (`getMeshFrameDelta`). + * Those skirts end up in the captured style pass as smeared strips at the + * tile borders once Three draws the actual ground, so they are switched off + * while a runtime supplies the terrain, with or without the shadow scene. + */ +const suppressTerrainFrame = ( + map: MaplibreMap, + entry: SharedSceneEntry +): void => { + if (!hasTerrainProvider(entry)) { + restoreTerrainFrame(entry); + return; + } + const terrain = (map as unknown as { terrain?: MapLibreTerrainWithFrame }) + .terrain; + if (!terrain || typeof terrain.getMeshFrameDelta !== "function") { + restoreTerrainFrame(entry); + return; + } + if (entry.terrainFramePatch?.terrain === terrain) return; + restoreTerrainFrame(entry); + entry.terrainFramePatch = { + terrain, + inherited: !Object.prototype.hasOwnProperty.call( + terrain, + "getMeshFrameDelta" + ), + original: terrain.getMeshFrameDelta, + }; + terrain.getMeshFrameDelta = () => 0; +}; + +/** An explicit request (the shadow scene) wins; otherwise the mesh decides. */ +const isMeshLabelStyle = (entry: SharedSceneEntry): boolean => { + let enabled: boolean | null = null; + for (const requested of entry.meshLabelStyleRequests.values()) { + enabled = requested; + } + return enabled ?? hasMeshDrapeProvider(entry); +}; + +const restoreMeshDrape = (map: MaplibreMap, entry: SharedSceneEntry): void => { + for (const [layerId, saved] of entry.savedMeshDrapeVisibilities) { + try { + const runtimeLayer = map.getLayer(layerId) as + | RuntimeStyleLayer + | undefined; + if ( + runtimeLayer && + getLayerSignature(runtimeLayer) === saved.signature && + map.getLayoutProperty(layerId, "visibility") === "none" + ) { + map.setLayoutProperty( + layerId, + "visibility", + saved.original === undefined ? null : saved.original + ); + } + } catch { + // The host may already have disposed or replaced its style. + } + } + entry.savedMeshDrapeVisibilities.clear(); + for (const [layerId, saved] of entry.savedContourOpacities) { + try { + const runtimeLayer = map.getLayer(layerId) as + | RuntimeStyleLayer + | undefined; + if ( + runtimeLayer && + getLayerSignature(runtimeLayer) === saved.signature && + map.getPaintProperty(layerId, "line-opacity") === + MESH_DRAPE_CONTOUR_OPACITY + ) { + map.setPaintProperty( + layerId, + "line-opacity", + saved.original === undefined ? null : saved.original + ); + } + } catch { + // The host may already have disposed or replaced its style. + } + } + entry.savedContourOpacities.clear(); +}; + +/** + * Keep only the symbol layers and the contour lines in the pass captured + * below Three. Fills, strokes, rasters and the background would otherwise + * paint over the mesh texture; contour lines stay at half strength so they + * lighten the surface instead of covering it. Runs whenever a textured mesh + * supplies the terrain, with or without the shadow simulation. + */ +const applyMeshDrape = ( + map: MaplibreMap, + entry: SharedSceneEntry, + layers: readonly RuntimeStyleLayer[] +): void => { + const currentIds = new Set(layers.map(({ id }) => id)); + for (const id of entry.savedMeshDrapeVisibilities.keys()) { + if (!currentIds.has(id)) entry.savedMeshDrapeVisibilities.delete(id); + } + for (const id of entry.savedContourOpacities.keys()) { + if (!currentIds.has(id)) entry.savedContourOpacities.delete(id); + } + for (const layer of layers) { + if (layer.type === "custom" || layer.type === "symbol") continue; + if (layer.id.startsWith("carma-")) continue; + try { + const signature = getLayerSignature(layer); + if (isMapStyleContourLineLayer(layer)) { + const current = map.getPaintProperty(layer.id, "line-opacity"); + const saved = entry.savedContourOpacities.get(layer.id); + if (!saved || saved.signature !== signature) { + entry.savedContourOpacities.set(layer.id, { + signature, + original: current, + }); + } + if (current !== MESH_DRAPE_CONTOUR_OPACITY) { + map.setPaintProperty( + layer.id, + "line-opacity", + MESH_DRAPE_CONTOUR_OPACITY + ); + } + continue; + } + const current = map.getLayoutProperty(layer.id, "visibility"); + const saved = entry.savedMeshDrapeVisibilities.get(layer.id); + if (!saved || saved.signature !== signature) { + entry.savedMeshDrapeVisibilities.set(layer.id, { + signature, + original: current, + }); + } + if (current !== "none") { + map.setLayoutProperty(layer.id, "visibility", "none"); + } + } catch { + // A style rebuild can remove a layer between inspection and update. + } + } +}; + +/** + * Whether the mesh drape owns this layer's text, halo and icon paint: every + * point label (places, POIs, areas, house numbers, shields), the street names + * draped below Three, and water names. + */ +const isMeshStyledLabelLayer = (layer: RuntimeStyleLayer): boolean => + isMapStyleWaterLabelLayer(layer) || + isMapStyleElevationLabelLayer(layer) || + (isMapStylePointLabelLayer(layer) + ? // Shields keep their authored text on their own icon backdrop. + !isMapStyleRoadShieldLayer(layer) + : isMapStyleRoadLabelLayer(layer)); + +const getMeshLabelPaint = ( + map: MaplibreMap, + layer: RuntimeStyleLayer, + textColor: string | null, + authoredProperty: ( + layer: RuntimeStyleLayer, + property: MeshLabelPaintProperty + ) => unknown +): Array<[MeshLabelPaintProperty, unknown]> => { + // Water names keep their authored blue and only drop the halo. + if (isMapStyleWaterLabelLayer(layer)) return [["text-halo-width", 0]]; + // Contour and spot-height numbers: sun colored, no halo, draped or lifted. + // Without a sun (shadow simulation off) they stay white. + if (isMapStyleElevationLabelLayer(layer)) { + return [ + ["text-color", textColor ?? "#ffffff"], + ["text-halo-width", 0], + ]; + } + // Draped street names are lit and shadowed in place on the mesh, so they + // stay pure white; only the overlaid point labels take the sun color. + // They also get more body and a crisp, narrower halo so the halo does + // not creep into the glyphs on the textured ground. + if (!isMapStylePointLabelLayer(layer)) { + const paint: Array<[MeshLabelPaintProperty, unknown]> = [ + ["text-color", "#ffffff"], + ["text-halo-color", MESH_LABEL_HALO_COLOR], + ["text-halo-width", MESH_STREET_LABEL_HALO_WIDTH], + ["text-halo-blur", 0], + ]; + const size = scaleTextSize( + authoredProperty(layer, "text-size"), + MESH_STREET_LABEL_SIZE_FACTOR + ); + if (size !== undefined) paint.push(["text-size", size]); + return paint; + } + if (textColor === null) return []; + const paint: Array<[MeshLabelPaintProperty, unknown]> = [ + ["text-color", textColor], + ["text-halo-color", MESH_LABEL_HALO_COLOR], + ]; + // Flat white SDF icons (churches, POIs) take the sun color as well. + if (isWhiteLabelHalo(authoredProperty(layer, "icon-color"))) { + paint.push(["icon-color", textColor]); + } + return paint; +}; + +/** Expression values come back as fresh arrays; compare by content. */ +const isSameMeshLabelValue = (left: unknown, right: unknown): boolean => + left === right || + (Array.isArray(left) && + Array.isArray(right) && + JSON.stringify(left) === JSON.stringify(right)); + +const restoreMeshLabelPaint = ( + map: MaplibreMap, + saved: Map +): void => { + for (const entry of saved.values()) { + try { + const runtimeLayer = map.getLayer(entry.layerId) as + | RuntimeStyleLayer + | undefined; + if ( + runtimeLayer && + getLayerSignature(runtimeLayer) === entry.signature && + isSameMeshLabelValue( + getMeshLabelProperty(map, entry.layerId, entry.property), + entry.applied + ) + ) { + setMeshLabelProperty( + map, + entry.layerId, + entry.property, + entry.original === undefined ? null : entry.original + ); + } + } catch { + // The host may already have disposed or replaced its style. + } + } + saved.clear(); +}; + +const applyMeshLabelPaint = ( + map: MaplibreMap, + layers: readonly RuntimeStyleLayer[], + saved: Map, + textColor: string | null +): void => { + const wanted = new Map< + string, + [RuntimeStyleLayer, MeshLabelPaintProperty, unknown] + >(); + const authoredProperty = ( + layer: RuntimeStyleLayer, + property: MeshLabelPaintProperty + ): unknown => { + const entry = saved.get(`${layer.id}|${property}`); + if (entry && entry.signature === getLayerSignature(layer)) { + return entry.original; + } + try { + return getMeshLabelProperty(map, layer.id, property); + } catch { + return undefined; + } + }; + for (const layer of layers) { + for (const [property, value] of getMeshLabelPaint( + map, + layer, + textColor, + authoredProperty + )) { + wanted.set(`${layer.id}|${property}`, [layer, property, value]); + } + } + // Hand back paint that is no longer wanted (layer gone, sun color gone). + const stale = new Map(); + for (const [key, entry] of saved) { + if (!wanted.has(key)) { + stale.set(key, entry); + saved.delete(key); + } + } + restoreMeshLabelPaint(map, stale); + for (const [key, [layer, property, value]] of wanted) { + try { + const signature = getLayerSignature(layer); + const current = getMeshLabelProperty(map, layer.id, property); + let entry = saved.get(key); + if ( + !entry || + entry.signature !== signature || + !isSameMeshLabelValue(current, entry.applied) + ) { + entry = { + layerId: layer.id, + property, + signature, + original: current, + applied: value, + }; + saved.set(key, entry); + } else { + entry.applied = value; + } + if (!isSameMeshLabelValue(current, value)) { + setMeshLabelProperty(map, layer.id, property, value); + } + } catch { + // A style rebuild can remove a layer between inspection and update. + } + } +}; + +const restoreLabelLifts = ( + map: MaplibreMap, + saved: Map +): void => { + for (const [layerId, entry] of saved) { + try { + const runtimeLayer = map.getLayer(layerId) as + | RuntimeStyleLayer + | undefined; + if ( + !runtimeLayer || + getLayerSignature(runtimeLayer) !== entry.signature + ) { + continue; + } + map.setPaintProperty( + layerId, + "text-translate", + entry.originalTranslate === undefined ? null : entry.originalTranslate + ); + map.setPaintProperty( + layerId, + "text-translate-anchor", + entry.originalAnchor === undefined ? null : entry.originalAnchor + ); + } catch { + // The host may already have disposed or replaced its style. + } + } + saved.clear(); +}; + +/** + * Register the place-name layers that float above the scene and pin their + * translate to the viewport, so the per-frame lift is a plain pixel offset. + */ +const configureLabelLifts = ( + map: MaplibreMap, + entry: SharedSceneEntry, + layers: readonly RuntimeStyleLayer[] +): void => { + const next: LabelLiftLayer[] = []; + for (const layer of layers) { + const meters = getMapStylePointLabelLiftMeters(layer); + if (meters === null) continue; + next.push({ id: layer.id, signature: getLayerSignature(layer), meters }); + } + const nextIds = new Set(next.map(({ id }) => id)); + const stale = new Map(); + for (const [layerId, saved] of entry.savedLabelLifts) { + if (!nextIds.has(layerId)) { + stale.set(layerId, saved); + entry.savedLabelLifts.delete(layerId); + } + } + restoreLabelLifts(map, stale); + for (const lift of next) { + try { + let saved = entry.savedLabelLifts.get(lift.id); + if (!saved || saved.signature !== lift.signature) { + saved = { + signature: lift.signature, + originalTranslate: map.getPaintProperty(lift.id, "text-translate"), + originalAnchor: map.getPaintProperty( + lift.id, + "text-translate-anchor" + ), + appliedPixels: Number.NaN, + }; + entry.savedLabelLifts.set(lift.id, saved); + } + if ( + map.getPaintProperty(lift.id, "text-translate-anchor") !== "viewport" + ) { + map.setPaintProperty(lift.id, "text-translate-anchor", "viewport"); + } + } catch { + // A style rebuild can remove a layer between inspection and update. + } + } + entry.liftLayers = next; +}; + +/** + * Screen pixels a point `meters` above the ground moves up at the map center + * for the current zoom and pitch. Every label of a layer gets the same lift, + * which reads as a uniform floating height across the view. + */ +const getLabelLiftPixels = ( + map: MaplibreMap, + meters: number +): number | null => { + const zoom = map.getZoom?.(); + const pitch = map.getPitch?.(); + const center = map.getCenter?.(); + const canvas = map.getCanvas?.(); + if ( + typeof zoom !== "number" || + typeof pitch !== "number" || + !center || + !Number.isFinite(center.lat) + ) { + return null; + } + const pixelsPerMeter = + (MAPLIBRE_TILE_SIZE * 2 ** zoom) / + (EARTH_CIRCUMFERENCE_METERS * Math.cos((center.lat * Math.PI) / 180)); + const lifted = meters * pixelsPerMeter * Math.cos((pitch * Math.PI) / 180); + const viewportHeight = canvas?.clientHeight ?? 0; + const cap = + viewportHeight > 0 + ? viewportHeight * MAX_LABEL_LIFT_VIEWPORT_FRACTION + : Number.POSITIVE_INFINITY; + return Math.min(lifted, cap); +}; + +const updateLabelLiftPaint = (map: MaplibreMap, entry: SharedSceneEntry) => { + for (const lift of entry.liftLayers) { + const saved = entry.savedLabelLifts.get(lift.id); + if (!saved) continue; + const pixels = getLabelLiftPixels(map, lift.meters); + if (pixels === null) return; + if (Math.abs(pixels - saved.appliedPixels) < 0.5) continue; + try { + map.setPaintProperty(lift.id, "text-translate", [0, -pixels]); + saved.appliedPixels = pixels; + } catch { + // A style rebuild can remove a layer between inspection and update. + } + } +}; + +const isSpriteImageData = (value: unknown): value is SpriteImageData => { + if (!value || typeof value !== "object") return false; + const image = value as Partial; + return ( + typeof image.width === "number" && + typeof image.height === "number" && + (image.data instanceof Uint8Array || + image.data instanceof Uint8ClampedArray) + ); +}; + +const getSpriteImageData = ( + map: MaplibreMap, + id: string +): SpriteImageData | null => { + const host = map as unknown as { + style?: { imageManager?: { getImage?: (id: string) => unknown } }; + }; + try { + const image = host.style?.imageManager?.getImage?.(id) as + | { data?: unknown; sdf?: boolean } + | undefined; + if (!image || image.sdf || !isSpriteImageData(image.data)) return null; + return image.data; + } catch { + return null; + } +}; + +/** Every visible sprite is lit by the sun; fully transparent ones are skipped. */ +const isSunTintableSprite = ({ data }: SpriteImageData): boolean => { + for (let index = 3; index < data.length; index += 4) { + if (data[index] > 0) return true; + } + return false; +}; + +const parseHexColor = (color: string): [number, number, number] | null => { + const match = /^#([0-9a-f]{6})$/i.exec(color.trim()); + if (!match) return null; + const value = Number.parseInt(match[1], 16); + return [(value >> 16) & 255, (value >> 8) & 255, value & 255]; +}; + +const tintSpriteImage = ( + image: SpriteImageData, + rgb: [number, number, number] +): SpriteImageData => { + // The sprite is lit by the sun: its authored color is the albedo, the sun + // color the light, and the result is their product per channel. White + // becomes the sun color, a yellow shield a sun-lit yellow, black stays + // black. + const data = new Uint8Array(image.data); + for (let index = 0; index < data.length; index += 4) { + if (data[index + 3] === 0) continue; + data[index] = Math.round((data[index] * rgb[0]) / 255); + data[index + 1] = Math.round((data[index + 1] * rgb[1]) / 255); + data[index + 2] = Math.round((data[index + 2] * rgb[2]) / 255); + } + return { width: image.width, height: image.height, data }; +}; + +const restoreMeshIconTint = (map: MaplibreMap, entry: SharedSceneEntry) => { + for (const [id, tinted] of entry.tintedImages) { + try { + if (map.hasImage?.(id)) map.updateImage(id, tinted.original as never); + } catch { + // The sprite may already have been replaced with the style. + } + } + entry.tintedImages.clear(); +}; + +const applyMeshIconTint = ( + map: MaplibreMap, + entry: SharedSceneEntry, + color: string | null +) => { + const rgb = color === null ? null : parseHexColor(color); + if (!rgb || typeof map.listImages !== "function") { + restoreMeshIconTint(map, entry); + return; + } + let ids: string[]; + try { + ids = map.listImages(); + } catch { + return; + } + const present = new Set(ids); + for (const id of entry.tintedImages.keys()) { + if (!present.has(id)) entry.tintedImages.delete(id); + } + for (const id of ids) { + const tinted = entry.tintedImages.get(id); + if (tinted?.color === color) continue; + const original = tinted?.original ?? getSpriteImageData(map, id); + if (!original) continue; + if (!tinted) { + if (!isSunTintableSprite(original)) continue; + // Keep a private copy: the manager hands out its live buffer. + entry.tintedImages.set(id, { + original: { + width: original.width, + height: original.height, + data: new Uint8Array(original.data), + }, + color, + }); + } else { + tinted.color = color; + } + try { + map.updateImage( + id, + tintSpriteImage(entry.tintedImages.get(id)!.original, rgb) as never + ); + } catch { + entry.tintedImages.delete(id); + } + } +}; + +const isPointLabelOverlayVisible = (entry: SharedSceneEntry): boolean => { + let visible = true; + for (const requestedVisibility of entry.pointLabelOverlayVisibilityRequests.values()) { + visible = requestedVisibility; + } + return visible; +}; + +const ensureSharedLayerOrder = ( + map: MaplibreMap, + entry: SharedSceneEntry, + textColor: string | null, + pointLabelOverlayVisible: boolean +): void => { + const sceneLayer = entry.layer; + const savedOffsets = entry.savedLocationLabelOffsets; + const savedHaloWidths = entry.savedLocationLabelHaloWidths; + const savedHaloColors = entry.savedLocationLabelHaloColors; + const savedTextColors = entry.savedLocationLabelTextColors; + const savedFilters = entry.savedLocationLabelFilters; + const savedVisibilities = entry.savedPointLabelVisibilities; + const layerOrder = map.getLayersOrder(); + const layerIndex = layerOrder.indexOf(SHARED_SCENE_LAYER_ID); + if (layerIndex < 0) return; + + const locationLabelLayers = getMapStylePointLabelLayers( + map, + entry, + layerOrder + ); + suppressTerrainFrame(map, entry); + const meshLabelStyle = isMeshLabelStyle(entry); + if (meshLabelStyle) { + applyMeshDrape(map, entry, getCachedStyleLayers(map, entry, layerOrder)); + } else { + restoreMeshDrape(map, entry); + } + if (!pointLabelOverlayVisible) { + restoreMeshIconTint(map, entry); + restoreMeshLabelPaint(map, entry.savedMeshLabelPaint); + restoreLabelLifts(map, entry.savedLabelLifts); + entry.liftLayers = []; + restoreLocationLabelOffsets(map, savedOffsets); + restoreLocationLabelPaint(map, "text-halo-width", savedHaloWidths); + restoreLocationLabelPaint(map, "text-halo-color", savedHaloColors); + restoreLocationLabelPaint(map, "text-color", savedTextColors); + restoreLocationLabelFilters(map, savedFilters); + setPointLabelLayersHidden( + map, + locationLabelLayers, + savedVisibilities, + true + ); + return; + } + setPointLabelLayersHidden(map, locationLabelLayers, savedVisibilities, false); + applyLocationLabelCoverageFilters( + map, + sceneLayer, + locationLabelLayers, + savedFilters + ); + // Hand the mesh paint back before the default rules read the layers, so + // they see the authored values and not the mesh colors as "originals". + if (!meshLabelStyle) restoreMeshLabelPaint(map, entry.savedMeshLabelPaint); + applyLocationLabelOffsets( + map, + locationLabelLayers, + savedOffsets, + savedHaloWidths, + savedHaloColors, + savedTextColors, + textColor, + meshLabelStyle ? isMeshStyledLabelLayer : undefined + ); + if (meshLabelStyle) { + applyMeshLabelPaint( + map, + [ + ...locationLabelLayers, + ...getMapStyleLineLabelLayers(map, entry, layerOrder), + ].filter(isMeshStyledLabelLayer), + entry.savedMeshLabelPaint, + textColor + ); + applyMeshIconTint(map, entry, textColor); + } else { + restoreMeshIconTint(map, entry); + } + configureLabelLifts(map, entry, locationLabelLayers); + updateLabelLiftPaint(map, entry); + const locationLabelIds = locationLabelLayers + .map(({ id }) => id) + .filter((id) => layerOrder.includes(id)); + const locationLabelSet = new Set(locationLabelIds); + const expectedOrder = [ + ...layerOrder.filter( + (id) => id !== SHARED_SCENE_LAYER_ID && !locationLabelSet.has(id) + ), + SHARED_SCENE_LAYER_ID, + ...locationLabelIds, + ]; + if (expectedOrder.every((id, index) => layerOrder[index] === id)) return; + + // Capture the complete authored style below Three, then redraw point-based + // labels above it. Line labels remain part of the projected, shadowed + // terrain texture instead of being drawn a second time. + map.moveLayer(SHARED_SCENE_LAYER_ID); + for (const id of locationLabelIds) map.moveLayer(id); +}; + +const clearLabelMaintenanceTimer = (entry: SharedSceneEntry): void => { + if (entry.labelMaintenanceTimer === null) return; + clearTimeout(entry.labelMaintenanceTimer); + entry.labelMaintenanceTimer = null; +}; + +const mountSharedLayer = (map: MaplibreMap, entry: SharedSceneEntry): void => { + try { + if (!getMountedSharedThreeSceneLayer(map)) map.addLayer(entry.layer); + } catch { + // A style replacement or map teardown can race this callback. + } +}; + +const configureEnsureLayer = ( + map: MaplibreMap, + entry: SharedSceneEntry +): void => { + entry.updateLabelLift = () => { + if (entry.disposed || entry.liftLayers.length === 0) return; + updateLabelLiftPaint(map, entry); + }; + entry.ensureLayerNow = () => { + if (entry.disposed) return; + clearLabelMaintenanceTimer(entry); + entry.lastLabelMaintenanceMs = Date.now(); + mountSharedLayer(map, entry); + try { + ensureSharedLayerOrder( + map, + entry, + getLocationLabelColor(entry), + isPointLabelOverlayVisible(entry) + ); + } catch { + // A style replacement or map teardown can race this callback. + } + }; + entry.ensureLayer = (event) => { + if (entry.disposed) return; + if ( + (event as { type?: unknown } | undefined)?.type === + MAPLIBRE_EVENT.STYLE_LOAD + ) { + // A new style carries new layer objects; drop the classification. + entry.styleLayersCache = null; + entry.ensureLayerNow(); + return; + } + const elapsedMs = Date.now() - entry.lastLabelMaintenanceMs; + if (elapsedMs >= LABEL_OVERLAY_MAINTENANCE_INTERVAL_MS) { + entry.ensureLayerNow(); + return; + } + // Mounting is cheap and must not wait: the custom layer has to exist for + // the next frame. Only the label overlay maintenance is rate limited. + mountSharedLayer(map, entry); + if (entry.labelMaintenanceTimer !== null) return; + entry.labelMaintenanceTimer = setTimeout(() => { + entry.labelMaintenanceTimer = null; + entry.ensureLayerNow(); + }, LABEL_OVERLAY_MAINTENANCE_INTERVAL_MS - elapsedMs); + }; +}; + +const addEnsureLayerListeners = ( + map: MaplibreMap, + entry: SharedSceneEntry +): void => { + map.on(MAPLIBRE_EVENT.STYLE_DATA, entry.ensureLayer); + map.on(MAPLIBRE_EVENT.STYLE_LOAD, entry.ensureLayer); + map.on(MAPLIBRE_EVENT.IDLE, entry.ensureLayer); + map.on(MAPLIBRE_EVENT.MOVE, entry.updateLabelLift); +}; + +const removeEnsureLayerListeners = ( + map: MaplibreMap, + entry: SharedSceneEntry +): void => { + map.off(MAPLIBRE_EVENT.STYLE_DATA, entry.ensureLayer); + map.off(MAPLIBRE_EVENT.STYLE_LOAD, entry.ensureLayer); + map.off(MAPLIBRE_EVENT.IDLE, entry.ensureLayer); + map.off(MAPLIBRE_EVENT.MOVE, entry.updateLabelLift); +}; + +/** + * Acquire the one shared Three.js custom layer belonging to a MapLibre map. + * Consumers contribute runtimes or lights and release their lease on cleanup; + * the last release removes and disposes the shared renderer and scene. + */ +export const acquireSharedThreeScene = ( + map: MaplibreMap +): SharedThreeSceneLease => { + let entry = entries.get(map); + if (entry && entry.version !== SHARED_SCENE_ENTRY_VERSION) { + removeEnsureLayerListeners(map, entry); + if (entry.labelMaintenanceTimer != null) { + clearTimeout(entry.labelMaintenanceTimer); + } + entry.labelMaintenanceTimer = null; + entry.lastLabelMaintenanceMs = Number.NEGATIVE_INFINITY; + entry.styleLayersCache = null; + entry.savedMeshDrapeVisibilities ??= new Map(); + entry.savedContourOpacities ??= new Map(); + entry.terrainFramePatch ??= null; + restoreTerrainFrame(entry); + restoreMeshDrape(map, entry); + entry.liftLayers ??= []; + entry.savedLabelLifts ??= new Map(); + entry.tintedImages ??= new Map(); + restoreMeshIconTint(map, entry); + restoreMeshLabelPaint(map, entry.savedMeshLabelPaint ?? new Map()); + restoreLabelLifts(map, entry.savedLabelLifts); + entry.liftLayers = []; + restoreLocationLabelOffsets( + map, + entry.savedLocationLabelOffsets ?? new Map() + ); + restoreLocationLabelPaint( + map, + "text-halo-width", + entry.savedLocationLabelHaloWidths ?? new Map() + ); + restoreLocationLabelPaint( + map, + "text-halo-color", + entry.savedLocationLabelHaloColors ?? new Map() + ); + restoreLocationLabelPaint( + map, + "text-color", + entry.savedLocationLabelTextColors ?? new Map() + ); + restoreLocationLabelFilters( + map, + entry.savedLocationLabelFilters ?? new Map() + ); + setPointLabelLayersHidden( + map, + [], + entry.savedPointLabelVisibilities ?? new Map(), + false + ); + entry.version = SHARED_SCENE_ENTRY_VERSION; + entry.savedLocationLabelOffsets ??= new Map(); + entry.savedLocationLabelHaloWidths ??= new Map(); + entry.savedLocationLabelHaloColors ??= new Map(); + entry.savedLocationLabelTextColors ??= new Map(); + entry.savedLocationLabelFilters ??= new Map(); + entry.savedPointLabelVisibilities ??= new Map(); + entry.locationLabelColorRequests ??= new Map(); + entry.pointLabelOverlayVisibilityRequests ??= new Map(); + entry.meshLabelStyleRequests ??= new Map(); + entry.savedMeshLabelPaint ??= new Map(); + entry.updateLabelLift ??= () => undefined; + configureEnsureLayer(map, entry); + addEnsureLayerListeners(map, entry); + entry.ensureLayer(); + } + if (!entry) { + // Vite may reload this registry while MapLibre keeps the existing custom + // layer alive. Reuse that mounted implementation so new runtimes are not + // attached to a fresh, unmounted Three.js scene. + const layer = + getMountedSharedThreeSceneLayer(map) ?? + buildSharedThreeSceneLayer(SHARED_SCENE_LAYER_ID, { + ambientLightIntensity: 0.58, + }); + const nextEntry: SharedSceneEntry = { + version: SHARED_SCENE_ENTRY_VERSION, + layer, + references: 0, + disposed: false, + styleLayersCache: null, + labelMaintenanceTimer: null, + lastLabelMaintenanceMs: Number.NEGATIVE_INFINITY, + savedLocationLabelOffsets: new Map(), + savedLocationLabelHaloWidths: new Map(), + savedLocationLabelHaloColors: new Map(), + savedLocationLabelTextColors: new Map(), + savedLocationLabelFilters: new Map(), + savedPointLabelVisibilities: new Map(), + locationLabelColorRequests: new Map(), + pointLabelOverlayVisibilityRequests: new Map(), + meshLabelStyleRequests: new Map(), + savedMeshLabelPaint: new Map(), + savedMeshDrapeVisibilities: new Map(), + savedContourOpacities: new Map(), + terrainFramePatch: null, + liftLayers: [], + savedLabelLifts: new Map(), + tintedImages: new Map(), + updateLabelLift: () => undefined, + ensureLayer: () => undefined, + ensureLayerNow: () => undefined, + }; + configureEnsureLayer(map, nextEntry); + entries.set(map, nextEntry); + addEnsureLayerListeners(map, nextEntry); + nextEntry.ensureLayer(); + entry = nextEntry; + } + + entry.references += 1; + const labelColorRequestId = Symbol("location-label-color"); + const labelVisibilityRequestId = Symbol("point-label-overlay-visibility"); + const meshLabelStyleRequestId = Symbol("mesh-label-style"); + let released = false; + + return { + layer: entry.layer, + setLocationLabelColor(color) { + const current = entries.get(map); + if (!current || current !== entry || released) return; + const existingColor = + current.locationLabelColorRequests.get(labelColorRequestId); + if (color === null) { + if (existingColor === undefined) return; + current.locationLabelColorRequests.delete(labelColorRequestId); + } else { + if (existingColor === color) return; + current.locationLabelColorRequests.set(labelColorRequestId, color); + } + current.ensureLayer(); + }, + setPointLabelOverlayVisible(visible) { + const current = entries.get(map); + if (!current || current !== entry || released) return; + if ( + current.pointLabelOverlayVisibilityRequests.get( + labelVisibilityRequestId + ) === visible + ) { + return; + } + current.pointLabelOverlayVisibilityRequests.set( + labelVisibilityRequestId, + visible + ); + // A user toggle should repaint in place, not after the rate limit. + current.ensureLayerNow(); + }, + setMeshLabelStyle(enabled) { + const current = entries.get(map); + if (!current || current !== entry || released) return; + if ( + current.meshLabelStyleRequests.get(meshLabelStyleRequestId) === enabled + ) { + return; + } + current.meshLabelStyleRequests.set(meshLabelStyleRequestId, enabled); + current.ensureLayerNow(); + }, + release() { + if (released) return; + released = true; + const current = entries.get(map); + if (!current || current !== entry) return; + current.locationLabelColorRequests.delete(labelColorRequestId); + current.pointLabelOverlayVisibilityRequests.delete( + labelVisibilityRequestId + ); + current.meshLabelStyleRequests.delete(meshLabelStyleRequestId); + current.references -= 1; + if (current.references > 0) { + current.ensureLayer(); + return; + } + + current.disposed = true; + clearLabelMaintenanceTimer(current); + removeEnsureLayerListeners(map, current); + try { + if (getMountedSharedThreeSceneLayer(map) === current.layer) { + map.removeLayer(current.layer.id); + } + } catch { + // The host may already have disposed or replaced its style. + } + restoreMeshIconTint(map, current); + restoreTerrainFrame(current); + restoreMeshDrape(map, current); + restoreMeshLabelPaint(map, current.savedMeshLabelPaint); + restoreLabelLifts(map, current.savedLabelLifts); + restoreLocationLabelOffsets(map, current.savedLocationLabelOffsets); + restoreLocationLabelPaint( + map, + "text-halo-width", + current.savedLocationLabelHaloWidths + ); + restoreLocationLabelPaint( + map, + "text-halo-color", + current.savedLocationLabelHaloColors + ); + restoreLocationLabelPaint( + map, + "text-color", + current.savedLocationLabelTextColors + ); + restoreLocationLabelFilters(map, current.savedLocationLabelFilters); + setPointLabelLayersHidden( + map, + [], + current.savedPointLabelVisibilities, + false + ); + terrainCoverageCache.delete(current.layer); + current.layer.dispose(); + entries.delete(map); + }, + }; +}; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-terrain-registry.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-terrain-registry.spec.ts new file mode 100644 index 0000000000..b922a7f0a7 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-terrain-registry.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + getSharedThreeTerrainElevation, + isSharedThreeTerrainLoading, + notifySharedThreeTerrainChanged, + registerSharedThreeTerrainSampler, + subscribeSharedThreeTerrain, + subscribeSharedThreeTerrainLoading, + setSharedThreeTerrainLoading, +} from "./shared-three-terrain-registry"; + +describe("shared Three terrain registry", () => { + it("samples registered decoded terrain and notifies consumers", () => { + const map = {} as never; + const listener = vi.fn(); + const unsubscribe = subscribeSharedThreeTerrain(map, listener); + const unregister = registerSharedThreeTerrainSampler( + map, + "terrain", + () => 157.25 + ); + + expect(getSharedThreeTerrainElevation(map, 7.15, 51.25)).toBe(157.25); + expect(listener).toHaveBeenCalledOnce(); + + notifySharedThreeTerrainChanged(map); + expect(listener).toHaveBeenCalledTimes(2); + + unregister(); + expect(getSharedThreeTerrainElevation(map, 7.15, 51.25)).toBeUndefined(); + expect(listener).toHaveBeenCalledTimes(3); + unsubscribe(); + }); + + it("tracks terrain loading independently for every runtime", () => { + const map = {} as never; + const listener = vi.fn(); + const unsubscribe = subscribeSharedThreeTerrainLoading(map, listener); + + setSharedThreeTerrainLoading(map, "terrain-a", true); + setSharedThreeTerrainLoading(map, "terrain-b", true); + expect(isSharedThreeTerrainLoading(map)).toBe(true); + + setSharedThreeTerrainLoading(map, "terrain-a", false); + expect(isSharedThreeTerrainLoading(map)).toBe(true); + setSharedThreeTerrainLoading(map, "terrain-b", false); + expect(isSharedThreeTerrainLoading(map)).toBe(false); + expect(listener).toHaveBeenCalledTimes(4); + unsubscribe(); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-terrain-registry.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-terrain-registry.ts new file mode 100644 index 0000000000..0b39f4c286 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-terrain-registry.ts @@ -0,0 +1,97 @@ +import type { Map as MaplibreMap } from "maplibre-gl"; + +type TerrainHeightSampler = ( + longitude: number, + latitude: number +) => number | undefined; + +const samplers = new WeakMap>(); +const listeners = new WeakMap void>>(); +const loadingRuntimeIds = new WeakMap>(); +const loadingListeners = new WeakMap void>>(); + +const notifySharedThreeTerrainLoadingChanged = (map: MaplibreMap) => { + for (const listener of loadingListeners.get(map) ?? []) listener(); +}; + +export const setSharedThreeTerrainLoading = ( + map: MaplibreMap, + runtimeId: string, + loading: boolean +) => { + const runtimeIds = loadingRuntimeIds.get(map) ?? new Set(); + const changed = loading + ? !runtimeIds.has(runtimeId) + : runtimeIds.has(runtimeId); + if (!changed) return; + if (loading) { + runtimeIds.add(runtimeId); + loadingRuntimeIds.set(map, runtimeIds); + } else { + runtimeIds.delete(runtimeId); + if (runtimeIds.size === 0) loadingRuntimeIds.delete(map); + } + notifySharedThreeTerrainLoadingChanged(map); +}; + +export const isSharedThreeTerrainLoading = (map: MaplibreMap): boolean => + (loadingRuntimeIds.get(map)?.size ?? 0) > 0; + +export const subscribeSharedThreeTerrainLoading = ( + map: MaplibreMap, + listener: () => void +): (() => void) => { + const mapListeners = loadingListeners.get(map) ?? new Set<() => void>(); + mapListeners.add(listener); + loadingListeners.set(map, mapListeners); + return () => { + mapListeners.delete(listener); + if (mapListeners.size === 0) loadingListeners.delete(map); + }; +}; + +export const notifySharedThreeTerrainChanged = (map: MaplibreMap) => { + for (const listener of listeners.get(map) ?? []) listener(); +}; + +export const subscribeSharedThreeTerrain = ( + map: MaplibreMap, + listener: () => void +): (() => void) => { + const mapListeners = listeners.get(map) ?? new Set<() => void>(); + mapListeners.add(listener); + listeners.set(map, mapListeners); + return () => { + mapListeners.delete(listener); + if (mapListeners.size === 0) listeners.delete(map); + }; +}; + +export const registerSharedThreeTerrainSampler = ( + map: MaplibreMap, + id: string, + sampler: TerrainHeightSampler +): (() => void) => { + const mapSamplers = + samplers.get(map) ?? new Map(); + mapSamplers.set(id, sampler); + samplers.set(map, mapSamplers); + notifySharedThreeTerrainChanged(map); + return () => { + mapSamplers.delete(id); + if (mapSamplers.size === 0) samplers.delete(map); + notifySharedThreeTerrainChanged(map); + }; +}; + +export const getSharedThreeTerrainElevation = ( + map: MaplibreMap, + longitude: number, + latitude: number +): number | undefined => { + for (const sampler of samplers.get(map)?.values() ?? []) { + const height = sampler(longitude, latitude); + if (Number.isFinite(height)) return height; + } + return undefined; +}; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-admission.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-admission.spec.ts new file mode 100644 index 0000000000..1c4ee54cff --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-admission.spec.ts @@ -0,0 +1,347 @@ +// @vitest-environment jsdom + +/** + * D2 byte-accounted admission of the 3D Tiles runtime against the real + * `TilesRenderer` traversal, `LRUCache` and priority queues of + * 3d-tiles-renderer 0.5.2: tiles register a predicted size when they are + * admitted, so the cache fills before downloads finish, late completions are + * never discarded, in-flight downloads are never aborted by the over-max + * eviction, and the request concurrency follows the remaining headroom. + * + * Frame scheduling (`requestAnimationFrame`) and download latency are driven + * deterministically: one `update()` per animation frame, every fetch resolves + * at a later frame boundary. + */ + +import { TilesRenderer } from "3d-tiles-renderer"; +import type { Tile } from "3d-tiles-renderer/core"; +import type { Map as MaplibreMap } from "maplibre-gl"; +import * as THREE from "three"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + TILES_CACHE_CEILING_BYTES, + TILES_LOAD_POLICY, + TILE_BYTES_PREDICTION, +} from "./three-tiles-load-policy"; +import { buildThreeTilesRuntime } from "./three-tiles-runtime"; + +vi.hoisted(() => { + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: () => "blob:vitest-maplibre-worker", + }); +}); + +const MIB = 1024 ** 2; +const ROOT_URL = "https://tiles.test/admission/tileset.json"; +const CHILD_COUNT = 48; +const CEILING = TILES_CACHE_CEILING_BYTES.floor; +/** Measured bytes whose resident-overhead price averages the initial estimate. */ +const BASE_MEASURED_BYTES = + TILE_BYTES_PREDICTION.initialBytes / TILES_LOAD_POLICY.residentOverhead; +const LOADED = 4; + +type HarnessTile = Tile & { + engineData: { + scene: THREE.Object3D | null; + geometry: THREE.BufferGeometry[] | null; + materials: THREE.Material[] | null; + textures: THREE.Texture[] | null; + }; +}; +type HarnessRenderer = TilesRenderer & { + root: HarnessTile | null; + loadingTiles: Set; + stats: { + queued: number; + downloading: number; + parsing: number; + failed: number; + }; + calculateTileViewError: ( + tile: { geometricError: number }, + target: { inView: boolean; error: number; distanceFromCamera: number } + ) => void; + calculateBytesUsed: (tile: Tile, scene: THREE.Object3D | null) => number; +}; +type HarnessCache = TilesRenderer["lruCache"] & { + itemSet: Map; + cachedBytes: number; +}; + +const tileUri = (tile: Tile) => tile.content?.uri ?? ""; + +/** +-50 % drift around the base size, deterministic per child index. */ +const measuredBytesFor = (uri: string) => { + const index = Number(uri.replace(/\D/g, "")) || 0; + const drift = [0.5, 1.5, 1, 0.75, 1.25, 0.6, 1.4, 0.9][index % 8]; + return Math.round(BASE_MEASURED_BYTES * drift); +}; + +const buildTilesetJson = () => ({ + asset: { version: "1.0" }, + geometricError: 100, + root: { + boundingVolume: { box: [0, 0, 0, 80, 0, 0, 0, 60, 0, 0, 0, 1] }, + geometricError: 100, + refine: "REPLACE", + children: Array.from({ length: CHILD_COUNT }, (_, index) => ({ + boundingVolume: { + box: [ + -70 + (index % 8) * 20, + -50 + Math.floor(index / 8) * 20, + 0, + 10, + 0, + 0, + 0, + 10, + 0, + 0, + 0, + 1, + ], + }, + geometricError: 1, + refine: "REPLACE", + content: { uri: `tile-${index}.b3dm` }, + })), + }, +}); + +describe("three tiles admission (D2)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("settles at the ceiling without discards or in-flight aborts under +-50 % size drift", async () => { + vi.spyOn(console, "warn").mockImplementation(() => undefined); + // Deterministic animation frames. + let frame = 0; + let nextHandle = 1; + const frameCallbacks = new Map(); + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + const handle = nextHandle++; + frameCallbacks.set(handle, cb); + return handle; + }); + vi.stubGlobal("cancelAnimationFrame", (handle: number) => { + frameCallbacks.delete(handle); + }); + // Deterministic network: every fetch resolves at a later frame boundary. + const pendingFetches: Array<{ dueFrame: number; resolve: () => void }> = []; + const deferUntilFrame = (dueFrame: number, value: () => T) => + new Promise((resolve) => { + pendingFetches.push({ dueFrame, resolve: () => resolve(value()) }); + }); + const fetchesPerTile = new Map(); + let requestIndex = 0; + vi.stubGlobal("fetch", (input: string | URL) => { + const url = String(input); + if (url === ROOT_URL) { + return deferUntilFrame( + frame + 1, + () => + new Response(JSON.stringify(buildTilesetJson()), { + headers: { "content-type": "application/json" }, + }) + ); + } + const uri = url.slice(url.lastIndexOf("/") + 1); + fetchesPerTile.set(uri, (fetchesPerTile.get(uri) ?? 0) + 1); + const latency = 1 + (requestIndex++ % 3); + return deferUntilFrame( + frame + latency, + () => new Response(new ArrayBuffer(16)) + ); + }); + // The measured size of a parsed tile (the runtime adds the resident overhead). + vi.spyOn( + TilesRenderer.prototype as unknown as { + calculateBytesUsed: ( + tile: Tile, + scene: THREE.Object3D | null + ) => number | null; + }, + "calculateBytesUsed" + ).mockImplementation((tile, scene) => + scene ? measuredBytesFor(tileUri(tile)) : null + ); + + let captured: HarnessRenderer | undefined; + const registerPlugin = TilesRenderer.prototype.registerPlugin; + vi.spyOn(TilesRenderer.prototype, "registerPlugin").mockImplementation( + function (this: TilesRenderer, plugin: object) { + captured = this as HarnessRenderer; + return registerPlugin.call(this, plugin); + } + ); + const map = { + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + getZoom: () => 17, + getPitch: () => 45, + } as unknown as MaplibreMap; + // A style budget below the floor: the ceiling is the 128 MiB floor. + const layer = buildThreeTilesRuntime("mesh", ROOT_URL, [7.15, 51.25], { + cacheBudgetBytes: 1, + cacheOverflowBytes: 0, + }); + layer.onAdd?.(map); + const tiles = captured!; + const cache = tiles.lruCache as HarnessCache; + expect(cache.isFull()).toBe(false); + // Every tile is in view and above the target (5 px per metre of error). + tiles.calculateTileViewError = (tile, target) => { + target.inView = true; + target.error = tile.geometricError * 5; + target.distanceFromCamera = 100; + }; + const disposals: Array<{ uri: string; state: number; frame: number }> = []; + tiles.registerPlugin({ + name: "TEST_PARSE_PLUGIN", + parseTile: (_buffer: ArrayBuffer, tile: Tile) => { + const geometry = new THREE.BoxGeometry(1, 1, 1); + const material = new THREE.MeshBasicMaterial(); + const scene = new THREE.Group(); + scene.add(new THREE.Mesh(geometry, material)); + const engineData = (tile as HarnessTile).engineData; + engineData.scene = scene; + engineData.geometry = [geometry]; + engineData.materials = [material]; + engineData.textures = []; + return Promise.resolve(); + }, + disposeTile: (tile: Tile) => { + disposals.push({ + uri: tileUri(tile), + state: tile.internal.loadingState, + frame, + }); + }, + }); + // The completion branch of requestTileContents removes a LOADED tile whose + // memory was never registered: the "discard after load" signature. + const discardsAfterLoad: string[] = []; + const originalRemove = cache.remove.bind(cache); + cache.remove = (item: Tile) => { + if ( + item.internal?.loadingState === LOADED && + cache.getMemoryUsage(item) === 0 + ) { + discardsAfterLoad.push(tileUri(item)); + } + return originalRemove(item); + }; + + const camera = new THREE.PerspectiveCamera(60, 800 / 600, 1, 1_000); + camera.position.set(0, 0, 60); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(true); + const frameInput = { + map, + renderCamera: camera, + lodCamera: camera, + lookTarget: new THREE.Vector3(), + viewport: new THREE.Vector2(800, 600), + }; + const flushMicrotasks = () => + new Promise((resolve) => setTimeout(resolve, 0)); + const concurrencySamples: number[] = []; + const cachedBytesSamples: number[] = []; + for (let index = 0; index < 80; index += 1) { + tiles.dispatchEvent({ type: "needs-update" }); + layer.root.updateMatrixWorld(true); + layer.update(frameInput); + concurrencySamples.push(tiles.downloadQueue.maxJobsPerOrigin); + cachedBytesSamples.push(cache.cachedBytes); + await flushMicrotasks(); + frame += 1; + for ( + let pending = pendingFetches.length - 1; + pending >= 0; + pending -= 1 + ) { + const entry = pendingFetches[pending]; + if (entry.dueFrame <= frame) { + pendingFetches.splice(pending, 1); + entry.resolve(); + } + } + const callbacks = [...frameCallbacks.values()]; + frameCallbacks.clear(); + callbacks.forEach((cb) => cb(frame)); + await flushMicrotasks(); + } + + const children = (tiles.root?.children ?? []) as HarnessTile[]; + const loadedChildren = children.filter( + (child) => child.internal.loadingState === LOADED + ); + const abortsInFlight = disposals.filter((entry) => entry.state !== LOADED); + + // Nothing was thrown away after loading and nothing was aborted mid-flight. + expect(discardsAfterLoad).toEqual([]); + expect(abortsInFlight).toEqual([]); + expect([...fetchesPerTile.values()].every((count) => count === 1)).toBe( + true + ); + expect(tiles.stats.failed).toBe(0); + + // The pipeline drained and the cache settled at the physical ceiling: the + // demand (48 x ~4 MiB) exceeds 128 MiB, so the surplus was never admitted. + expect(tiles.loadingTiles.size).toBe(0); + expect( + tiles.stats.queued + tiles.stats.downloading + tiles.stats.parsing + ).toBe(0); + expect(cache.isFull()).toBe(true); + expect(loadedChildren.length).toBe(fetchesPerTile.size); + expect(loadedChildren.length).toBeGreaterThan(16); + expect(loadedChildren.length).toBeLessThan(CHILD_COUNT); + const largestPrice = Math.max( + ...loadedChildren.map((child) => cache.getMemoryUsage(child)) + ); + expect(cache.cachedBytes).toBeGreaterThanOrEqual(CEILING); + expect(cache.cachedBytes).toBeLessThan(CEILING + largestPrice); + expect(Math.max(...cachedBytesSamples)).toBeLessThan(cache.maxBytesSize); + expect(cache.minBytesSize).toBe( + Math.floor(CEILING * TILES_LOAD_POLICY.cacheRetentionFraction) + ); + expect(cache.maxBytesSize).toBeGreaterThan(CEILING); + + // Request concurrency followed the headroom: 128 MiB / 4 MiB at the start, + // the floor once the cache is full, never above the configured maximum. + expect(concurrencySamples[0]).toBe( + Math.floor(CEILING / TILE_BYTES_PREDICTION.initialBytes) + ); + expect(Math.max(...concurrencySamples)).toBeLessThanOrEqual( + TILES_LOAD_POLICY.maximumRequestConcurrency + ); + expect(concurrencySamples[concurrencySamples.length - 1]).toBe( + TILES_LOAD_POLICY.minimumRequestConcurrency + ); + + // The predictor learned the level average of the registered prices. + const prediction = tiles.calculateBytesUsed( + { + content: { uri: "unseen.b3dm" }, + geometricError: 1, + internal: { + basePath: "https://tiles.test/admission", + hasUnrenderableContent: false, + }, + engineData: { scene: null }, + } as never, + null + ); + expect(prediction).toBeGreaterThan( + TILE_BYTES_PREDICTION.initialBytes * 0.6 + ); + expect(prediction).toBeLessThan(TILE_BYTES_PREDICTION.initialBytes * 1.4); + + layer.dispose(); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-debug-overlay.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-debug-overlay.spec.ts new file mode 100644 index 0000000000..3036406c5c --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-debug-overlay.spec.ts @@ -0,0 +1,67 @@ +// @vitest-environment jsdom + +import * as THREE from "three"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + THREE_TILES_DEBUG_COLORS, + createThreeTilesDebugOverlay, + getThreeTilesDebugColor, +} from "./three-tiles-debug-overlay"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("getThreeTilesDebugColor", () => { + it("distinguishes viewport and shadow-path tiles", () => { + expect(getThreeTilesDebugColor("viewport")).toBe( + THREE_TILES_DEBUG_COLORS.viewport + ); + expect(getThreeTilesDebugColor("shadow")).toBe( + THREE_TILES_DEBUG_COLORS.shadow + ); + expect(getThreeTilesDebugColor(undefined)).toBe( + THREE_TILES_DEBUG_COLORS.other + ); + }); +}); + +describe("createThreeTilesDebugOverlay", () => { + it("adds only bounding-box edges and tile labels to the scene", () => { + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ + font: "", + fillStyle: "", + strokeStyle: "", + lineWidth: 1, + textBaseline: "alphabetic", + measureText: () => ({ width: 80 }), + fillRect: vi.fn(), + strokeRect: vi.fn(), + fillText: vi.fn(), + } as unknown as CanvasRenderingContext2D); + + const parent = new THREE.Group(); + const overlay = createThreeTilesDebugOverlay(parent); + overlay.update([ + { + id: "mesh/tile-42.b3dm", + bounds: new THREE.Box3( + new THREE.Vector3(-1, 2, -3), + new THREE.Vector3(4, 8, 6) + ), + loadReason: "shadow", + }, + ]); + + expect(overlay.root.children).toHaveLength(2); + expect(overlay.root.children[0]).toBeInstanceOf(THREE.LineSegments); + expect(overlay.root.children[1]).toBeInstanceOf(THREE.Sprite); + expect( + overlay.root.children.some((child) => child instanceof THREE.Mesh) + ).toBe(false); + + overlay.dispose(); + expect(parent.children).not.toContain(overlay.root); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-debug-overlay.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-debug-overlay.ts new file mode 100644 index 0000000000..8a5d41beda --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-debug-overlay.ts @@ -0,0 +1,161 @@ +import * as THREE from "three"; + +import type { SharedThreeSceneTileVolume } from "./shared-three-scene-layer"; + +export type ThreeTilesDebugVolume = Readonly<{ + id: string; + bounds: THREE.Box3; + loadReason?: SharedThreeSceneTileVolume["loadReason"]; +}>; + +export const THREE_TILES_DEBUG_COLORS = { + viewport: "#0284c7", + shadow: "#ea580c", + other: "#64748b", +} as const; + +export const getThreeTilesDebugColor = ( + loadReason: ThreeTilesDebugVolume["loadReason"] +): string => THREE_TILES_DEBUG_COLORS[loadReason ?? "other"]; + +const disposeObject = (object: THREE.Object3D) => { + object.traverse((child) => { + const renderable = child as THREE.Object3D & { + geometry?: THREE.BufferGeometry; + material?: THREE.Material | THREE.Material[]; + }; + renderable.geometry?.dispose(); + const materials = Array.isArray(renderable.material) + ? renderable.material + : renderable.material + ? [renderable.material] + : []; + for (const material of materials) { + const texture = (material as THREE.SpriteMaterial).map; + texture?.dispose(); + material.dispose(); + } + }); +}; + +const clearGroup = (group: THREE.Group) => { + for (const child of [...group.children]) { + group.remove(child); + disposeObject(child); + } +}; + +const shortLabel = (id: string) => { + const decoded = (() => { + try { + return decodeURIComponent(id); + } catch { + return id; + } + })(); + return decoded.length <= 48 + ? decoded + : `${decoded.slice(0, 20)}…${decoded.slice(-27)}`; +}; + +const createLabel = ( + volume: ThreeTilesDebugVolume, + color: string +): THREE.Sprite | null => { + if (typeof document === "undefined") return null; + const text = shortLabel(volume.id); + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (!context) return null; + const fontSize = 24; + context.font = `600 ${fontSize}px ui-monospace, monospace`; + const textWidth = Math.ceil(context.measureText(text).width); + canvas.width = Math.max(64, textWidth + 20); + canvas.height = 38; + context.font = `600 ${fontSize}px ui-monospace, monospace`; + context.fillStyle = "rgba(255,255,255,0.9)"; + context.fillRect(0, 0, canvas.width, canvas.height); + context.strokeStyle = color; + context.lineWidth = 3; + context.strokeRect(1.5, 1.5, canvas.width - 3, canvas.height - 3); + context.fillStyle = color; + context.textBaseline = "middle"; + context.fillText(text, 10, canvas.height / 2 + 1); + + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + const material = new THREE.SpriteMaterial({ + map: texture, + depthTest: false, + depthWrite: false, + toneMapped: false, + }); + const sprite = new THREE.Sprite(material); + const size = volume.bounds.getSize(new THREE.Vector3()); + const labelHeight = THREE.MathUtils.clamp( + Math.max(size.x, size.y, size.z) * 0.06, + 1.5, + 12 + ); + sprite.scale.set( + labelHeight * (canvas.width / canvas.height), + labelHeight, + 1 + ); + sprite.position.copy(volume.bounds.getCenter(new THREE.Vector3())); + sprite.position.y = volume.bounds.max.y; + sprite.center.set(0.5, 0); + sprite.renderOrder = 10_001; + sprite.frustumCulled = false; + return sprite; +}; + +export const createThreeTilesDebugOverlay = (parent: THREE.Object3D) => { + const root = new THREE.Group(); + root.name = "CARMA 3D tiles bounds and labels"; + root.renderOrder = 10_000; + parent.add(root); + let signature = ""; + + const update = (volumes: readonly ThreeTilesDebugVolume[]) => { + const nextSignature = volumes + .map(({ id, bounds, loadReason }) => + [ + id, + loadReason ?? "other", + ...bounds.min.toArray().map((value) => value.toFixed(3)), + ...bounds.max.toArray().map((value) => value.toFixed(3)), + ].join(":") + ) + .join("|"); + if (nextSignature === signature) return; + signature = nextSignature; + clearGroup(root); + + for (const volume of volumes) { + const color = getThreeTilesDebugColor(volume.loadReason); + const helper = new THREE.Box3Helper(volume.bounds, color); + const helperMaterial = helper.material as THREE.LineBasicMaterial; + helper.name = `${volume.loadReason ?? "other"}: ${volume.id}`; + helperMaterial.depthTest = false; + helperMaterial.depthWrite = false; + helperMaterial.transparent = true; + helperMaterial.opacity = 0.9; + helperMaterial.toneMapped = false; + helper.renderOrder = 10_000; + helper.frustumCulled = false; + root.add(helper); + const label = createLabel(volume, color); + if (label) root.add(label); + } + }; + + return { + root, + update, + dispose() { + clearGroup(root); + root.removeFromParent(); + }, + }; +}; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-layer.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-layer.ts new file mode 100644 index 0000000000..30f84f7955 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-layer.ts @@ -0,0 +1,25 @@ +export const THREE_TILES_LAYER_TYPE = "three-tiles" as const; + +export const THREE_TILES_SHADER_KIND = { + CLAY: "clay", +} as const; + +export type ThreeTilesClayShader = { + kind: typeof THREE_TILES_SHADER_KIND.CLAY; + color: string; + roughness?: number; + metalness?: number; +}; + +/** Serializable 3D Tiles layer contract used by catalog/drop integrations. */ +export type ThreeTilesLayer = { + type: typeof THREE_TILES_LAYER_TYPE; + name: string; + url: string; + carmaLayerId?: string; + origin?: [longitude: number, latitude: number]; + shader: ThreeTilesClayShader; + opacity?: number; + errorTarget?: number; + requestConcurrency?: number; +}; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-load-policy.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-load-policy.spec.ts new file mode 100644 index 0000000000..cb85eae3a8 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-load-policy.spec.ts @@ -0,0 +1,560 @@ +import { describe, expect, it } from "vitest"; + +import { + ERROR_TARGET_POLICY, + TILES_CACHE_CEILING_BYTES, + TILES_LOAD_POLICY, + TILE_BYTES_PREDICTION, + createEffectiveErrorTargetState, + createTileBytesPredictor, + deriveTilePriority, + nextEffectiveErrorTarget, + resolveRequestConcurrency, + resolveTilesCacheBounds, + resolveTilesCacheCeiling, + shouldDeferTile, +} from "./three-tiles-load-policy"; +import type { + EffectiveErrorTargetState, + ErrorTargetObservation, +} from "./three-tiles-load-policy"; + +const MIB = 1024 ** 2; +const GIB = 1024 ** 3; + +const desktop = { + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/140", + platform: "MacIntel", + maxTouchPoints: 0, +}; + +describe("resolveTilesCacheCeiling", () => { + it("caps iOS and iPadOS devices, including touch Macs", () => { + expect( + resolveTilesCacheCeiling({ + userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)", + platform: "iPhone", + maxTouchPoints: 5, + }) + ).toBe(TILES_CACHE_CEILING_BYTES.ios); + expect( + resolveTilesCacheCeiling({ + ...desktop, + maxTouchPoints: 5, + deviceMemoryGiB: 8, + }) + ).toBe(TILES_CACHE_CEILING_BYTES.ios); + }); + + it("caps Android and other mobile devices", () => { + expect( + resolveTilesCacheCeiling({ + userAgent: "Mozilla/5.0 (Linux; Android 14; Pixel 8) Mobile Safari", + platform: "Linux armv8l", + maxTouchPoints: 5, + deviceMemoryGiB: 8, + }) + ).toBe(TILES_CACHE_CEILING_BYTES.mobile); + }); + + it("scales Chromium desktops by device memory within bounds", () => { + expect(resolveTilesCacheCeiling({ ...desktop, deviceMemoryGiB: 2 })).toBe( + TILES_CACHE_CEILING_BYTES.desktopMinimum + ); + expect(resolveTilesCacheCeiling({ ...desktop, deviceMemoryGiB: 4 })).toBe( + 4 * TILES_CACHE_CEILING_BYTES.perDeviceMemoryGiB + ); + expect(resolveTilesCacheCeiling({ ...desktop, deviceMemoryGiB: 64 })).toBe( + TILES_CACHE_CEILING_BYTES.desktopMaximum + ); + expect(resolveTilesCacheCeiling(desktop)).toBe( + TILES_CACHE_CEILING_BYTES.desktopDefault + ); + }); + + it("lets a style only lower the ceiling, never below the floor", () => { + expect( + resolveTilesCacheCeiling(desktop, { + cacheBudgetBytes: 256 * MIB, + cacheOverflowBytes: 256 * MIB, + }) + ).toBe(512 * MIB); + expect( + resolveTilesCacheCeiling(desktop, { + cacheBudgetBytes: 4 * GIB, + cacheOverflowBytes: 4 * GIB, + }) + ).toBe(TILES_CACHE_CEILING_BYTES.desktopDefault); + expect( + resolveTilesCacheCeiling(desktop, { + cacheBudgetBytes: 16 * MIB, + }) + ).toBe(TILES_CACHE_CEILING_BYTES.floor); + expect( + resolveTilesCacheCeiling(desktop, { + cacheBudgetBytes: 256 * MIB, + cacheOverflowBytes: Number.POSITIVE_INFINITY, + }) + ).toBe(TILES_CACHE_CEILING_BYTES.desktopDefault); + }); + + it("derives eviction bounds around the physical ceiling", () => { + const bounds = resolveTilesCacheBounds({ + ceilingBytes: 1 * GIB, + estimateBytes: 4 * MIB, + }); + expect(bounds.minBytesSize).toBe( + Math.floor(GIB * TILES_LOAD_POLICY.cacheRetentionFraction) + ); + expect(bounds.maxBytesSize).toBe( + GIB + TILES_LOAD_POLICY.cacheDriftSlackMinBytes + ); + expect( + resolveTilesCacheBounds({ ceilingBytes: GIB, estimateBytes: 16 * MIB }) + .maxBytesSize + ).toBe(GIB + 8 * 16 * MIB); + }); +}); + +describe("createTileBytesPredictor", () => { + it("starts from the initial estimate and learns per url, level and globally", () => { + const predictor = createTileBytesPredictor(); + const leaf = { url: "https://tiles.test/a.b3dm", geometricError: 0.5 }; + const coarse = { url: "https://tiles.test/b.b3dm", geometricError: 8 }; + + expect(predictor.predict(leaf)).toBe(TILE_BYTES_PREDICTION.initialBytes); + expect(predictor.globalEstimate()).toBe(TILE_BYTES_PREDICTION.initialBytes); + + predictor.observe(leaf, 3 * MIB); + expect(predictor.predict(leaf)).toBe(3 * MIB); + // same level, different url: the level average applies + expect( + predictor.predict({ + url: "https://tiles.test/c.b3dm", + geometricError: 0.6, + }) + ).toBe(3 * MIB); + // different level: the global average with its safety multiplier + expect(predictor.predict(coarse)).toBe( + Math.round(3 * MIB * TILE_BYTES_PREDICTION.globalMultiplier) + ); + expect(predictor.globalEstimate()).toBe(3 * MIB); + + predictor.observe(coarse, 5 * MIB); + expect(predictor.globalEstimate()).toBe( + Math.round( + 3 * MIB + (5 * MIB - 3 * MIB) * TILE_BYTES_PREDICTION.emaWeight + ) + ); + }); + + it("uses a small fixed size for external tilesets and ignores bad samples", () => { + const predictor = createTileBytesPredictor(); + expect( + predictor.predict({ + url: "https://tiles.test/sub/tileset.json", + geometricError: 100, + isExternalTileset: true, + }) + ).toBe(TILE_BYTES_PREDICTION.externalTilesetBytes); + predictor.observe({ url: null, geometricError: 1 }, 0); + predictor.observe({ url: null, geometricError: 1 }, Number.NaN); + expect(predictor.globalEstimate()).toBe(TILE_BYTES_PREDICTION.initialBytes); + }); + + it("bounds the url memo to the newest entries", () => { + const predictor = createTileBytesPredictor(); + const first = { url: "https://tiles.test/0.b3dm", geometricError: 1 }; + predictor.observe(first, 1 * MIB); + for ( + let index = 1; + index <= TILE_BYTES_PREDICTION.urlMemoLimit; + index += 1 + ) { + predictor.observe( + { url: `https://tiles.test/${index}.b3dm`, geometricError: 1 }, + 2 * MIB + ); + } + // the oldest url fell out of the memo, so the level average applies + expect(predictor.predict(first)).not.toBe(1 * MIB); + expect(predictor.predict(first)).toBeGreaterThan(1.9 * MIB); + }); +}); + +describe("deriveTilePriority", () => { + it("orders the main view first, then hierarchy, external tilesets and centre", () => { + const shallow = deriveTilePriority({ + depth: 3, + inMainFrustum: false, + isExternalTileset: false, + centerness: 0, + }); + const deepCentre = deriveTilePriority({ + depth: 4, + inMainFrustum: true, + isExternalTileset: true, + centerness: 1, + }); + expect(deepCentre).toBeGreaterThan(shallow); + + const external = deriveTilePriority({ + depth: 4, + inMainFrustum: false, + isExternalTileset: true, + centerness: 0, + }); + const mainCentre = deriveTilePriority({ + depth: 4, + inMainFrustum: true, + isExternalTileset: false, + centerness: 1, + }); + const mainEdge = deriveTilePriority({ + depth: 4, + inMainFrustum: true, + isExternalTileset: false, + centerness: 0, + }); + const margin = deriveTilePriority({ + depth: 4, + inMainFrustum: false, + isExternalTileset: false, + centerness: 0, + }); + expect(mainCentre).toBeGreaterThan(mainEdge); + expect(mainEdge).toBeGreaterThan(external); + expect(external).toBeGreaterThan(margin); + }); + + it("orders shadow-only tiles by receiver relevance and then light depth", () => { + const edgeReceiver = deriveTilePriority({ + depth: 4, + inMainFrustum: false, + isExternalTileset: false, + centerness: 0, + shadowReceiverCenterness: 0.2, + shadowLightFacing: 1, + }); + const centreReceiverBehind = deriveTilePriority({ + depth: 4, + inMainFrustum: false, + isExternalTileset: false, + centerness: 0, + shadowReceiverCenterness: 0.9, + shadowLightFacing: 0, + }); + const centreReceiverLightFacing = deriveTilePriority({ + depth: 4, + inMainFrustum: false, + isExternalTileset: false, + centerness: 0, + shadowReceiverCenterness: 0.9, + shadowLightFacing: 1, + }); + + expect(centreReceiverBehind).toBeGreaterThan(edgeReceiver); + expect(centreReceiverLightFacing).toBeGreaterThan(centreReceiverBehind); + }); + + it("clamps depth and centerness", () => { + expect( + deriveTilePriority({ + depth: 500, + inMainFrustum: false, + isExternalTileset: false, + centerness: 4, + }) + ).toBe( + deriveTilePriority({ + depth: 63, + inMainFrustum: false, + isExternalTileset: false, + centerness: 1, + }) + ); + }); +}); + +describe("shouldDeferTile", () => { + const displayable = { + displayable: true, + inView: false, + inMargin: false, + loadingState: 0, + isDeferred: false, + }; + + it("defers unloaded displayable tiles outside the view and margin", () => { + expect(shouldDeferTile(displayable)).toBe("defer"); + }); + + it("keeps tiles that are in view, in the margin, loading or not displayable", () => { + expect(shouldDeferTile({ ...displayable, inView: true })).toBe("keep"); + expect(shouldDeferTile({ ...displayable, inMargin: true })).toBe("keep"); + expect(shouldDeferTile({ ...displayable, loadingState: 2 })).toBe("keep"); + expect(shouldDeferTile({ ...displayable, displayable: false })).toBe( + "keep" + ); + expect(shouldDeferTile({ ...displayable, isDeferred: true })).toBe("keep"); + }); + + it("releases deferred tiles once they enter the view or margin", () => { + expect( + shouldDeferTile({ ...displayable, isDeferred: true, inView: true }) + ).toBe("undefer"); + expect( + shouldDeferTile({ ...displayable, isDeferred: true, inMargin: true }) + ).toBe("undefer"); + }); +}); + +describe("nextEffectiveErrorTarget", () => { + const ceiling = 1 * GIB; + const baseObservation: ErrorTargetObservation = { + now: 10_000, + physicallyFull: true, + pipelineIdle: true, + mainConverged: false, + usedBytesMain: ceiling, + cachedBytes: ceiling, + ceiling, + zoom: 17, + pitch: 45, + unusedEvictable: false, + lastProgressAt: 0, + }; + const step = ( + state: EffectiveErrorTargetState, + patch: Partial + ) => nextEffectiveErrorTarget(state, { ...baseObservation, ...patch }); + + it("relaxes once the stall held for the hold time and remembers the failure", () => { + let state = createEffectiveErrorTargetState(0.25, 10_000); + let result = step(state, { now: 10_000 }); + expect(result.changed).toBe(false); + expect(result.retryInMs).toBe(ERROR_TARGET_POLICY.relaxHoldMs); + state = result.state; + + result = step(state, { now: 10_500 }); + expect(result.changed).toBe(false); + state = result.state; + + result = step(state, { now: 11_000 }); + expect(result.changed).toBe(true); + expect(result.state.effective).toBe(0.5); + expect(result.state.failedTarget).toBe(0.25); + expect(result.state.failedView).toEqual({ zoom: 17, pitch: 45, ceiling }); + state = result.state; + + // a second stall relaxes again up to the cap of 4x the requested target + result = step(state, { now: 11_000 }); + expect(result.changed).toBe(false); + result = step(result.state, { now: 12_000 }); + expect(result.state.effective).toBe(1); + result = step(result.state, { now: 13_000 }); + expect(result.changed).toBe(false); + expect(result.state.effective).toBe(1); + }); + + it("restarts the hold on progress but not on its own evictions", () => { + let state = createEffectiveErrorTargetState(1, 10_000); + state = step(state, { now: 10_000 }).state; + // eviction dip: not full, something evictable, still unconverged + state = step(state, { + now: 10_400, + physicallyFull: false, + unusedEvictable: true, + }).state; + let result = step(state, { now: 11_000 }); + expect(result.changed).toBe(true); + expect(result.state.effective).toBe(2); + + // progress at 11_600 restarts the hold + state = result.state; + state = step(state, { now: 11_100 }).state; + result = step(state, { now: 12_100, lastProgressAt: 11_600 }); + expect(result.changed).toBe(false); + result = step(result.state, { now: 12_600, lastProgressAt: 11_600 }); + expect(result.changed).toBe(true); + expect(result.state.effective).toBe(4); + }); + + it("does not relax while something can still be evicted or the pipeline is busy", () => { + const state = createEffectiveErrorTargetState(1, 10_000); + let result = step(state, { now: 10_000, unusedEvictable: true }); + result = step(result.state, { now: 12_000, unusedEvictable: true }); + expect(result.changed).toBe(false); + result = step(result.state, { now: 14_000, pipelineIdle: false }); + expect(result.changed).toBe(false); + result = step(result.state, { now: 16_000, physicallyFull: false }); + expect(result.changed).toBe(false); + }); + + it("does not re-tighten into the failed target in the same view class", () => { + let state = createEffectiveErrorTargetState(0.25, 10_000); + state = step(state, { now: 10_000 }).state; + state = step(state, { now: 11_000 }).state; + expect(state.effective).toBe(0.5); + + // converged with plenty of headroom after eviction, cooldown elapsed + const result = step(state, { + now: 20_000, + physicallyFull: false, + mainConverged: true, + usedBytesMain: 64 * MIB, + cachedBytes: 64 * MIB, + }); + expect(result.changed).toBe(false); + expect(result.state.effective).toBe(0.5); + expect(result.retryInMs).toBeNull(); + }); + + it("clears the failure memory after a zoom change and tightens stepwise", () => { + let state = createEffectiveErrorTargetState(0.25, 10_000); + state = step(state, { now: 10_000 }).state; + state = step(state, { now: 11_000 }).state; + state = step(state, { now: 11_000 }).state; + state = step(state, { now: 12_000 }).state; + expect(state.effective).toBe(1); + + const zoomedIn = { + zoom: 17.6, + physicallyFull: false, + mainConverged: true, + usedBytesMain: 64 * MIB, + cachedBytes: 64 * MIB, + }; + let result = step(state, { ...zoomedIn, now: 12_500 }); + expect(result.changed).toBe(false); + expect(result.retryInMs).toBe(1_000); + result = step(result.state, { ...zoomedIn, now: 13_500 }); + expect(result.changed).toBe(true); + expect(result.state.effective).toBe(0.5); + expect(result.state.failedTarget).toBeNull(); + expect(result.state.tightenBaselineBytes).toBe(64 * MIB); + + // converging after the step teaches the growth ratio + result = step(result.state, { + ...zoomedIn, + now: 14_000, + usedBytesMain: 192 * MIB, + }); + expect(result.state.tightenBaselineBytes).toBeNull(); + expect(result.state.growthRatio).toBeCloseTo( + 4 + (3 - 4) * ERROR_TARGET_POLICY.growthRatioWeight + ); + result = step(result.state, { + ...zoomedIn, + now: 15_000, + usedBytesMain: 192 * MIB, + }); + expect(result.changed).toBe(true); + expect(result.state.effective).toBe(0.25); + }); + + it("does not tighten without headroom for the predicted growth", () => { + let state = createEffectiveErrorTargetState(0.25, 10_000); + state = step(state, { now: 10_000 }).state; + state = step(state, { now: 11_000 }).state; + const result = step(state, { + now: 20_000, + zoom: 18, + physicallyFull: false, + mainConverged: true, + usedBytesMain: 300 * MIB, + cachedBytes: 300 * MIB, + }); + expect(result.changed).toBe(false); + expect(result.retryInMs).toBeNull(); + }); + + it("treats a pan without a zoom or pitch change as the same view class", () => { + let state = createEffectiveErrorTargetState(0.25, 10_000); + state = step(state, { now: 10_000 }).state; + state = step(state, { now: 11_000 }).state; + const result = step(state, { + now: 20_000, + zoom: 17.2, + pitch: 55, + physicallyFull: false, + mainConverged: true, + usedBytesMain: 64 * MIB, + }); + expect(result.state.failedTarget).toBe(0.25); + expect(result.changed).toBe(false); + }); + + it("clears the failure memory when the ceiling grows", () => { + let state = createEffectiveErrorTargetState(0.25, 10_000); + state = step(state, { now: 10_000 }).state; + state = step(state, { now: 11_000 }).state; + const result = step(state, { + now: 20_000, + ceiling: 2 * GIB, + physicallyFull: false, + mainConverged: true, + usedBytesMain: 64 * MIB, + }); + expect(result.changed).toBe(true); + expect(result.state.effective).toBe(0.25); + }); + + it("still relaxes a zero requested target", () => { + let state = createEffectiveErrorTargetState(0, 10_000); + state = step(state, { now: 10_000 }).state; + const result = step(state, { now: 11_000 }); + expect(result.changed).toBe(true); + expect(result.state.effective).toBe( + 2 * ERROR_TARGET_POLICY.minimumRelaxBase + ); + }); +}); + +describe("resolveRequestConcurrency", () => { + it("never keeps more requests in flight than the cache headroom admits", () => { + expect( + resolveRequestConcurrency({ + configured: 64, + ceilingBytes: GIB, + cachedBytes: GIB - 40 * MIB, + estimateBytes: 4 * MIB, + }) + ).toBe(10); + expect( + resolveRequestConcurrency({ + configured: 64, + ceilingBytes: GIB, + cachedBytes: GIB, + estimateBytes: 4 * MIB, + }) + ).toBe(TILES_LOAD_POLICY.minimumRequestConcurrency); + expect( + resolveRequestConcurrency({ + configured: 256, + ceilingBytes: GIB, + cachedBytes: 0, + estimateBytes: 1, + }) + ).toBe(TILES_LOAD_POLICY.maximumRequestConcurrency); + }); + + it("respects a caller limit below the floor and a cooldown of zero", () => { + expect( + resolveRequestConcurrency({ + configured: 2, + ceilingBytes: GIB, + cachedBytes: 0, + estimateBytes: 4 * MIB, + }) + ).toBe(2); + expect( + resolveRequestConcurrency({ + configured: 0, + ceilingBytes: GIB, + cachedBytes: 0, + estimateBytes: 4 * MIB, + }) + ).toBe(0); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-load-policy.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-load-policy.ts new file mode 100644 index 0000000000..5393c03746 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-load-policy.ts @@ -0,0 +1,517 @@ +import { clamp } from "@carma-commons/math"; + +/** + * Pure loading policy for the 3D Tiles runtime: cache ceilings, byte + * prediction, download priorities, sibling deferral, the effective error + * target with hysteresis and request concurrency. No renderer references, no + * side effects; the runtime orchestrates. + */ + +const MIB = 1024 ** 2; +const GIB = 1024 ** 3; + +const FAILED_LOADING_STATE = -1; +const UNLOADED_LOADING_STATE = 0; + +export const TILES_CACHE_CEILING_BYTES = { + ios: 384 * MIB, + mobile: 512 * MIB, + desktopDefault: 1 * GIB, + desktopMinimum: 768 * MIB, + desktopMaximum: 2 * GIB, + perDeviceMemoryGiB: 256 * MIB, + floor: 128 * MIB, +} as const; + +export const TILES_LOAD_POLICY = { + /** Fov multiplier of the prefetch margin around the main view. */ + prefetchMarginFovFactor: 1.25, + /** CPU copies of textures/geometry stay alive next to the GPU upload. */ + residentOverhead: 1.5, + /** Drift slack above the ceiling before the over-max abort loop may run. */ + cacheDriftSlackMinBytes: 64 * MIB, + cacheDriftSlackEstimates: 8, + /** Fraction of the ceiling retained by the asynchronous eviction. */ + cacheRetentionFraction: 0.75, + cacheUnloadPercent: 0.05, + /** Cache bound change that warrants re-applying the cache configuration. */ + cacheBoundsReapplyBytes: 8 * MIB, + minimumRequestConcurrency: 4, + maximumRequestConcurrency: 64, +} as const; + +export const TILE_BYTES_PREDICTION = { + externalTilesetBytes: 16 * 1024, + initialBytes: 4 * MIB, + globalMultiplier: 1.25, + emaWeight: 0.2, + urlMemoLimit: 20_000, +} as const; + +export const TILE_PRIORITY = { + maxDepth: 63, + depthStep: 1_000, + externalTilesetBonus: 500, + centernessWeight: 100, + shadowLightFacingWeight: 50, +} as const; + +const MAIN_FRUSTUM_PRIORITY_BONUS = + (TILE_PRIORITY.maxDepth + 1) * TILE_PRIORITY.depthStep + + TILE_PRIORITY.externalTilesetBonus + + TILE_PRIORITY.centernessWeight + + TILE_PRIORITY.shadowLightFacingWeight; + +export const ERROR_TARGET_POLICY = { + relaxFactor: 2, + relaxHoldMs: 1_000, + maxRelaxMultiplier: 4, + maxErrorTarget: 50, + /** Base used for the relax cap when the requested target is (near) zero. */ + minimumRelaxBase: 0.125, + tightenFactor: 2, + tightenCooldownMs: 1_500, + tightenHeadroomFraction: 0.8, + growthRatioInitial: 4, + growthRatioMinimum: 2, + growthRatioMaximum: 8, + growthRatioWeight: 0.5, + failedViewZoomDelta: 0.5, + failedViewPitchDeltaDeg: 20, +} as const; + +// D3 — cache ceiling per device + +export type TilesDeviceProfile = Readonly<{ + deviceMemoryGiB?: number; + userAgent: string; + platform: string; + maxTouchPoints: number; +}>; + +export type TilesCacheStyleLimits = Readonly<{ + cacheBudgetBytes?: number; + cacheOverflowBytes?: number; +}>; + +const isIosDevice = (device: TilesDeviceProfile): boolean => + /iPhone|iPad|iPod/i.test(device.userAgent) || + (device.platform === "MacIntel" && device.maxTouchPoints > 1); + +const isMobileDevice = (device: TilesDeviceProfile): boolean => + /Android|Mobile/i.test(device.userAgent); + +export const resolveTilesCacheCeiling = ( + device: TilesDeviceProfile, + style?: TilesCacheStyleLimits +): number => { + let ceiling: number; + if (isIosDevice(device)) { + ceiling = TILES_CACHE_CEILING_BYTES.ios; + } else if (isMobileDevice(device)) { + ceiling = TILES_CACHE_CEILING_BYTES.mobile; + } else if ( + device.deviceMemoryGiB !== undefined && + Number.isFinite(device.deviceMemoryGiB) && + device.deviceMemoryGiB > 0 + ) { + ceiling = clamp( + device.deviceMemoryGiB * TILES_CACHE_CEILING_BYTES.perDeviceMemoryGiB, + TILES_CACHE_CEILING_BYTES.desktopMinimum, + TILES_CACHE_CEILING_BYTES.desktopMaximum + ); + } else { + ceiling = TILES_CACHE_CEILING_BYTES.desktopDefault; + } + + const budget = style?.cacheBudgetBytes; + if (budget !== undefined && Number.isFinite(budget)) { + const overflow = style?.cacheOverflowBytes ?? 0; + const styleCeiling = Number.isFinite(overflow) + ? Math.max(0, budget) + Math.max(0, overflow) + : Number.POSITIVE_INFINITY; + ceiling = Math.min(ceiling, styleCeiling); + } + return Math.max(TILES_CACHE_CEILING_BYTES.floor, Math.floor(ceiling)); +}; + +export type TilesCacheBounds = Readonly<{ + minBytesSize: number; + maxBytesSize: number; +}>; + +/** Eviction bounds of the LRU around a physical admission ceiling. */ +export const resolveTilesCacheBounds = (input: { + ceilingBytes: number; + estimateBytes: number; +}): TilesCacheBounds => ({ + minBytesSize: Math.floor( + input.ceilingBytes * TILES_LOAD_POLICY.cacheRetentionFraction + ), + maxBytesSize: + input.ceilingBytes + + Math.max( + TILES_LOAD_POLICY.cacheDriftSlackMinBytes, + TILES_LOAD_POLICY.cacheDriftSlackEstimates * input.estimateBytes + ), +}); + +// D2 — byte prediction for admission + +export type TileBytesSample = Readonly<{ + url: string | null; + geometricError: number; + isExternalTileset?: boolean; +}>; + +export interface TileBytesPredictor { + predict: (tile: TileBytesSample) => number; + observe: (tile: TileBytesSample, bytes: number) => void; + globalEstimate: () => number; +} + +const resolveTileLevel = (geometricError: number): number => { + const level = Math.log2(Math.max(geometricError, Number.EPSILON)); + return Number.isFinite(level) ? Math.round(level) : Number.MIN_SAFE_INTEGER; +}; + +const blend = (previous: number | undefined, sample: number): number => + previous === undefined + ? sample + : previous + (sample - previous) * TILE_BYTES_PREDICTION.emaWeight; + +export const createTileBytesPredictor = (): TileBytesPredictor => { + const urlMemo = new Map(); + const levelEstimates = new Map(); + let globalEstimate: number | undefined; + + const rememberUrl = (url: string, bytes: number) => { + if (urlMemo.has(url)) urlMemo.delete(url); + urlMemo.set(url, bytes); + if (urlMemo.size > TILE_BYTES_PREDICTION.urlMemoLimit) { + const oldest = urlMemo.keys().next().value; + if (oldest !== undefined) urlMemo.delete(oldest); + } + }; + + return { + predict(tile) { + if (tile.isExternalTileset) { + return TILE_BYTES_PREDICTION.externalTilesetBytes; + } + const remembered = tile.url === null ? undefined : urlMemo.get(tile.url); + if (remembered !== undefined) return remembered; + const levelEstimate = levelEstimates.get( + resolveTileLevel(tile.geometricError) + ); + if (levelEstimate !== undefined) return Math.round(levelEstimate); + if (globalEstimate !== undefined) { + return Math.round( + globalEstimate * TILE_BYTES_PREDICTION.globalMultiplier + ); + } + return TILE_BYTES_PREDICTION.initialBytes; + }, + observe(tile, bytes) { + if (!Number.isFinite(bytes) || bytes <= 0) return; + if (tile.url !== null) rememberUrl(tile.url, bytes); + const level = resolveTileLevel(tile.geometricError); + levelEstimates.set(level, blend(levelEstimates.get(level), bytes)); + globalEstimate = blend(globalEstimate, bytes); + }, + globalEstimate: () => + Math.round(globalEstimate ?? TILE_BYTES_PREDICTION.initialBytes), + }; +}; + +// D6 — download order + +export type TilePriorityInput = Readonly<{ + depth: number; + inMainFrustum: boolean; + isExternalTileset: boolean; + /** 1 at the view centre, 0 at the edge (or unknown). */ + centerness: number; + /** View-centre relevance of the visible receiver for a shadow-only tile. */ + shadowReceiverCenterness?: number; + /** 1 at the light-facing end of the relevant receiver sweep. */ + shadowLightFacing?: number; +}>; + +/** Higher values download first (upstream pops from the end of the queue). */ +export const deriveTilePriority = (input: TilePriorityInput): number => { + const depth = clamp(Math.floor(input.depth), 0, TILE_PRIORITY.maxDepth); + const requestedCenterness = input.inMainFrustum + ? input.centerness + : input.shadowReceiverCenterness ?? input.centerness; + const centerness = clamp( + Number.isFinite(requestedCenterness) ? requestedCenterness : 0, + 0, + 1 + ); + const shadowLightFacing = clamp( + Number.isFinite(input.shadowLightFacing) ? input.shadowLightFacing ?? 0 : 0, + 0, + 1 + ); + return ( + (TILE_PRIORITY.maxDepth + 1 - depth) * TILE_PRIORITY.depthStep + + (input.isExternalTileset ? TILE_PRIORITY.externalTilesetBonus : 0) + + (input.inMainFrustum ? MAIN_FRUSTUM_PRIORITY_BONUS : 0) + + centerness * TILE_PRIORITY.centernessWeight + + (input.inMainFrustum + ? 0 + : shadowLightFacing * TILE_PRIORITY.shadowLightFacingWeight) + ); +}; + +// D1 — off-frustum sibling deferral + +export type TileDeferralDecision = "defer" | "undefer" | "keep"; + +export type TileDeferralInput = Readonly<{ + /** Renderable REPLACE content that is not unconditionally refined. */ + displayable: boolean; + inView: boolean; + inMargin: boolean; + loadingState: number; + isDeferred: boolean; +}>; + +export const shouldDeferTile = ( + input: TileDeferralInput +): TileDeferralDecision => { + if (input.inView || input.inMargin) { + return input.isDeferred ? "undefer" : "keep"; + } + if ( + input.displayable && + input.loadingState === UNLOADED_LOADING_STATE && + !input.isDeferred + ) { + return "defer"; + } + return "keep"; +}; + +/** Loading state that makes a deferred tile count as finished for its parent. */ +export const DEFERRED_TILE_LOADING_STATE = FAILED_LOADING_STATE; + +// D5 — effective error target with hysteresis + +export type ErrorTargetFailedView = Readonly<{ + zoom: number; + pitch: number; + ceiling: number; +}>; + +export type EffectiveErrorTargetState = Readonly<{ + requested: number; + effective: number; + lastChangeAt: number; + /** Start of the current full-idle-unconverged stall, if any. */ + stallSince: number | null; + failedTarget: number | null; + failedView: ErrorTargetFailedView | null; + growthRatio: number; + /** Main-view bytes before the last tighten step, until it converged. */ + tightenBaselineBytes: number | null; +}>; + +export type ErrorTargetObservation = Readonly<{ + now: number; + physicallyFull: boolean; + pipelineIdle: boolean; + mainConverged: boolean; + usedBytesMain: number; + cachedBytes: number; + ceiling: number; + zoom: number; + pitch: number; + unusedEvictable: boolean; + /** Timestamp of the last loaded model (progress); 0 when none. */ + lastProgressAt: number; +}>; + +export type EffectiveErrorTargetResult = Readonly<{ + state: EffectiveErrorTargetState; + changed: boolean; + /** Delay until a time-gated decision may flip without new frames. */ + retryInMs: number | null; +}>; + +export const createEffectiveErrorTargetState = ( + requested: number, + now: number +): EffectiveErrorTargetState => ({ + requested, + effective: requested, + lastChangeAt: now, + stallSince: null, + failedTarget: null, + failedView: null, + growthRatio: ERROR_TARGET_POLICY.growthRatioInitial, + tightenBaselineBytes: null, +}); + +const resolveRelaxCap = (requested: number): number => + Math.min( + ERROR_TARGET_POLICY.maxErrorTarget, + ERROR_TARGET_POLICY.maxRelaxMultiplier * + Math.max(requested, ERROR_TARGET_POLICY.minimumRelaxBase) + ); + +const hasFailedViewExpired = ( + failedView: ErrorTargetFailedView, + observation: ErrorTargetObservation +): boolean => + Math.abs(observation.zoom - failedView.zoom) >= + ERROR_TARGET_POLICY.failedViewZoomDelta || + Math.abs(observation.pitch - failedView.pitch) >= + ERROR_TARGET_POLICY.failedViewPitchDeltaDeg || + observation.ceiling > failedView.ceiling; + +export const nextEffectiveErrorTarget = ( + state: EffectiveErrorTargetState, + observation: ErrorTargetObservation +): EffectiveErrorTargetResult => { + const { now } = observation; + let next: EffectiveErrorTargetState = state; + const assign = (patch: Partial) => { + next = { ...next, ...patch }; + }; + + // Failure memory only applies to the view class it was recorded in. + if (next.failedView && hasFailedViewExpired(next.failedView, observation)) { + assign({ failedTarget: null, failedView: null }); + } + + // Learn how much the used set grows per tighten step once it converged. + if ( + next.tightenBaselineBytes !== null && + observation.pipelineIdle && + observation.mainConverged && + now > next.lastChangeAt + ) { + const baseline = next.tightenBaselineBytes; + if (baseline > 0 && observation.usedBytesMain > 0) { + const ratio = observation.usedBytesMain / baseline; + assign({ + growthRatio: clamp( + next.growthRatio + + (ratio - next.growthRatio) * ERROR_TARGET_POLICY.growthRatioWeight, + ERROR_TARGET_POLICY.growthRatioMinimum, + ERROR_TARGET_POLICY.growthRatioMaximum + ), + }); + } + assign({ tightenBaselineBytes: null }); + } + + let retryInMs: number | null = null; + + // Tighten: converged with headroom, outside the cooldown and above what + // already failed in this view class. + if ( + observation.pipelineIdle && + observation.mainConverged && + next.effective > next.requested + ) { + const candidate = Math.max( + next.requested, + next.effective / ERROR_TARGET_POLICY.tightenFactor + ); + const headroomOk = + observation.usedBytesMain * next.growthRatio <= + ERROR_TARGET_POLICY.tightenHeadroomFraction * observation.ceiling; + const aboveFailure = + next.failedTarget === null || candidate > next.failedTarget; + const cooldownRemaining = + next.lastChangeAt + ERROR_TARGET_POLICY.tightenCooldownMs - now; + if (headroomOk && aboveFailure) { + if (cooldownRemaining > 0) { + retryInMs = cooldownRemaining; + } else { + assign({ + effective: candidate, + lastChangeAt: now, + stallSince: null, + tightenBaselineBytes: observation.usedBytesMain, + }); + return { state: next, changed: true, retryInMs: null }; + } + } + } + + // Relax: physically full, idle, unconverged and nothing left to evict for + // at least the hold time since the last progress. + const stalled = + observation.physicallyFull && + observation.pipelineIdle && + !observation.mainConverged && + !observation.unusedEvictable; + if (observation.mainConverged) { + if (next.stallSince !== null) assign({ stallSince: null }); + } else if (stalled) { + const stallSince = + next.stallSince === null + ? now + : Math.max(next.stallSince, observation.lastProgressAt); + if (stallSince !== next.stallSince) assign({ stallSince }); + const cap = resolveRelaxCap(next.requested); + const relaxed = Math.min( + cap, + Math.max(next.effective, ERROR_TARGET_POLICY.minimumRelaxBase) * + ERROR_TARGET_POLICY.relaxFactor + ); + if (relaxed > next.effective) { + const holdRemaining = stallSince + ERROR_TARGET_POLICY.relaxHoldMs - now; + if (holdRemaining > 0) { + retryInMs = + retryInMs === null + ? holdRemaining + : Math.min(retryInMs, holdRemaining); + } else { + assign({ + failedTarget: next.effective, + failedView: { + zoom: observation.zoom, + pitch: observation.pitch, + ceiling: observation.ceiling, + }, + effective: relaxed, + lastChangeAt: now, + stallSince: null, + tightenBaselineBytes: null, + }); + return { state: next, changed: true, retryInMs: null }; + } + } + } + + return { state: next, changed: false, retryInMs }; +}; + +// D2 — request concurrency bounded by cache headroom + +export const resolveRequestConcurrency = (input: { + configured: number; + ceilingBytes: number; + cachedBytes: number; + estimateBytes: number; +}): number => { + const configured = Math.floor(input.configured); + if (!Number.isFinite(configured) || configured <= 0) return 0; + const headroom = Math.max(0, input.ceilingBytes - input.cachedBytes); + const estimate = Math.max(1, input.estimateBytes); + const admissible = Math.floor(headroom / estimate); + const upperBound = Math.min( + configured, + TILES_LOAD_POLICY.maximumRequestConcurrency + ); + const lowerBound = Math.min( + configured, + TILES_LOAD_POLICY.minimumRequestConcurrency + ); + return clamp(Math.min(configured, admissible), lowerBound, upperBound); +}; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-lod-camera.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-lod-camera.spec.ts new file mode 100644 index 0000000000..9a64630e3b --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-lod-camera.spec.ts @@ -0,0 +1,176 @@ +// D0: the 3D Tiles runtime selects tiles with the true perspective LOD camera. +// The MapLibre-composite render camera (shared-three-scene-layer.ts) carries a +// uniformly scaled projective matrix, from which 3d-tiles-renderer 0.5.2 +// (prepareForTraversal, TilesRenderer.js) would derive a screen-space-error +// denominator inflated by the metres per pixel of the map. +import { TilesRenderer } from "3d-tiles-renderer"; +import { MercatorCoordinate } from "maplibre-gl"; +import type { Map as MaplibreMap } from "maplibre-gl"; +import * as THREE from "three"; +import { describe, expect, it } from "vitest"; + +import { synthesizeLodCamera } from "@carma-mapping/engines/threejs"; +import { configureSharedRenderCamera } from "./shared-three-scene-layer"; +import { + createTilesCameraSet, + resolveTilesViewCamera, +} from "./tiles-camera-set"; + +const FOV = 0.6435011087932844; +const W = 1600; +const H = 900; +const ZOOM = 17; +const PITCH = 60; +const LAT = 51.26; +const LNG = 7.15; + +type CameraInfo = { + isOrthographic: boolean; + sseDenominator: number; + pixelSize: number; +}; +type Internals = { + cameraInfo: CameraInfo[]; + prepareForTraversal: () => void; +}; + +// MapLibre 5.18 mercator_transform.ts:611-630 + custom-layer mainMatrix (:828-838) +const buildMapLibreMainMatrix = ( + worldSize: number, + mercX: number, + mercY: number, + camToCenterPx: number +) => { + const top = Math.tan(FOV / 2); + const m = new THREE.Matrix4().makePerspective( + -top * (W / H), + top * (W / H), + top, + -top, + 1, + 1e7 + ); + m.multiply(new THREE.Matrix4().makeScale(1, -1, 1)); + m.multiply(new THREE.Matrix4().makeTranslation(0, 0, -camToCenterPx)); + m.multiply( + new THREE.Matrix4().makeRotationX(THREE.MathUtils.degToRad(PITCH)) + ); + m.multiply( + new THREE.Matrix4().makeTranslation( + -mercX * worldSize, + -mercY * worldSize, + 0 + ) + ); + return m.multiply( + new THREE.Matrix4().makeScale(worldSize, worldSize, worldSize) + ); +}; + +const buildCameras = () => { + const worldSize = 512 * 2 ** ZOOM; + const originMerc = MercatorCoordinate.fromLngLat([LNG, LAT], 0); + const meterScale = originMerc.meterInMercatorCoordinateUnits(); + const camToCenterPx = (0.5 / Math.tan(FOV / 2)) * H; + const map = { + transform: { _fov: FOV, cameraToCenterDistance: camToCenterPx, worldSize }, + getCenter: () => ({ lng: LNG, lat: LAT }), + getPitch: () => PITCH, + getBearing: () => 0, + queryTerrainElevation: () => 0, + } as unknown as MaplibreMap; + const lodCamera = new THREE.PerspectiveCamera(); + const lookTarget = new THREE.Vector3(); + const viewport = new THREE.Vector2(W, H); + expect( + synthesizeLodCamera( + lodCamera, + map, + { originMerc, meterScale, viewport }, + lookTarget + ) + ).toBe(true); + // shared-three-scene-layer.ts:269-272, 428-433 + const rotationX = new THREE.Matrix4().makeRotationAxis( + new THREE.Vector3(1, 0, 0), + Math.PI / 2 + ); + const localFromScene = new THREE.Matrix4() + .makeTranslation(originMerc.x, originMerc.y, originMerc.z) + .scale(new THREE.Vector3(meterScale, -meterScale, meterScale)) + .multiply(rotationX); + const sceneToClip = buildMapLibreMainMatrix( + worldSize, + originMerc.x, + originMerc.y, + camToCenterPx + ).multiply(localFromScene); + const renderCamera = new THREE.PerspectiveCamera(); + configureSharedRenderCamera(renderCamera, lodCamera, sceneToClip); + return { renderCamera, lodCamera }; +}; + +const trueDenominator = (2 * Math.tan(FOV / 2)) / H; + +describe("three tiles LOD camera (D0)", () => { + it("registers the LOD camera, whose projection yields the true sse denominator", () => { + const { renderCamera, lodCamera } = buildCameras(); + const tiles = new TilesRenderer() as unknown as TilesRenderer & Internals; + const viewCamera = resolveTilesViewCamera(renderCamera, lodCamera); + expect(viewCamera).toBe(lodCamera); + expect(lodCamera.projectionMatrix.elements[5]).toBeCloseTo( + 1 / Math.tan(FOV / 2), + 6 + ); + + const cameras = createTilesCameraSet(tiles, viewCamera); + cameras.update(viewCamera, W, H); + tiles.group.updateMatrixWorld(true); + tiles.prepareForTraversal(); + + expect(tiles.cameras).toEqual([lodCamera]); + const info = tiles.cameraInfo[0]; + expect(info.isOrthographic).toBe(false); + expect(info.sseDenominator / trueDenominator).toBeCloseTo(1, 6); + cameras.dispose(); + }); + + it("documents why the composite render camera is unusable for selection", () => { + const { renderCamera } = buildCameras(); + const tiles = new TilesRenderer() as unknown as TilesRenderer & Internals; + tiles.setCamera(renderCamera); + tiles.setResolution(renderCamera, W, H); + tiles.group.updateMatrixWorld(true); + tiles.prepareForTraversal(); + + // The scaled composite matrix shrinks the denominator by the metres per + // pixel of the map (~2.7x at z17), inflating every screen-space error by + // the same factor: a 4 px target would behave like a ~1.5 px target. + expect(tiles.cameraInfo[0].sseDenominator / trueDenominator).toBeLessThan( + 0.5 + ); + }); + + it("keeps the ortho shadow camera at viewport pixel density", () => { + const L = 1415; // flat-ground footprint square at z17 / pitch 60 + const tiles = new TilesRenderer() as unknown as TilesRenderer & Internals; + const shadowCamera = new THREE.OrthographicCamera( + -L / 2, + L / 2, + L / 2, + -L / 2, + 0.1, + 5000 + ); + shadowCamera.position.set(0, 2000, 0); + shadowCamera.lookAt(0, 0, 0); + shadowCamera.updateProjectionMatrix(); + shadowCamera.updateMatrixWorld(true); + tiles.setCamera(shadowCamera); + tiles.setResolution(shadowCamera, W, H); + tiles.group.updateMatrixWorld(true); + tiles.prepareForTraversal(); + expect(tiles.cameraInfo[0].isOrthographic).toBe(true); + expect(tiles.cameraInfo[0].pixelSize).toBeCloseTo(L / H, 6); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-retry-controller.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-retry-controller.spec.ts new file mode 100644 index 0000000000..8c3ebfe414 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-retry-controller.spec.ts @@ -0,0 +1,231 @@ +import type { Tile } from "3d-tiles-renderer/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + createThreeTilesRetryController, + EXHAUSTED_RETRY_TTL_MS, + MAX_TILE_RETRIES, +} from "./three-tiles-retry-controller"; + +const failedTile = (uri = "tile.b3dm"): Tile => + ({ + content: { uri }, + internal: { basePath: "https://example.com/tiles", loadingState: -1 }, + } as unknown as Tile); + +const buildRenderer = () => ({ + stats: { failed: 1 }, + dispatchEvent: vi.fn(), +}); + +describe("createThreeTilesRetryController", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("blocks a failed tile until its backoff fired, then asks for a traversal", () => { + vi.useFakeTimers(); + const tile = failedTile(); + const renderer = buildRenderer(); + const requestRender = vi.fn(); + const retries = createThreeTilesRetryController( + () => renderer, + requestRender + ); + const url = "https://example.com/tiles/tile.b3dm"; + + expect(retries.handleFailure(tile, url, new Error("status 503"))).toBe( + "scheduled" + ); + expect(retries.hasPendingRetries()).toBe(true); + expect(retries.isBlocked(tile, url)).toBe(true); + expect(retries.isExhausted(tile, url)).toBe(false); + expect(tile.internal.loadingState).toBe(-1); + vi.runOnlyPendingTimers(); + + // A tile still marked FAILED is released; the runtime normally leaves it + // UNLOADED already by removing it from the cache. + expect(tile.internal.loadingState).toBe(0); + expect(renderer.stats.failed).toBe(0); + expect(renderer.dispatchEvent).toHaveBeenCalledWith({ + type: "needs-update", + }); + expect(requestRender).toHaveBeenCalledOnce(); + expect(retries.hasPendingRetries()).toBe(false); + expect(retries.isBlocked(tile, url)).toBe(false); + }); + + it("asks for a traversal even when the tile was already unloaded", () => { + vi.useFakeTimers(); + const tile = failedTile(); + const renderer = buildRenderer(); + const requestRender = vi.fn(); + const retries = createThreeTilesRetryController( + () => renderer, + requestRender + ); + + retries.handleFailure(tile, "https://example.com/tiles/tile.b3dm"); + tile.internal.loadingState = 0; + vi.runOnlyPendingTimers(); + + expect(renderer.stats.failed).toBe(1); + expect(renderer.dispatchEvent).toHaveBeenCalledWith({ + type: "needs-update", + }); + expect(requestRender).toHaveBeenCalledOnce(); + }); + + it("stops after five retries for the same tile", () => { + vi.useFakeTimers(); + const tile = failedTile(); + const renderer = buildRenderer(); + const retries = createThreeTilesRetryController(() => renderer, vi.fn()); + const url = "https://example.com/tiles/tile.b3dm"; + + for (let attempt = 0; attempt < MAX_TILE_RETRIES; attempt += 1) { + tile.internal.loadingState = -1; + renderer.stats.failed = 1; + retries.handleFailure(tile, url, new Error("status 503")); + expect(tile.internal.loadingState).toBe(-1); + vi.runOnlyPendingTimers(); + expect(tile.internal.loadingState).toBe(0); + } + + tile.internal.loadingState = -1; + expect(retries.handleFailure(tile, url, new Error("status 503"))).toBe( + "exhausted" + ); + expect(vi.getTimerCount()).toBe(0); + expect(tile.internal.loadingState).toBe(-1); + expect(retries.hasExhaustedRetries()).toBe(true); + expect(retries.isExhausted(tile, url)).toBe(true); + expect(retries.isBlocked(tile, url)).toBe(true); + }); + + it("exhausts permanent failures at once without a retry", () => { + vi.useFakeTimers(); + const renderer = buildRenderer(); + const retries = createThreeTilesRetryController(() => renderer, vi.fn()); + const url = "https://example.com/tiles/missing.b3dm"; + const tile = failedTile("missing.b3dm"); + + expect(retries.handleFailure(tile, url, new Error("status 404"))).toBe( + "exhausted" + ); + expect(vi.getTimerCount()).toBe(0); + expect(retries.hasPendingRetries()).toBe(false); + expect(retries.isExhausted(tile, url)).toBe(true); + expect(retries.handleFailure(tile, url, new Error("status 404"))).toBe( + "exhausted" + ); + expect( + retries.handleFailure( + failedTile("forbidden.b3dm"), + "https://example.com/tiles/forbidden.b3dm", + { status: 403 } + ) + ).toBe("exhausted"); + }); + + it("lets an exhausted resource be tried once more after the expiry", () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + const renderer = buildRenderer(); + const retries = createThreeTilesRetryController(() => renderer, vi.fn()); + const url = "https://example.com/tiles/missing.b3dm"; + const tile = failedTile("missing.b3dm"); + + retries.handleFailure(tile, url, new Error("status 404")); + vi.advanceTimersByTime(EXHAUSTED_RETRY_TTL_MS - 1); + expect(retries.isBlocked(tile, url)).toBe(true); + vi.advanceTimersByTime(1); + expect(retries.isBlocked(tile, url)).toBe(false); + expect(retries.isExhausted(tile, url)).toBe(false); + expect(retries.hasExhaustedRetries()).toBe(false); + + // The next failure exhausts it again for another period. + expect(retries.handleFailure(tile, url, new Error("status 404"))).toBe( + "exhausted" + ); + expect(retries.isBlocked(tile, url)).toBe(true); + }); + + it("keeps one retry budget across recreated tiles and successful reloads", () => { + vi.useFakeTimers(); + const renderer = buildRenderer(); + const retries = createThreeTilesRetryController(() => renderer, vi.fn()); + const url = "https://example.com/tiles/recreated.b3dm"; + + for (let attempt = 0; attempt < MAX_TILE_RETRIES; attempt += 1) { + const tile = failedTile("recreated.b3dm"); + renderer.stats.failed = 1; + retries.handleFailure(tile, url); + vi.runOnlyPendingTimers(); + expect(tile.internal.loadingState).toBe(0); + retries.handleSuccess(tile, url); + } + + const replacement = failedTile("recreated.b3dm"); + retries.handleFailure(replacement, url); + expect(vi.getTimerCount()).toBe(0); + expect(replacement.internal.loadingState).toBe(-1); + }); + + it("keys retries by the requested URL when tile objects change", () => { + vi.useFakeTimers(); + const renderer = buildRenderer(); + const retries = createThreeTilesRetryController(() => renderer, vi.fn()); + const url = "https://example.com/tiles/stable.b3dm"; + + for (let attempt = 0; attempt < MAX_TILE_RETRIES; attempt += 1) { + const tile = failedTile(`recreated-${attempt}.b3dm`); + retries.handleFailure(tile, url); + vi.runOnlyPendingTimers(); + } + + const replacement = failedTile("another-object.b3dm"); + retries.handleFailure(replacement, url); + expect(vi.getTimerCount()).toBe(0); + expect(replacement.internal.loadingState).toBe(-1); + }); + + it("cancels a pending retry after a successful load", () => { + vi.useFakeTimers(); + const tile = failedTile(); + const renderer = buildRenderer(); + const retries = createThreeTilesRetryController(() => renderer, vi.fn()); + + const url = "https://example.com/tiles/tile.b3dm"; + retries.handleFailure(tile, url); + retries.handleSuccess(tile, url); + expect(retries.hasPendingRetries()).toBe(false); + expect(retries.isBlocked(tile, url)).toBe(false); + vi.runAllTimers(); + + expect(tile.internal.loadingState).toBe(-1); + expect(renderer.dispatchEvent).not.toHaveBeenCalled(); + }); + + it("forgets pending and exhausted resources on reset", () => { + vi.useFakeTimers(); + const renderer = buildRenderer(); + const retries = createThreeTilesRetryController(() => renderer, vi.fn()); + const pendingUrl = "https://example.com/tiles/pending.b3dm"; + const missingUrl = "https://example.com/tiles/missing.b3dm"; + + retries.handleFailure(failedTile("pending.b3dm"), pendingUrl); + retries.handleFailure( + failedTile("missing.b3dm"), + missingUrl, + new Error("status 404") + ); + retries.reset(); + + expect(vi.getTimerCount()).toBe(0); + expect(retries.hasPendingRetries()).toBe(false); + expect(retries.hasExhaustedRetries()).toBe(false); + expect(retries.isBlocked(null, pendingUrl)).toBe(false); + expect(retries.isBlocked(null, missingUrl)).toBe(false); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-retry-controller.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-retry-controller.ts new file mode 100644 index 0000000000..1c1166d6ec --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-retry-controller.ts @@ -0,0 +1,196 @@ +import type { Tile } from "3d-tiles-renderer/core"; + +import { isPermanentTileRequestFailure } from "./payload-aware-request-concurrency"; + +const FAILED_LOADING_STATE = -1; +const UNLOADED_LOADING_STATE = 0; + +export const MAX_TILE_RETRIES = 5; +const TILE_RETRY_BASE_DELAY_MS = 1_000; +/** An exhausted resource may be tried once more after this long. */ +export const EXHAUSTED_RETRY_TTL_MS = 120_000; + +export interface RetryableTilesRenderer { + stats: { failed: number }; + rootLoadingState?: number; + dispatchEvent: (event: { type: string }) => void; +} + +export type TileRetryState = "scheduled" | "pending" | "exhausted" | "ignored"; + +interface ThreeTilesRetryController { + handleFailure: ( + tile: Tile | null, + url?: string | URL | null, + error?: unknown + ) => TileRetryState; + handleSuccess: (tile: Tile | null, url?: string | URL | null) => void; + /** A retry is pending or the budget is exhausted: do not request it now. */ + isBlocked: (tile: Tile | null, url?: string | URL | null) => boolean; + isExhausted: (tile: Tile | null, url?: string | URL | null) => boolean; + hasPendingRetries: () => boolean; + hasExhaustedRetries: () => boolean; + /** Forget every pending retry and exhausted resource. */ + reset: () => void; + dispose: () => void; +} + +interface PendingRetry { + timer: ReturnType; + tiles: Set; + retryRoot: boolean; +} + +const getStableJitter = (key: string): number => { + let hash = 0; + for (let index = 0; index < key.length; index += 1) { + hash = (Math.imul(hash, 31) + key.charCodeAt(index)) | 0; + } + return 0.8 + ((hash >>> 0) % 401) / 1_000; +}; + +const getTileRetryDelayMs = (retryNumber: number, key: string): number => + Math.round( + TILE_RETRY_BASE_DELAY_MS * + 2 ** Math.max(0, retryNumber - 1) * + getStableJitter(key) + ); + +const getTileRetryKey = ( + tile: Tile | null, + url?: string | URL | null +): string | null => { + const resourceUrl = url ? String(url) : tile?.content?.uri; + if (!resourceUrl) return null; + + try { + return tile + ? new URL(resourceUrl, `${tile.internal.basePath}/`).toString() + : new URL(resourceUrl).toString(); + } catch { + return resourceUrl; + } +}; + +export const createThreeTilesRetryController = ( + getRenderer: () => RetryableTilesRenderer | null, + requestRender: () => void +): ThreeTilesRetryController => { + const retryCounts = new Map(); + const pendingRetries = new Map(); + /** Resource key → time the exhausted state expires. */ + const exhaustedRetries = new Map(); + + const isKeyExhausted = (key: string): boolean => { + const expiresAt = exhaustedRetries.get(key); + if (expiresAt === undefined) return false; + if (Date.now() < expiresAt) return true; + exhaustedRetries.delete(key); + return false; + }; + const exhaust = (key: string) => { + exhaustedRetries.set(key, Date.now() + EXHAUSTED_RETRY_TTL_MS); + }; + const pruneExhausted = () => { + for (const key of [...exhaustedRetries.keys()]) isKeyExhausted(key); + }; + + const handleSuccess = (tile: Tile | null, url?: string | URL | null) => { + const key = getTileRetryKey(tile, url); + if (!key) return; + const pending = pendingRetries.get(key); + if (pending) clearTimeout(pending.timer); + pendingRetries.delete(key); + }; + + const handleFailure: ThreeTilesRetryController["handleFailure"] = ( + tile, + url, + error + ) => { + const key = getTileRetryKey(tile, url); + if (!key) return "ignored"; + if (isKeyExhausted(key)) return "exhausted"; + + const pending = pendingRetries.get(key); + if (pending) { + if (tile) pending.tiles.add(tile); + else pending.retryRoot = true; + return "pending"; + } + + const retryNumber = (retryCounts.get(key) ?? 0) + 1; + if ( + retryNumber > MAX_TILE_RETRIES || + isPermanentTileRequestFailure(error) + ) { + exhaust(key); + return "exhausted"; + } + retryCounts.set(key, retryNumber); + + const timer = setTimeout(() => { + const current = pendingRetries.get(key); + pendingRetries.delete(key); + const renderer = getRenderer(); + if (!renderer || !current) return; + + // The runtime removes failed tiles from its cache, which already leaves + // them UNLOADED; tiles still marked FAILED are released here so the next + // traversal can request them. + let resetCount = 0; + for (const failedTile of current.tiles) { + if (failedTile.internal.loadingState !== FAILED_LOADING_STATE) continue; + failedTile.internal.loadingState = UNLOADED_LOADING_STATE; + resetCount += 1; + } + if ( + current.retryRoot && + renderer.rootLoadingState === FAILED_LOADING_STATE + ) { + renderer.rootLoadingState = UNLOADED_LOADING_STATE; + resetCount += 1; + } + if (resetCount > 0) { + renderer.stats.failed = Math.max(0, renderer.stats.failed - resetCount); + } + renderer.dispatchEvent({ type: "needs-update" }); + requestRender(); + }, getTileRetryDelayMs(retryNumber, key)); + pendingRetries.set(key, { + timer, + tiles: new Set(tile ? [tile] : []), + retryRoot: tile === null, + }); + return "scheduled"; + }; + + const clear = () => { + for (const pending of pendingRetries.values()) { + clearTimeout(pending.timer); + } + pendingRetries.clear(); + retryCounts.clear(); + exhaustedRetries.clear(); + }; + + return { + handleFailure, + handleSuccess, + isBlocked: (tile, url) => { + const key = getTileRetryKey(tile, url); + return key !== null && (pendingRetries.has(key) || isKeyExhausted(key)); + }, + isExhausted: (tile, url) => { + const key = getTileRetryKey(tile, url); + return key !== null && isKeyExhausted(key); + }, + hasPendingRetries: () => pendingRetries.size > 0, + hasExhaustedRetries: () => { + pruneExhausted(); + return exhaustedRetries.size > 0; + }, + reset: clear, + dispose: clear, + }; +}; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-runtime.error-target.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-runtime.error-target.spec.ts new file mode 100644 index 0000000000..5a222d8ba3 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-runtime.error-target.spec.ts @@ -0,0 +1,222 @@ +// @vitest-environment jsdom + +/** + * D5 wiring of the effective error target in the runtime: a full, idle and + * unconverged view relaxes the target once after the hold time, a pan keeps + * the relaxed target, the failure memory blocks a re-tighten in the same view + * class until the view zooms in, and a placeholder parent whose children can + * never load counts as converged so the shadow camera can join. + */ + +import { TilesRenderer } from "3d-tiles-renderer"; +import type { Map as MaplibreMap } from "maplibre-gl"; +import * as THREE from "three"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ERROR_TARGET_POLICY } from "./three-tiles-load-policy"; +import { buildThreeTilesRuntime } from "./three-tiles-runtime"; + +vi.hoisted(() => { + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: () => "blob:vitest-maplibre-worker", + }); +}); + +const MIB = 1024 ** 2; +const GIB = 1024 ** 3; + +type StubRenderer = TilesRenderer & { + usedSet: Set; + stats: { failed: number }; +}; + +describe("three tiles runtime effective error target", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + const setup = () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + let renderer: StubRenderer | undefined; + vi.spyOn(TilesRenderer.prototype, "update").mockImplementation(function ( + this: TilesRenderer + ) { + renderer = this as StubRenderer; + }); + const handlers = new Map void>(); + const view = { zoom: 17, pitch: 45 }; + const map = { + on: vi.fn((event: string, handler: () => void) => { + handlers.set(event, handler); + }), + off: vi.fn(), + triggerRepaint: vi.fn(), + getZoom: () => view.zoom, + getPitch: () => view.pitch, + } as unknown as MaplibreMap; + const layer = buildThreeTilesRuntime("mesh", "tileset.json", [7.15, 51.25]); + const viewCamera = new THREE.PerspectiveCamera(); + const frame = { + map, + renderCamera: viewCamera, + lodCamera: viewCamera, + lookTarget: new THREE.Vector3(), + viewport: new THREE.Vector2(800, 600), + }; + layer.setErrorTarget(0.25); + layer.onAdd?.(map); + layer.setShadowView({ + camera: new THREE.OrthographicCamera(), + shadowMapSize: { width: 2048, height: 2048 }, + }); + layer.update(frame); + // A displayed placeholder above the target whose child still has to load, + // and one used tile that fills the whole ceiling: full, idle, unconverged. + const child = { + content: { uri: "child.b3dm" }, + internal: { + basePath: "https://example.test/tiles", + hasContent: true, + loadingState: 0, + }, + children: [], + }; + const visibleTile = { + traversal: { error: 1, inFrustum: true }, + children: [child], + }; + const requiredTile = {} as never; + const disposeRequiredTile = vi.fn(); + renderer!.visibleTiles.add(visibleTile as never); + renderer!.usedSet.add(requiredTile); + renderer!.lruCache.add(requiredTile, disposeRequiredTile); + renderer!.lruCache.setMemoryUsage(requiredTile, 2 * GIB); + const tick = () => layer.update(frame); + const stall = (ms: number) => { + tick(); + vi.advanceTimersByTime(ms); + tick(); + }; + return { + layer, + renderer: renderer!, + map, + handlers, + view, + tick, + stall, + child, + visibleTile, + requiredTile, + disposeRequiredTile, + }; + }; + + it("relaxes once after the hold time and keeps the target across pans", () => { + const { layer, renderer, handlers, stall, tick, disposeRequiredTile } = + setup(); + + stall(ERROR_TARGET_POLICY.relaxHoldMs - 1); + expect(renderer.errorTarget).toBe(0.25); + vi.advanceTimersByTime(1); + tick(); + expect(renderer.errorTarget).toBe(0.5); + expect(disposeRequiredTile).not.toHaveBeenCalled(); + + // The pan does not reset the effective target ... + handlers.get("movestart")?.(); + expect(renderer.errorTarget).toBe(0.5); + handlers.get("moveend")?.(); + expect(renderer.errorTarget).toBe(0.5); + // ... and the next stall relaxes after the same hold, not a longer one. + stall(ERROR_TARGET_POLICY.relaxHoldMs); + expect(renderer.errorTarget).toBe(1); + // Four times the requested target is the cap. + stall(ERROR_TARGET_POLICY.relaxHoldMs); + expect(renderer.errorTarget).toBe(1); + layer.dispose(); + }); + + it("does not tighten below the target that failed in this view until the view zooms in", () => { + const { layer, renderer, view, stall, tick, visibleTile, requiredTile } = + setup(); + stall(ERROR_TARGET_POLICY.relaxHoldMs); + expect(renderer.errorTarget).toBe(0.5); + + // Eviction frees memory and the placeholder meets the relaxed target. + renderer.lruCache.setMemoryUsage(requiredTile, 100 * MIB); + visibleTile.traversal.error = 0.4; + stall(ERROR_TARGET_POLICY.tightenCooldownMs); + expect(renderer.errorTarget).toBe(0.5); + stall(ERROR_TARGET_POLICY.tightenCooldownMs); + expect(renderer.errorTarget).toBe(0.5); + + // A zoom-in clears the failure memory and tightens again. + view.zoom = 17.5; + tick(); + expect(renderer.errorTarget).toBe(0.25); + layer.dispose(); + }); + + it("does not retraverse when the same requested target is re-applied", () => { + const { layer, renderer, stall } = setup(); + stall(ERROR_TARGET_POLICY.relaxHoldMs); + expect(renderer.errorTarget).toBe(0.5); + const dispatchSpy = vi.spyOn(renderer, "dispatchEvent"); + + // shadow-scene.ts re-applies the same requested target on every content + // change; that must not restart the relaxation cycle. + layer.setErrorTarget(0.25); + expect(renderer.errorTarget).toBe(0.5); + expect(dispatchSpy).not.toHaveBeenCalled(); + + layer.setErrorTarget(1); + expect(renderer.errorTarget).toBe(1); + expect( + dispatchSpy.mock.calls.map(([event]) => (event as { type: string }).type) + ).toContain("needs-update"); + layer.dispose(); + }); + + it("treats a placeholder whose child can never load as ready for receiver traversal", () => { + const { layer, renderer, tick, child, visibleTile, requiredTile } = setup(); + renderer.lruCache.setMemoryUsage(requiredTile, 16 * MIB); + const receiverBounds = new THREE.Box3( + new THREE.Vector3(-10, -10, -110), + new THREE.Vector3(10, 10, -90) + ); + const boundingVolume = { + getAABB: (target: THREE.Box3) => target.copy(receiverBounds), + getSphere: (target: THREE.Sphere) => + receiverBounds.getBoundingSphere(target), + intersectsFrustum: () => true, + }; + Object.assign(visibleTile, { engineData: { boundingVolume } }); + Object.assign(renderer, { + rootTileset: { root: { engineData: { boundingVolume } } }, + }); + renderer.group.add(new THREE.Group()); + + // The child is missing on the server: exhausted at once, never requested. + child.internal.loadingState = -1; + renderer.stats.failed = 1; + renderer.dispatchEvent({ + type: "load-error", + tile: child as never, + error: new Error("status 404"), + url: "https://example.test/tiles/child.b3dm", + }); + expect(child.internal.loadingState).toBe(0); + + for (let frame = 0; frame < 3; frame += 1) { + tick(); + vi.advanceTimersByTime(ERROR_TARGET_POLICY.relaxHoldMs); + } + expect(renderer.errorTarget).toBe(0.25); + expect(layer.getRequestDemand()).toBe(0); + layer.dispose(); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-runtime.liveness.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-runtime.liveness.spec.ts new file mode 100644 index 0000000000..d05b2d029a --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-runtime.liveness.spec.ts @@ -0,0 +1,295 @@ +// @vitest-environment jsdom + +/** + * D8/D9 liveness of the 3D Tiles runtime: a failed tile leaves the cache and + * is requested again after its backoff, a disposed model wakes the traversal, + * the kickstart stops once the root tileset arrived, the hidden-tab wipe is + * debounced, the debug overlay only exists while bounds are shown, and a + * disposed runtime reports no demand. + */ + +import { TilesRenderer } from "3d-tiles-renderer"; +import type { Map as MaplibreMap } from "maplibre-gl"; +import * as THREE from "three"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + buildThreeTilesRuntime, + HIDDEN_TAB_WIPE_DELAY_MS, +} from "./three-tiles-runtime"; + +vi.hoisted(() => { + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: () => "blob:vitest-maplibre-worker", + }); +}); + +type LivenessRenderer = TilesRenderer & { + requestTileContents: (tile: unknown) => unknown; + queueTileForDownload: (tile: unknown) => void; + queuedTiles: unknown[]; + loadingTiles: Set; + stats: { + queued: number; + downloading: number; + parsing: number; + failed: number; + }; +}; + +const buildTile = (uri: string, scene: THREE.Object3D | null = null) => ({ + content: { uri }, + geometricError: 1, + internal: { + basePath: "https://example.test/tiles", + loadingState: 0, + depth: 3, + hasContent: true, + hasRenderableContent: true, + }, + traversal: { inFrustum: true, visible: false, active: false }, + engineData: { scene, geometry: [], materials: [], textures: [] }, + children: [], +}); + +const buildMap = () => + ({ + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + } as unknown as MaplibreMap); + +/** Event types seen by a `dispatchEvent` spy (three stamps `target` onto events). */ +const dispatchedTypes = (spy: { mock: { calls: unknown[][] } }) => + spy.mock.calls.map(([event]) => (event as { type: string }).type); + +const mountRuntime = () => { + let renderer: LivenessRenderer | undefined; + vi.spyOn(TilesRenderer.prototype, "update").mockImplementation(function ( + this: TilesRenderer + ) { + renderer = this as LivenessRenderer; + }); + const map = buildMap(); + const repaint = map.triggerRepaint as unknown as ReturnType; + const layer = buildThreeTilesRuntime("mesh", "tileset.json", [7.15, 51.25]); + const camera = new THREE.PerspectiveCamera(); + const frame = { + map, + renderCamera: camera, + lodCamera: camera, + lookTarget: new THREE.Vector3(), + viewport: new THREE.Vector2(800, 600), + }; + layer.onAdd?.(map); + layer.update(frame); + return { layer, map, repaint, frame, renderer: renderer as LivenessRenderer }; +}; + +describe("three tiles runtime liveness", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + vi.stubGlobal("requestAnimationFrame", () => 1); + vi.stubGlobal("cancelAnimationFrame", () => undefined); + vi.stubGlobal("fetch", () => new Promise(() => undefined)); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("removes a failed tile from the cache and requests it again after the backoff", () => { + const { layer, renderer } = mountRuntime(); + // Content exists, so the demand below only reflects the retry state. + renderer.group.add(new THREE.Group()); + const tile = buildTile("child.b3dm"); + expect(layer.getRequestDemand()).toBe(0); + + // first request: enters the cache and the download queue + renderer.requestTileContents(tile); + expect(tile.internal.loadingState).toBe(1); + expect(renderer.lruCache.has(tile as never)).toBe(true); + expect(renderer.downloadQueue.has(tile)).toBe(true); + + // upstream's failure bookkeeping: state FAILED, tile stays cached + renderer.downloadQueue.remove(tile); + renderer.loadingTiles.delete(tile); + tile.internal.loadingState = -1; + renderer.stats.queued = 0; + renderer.stats.failed = 1; + renderer.lruCache.setLoaded(tile as never, true); + renderer.dispatchEvent({ + type: "load-error", + tile: tile as never, + error: new Error("status 503"), + url: "https://example.test/tiles/child.b3dm", + } as never); + + // The tile left the cache, is UNLOADED and skipped while the retry is + // pending, so the parent keeps rendering as the fallback. + expect(renderer.lruCache.has(tile as never)).toBe(false); + expect(tile.internal.loadingState).toBe(0); + expect(renderer.stats.failed).toBe(0); + renderer.queueTileForDownload(tile); + expect(renderer.queuedTiles).toHaveLength(0); + // Only the required retry blocks a settled scene. Policy cooldowns and + // adaptive-error timers do not represent missing tile content. + expect(layer.getRequestDemand()).toBe(1); + + const dispatchSpy = vi.spyOn(renderer, "dispatchEvent"); + vi.advanceTimersByTime(2_000); + expect(dispatchedTypes(dispatchSpy)).toContain("needs-update"); + expect(layer.getRequestDemand()).toBe(0); + + // the next traversal can request it again + renderer.queueTileForDownload(tile); + expect(renderer.queuedTiles).toHaveLength(1); + renderer.queuedTiles.length = 0; + renderer.requestTileContents(tile); + expect(tile.internal.loadingState).toBe(1); + expect(renderer.lruCache.has(tile as never)).toBe(true); + expect(renderer.downloadQueue.has(tile)).toBe(true); + + layer.dispose(); + }); + + it("asks for a traversal when a disposed model frees cache space", () => { + const { layer, repaint, renderer } = mountRuntime(); + const dispatchSpy = vi.spyOn(renderer, "dispatchEvent"); + const tile = buildTile("leaf.b3dm", new THREE.Group()); + renderer.requestTileContents(tile); + repaint.mockClear(); + dispatchSpy.mockClear(); + + // eviction and the discard path only run lruCache.remove -> dispose-model + renderer.lruCache.remove(tile as never); + + expect(dispatchedTypes(dispatchSpy)).toEqual([ + "dispose-model", + "needs-update", + ]); + expect(repaint).toHaveBeenCalled(); + layer.dispose(); + }); + + it("stops kickstarting frames once the root tileset arrived", () => { + const { layer, repaint, renderer } = mountRuntime(); + // No debug helper groups: the tiles group is empty until content loads. + expect(renderer.group.children).toHaveLength(0); + + repaint.mockClear(); + vi.advanceTimersByTime(800); + expect(repaint).toHaveBeenCalledTimes(2); + + renderer.dispatchEvent({ + type: "load-tileset", + url: "tileset.json", + } as never); + repaint.mockClear(); + vi.advanceTimersByTime(4_000); + expect(repaint).not.toHaveBeenCalled(); + layer.dispose(); + }); + + it("keeps kickstarting after a tile error but not while hidden", () => { + const { layer, repaint, renderer } = mountRuntime(); + renderer.dispatchEvent({ + type: "load-error", + tile: buildTile("child.b3dm") as never, + error: new Error("status 404"), + url: "https://example.test/tiles/child.b3dm", + } as never); + repaint.mockClear(); + vi.advanceTimersByTime(800); + expect(repaint).toHaveBeenCalledTimes(2); + + layer.setVisible(false); + repaint.mockClear(); + vi.advanceTimersByTime(2_000); + expect(repaint).not.toHaveBeenCalled(); + layer.setVisible(true); + repaint.mockClear(); + vi.advanceTimersByTime(800); + expect(repaint).toHaveBeenCalledTimes(2); + layer.dispose(); + }); + + it("evicts unused tiles at once when hidden and wipes the rest after the delay", () => { + const { layer, renderer } = mountRuntime(); + const cache = renderer.lruCache as TilesRenderer["lruCache"] & { + usedSet: Set; + }; + const usedTile = buildTile("used.b3dm"); + const unusedTile = buildTile("unused.b3dm"); + const disposeUsed = vi.fn(); + const disposeUnused = vi.fn(); + cache.add(usedTile as never, disposeUsed); + cache.add(unusedTile as never, disposeUnused); + cache.markUnused(unusedTile as never); + expect(cache.usedSet.has(usedTile)).toBe(true); + + const visibilitySpy = vi + .spyOn(document, "visibilityState", "get") + .mockReturnValue("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + expect(disposeUnused).toHaveBeenCalledOnce(); + expect(disposeUsed).not.toHaveBeenCalled(); + + // Returning before the delay cancels the full wipe. + vi.advanceTimersByTime(HIDDEN_TAB_WIPE_DELAY_MS - 1); + visibilitySpy.mockReturnValue("visible"); + document.dispatchEvent(new Event("visibilitychange")); + vi.advanceTimersByTime(HIDDEN_TAB_WIPE_DELAY_MS); + expect(disposeUsed).not.toHaveBeenCalled(); + + visibilitySpy.mockReturnValue("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + vi.advanceTimersByTime(HIDDEN_TAB_WIPE_DELAY_MS); + expect(disposeUsed).toHaveBeenCalledOnce(); + expect(cache.has(usedTile as never)).toBe(false); + + visibilitySpy.mockRestore(); + layer.dispose(); + }); + + it("attaches the tile edge and label overlay only while enabled", () => { + const { layer, renderer } = mountRuntime(); + const findOverlay = () => + renderer.group.getObjectByName("CARMA 3D tiles bounds and labels"); + expect(findOverlay()).toBeUndefined(); + + layer.setTileBoundsVisible(true); + expect(findOverlay()).toBeDefined(); + + layer.setTileBoundsVisible(false); + expect(findOverlay()).toBeUndefined(); + expect(renderer.group.children).toHaveLength(0); + layer.dispose(); + }); + + it("keeps rendering for queued downloads only while downloads may run", () => { + const { layer, repaint, frame, renderer } = mountRuntime(); + renderer.stats.queued = 1; + + layer.setRequestConcurrency(0); + repaint.mockClear(); + layer.update(frame); + expect(repaint).not.toHaveBeenCalled(); + + layer.setRequestConcurrency(8); + repaint.mockClear(); + layer.update(frame); + expect(repaint).toHaveBeenCalled(); + layer.dispose(); + }); + + it("reports no request demand after dispose", () => { + const { layer } = mountRuntime(); + expect(layer.getRequestDemand()).toBeGreaterThan(0); + layer.dispose(); + expect(layer.getRequestDemand()).toBe(0); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-runtime.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-runtime.spec.ts new file mode 100644 index 0000000000..93565decb5 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-runtime.spec.ts @@ -0,0 +1,1636 @@ +// @vitest-environment jsdom + +import { TilesRenderer } from "3d-tiles-renderer"; +import { PriorityQueue } from "3d-tiles-renderer/core"; +import type { Map as MaplibreMap } from "maplibre-gl"; +import * as THREE from "three"; +import { describe, expect, it, vi } from "vitest"; + +import { TILE_OUTLINE_FLAG } from "@carma-mapping/engines/threejs"; +import { setSharedThreeTerrainLoading } from "./shared-three-terrain-registry"; +import { TILES_LOAD_POLICY } from "./three-tiles-load-policy"; +import { + buildThreeTilesRuntime, + HIDDEN_TAB_WIPE_DELAY_MS, +} from "./three-tiles-runtime"; + +const MIB = 1024 ** 2; + +type BytesRenderer = { + calculateBytesUsed: (tile: unknown, scene: THREE.Object3D | null) => number; +}; + +vi.hoisted(() => { + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: () => "blob:vitest-maplibre-worker", + }); +}); + +describe("three tiles runtime styling", () => { + it("isolates loader resources per tileset and prices measured tiles with the resident overhead", () => { + const renderers: TilesRenderer[] = []; + const updateSpy = vi + .spyOn(TilesRenderer.prototype, "update") + .mockImplementation(function (this: TilesRenderer) { + renderers.push(this); + }); + // `calculateBytesUsed` is an untyped upstream plugin hook. + const bytesSpy = vi + .spyOn( + TilesRenderer.prototype as unknown as BytesRenderer, + "calculateBytesUsed" + ) + .mockReturnValue(10.6); + const map = { + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + } as unknown as MaplibreMap; + const camera = new THREE.PerspectiveCamera(); + const frame = { + map, + renderCamera: camera, + lodCamera: camera, + lookTarget: new THREE.Vector3(), + viewport: new THREE.Vector2(800, 600), + }; + const first = buildThreeTilesRuntime("first", "first.json", [7.15, 51.25]); + const second = buildThreeTilesRuntime( + "second", + "second.json", + [7.15, 51.25] + ); + + first.onAdd?.(map); + second.onAdd?.(map); + first.update(frame); + second.update(frame); + + expect(renderers).toHaveLength(2); + expect(renderers[0]?.lruCache).not.toBe(renderers[1]?.lruCache); + expect(renderers[0]?.downloadQueue).not.toBe(renderers[1]?.downloadQueue); + expect(renderers[0]?.parseQueue).not.toBe(renderers[1]?.parseQueue); + expect(renderers[0]?.processNodeQueue).not.toBe( + renderers[1]?.processNodeQueue + ); + expect(renderers[0]?.loadAncestors).toBe(true); + expect(renderers[0]?.loadSiblings).toBe(false); + expect(renderers[0]?.displayActiveTiles).toBe(true); + expect( + (renderers[0] as unknown as BytesRenderer).calculateBytesUsed( + {} as never, + new THREE.Group() + ) + ).toBe(Math.round(10.6 * TILES_LOAD_POLICY.residentOverhead)); + + first.dispose(); + second.dispose(); + bytesSpy.mockRestore(); + updateSpy.mockRestore(); + }); + + it("keeps the parent fallback eligible after a child exhausts its retries", () => { + vi.useFakeTimers(); + type TraversalRenderer = TilesRenderer & { + queueTileForDownload: (tile: unknown) => void; + stats: { failed: number }; + }; + const prototype = TilesRenderer.prototype as TraversalRenderer; + const queueSpy = vi + .spyOn(prototype, "queueTileForDownload") + .mockImplementation(() => undefined); + let renderer: TraversalRenderer | undefined; + const updateSpy = vi + .spyOn(TilesRenderer.prototype, "update") + .mockImplementation(function (this: TilesRenderer) { + renderer = this as TraversalRenderer; + }); + const map = { + getCenter: vi.fn(() => ({ lng: 7.15, lat: 51.25 })), + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + } as unknown as MaplibreMap; + const layer = buildThreeTilesRuntime("mesh", "tileset.json", [7.15, 51.25]); + const camera = new THREE.PerspectiveCamera(); + const failedTile = { + content: { uri: "child.b3dm" }, + internal: { + basePath: "https://example.test/tiles", + loadingState: -1, + }, + traversal: { inFrustum: true }, + }; + + layer.onAdd?.(map); + layer.update({ + map, + renderCamera: camera, + lodCamera: camera, + lookTarget: new THREE.Vector3(), + viewport: new THREE.Vector2(800, 600), + }); + for (let attempt = 0; attempt < 5; attempt += 1) { + failedTile.internal.loadingState = -1; + renderer!.stats.failed = 1; + renderer!.dispatchEvent({ + type: "load-error", + tile: failedTile as never, + error: new Error("status 503"), + url: "https://example.test/tiles/child.b3dm", + }); + // The failed tile is released at once (UNLOADED, out of the cache) but + // not requested again until its backoff fired: the parent keeps + // rendering as the fallback meanwhile. + expect(failedTile.internal.loadingState).toBe(0); + expect(renderer!.stats.failed).toBe(0); + renderer!.queueTileForDownload(failedTile); + expect(queueSpy).toHaveBeenCalledTimes(attempt); + vi.runOnlyPendingTimers(); + renderer!.queueTileForDownload(failedTile); + expect(queueSpy).toHaveBeenCalledTimes(attempt + 1); + } + + failedTile.internal.loadingState = -1; + renderer!.stats.failed = 1; + renderer!.dispatchEvent({ + type: "load-error", + tile: failedTile as never, + error: new Error("status 503"), + url: "https://example.test/tiles/child.b3dm", + }); + + expect(failedTile.internal.loadingState).toBe(0); + expect(renderer!.stats.failed).toBe(0); + queueSpy.mockClear(); + renderer!.queueTileForDownload(failedTile); + expect(queueSpy).not.toHaveBeenCalled(); + + // A missing tile is never retried at all. + const missingTile = { + content: { uri: "missing.b3dm" }, + internal: { basePath: "https://example.test/tiles", loadingState: -1 }, + traversal: { inFrustum: true }, + }; + renderer!.stats.failed = 1; + renderer!.dispatchEvent({ + type: "load-error", + tile: missingTile as never, + error: new Error("status 404"), + url: "https://example.test/tiles/missing.b3dm", + }); + expect(missingTile.internal.loadingState).toBe(0); + vi.advanceTimersByTime(60_000); + renderer!.queueTileForDownload(missingTile); + expect(queueSpy).not.toHaveBeenCalled(); + + layer.dispose(); + updateSpy.mockRestore(); + queueSpy.mockRestore(); + vi.useRealTimers(); + }); + + it("admits at one physical ceiling below the device limit and evicts around it", () => { + let renderer: TilesRenderer | undefined; + const updateSpy = vi + .spyOn(TilesRenderer.prototype, "update") + .mockImplementation(function (this: TilesRenderer) { + renderer = this; + }); + const map = { + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + } as unknown as MaplibreMap; + const cacheBudgetBytes = 256 * MIB; + const cacheOverflowBytes = 256 * MIB; + const ceilingBytes = cacheBudgetBytes + cacheOverflowBytes; + const layer = buildThreeTilesRuntime( + "mesh", + "tileset.json", + [7.15, 51.25], + { + cacheBudgetBytes, + cacheOverflowBytes, + providesTerrain: true, + shadowBuildingStyle: true, + } + ); + const camera = new THREE.PerspectiveCamera(); + + layer.onAdd?.(map); + layer.update({ + map, + renderCamera: camera, + lodCamera: camera, + lookTarget: new THREE.Vector3(), + viewport: new THREE.Vector2(800, 600), + }); + + const expectBounds = () => { + expect(renderer?.lruCache.minBytesSize).toBe( + Math.floor(ceilingBytes * TILES_LOAD_POLICY.cacheRetentionFraction) + ); + expect(renderer?.lruCache.maxBytesSize).toBe( + ceilingBytes + TILES_LOAD_POLICY.cacheDriftSlackMinBytes + ); + expect(renderer?.lruCache.unloadPercent).toBe( + TILES_LOAD_POLICY.cacheUnloadPercent + ); + expect(renderer?.lruCache.minSize).toBe(6_000); + expect(renderer?.lruCache.maxSize).toBe(8_000); + }; + expectBounds(); + layer.setShadowSimulationStyle({ + fullOpacity: true, + uniformColor: null, + }); + expectBounds(); + layer.setShadowSimulationStyle(null); + expectBounds(); + + const cache = renderer?.lruCache as TilesRenderer["lruCache"] & { + cachedBytes: number; + isFull: () => boolean; + }; + cache.cachedBytes = ceilingBytes - 1; + expect(cache.isFull()).toBe(false); + cache.cachedBytes = ceilingBytes; + expect(cache.isFull()).toBe(true); + + const shadowCamera = new THREE.OrthographicCamera(); + layer.setShadowView({ + camera: shadowCamera, + shadowMapSize: { width: 4_096, height: 4_096 }, + }); + expect(cache.isFull()).toBe(true); + layer.setShadowView(null); + expect(cache.isFull()).toBe(true); + + // A style may only lower the ceiling; the floor still applies. + layer.setCacheBudget(1024); + expect(cache.isFull()).toBe(true); + cache.cachedBytes = 100 * MIB; + expect(cache.isFull()).toBe(false); + + layer.dispose(); + updateSpy.mockRestore(); + }); + + it("loads a terrain-providing mesh while fallback terrain is still loading", () => { + let renderer: TilesRenderer | undefined; + const updateSpy = vi + .spyOn(TilesRenderer.prototype, "update") + .mockImplementation(function (this: TilesRenderer) { + renderer = this; + }); + const map = { + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + } as unknown as MaplibreMap; + const layer = buildThreeTilesRuntime( + "mesh", + "tileset.json", + [7.15, 51.25], + { + providesTerrain: true, + } + ); + const camera = new THREE.PerspectiveCamera(); + setSharedThreeTerrainLoading(map, "fallback-terrain", true); + + layer.onAdd?.(map); + layer.update({ + map, + renderCamera: camera, + lodCamera: camera, + lookTarget: new THREE.Vector3(), + viewport: new THREE.Vector2(800, 600), + }); + + expect(renderer?.downloadQueue.maxJobsPerOrigin).toBeGreaterThan(0); + expect(layer.hasRenderableContent?.()).toBe(false); + const tilesGroup = layer.root.children[0]?.children[0]; + tilesGroup?.add( + new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshBasicMaterial() + ) + ); + expect(layer.hasRenderableContent?.()).toBe(true); + + setSharedThreeTerrainLoading(map, "fallback-terrain", false); + layer.dispose(); + updateSpy.mockRestore(); + }); + + it("keeps progressive visible-content slots while terrain is loading", () => { + let renderer: TilesRenderer | undefined; + const updateSpy = vi + .spyOn(TilesRenderer.prototype, "update") + .mockImplementation(function (this: TilesRenderer) { + renderer = this; + }); + const map = { + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + } as unknown as MaplibreMap; + const layer = buildThreeTilesRuntime( + "buildings", + "tileset.json", + [7.15, 51.25] + ); + const camera = new THREE.PerspectiveCamera(); + setSharedThreeTerrainLoading(map, "fallback-terrain", true); + + layer.onAdd?.(map); + layer.update({ + map, + renderCamera: camera, + lodCamera: camera, + lookTarget: new THREE.Vector3(), + viewport: new THREE.Vector2(800, 600), + }); + + expect(renderer?.downloadQueue.maxJobsPerOrigin).toBe(8); + + setSharedThreeTerrainLoading(map, "fallback-terrain", false); + expect(renderer?.downloadQueue.maxJobsPerOrigin).toBeGreaterThan(1); + layer.dispose(); + updateSpy.mockRestore(); + }); + + it("exposes the active 3D tile volumes in shared scene coordinates", () => { + let renderer: TilesRenderer | undefined; + const updateSpy = vi + .spyOn(TilesRenderer.prototype, "update") + .mockImplementation(function (this: TilesRenderer) { + renderer = this; + }); + const map = { + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + } as unknown as MaplibreMap; + const layer = buildThreeTilesRuntime( + "buildings", + "tileset.json", + [7.15, 51.25] + ); + const camera = new THREE.PerspectiveCamera(); + + layer.onAdd?.(map); + layer.update({ + map, + renderCamera: camera, + lodCamera: camera, + lookTarget: new THREE.Vector3(), + viewport: new THREE.Vector2(800, 600), + }); + renderer!.activeTiles.add({ + content: { uri: "building.b3dm" }, + internal: { depth: 3 }, + engineData: { + scene: new THREE.Group(), + boundingVolume: { + getAABB: (target: THREE.Box3) => + target.set( + new THREE.Vector3(-2, 10, -4), + new THREE.Vector3(2, 30, 4) + ), + }, + }, + } as never); + + const volumes = layer.getActiveTileVolumes?.() ?? []; + + expect(volumes).toHaveLength(1); + expect(volumes[0]).toMatchObject({ + id: "buildings:building.b3dm", + kind: "3d-tile", + }); + expect(volumes[0]?.minimum.every(Number.isFinite)).toBe(true); + expect(volumes[0]?.maximum.every(Number.isFinite)).toBe(true); + + layer.dispose(); + updateSpy.mockRestore(); + }); + + it("does not retraverse for an unchanged error target", () => { + let renderer: TilesRenderer | undefined; + const updateSpy = vi + .spyOn(TilesRenderer.prototype, "update") + .mockImplementation(function (this: TilesRenderer) { + renderer = this; + }); + const map = { + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + } as unknown as MaplibreMap; + const layer = buildThreeTilesRuntime("mesh", "tileset.json", [7.15, 51.25]); + const camera = new THREE.PerspectiveCamera(); + + layer.onAdd?.(map); + layer.update({ + map, + renderCamera: camera, + lodCamera: camera, + lookTarget: new THREE.Vector3(), + viewport: new THREE.Vector2(800, 600), + }); + const dispatchSpy = vi.spyOn(renderer!, "dispatchEvent"); + + layer.setErrorTarget(1); + const callsAfterChange = dispatchSpy.mock.calls.length; + layer.setErrorTarget(1); + + expect(dispatchSpy).toHaveBeenCalled(); + expect(dispatchSpy).toHaveBeenCalledTimes(callsAfterChange); + + layer.dispose(); + updateSpy.mockRestore(); + }); + + it("uses receiver extrusion without registering a shadow selection camera", () => { + const updateErrorTargets: number[] = []; + const eventOrder: string[] = []; + let renderer: TilesRenderer | undefined; + const updateSpy = vi + .spyOn(TilesRenderer.prototype, "update") + .mockImplementation(function (this: TilesRenderer) { + renderer = this; + updateErrorTargets.push(this.errorTarget); + eventOrder.push("update"); + }); + const setCameraSpy = vi + .spyOn(TilesRenderer.prototype, "setCamera") + .mockImplementation((camera) => { + eventOrder.push( + camera instanceof THREE.OrthographicCamera + ? "shadow-camera" + : "view-camera" + ); + return true; + }); + const deleteCameraSpy = vi + .spyOn(TilesRenderer.prototype, "deleteCamera") + .mockImplementation(() => true); + const setResolutionSpy = vi + .spyOn(TilesRenderer.prototype, "setResolution") + .mockImplementation(() => true); + const registerPluginSpy = vi.spyOn( + TilesRenderer.prototype, + "registerPlugin" + ); + const handlers = new Map void>(); + const map = { + on: vi.fn((event: string, handler: () => void) => { + handlers.set(event, handler); + }), + off: vi.fn(), + triggerRepaint: vi.fn(), + } as unknown as MaplibreMap; + const layer = buildThreeTilesRuntime( + "mesh", + "tileset.json", + [7.15, 51.25], + { providesTerrain: true } + ); + const viewCamera = new THREE.PerspectiveCamera(); + const shadowCamera = new THREE.OrthographicCamera(); + const isShadowCameraCall = ([camera]: unknown[]) => + camera instanceof THREE.OrthographicCamera; + const registeredShadowCamera = (spy: { mock: { calls: unknown[][] } }) => + spy.mock.calls.some(isShadowCameraCall); + + layer.setErrorTarget(0.25); + layer.onAdd?.(map); + layer.setShadowView({ + camera: shadowCamera, + shadowMapSize: { width: 2048, height: 2048 }, + }); + layer.update({ + map, + renderCamera: viewCamera, + lodCamera: viewCamera, + lookTarget: new THREE.Vector3(), + viewport: new THREE.Vector2(800, 600), + }); + + expect(updateErrorTargets).toEqual([0.25]); + expect(registeredShadowCamera(setCameraSpy)).toBe(false); + // Loaded content exists; the shadow camera never joins an empty scene. + renderer!.group.add(new THREE.Group()); + const visibleTile = { + traversal: { error: 0.2, inFrustum: true }, + children: [{ internal: { hasContent: true, loadingState: 0 } }], + }; + renderer!.visibleTiles.add(visibleTile as never); + layer.update({ + map, + renderCamera: viewCamera, + lodCamera: viewCamera, + lookTarget: new THREE.Vector3(), + viewport: new THREE.Vector2(800, 600), + }); + + expect(updateErrorTargets).toEqual([0.25, 0.25]); + expect(registeredShadowCamera(setCameraSpy)).toBe(false); + expect(setCameraSpy).not.toHaveBeenCalledWith(shadowCamera); + expect(eventOrder).not.toContain("shadow-camera"); + expect( + registerPluginSpy.mock.calls.some( + ([plugin]) => + (plugin as { name?: string }).name === "UPDATE_ON_CHANGE_PLUGIN" + ) + ).toBe(true); + + deleteCameraSpy.mockClear(); + setCameraSpy.mockClear(); + visibleTile.traversal.error = 1; + const staleTile = {} as never; + const disposeStaleTile = vi.fn(); + renderer!.lruCache.add(staleTile, disposeStaleTile); + renderer!.lruCache.setMemoryUsage(staleTile, 2 * 1024 ** 3); + + handlers.get("moveend")?.(); + expect(registeredShadowCamera(deleteCameraSpy)).toBe(false); + layer.update({ + map, + renderCamera: viewCamera, + lodCamera: viewCamera, + lookTarget: new THREE.Vector3(), + viewport: new THREE.Vector2(800, 600), + }); + + expect(updateErrorTargets).toEqual([0.25, 0.25, 0.25]); + expect(registeredShadowCamera(setCameraSpy)).toBe(false); + // Unused content is left to the LRU's own eviction; the runtime no + // longer purges the cache while the view refines. + expect(disposeStaleTile).not.toHaveBeenCalled(); + + visibleTile.traversal.error = 0.2; + layer.update({ + map, + renderCamera: viewCamera, + lodCamera: viewCamera, + lookTarget: new THREE.Vector3(), + viewport: new THREE.Vector2(800, 600), + }); + expect(registeredShadowCamera(setCameraSpy)).toBe(false); + + deleteCameraSpy.mockClear(); + setResolutionSpy.mockClear(); + + handlers.get("movestart")?.(); + + expect(renderer!.errorTarget).toBe(0.25); + expect(registeredShadowCamera(deleteCameraSpy)).toBe(false); + + shadowCamera.position.x = 2; + shadowCamera.updateMatrixWorld(true); + layer.setShadowView({ + camera: shadowCamera, + shadowMapSize: { width: 4096, height: 2048 }, + }); + layer.update({ + map, + renderCamera: viewCamera, + lodCamera: viewCamera, + lookTarget: new THREE.Vector3(), + viewport: new THREE.Vector2(800, 600), + }); + + expect(updateErrorTargets).toEqual([0.25, 0.25, 0.25, 0.25, 0.25]); + expect(registeredShadowCamera(setResolutionSpy)).toBe(false); + + deleteCameraSpy.mockClear(); + layer.setShadowView(null); + expect(deleteCameraSpy).not.toHaveBeenCalled(); + layer.dispose(); + updateSpy.mockRestore(); + setCameraSpy.mockRestore(); + deleteCameraSpy.mockRestore(); + setResolutionSpy.mockRestore(); + registerPluginSpy.mockRestore(); + }); + + it("waits for visible mesh work before starting receiver extrusion", () => { + let renderer: TilesRenderer | undefined; + const updateSpy = vi + .spyOn(TilesRenderer.prototype, "update") + .mockImplementation(function (this: TilesRenderer) { + renderer = this; + }); + const viewErrorSpy = vi + .spyOn(TilesRenderer.prototype, "calculateTileViewErrorWithPlugin") + .mockImplementation((_tile, target) => { + target.inView = false; + target.error = Number.POSITIVE_INFINITY; + }); + const handlers = new Map void>(); + const map = { + on: vi.fn((event: string, handler: () => void) => { + handlers.set(event, handler); + }), + off: vi.fn(), + triggerRepaint: vi.fn(), + } as unknown as MaplibreMap; + const layer = buildThreeTilesRuntime("mesh", "tileset.json", [7.15, 51.25]); + const viewCamera = new THREE.PerspectiveCamera(60, 1, 0.1, 1_000); + viewCamera.updateProjectionMatrix(); + viewCamera.updateMatrixWorld(true); + const shadowCamera = new THREE.OrthographicCamera( + -100, + 100, + 100, + -100, + 1, + 500 + ); + shadowCamera.position.set(0, 0, 100); + shadowCamera.lookAt(0, 0, 0); + shadowCamera.updateMatrixWorld(true); + const frame = { + map, + renderCamera: viewCamera, + lodCamera: viewCamera, + lookTarget: new THREE.Vector3(), + viewport: new THREE.Vector2(800, 800), + }; + + layer.onAdd?.(map); + layer.setErrorTarget(1); + layer.setShadowView({ + camera: shadowCamera, + shadowMapSize: { width: 2048, height: 2048 }, + }); + layer.update(frame); + + const boundingVolume = (bounds: THREE.Box3, inMainView: boolean) => ({ + getAABB: (target: THREE.Box3) => target.copy(bounds), + getSphere: (target: THREE.Sphere) => bounds.getBoundingSphere(target), + intersectsFrustum: () => inMainView, + }); + const rootBounds = new THREE.Box3( + new THREE.Vector3(-200, -200, -200), + new THREE.Vector3(200, 200, 200) + ); + const receiverBounds = new THREE.Box3( + new THREE.Vector3(-10, -10, -110), + new THREE.Vector3(10, 10, -90) + ); + const localSunward = new THREE.Vector3(0, 0, 1).transformDirection( + renderer!.group.matrixWorld.clone().invert() + ); + const casterBounds = receiverBounds + .clone() + .translate(localSunward.multiplyScalar(50)); + Object.assign(renderer!, { + rootTileset: { + root: { + engineData: { boundingVolume: boundingVolume(rootBounds, true) }, + }, + }, + }); + renderer!.group.add(new THREE.Group()); + const receiverTile = { + geometricError: 1, + traversal: { error: 0.2, inFrustum: true }, + children: [], + parent: null, + engineData: { boundingVolume: boundingVolume(receiverBounds, true) }, + }; + renderer!.visibleTiles.add(receiverTile as never); + const busyQueue = new PriorityQueue(); + busyQueue.items.push({ internal: { depth: 1 } } as never); + renderer!.downloadQueue.originQueues.set("mesh", busyQueue); + + layer.update(frame); + + const target = { + inView: false, + error: Number.POSITIVE_INFINITY, + distanceFromCamera: Number.POSITIVE_INFINITY, + }; + renderer!.calculateTileViewErrorWithPlugin( + { + geometricError: 1, + traversal: { error: 1, inFrustum: false }, + children: [], + parent: null, + internal: { depth: 1 }, + engineData: { boundingVolume: boundingVolume(casterBounds, false) }, + } as never, + target + ); + + expect(target.inView).toBe(false); + expect(target.error).toBe(Number.POSITIVE_INFINITY); + + busyQueue.items.length = 0; + layer.update(frame); + renderer!.calculateTileViewErrorWithPlugin( + { + geometricError: 1, + traversal: { error: 1, inFrustum: false }, + children: [], + parent: null, + internal: { depth: 1 }, + engineData: { boundingVolume: boundingVolume(casterBounds, false) }, + } as never, + target + ); + + expect(target.inView).toBe(true); + expect(target.error).toBeCloseTo(1); + + // A camera move must retain the last complete offscreen caster set while + // the new viewport refines. Clearing the receiver mask here made terrain + // and shadow casters disappear before their replacements were ready. + handlers.get("movestart")?.(); + receiverTile.traversal.error = 4; + layer.update(frame); + target.inView = false; + target.error = Number.POSITIVE_INFINITY; + renderer!.calculateTileViewErrorWithPlugin( + { + geometricError: 1, + traversal: { error: 1, inFrustum: false }, + children: [], + parent: null, + internal: { depth: 1 }, + engineData: { boundingVolume: boundingVolume(casterBounds, false) }, + } as never, + target + ); + expect(target.inView).toBe(true); + receiverTile.traversal.error = 0.2; + + const localLightSpaceX = new THREE.Vector3(1, 0, 0) + .transformDirection(renderer!.group.matrixWorld.clone().invert()) + .multiplyScalar(100); + const shiftedReceiverBounds = receiverBounds + .clone() + .translate(localLightSpaceX); + const shiftedCasterBounds = shiftedReceiverBounds + .clone() + .translate(localSunward); + renderer!.visibleTiles.clear(); + renderer!.visibleTiles.add({ + geometricError: 1, + traversal: { error: 0.2, inFrustum: true }, + children: [], + parent: null, + engineData: { + boundingVolume: boundingVolume(shiftedReceiverBounds, true), + }, + } as never); + + layer.update(frame); + renderer!.calculateTileViewErrorWithPlugin( + { + geometricError: 1, + traversal: { error: 1, inFrustum: false }, + children: [], + parent: null, + internal: { depth: 1 }, + engineData: { boundingVolume: boundingVolume(casterBounds, false) }, + } as never, + target + ); + expect(target.inView).toBe(false); + renderer!.calculateTileViewErrorWithPlugin( + { + geometricError: 1, + traversal: { error: 1, inFrustum: false }, + children: [], + parent: null, + internal: { depth: 1 }, + engineData: { + boundingVolume: boundingVolume(shiftedCasterBounds, false), + }, + } as never, + target + ); + expect(target.inView).toBe(true); + + busyQueue.items.length = 0; + layer.dispose(); + viewErrorSpy.mockRestore(); + updateSpy.mockRestore(); + }); + + it("relaxes the requested error only after the full, idle view stalled and keeps it across pans", () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + const updateErrorTargets: number[] = []; + let renderer: TilesRenderer | undefined; + const updateSpy = vi + .spyOn(TilesRenderer.prototype, "update") + .mockImplementation(function (this: TilesRenderer) { + renderer = this; + updateErrorTargets.push(this.errorTarget); + }); + const handlers = new Map void>(); + const map = { + on: vi.fn((event: string, handler: () => void) => { + handlers.set(event, handler); + }), + off: vi.fn(), + triggerRepaint: vi.fn(), + getZoom: () => 17, + getPitch: () => 45, + } as unknown as MaplibreMap; + const layer = buildThreeTilesRuntime("mesh", "tileset.json", [7.15, 51.25]); + const viewCamera = new THREE.PerspectiveCamera(); + const frame = { + map, + renderCamera: viewCamera, + lodCamera: viewCamera, + lookTarget: new THREE.Vector3(), + viewport: new THREE.Vector2(800, 600), + }; + + layer.setErrorTarget(0.25); + layer.onAdd?.(map); + layer.setShadowView({ + camera: new THREE.OrthographicCamera(), + shadowMapSize: { width: 2048, height: 2048 }, + }); + layer.update(frame); + + // A displayed placeholder above the target whose child still has to load, + // and one used tile that fills the whole ceiling: full, idle, unconverged. + const visibleTile = { + traversal: { error: 1, inFrustum: true }, + children: [{ internal: { hasContent: true, loadingState: 0 } }], + } as never; + const requiredTile = {} as never; + const disposeRequiredTile = vi.fn(); + renderer!.visibleTiles.add(visibleTile); + (renderer as TilesRenderer & { usedSet: Set }).usedSet.add( + requiredTile + ); + renderer!.lruCache.add(requiredTile, disposeRequiredTile); + renderer!.lruCache.setMemoryUsage(requiredTile, 2 * 1024 ** 3); + + layer.update(frame); + expect(disposeRequiredTile).not.toHaveBeenCalled(); + expect(renderer!.errorTarget).toBe(0.25); + expect(layer.getRequestDemand()).toBeGreaterThan(0); + + vi.advanceTimersByTime(999); + layer.update(frame); + expect(renderer!.errorTarget).toBe(0.25); + + vi.advanceTimersByTime(1); + layer.update(frame); + expect(renderer!.errorTarget).toBe(0.5); + expect(updateErrorTargets).toEqual([0.25, 0.25, 0.25, 0.25]); + + // A pan keeps the effective target; the next stall relaxes further, up to + // four times the requested target. + handlers.get("movestart")?.(); + handlers.get("moveend")?.(); + expect(renderer!.errorTarget).toBe(0.5); + layer.update(frame); + vi.advanceTimersByTime(1_000); + layer.update(frame); + expect(renderer!.errorTarget).toBe(1); + layer.update(frame); + vi.advanceTimersByTime(1_000); + layer.update(frame); + expect(renderer!.errorTarget).toBe(1); + + // A hidden tab keeps the used tiles and the effective target for a + // while; the debounced full wipe resets to the requested target. + const visibilitySpy = vi + .spyOn(document, "visibilityState", "get") + .mockReturnValue("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + expect(disposeRequiredTile).not.toHaveBeenCalled(); + expect(renderer!.errorTarget).toBe(1); + vi.advanceTimersByTime(HIDDEN_TAB_WIPE_DELAY_MS); + expect(disposeRequiredTile).toHaveBeenCalledOnce(); + expect(renderer!.errorTarget).toBe(0.25); + + visibilitySpy.mockRestore(); + layer.dispose(); + updateSpy.mockRestore(); + vi.useRealTimers(); + }); + + it("prioritizes hierarchy, then the visible view centre, ahead of shadow-only tiles", () => { + let renderer: TilesRenderer | undefined; + const updateSpy = vi + .spyOn(TilesRenderer.prototype, "update") + .mockImplementation(function (this: TilesRenderer) { + renderer = this; + }); + const map = { + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + } as unknown as MaplibreMap; + const layer = buildThreeTilesRuntime( + "mesh", + "tileset.json", + [7.15, 51.25], + { providesTerrain: true } + ); + const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 100); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(true); + + layer.onAdd?.(map); + layer.update({ + map, + renderCamera: camera, + lodCamera: camera, + lookTarget: new THREE.Vector3(), + viewport: new THREE.Vector2(800, 800), + }); + + type QueuedTile = { + priority?: number; + internal?: { depth: number; hasUnrenderableContent?: boolean }; + engineData: { + boundingVolume: { + getAABB: (target: THREE.Box3) => void; + getSphere: (target: THREE.Sphere) => void; + intersectsFrustum: (frustum: THREE.Frustum) => boolean; + }; + }; + }; + const tileForBox = (box: THREE.Box3, depth = 5): QueuedTile => ({ + internal: { depth }, + engineData: { + boundingVolume: { + getAABB: (target) => target.copy(box), + getSphere: (target) => box.getBoundingSphere(target), + intersectsFrustum: (frustum) => frustum.intersectsBox(box), + }, + }, + }); + const centerTile = tileForBox( + new THREE.Box3( + new THREE.Vector3(-0.5, -1.5, -10.5), + new THREE.Vector3(0.5, -0.5, -9.5) + ) + ); + const outerVisibleTile = tileForBox( + new THREE.Box3( + new THREE.Vector3(3.5, 2.5, -10.5), + new THREE.Vector3(4.5, 3.5, -9.5) + ) + ); + const shadowOnlyTile = tileForBox( + new THREE.Box3( + new THREE.Vector3(29.5, -0.5, -10.5), + new THREE.Vector3(30.5, 0.5, -9.5) + ) + ); + const shallowerVisibleTile = tileForBox( + new THREE.Box3( + new THREE.Vector3(5.5, 3.5, -10.5), + new THREE.Vector3(6.5, 4.5, -9.5) + ), + 4 + ); + const queue = new PriorityQueue() as PriorityQueue & { + items: QueuedTile[]; + }; + queue.priorityCallback = (first, second) => + (first.priority ?? 0) - (second.priority ?? 0); + renderer!.downloadQueue.originQueues.set("test", queue); + queue.items.push( + centerTile, + outerVisibleTile, + shadowOnlyTile, + shallowerVisibleTile + ); + const parsingTile = tileForBox( + new THREE.Box3( + new THREE.Vector3(-0.5, -0.5, -10.5), + new THREE.Vector3(0.5, 0.5, -9.5) + ), + 6 + ); + ( + renderer!.parseQueue as PriorityQueue & { items: QueuedTile[] } + ).items.push(parsingTile); + + layer.update({ + map, + renderCamera: camera, + lodCamera: camera, + lookTarget: new THREE.Vector3(), + viewport: new THREE.Vector2(800, 800), + }); + + // hierarchy first, then the view centre, then shadow-only tiles + expect(shallowerVisibleTile.priority).toBeGreaterThan(centerTile.priority!); + expect(centerTile.priority).toBeGreaterThan(outerVisibleTile.priority!); + expect(outerVisibleTile.priority).toBeGreaterThan(shadowOnlyTile.priority!); + expect(parsingTile.priority).toBeDefined(); + expect(parsingTile.priority).toBeLessThan(shadowOnlyTile.priority!); + queue.sort(); + expect(queue.items.at(-1)).toBe(shallowerVisibleTile); + + queue.items.length = 0; + layer.dispose(); + updateSpy.mockRestore(); + }); + + it("preserves the runtime controls used by the pointcloud playground", () => { + const layer = buildThreeTilesRuntime( + "mesh", + "tileset.json", + [7.15, 51.25], + { providesTerrain: true } + ); + const mesh = new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshStandardMaterial() + ); + layer.root.add(mesh); + + expect(layer.providesTerrain).toBe(true); + expect(layer.getRequestDemand()).toBe(1); + layer.setVisible(false); + expect(layer.root.visible).toBe(false); + expect(layer.getRequestDemand()).toBe(0); + layer.setVisible(true); + layer.setHeightOffset(12); + expect(layer.root.children[0].position.y).toBe(12); + layer.setClayColor("#abcdef"); + layer.setWhiteShading(true); + layer.setWireframe(true); + expect((mesh.material as THREE.MeshStandardMaterial).wireframe).toBe(true); + layer.setTileBoundsVisible(true); + layer.setCacheBudget(1024); + layer.setRequestConcurrency(2); + layer.dispose(); + }); + + it("derives the visible elevation range from model geometry", () => { + const model = new THREE.Mesh( + new THREE.BoxGeometry(20, 10, 20), + new THREE.MeshStandardMaterial() + ); + model.position.y = 150; + const forEachLoadedModelSpy = vi + .spyOn(TilesRenderer.prototype, "forEachLoadedModel") + .mockImplementation((callback) => { + callback(model, { + engineData: { + boundingVolume: { + getAABB: (target: THREE.Box3) => + target.set( + new THREE.Vector3(-10_000, -10_000, -10_000), + new THREE.Vector3(10_000, 10_000, 10_000) + ), + }, + }, + } as never); + }); + const map = { + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + } as unknown as MaplibreMap; + const layer = buildThreeTilesRuntime("mesh", "tileset.json", [7.15, 51.25]); + const camera = new THREE.PerspectiveCamera(60, 1, 1, 1_000); + camera.position.set(0, 150, 100); + camera.lookAt(0, 150, 0); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(true); + + layer.onAdd?.(map); + const range = layer.getViewElevationRange(camera); + + expect(range?.[0]).toBeCloseTo(145); + expect(range?.[1]).toBeCloseTo(155); + + forEachLoadedModelSpy.mockRestore(); + layer.dispose(); + model.geometry.dispose(); + (model.material as THREE.Material).dispose(); + }); + + it("keeps the panorama and frustum projector shader path available", () => { + const layer = buildThreeTilesRuntime("mesh", "tileset.json", [7.15, 51.25]); + const mesh = new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshStandardMaterial() + ); + layer.root.add(mesh); + layer.setWhiteShading(true); + const material = mesh.material as THREE.MeshStandardMaterial; + const shader = { + uniforms: {}, + vertexShader: "#include \n#include ", + fragmentShader: "#include \n#include ", + } as Parameters[0]; + + layer.setProjector({ + kind: "pano", + position: new THREE.Vector3(1, 2, 3), + headingRad: 0.5, + texture: new THREE.Texture(), + opacity: 0.7, + }); + material.onBeforeCompile( + shader, + {} as Parameters[1] + ); + const uniforms = shader.uniforms as Record; + expect(uniforms.uProjKind.value).toBe(1); + expect(uniforms.uProjOpacity.value).toBe(0.7); + expect(shader.fragmentShader).toContain("uProjMatrix"); + + layer.setProjector({ + kind: "frustum", + viewProj: new THREE.Matrix4(), + texture: new THREE.Texture(), + opacity: 0.8, + }); + expect(uniforms.uProjKind.value).toBe(2); + + layer.setProjector(null); + expect(uniforms.uProjKind.value).toBe(0); + expect(uniforms.tProj.value).toBeNull(); + layer.dispose(); + }); + + it("applies the declared clay material to meshes in the shared scene", () => { + const layer = buildThreeTilesRuntime("mesh", "tileset.json", [7.15, 51.25]); + const mesh = new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshBasicMaterial() + ); + layer.root.add(mesh); + + layer.setClayMaterial({ + color: "#d8d1c4", + roughness: 0.7, + metalness: 0.1, + }); + layer.setWhiteShading(true); + + const material: THREE.Material = mesh.material; + expect(material).toBeInstanceOf(THREE.MeshStandardMaterial); + if (!(material instanceof THREE.MeshStandardMaterial)) { + throw new Error("clay shader did not replace the source material"); + } + expect(material.color.getHexString()).toBe("d8d1c4"); + expect(material.roughness).toBe(0.7); + expect(material.metalness).toBe(0.1); + expect(mesh.castShadow).toBe(true); + expect(mesh.receiveShadow).toBe(true); + + layer.dispose(); + }); + + it("keeps native tile meshes shadeable and controls their declared outlines", () => { + const layer = buildThreeTilesRuntime("lod2", "tileset.json", [7.15, 51.25]); + const mesh = new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshStandardMaterial() + ); + const outline = new THREE.LineSegments(); + outline.userData[TILE_OUTLINE_FLAG] = true; + mesh.add(outline); + layer.root.add(mesh); + + layer.setWhiteShading(false); + expect(mesh.castShadow).toBe(true); + expect(mesh.receiveShadow).toBe(true); + + layer.setOutlineVisible(false); + expect(outline.visible).toBe(false); + layer.setOutlineVisible(true); + expect(outline.visible).toBe(true); + + layer.dispose(); + }); + + it("fades textured tiles to the shadow color without replacing their material", () => { + const layer = buildThreeTilesRuntime( + "lod2", + "tileset.json", + [7.15, 51.25], + { shadowBuildingStyle: true } + ); + const sourceMaterial = new THREE.MeshStandardMaterial({ + color: "#847466", + map: new THREE.Texture(), + opacity: 0.4, + transparent: true, + depthWrite: false, + side: THREE.DoubleSide, + }); + const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), sourceMaterial); + const outline = new THREE.LineSegments(); + outline.userData[TILE_OUTLINE_FLAG] = true; + mesh.add(outline); + layer.root.add(mesh); + + layer.setShadowSimulationStyle?.({ + fullOpacity: true, + uniformColor: "#d8d1c4", + uniformColorMix: 0.35, + textureSaturation: 0.4, + }); + + expect(mesh.material).toBe(sourceMaterial); + expect(mesh.castShadow).toBe(true); + expect(mesh.receiveShadow).toBe(true); + expect(sourceMaterial.map).not.toBeNull(); + expect(sourceMaterial.opacity).toBe(1); + expect(sourceMaterial.transparent).toBe(false); + expect(sourceMaterial.depthWrite).toBe(true); + expect(sourceMaterial.shadowSide).toBe(THREE.DoubleSide); + expect(outline.visible).toBe(false); + + const shader = { + uniforms: {}, + vertexShader: "#include \n#include ", + fragmentShader: + "#include \n#include \n#include ", + } as Parameters[0]; + sourceMaterial.onBeforeCompile( + shader, + {} as Parameters[1] + ); + const uniforms = shader.uniforms as Record; + expect(uniforms.uShadowUniformColorMix.value).toBe(0.35); + expect(uniforms.uShadowTextureSaturation.value).toBe(0.4); + expect( + (uniforms.uShadowUniformColor.value as THREE.Color).getHexString() + ).toBe("d8d1c4"); + expect(shader.fragmentShader).toContain("diffuseColor.rgb = mix("); + expect(shader.fragmentShader).toContain("shadowTextureLuma"); + + layer.setShadowSimulationStyle?.(null); + expect(mesh.material).toBe(sourceMaterial); + expect(sourceMaterial.opacity).toBe(0.4); + expect(sourceMaterial.transparent).toBe(true); + expect(sourceMaterial.depthWrite).toBe(false); + expect(sourceMaterial.side).toBe(THREE.DoubleSide); + expect(sourceMaterial.shadowSide).toBeNull(); + expect(uniforms.uShadowTextureSaturation.value).toBe(1); + expect(outline.visible).toBe(true); + + layer.setShadowSimulationStyle?.({ + fullOpacity: true, + uniformColor: null, + uniformColorMix: 1, + }); + expect(mesh.material).toBe(sourceMaterial); + expect(sourceMaterial.shadowSide).toBe(THREE.DoubleSide); + expect(uniforms.uShadowUniformColorMix.value).toBe(0); + + layer.setShadowSimulationStyle?.(null); + expect(sourceMaterial.shadowSide).toBeNull(); + + layer.dispose(); + }); + + it("keeps unclassified separated LoD2 surfaces visible from both sides", () => { + const layer = buildThreeTilesRuntime( + "lod2-city", + "tileset.json", + [7.15, 51.25], + { shadowBuildingStyle: true } + ); + const roofMaterial = new THREE.MeshStandardMaterial({ + name: "roof", + side: THREE.DoubleSide, + }); + const wallMaterial = new THREE.MeshStandardMaterial({ + name: "wall", + side: THREE.DoubleSide, + }); + const shellMaterial = new THREE.MeshStandardMaterial({ + side: THREE.DoubleSide, + }); + layer.root.add( + new THREE.Mesh(new THREE.PlaneGeometry(1, 1), roofMaterial), + new THREE.Mesh(new THREE.PlaneGeometry(1, 1), wallMaterial), + new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), shellMaterial) + ); + + layer.setShadowSimulationStyle?.({ fullOpacity: true, uniformColor: null }); + + expect(roofMaterial.shadowSide).toBe(THREE.FrontSide); + expect(wallMaterial.shadowSide).toBe(THREE.FrontSide); + expect(shellMaterial.shadowSide).toBe(THREE.DoubleSide); + expect(roofMaterial.side).toBe(THREE.DoubleSide); + expect(wallMaterial.side).toBe(THREE.DoubleSide); + expect(shellMaterial.side).toBe(THREE.DoubleSide); + + layer.setShadowSimulationStyle?.(null); + expect(roofMaterial.shadowSide).toBeNull(); + expect(wallMaterial.shadowSide).toBeNull(); + expect(shellMaterial.shadowSide).toBeNull(); + expect(roofMaterial.side).toBe(THREE.DoubleSide); + expect(wallMaterial.side).toBe(THREE.DoubleSide); + + layer.dispose(); + }); + + it("orients connected LoD2 roof and wall triangles into outward shells", () => { + const layer = buildThreeTilesRuntime( + "lod2-city", + "tileset.json", + [7.15, 51.25], + { shadowBuildingStyle: true } + ); + const points = [ + [0, 0, 0], + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + ]; + const roofFaces = [[1, 2, 3]]; + const wallFaces = [ + [0, 2, 1], + [0, 1, 3], + [0, 3, 2], + ]; + const buildSurface = (faces: number[][]) => { + const positions: number[] = []; + const featureIds: number[] = []; + for (const featureId of [0, 1]) { + const offset = featureId * 3; + for (const sourceFace of faces) { + const face = + featureId === 0 + ? sourceFace + : [sourceFace[0], sourceFace[2], sourceFace[1]]; + for (const pointIndex of face) { + const point = points[pointIndex]; + positions.push(point[0] + offset, point[1], point[2]); + featureIds.push(featureId); + } + } + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute( + "position", + new THREE.Float32BufferAttribute(positions, 3) + ); + geometry.setAttribute( + "_feature_id_0", + new THREE.Float32BufferAttribute(featureIds, 1) + ); + geometry.setIndex( + Array.from({ length: positions.length / 3 }, (_, index) => index) + ); + geometry.computeVertexNormals(); + return geometry; + }; + const roofGeometry = buildSurface(roofFaces); + const wallGeometry = buildSurface(wallFaces); + const wallIndex = wallGeometry.getIndex(); + const second = wallIndex?.getX(1) ?? 0; + wallIndex?.setX(1, wallIndex.getX(2)); + wallIndex?.setX(2, second); + wallGeometry.computeVertexNormals(); + const roofMaterial = new THREE.MeshStandardMaterial({ name: "roof" }); + const wallMaterial = new THREE.MeshStandardMaterial({ name: "wall" }); + const cityTile = new THREE.Group(); + cityTile.add( + new THREE.Mesh(roofGeometry, roofMaterial), + new THREE.Mesh(wallGeometry, wallMaterial) + ); + layer.root.add(cityTile); + + layer.setShadowSimulationStyle?.({ fullOpacity: true, uniformColor: null }); + + const edges = new Map(); + const signedVolumes = new Map(); + for (const geometry of [roofGeometry, wallGeometry]) { + const position = geometry.getAttribute("position"); + const normal = geometry.getAttribute("normal"); + const featureId = geometry.getAttribute("_feature_id_0"); + const index = geometry.getIndex(); + expect(index).not.toBeNull(); + for (let offset = 0; offset < (index?.count ?? 0); offset += 3) { + const indices = [ + index?.getX(offset) ?? 0, + index?.getX(offset + 1) ?? 0, + index?.getX(offset + 2) ?? 0, + ]; + const id = featureId.getX(indices[0]); + const keys = indices.map( + (vertex) => + `${position.getX(vertex)},${position.getY(vertex)},${position.getZ( + vertex + )}` + ); + for (const [first, second] of [ + [0, 1], + [1, 2], + [2, 0], + ]) { + const forward = keys[first] < keys[second]; + const edge = `${id}|${forward ? keys[first] : keys[second]}|${ + forward ? keys[second] : keys[first] + }`; + const directions = edges.get(edge) ?? []; + directions.push(forward); + edges.set(edge, directions); + } + const first = new THREE.Vector3().fromBufferAttribute( + position, + indices[0] + ); + const second = new THREE.Vector3().fromBufferAttribute( + position, + indices[1] + ); + const third = new THREE.Vector3().fromBufferAttribute( + position, + indices[2] + ); + const faceNormal = new THREE.Vector3() + .subVectors(second, first) + .cross(new THREE.Vector3().subVectors(third, first)); + const vertexNormal = new THREE.Vector3().fromBufferAttribute( + normal, + indices[0] + ); + expect(faceNormal.dot(vertexNormal)).toBeGreaterThan(0); + signedVolumes.set( + id, + (signedVolumes.get(id) ?? 0) + + first.dot(new THREE.Vector3().crossVectors(second, third)) / 6 + ); + } + } + expect( + [...edges.values()].every((directions) => directions.length === 2) + ).toBe(true); + expect( + [...edges.values()].every(([first, second]) => first !== second) + ).toBe(true); + expect([...signedVolumes.values()].every((volume) => volume > 0)).toBe( + true + ); + expect(roofMaterial.side).toBe(THREE.FrontSide); + expect(wallMaterial.side).toBe(THREE.FrontSide); + expect(roofMaterial.shadowSide).toBe(THREE.DoubleSide); + expect(wallMaterial.shadowSide).toBe(THREE.DoubleSide); + + layer.dispose(); + }); + + it("uses the regular lit tile material for unlit terrain textures", () => { + const layer = buildThreeTilesRuntime( + "mesh", + "tileset.json", + [7.15, 51.25], + { providesTerrain: true, shadowBuildingStyle: true } + ); + const sourceMaterial = new THREE.MeshBasicMaterial({ + color: "#847466", + map: new THREE.Texture(), + side: THREE.DoubleSide, + }); + const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), sourceMaterial); + const normals = mesh.geometry.getAttribute("normal"); + normals.setXYZ(0, 0.5, -0.5, 0.5); + layer.root.add(mesh); + + layer.setShadowSimulationStyle?.({ + fullOpacity: true, + uniformColor: null, + }); + + expect(mesh.material).not.toBe(sourceMaterial); + expect(mesh.material).toBeInstanceOf(THREE.MeshStandardMaterial); + const shadowMaterial = + mesh.material as unknown as THREE.MeshStandardMaterial; + expect(shadowMaterial.map).toBe(sourceMaterial.map); + expect(shadowMaterial.color.getHexString()).toBe("847466"); + expect(shadowMaterial.roughness).toBe(1); + expect(shadowMaterial.metalness).toBe(0); + expect(shadowMaterial.normalMap).toBeInstanceOf(THREE.DataTexture); + expect(shadowMaterial.normalMapType).toBe(THREE.ObjectSpaceNormalMap); + expect(mesh.castShadow).toBe(true); + expect(mesh.receiveShadow).toBe(true); + expect(shadowMaterial.shadowSide).toBe(THREE.FrontSide); + expect(sourceMaterial.shadowSide).toBeNull(); + expect(normals.getX(0)).toBeCloseTo(0.5); + expect(normals.getY(0)).toBeCloseTo(-0.5); + expect(normals.getZ(0)).toBeCloseTo(0.5); + + const shader = { + uniforms: {}, + vertexShader: "#include \n#include ", + fragmentShader: + "#include \n#include \n#include ", + } as Parameters[0]; + shadowMaterial.onBeforeCompile( + shader, + {} as Parameters[1] + ); + expect(shader.fragmentShader).not.toContain("flatTextureShadow"); + expect(shader.vertexShader).not.toContain("flatTextureNormalBias"); + + layer.setShadowSimulationStyle?.({ + fullOpacity: true, + uniformColor: "#d8d1c4", + uniformColorMix: 0.75, + textureSaturation: 0.8, + }); + expect(mesh.material).toBe(shadowMaterial); + expect(mesh.material).not.toBe(sourceMaterial); + + layer.setShadowSimulationStyle?.(null); + expect(mesh.material).toBe(sourceMaterial); + expect(sourceMaterial.side).toBe(THREE.DoubleSide); + expect(sourceMaterial.shadowSide).toBeNull(); + + layer.dispose(); + }); + + it("projects the map style onto terrain but not separated LoD2 surfaces", () => { + const layer = buildThreeTilesRuntime( + "lod2-native", + "tileset.json", + [7.15, 51.25], + { providesTerrain: true, shadowBuildingStyle: true } + ); + const parent = new THREE.Group(); + const buildSurface = (name: string) => { + const material = new THREE.MeshLambertMaterial(); + material.name = name; + const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material); + parent.add(mesh); + return mesh; + }; + const terrain = buildSurface("terrain"); + const roof = buildSurface("roof"); + const wall = buildSurface("wall"); + layer.root.add(parent); + + const receivesMapStyleBeforeTileStyling = layer.receivesMapStyleTexture; + expect(typeof receivesMapStyleBeforeTileStyling).toBe("function"); + expect(layer.mapStyleProjectionBlend).toBe("overlay"); + expect( + ( + receivesMapStyleBeforeTileStyling as ( + material: THREE.Material + ) => boolean + )(terrain.material as THREE.Material) + ).toBe(true); + expect( + ( + receivesMapStyleBeforeTileStyling as ( + material: THREE.Material + ) => boolean + )(roof.material as THREE.Material) + ).toBe(false); + + const initialVersion = layer.mapStyleProjectionVersion?.() ?? -1; + layer.setShadowSimulationStyle?.({ + fullOpacity: true, + uniformColor: null, + }); + const receivesMapStyle = layer.receivesMapStyleTexture; + + expect(typeof receivesMapStyle).toBe("function"); + expect( + (receivesMapStyle as (material: THREE.Material) => boolean)( + terrain.material as THREE.Material + ) + ).toBe(true); + expect( + (receivesMapStyle as (material: THREE.Material) => boolean)( + roof.material as THREE.Material + ) + ).toBe(false); + expect( + (receivesMapStyle as (material: THREE.Material) => boolean)( + wall.material as THREE.Material + ) + ).toBe(false); + expect(layer.mapStyleProjectionVersion?.()).toBeGreaterThan(initialVersion); + const styledVersion = layer.mapStyleProjectionVersion?.(); + layer.setShadowSimulationStyle?.({ + fullOpacity: true, + uniformColor: null, + }); + expect(layer.mapStyleProjectionVersion?.()).toBe(styledVersion); + + layer.dispose(); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-runtime.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-runtime.ts new file mode 100644 index 0000000000..4a4c02d01c --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-runtime.ts @@ -0,0 +1,2478 @@ +import { TilesRenderer } from "3d-tiles-renderer"; +import { + DownloadPriorityQueue, + LRUCache, + PriorityQueue, + type Tile, +} from "3d-tiles-renderer/core"; +import * as TilesRendererCore from "3d-tiles-renderer/core"; +import { + GLTFExtensionsPlugin, + ImplicitTilingPlugin, + ReorientationPlugin, + UpdateOnChangePlugin, +} from "3d-tiles-renderer/plugins"; +import { MercatorCoordinate } from "maplibre-gl"; +import type { Map as MaplibreMap } from "maplibre-gl"; +import * as THREE from "three"; +import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js"; + +import { clamp } from "@carma-commons/math"; +import { GLTFPrimitiveOutlineExtension } from "@carma-mapping/engines/threejs"; +import { degToRadNumeric } from "@carma-units"; + +import { MAPLIBRE_EVENT } from "../../../constants/mapEvents"; +import { Gltf1UpgradePlugin } from "./gltf1-upgrade-plugin"; +import { createPayloadAwareRequestConcurrency } from "./payload-aware-request-concurrency"; +import { + DEFERRED_TILE_LOADING_STATE, + TILES_LOAD_POLICY, + createEffectiveErrorTargetState, + createTileBytesPredictor, + deriveTilePriority, + nextEffectiveErrorTarget, + resolveRequestConcurrency, + resolveTilesCacheBounds, + resolveTilesCacheCeiling, + shouldDeferTile, +} from "./three-tiles-load-policy"; +import type { + EffectiveErrorTargetState, + TilesDeviceProfile, +} from "./three-tiles-load-policy"; +import { + createTilesCameraSet, + resolveTilesViewCamera, +} from "./tiles-camera-set"; +import type { TilesCameraSet } from "./tiles-camera-set"; +import { + createThreeTilesRetryController, + type RetryableTilesRenderer, +} from "./three-tiles-retry-controller"; +import { createThreeTilesDebugOverlay } from "./three-tiles-debug-overlay"; +import { + applyShadowReceiverMask, + createShadowReceiverMask, + maximumSweepDistanceWithinBox, + type ShadowReceiverMatch, + type ShadowReceiverMask, + type ShadowReceiverSource, +} from "./three-tiles-shadow-receiver-mask"; +import { + isSharedThreeTerrainLoading, + subscribeSharedThreeTerrainLoading, +} from "./shared-three-terrain-registry"; +import type { + SharedThreeSceneFrame, + SharedThreeSceneRuntime, + SharedThreeSceneShadowStyle, + SharedThreeSceneShadowView, + SharedThreeSceneTileVolume, +} from "./shared-three-scene-layer"; +import { getSharedThreeShadowViewSignature } from "./shared-three-scene-layer"; + +// Match the direct screen-space-error control used by the official +// 3DTilesRendererJS kitchen-sink demo. Lower values request more detail. +export const TILES_ERROR_TARGET_MIN_PIXELS = 0; +export const TILES_ERROR_TARGET_MAX_PIXELS = 50; +export const TILES_ERROR_TARGET_DEFAULT_PIXELS = 4; + +const VIEW_QUALITY_AUDIT_PASSES = 2; +const SHADOW_SELECTION_ERROR_FACTOR = 1.25; +const DEFAULT_CACHE_MIN_ITEMS = 6_000; +const DEFAULT_CACHE_MAX_ITEMS = 8_000; +export const THREE_TILES_DEFAULT_REQUEST_CONCURRENCY = 64; +const TERRAIN_LOADING_CONTENT_BOOTSTRAP_CONCURRENCY = 8; +/** Frames are requested at this interval until the root tileset arrived. */ +const KICKSTART_INTERVAL_MS = 400; +/** A hidden tab keeps its used tiles this long before the cache is wiped. */ +export const HIDDEN_TAB_WIPE_DELAY_MS = 30_000; +// tile.internal.loadingState values (3d-tiles-renderer core constants.js; the +// core typings do not export them). +const UNLOADED_LOADING_STATE = 0; +const FAILED_LOADING_STATE = -1; +const CLAY_COLOR = 0xd6d2ca; +const TILE_OUTLINE_FLAG = "isTileOutline"; +const tilesRendererCoreRuntime = TilesRendererCore as unknown as { + DEFAULT_LRU_CACHE: { + unloadPriorityCallback: (first: unknown, second: unknown) => number; + }; + unifiedPriorityCallback: (first: unknown, second: unknown) => number; +}; +const tilesCacheUnloadPriorityCallback = + tilesRendererCoreRuntime.DEFAULT_LRU_CACHE.unloadPriorityCallback; +const tilesQueuePriorityCallback = + tilesRendererCoreRuntime.unifiedPriorityCallback; +// Mirrors upstream DEFAULT_NODE_QUEUE.priorityCallback (not exported from the +// bundled build): children are processed in the load order of their parents. +const tilesNodeQueuePriorityCallback = (first: Tile, second: Tile): number => { + const firstParent = first.parent; + const secondParent = second.parent; + if (firstParent === secondParent) return 0; + if (!firstParent) return 1; + if (!secondParent) return -1; + return tilesQueuePriorityCallback(firstParent, secondParent); +}; + +type RuntimePriorityQueue = PriorityQueue & { + items: Tile[]; + currJobs: number; +}; + +type TileViewErrorTarget = { + inView: boolean; + error: number; + distanceFromCamera: number; +}; + +type RuntimeTile = Tile & { + priority?: number; + shadowLightFacing?: number; + shadowReceiverCenterness?: number; + shadowReceiverCurrent?: boolean; + traversal: Tile["traversal"] & { unconditionallyRefine?: boolean }; + engineData?: { + boundingVolume?: { + getAABB: (target: THREE.Box3) => void; + getSphere: (target: THREE.Sphere) => void; + intersectsFrustum: (frustum: THREE.Frustum) => boolean; + }; + }; +}; + +const frustumCornerPlanes = [ + [0, 3, 4], + [1, 3, 4], + [0, 2, 4], + [1, 2, 4], + [0, 3, 5], + [1, 3, 5], + [0, 2, 5], + [1, 2, 5], +] as const; +const frustumCornerMatrix = new THREE.Matrix3(); + +/** + * Frustum with its eight corner points, which upstream's oriented bounding + * box test needs (mirrors the unexported `ExtendedFrustum` of the renderer). + */ +class TilesViewFrustum extends THREE.Frustum { + readonly points = Array.from({ length: 8 }, () => new THREE.Vector3()); + + override setFromProjectionMatrix( + matrix: THREE.Matrix4, + coordinateSystem?: THREE.CoordinateSystem, + reversedDepth?: boolean + ): this { + super.setFromProjectionMatrix(matrix, coordinateSystem, reversedDepth); + const { planes, points } = this; + frustumCornerPlanes.forEach(([first, second, third], index) => { + const a = planes[first]; + const b = planes[second]; + const c = planes[third]; + frustumCornerMatrix.set( + a.normal.x, + a.normal.y, + a.normal.z, + b.normal.x, + b.normal.y, + b.normal.z, + c.normal.x, + c.normal.y, + c.normal.z + ); + points[index] + .set(-a.constant, -b.constant, -c.constant) + .applyMatrix3(frustumCornerMatrix.invert()); + }); + return this; + } +} + +type RuntimeTilesRenderer = TilesRenderer & { + calculateBytesUsed: ( + tile: Tile, + scene: THREE.Object3D | null + ) => number | null; + calculateTileViewErrorWithPlugin: ( + tile: Tile, + target: TileViewErrorTarget + ) => void; + loadingTiles: Set; + usedSet: Set; + /** Incremented by every traversal that actually ran. */ + frameCount: number; + stats: { + failed: number; + queued: number; + downloading: number; + parsing: number; + }; + queueTileForDownload: (tile: Tile) => void; +}; + +type RuntimeLruCache = TilesRenderer["lruCache"] & { + itemSet: Map; + itemList: Tile[]; + usedSet: Set; + cachedBytes: number; +}; + +const readTilesDeviceProfile = (): TilesDeviceProfile => { + if (typeof navigator === "undefined") { + return { userAgent: "", platform: "", maxTouchPoints: 0 }; + } + const deviceMemory = (navigator as Navigator & { deviceMemory?: number }) + .deviceMemory; + return { + deviceMemoryGiB: + typeof deviceMemory === "number" ? deviceMemory : undefined, + userAgent: navigator.userAgent ?? "", + platform: navigator.platform ?? "", + maxTouchPoints: navigator.maxTouchPoints ?? 0, + }; +}; + +const resolveTileContentUrl = (tile: Tile): string | null => { + const uri = tile.content?.uri; + if (!uri) return null; + try { + return new URL(uri, `${tile.internal.basePath}/`).toString(); + } catch { + return uri; + } +}; + +/** + * Upstream computes `unconditionallyRefine` after the view error of a tile, so + * derive the current-frame value for the deferral decision the same way. + */ +const isUnconditionallyRefined = (tile: Tile): boolean => { + if (tile.internal.hasUnrenderableContent) return true; + let ancestor = tile.parent as RuntimeTile | null; + while (ancestor && ancestor.traversal?.unconditionallyRefine) { + ancestor = ancestor.parent as RuntimeTile | null; + } + return ancestor !== null && ancestor.geometricError <= tile.geometricError; +}; + +const readMapView = ( + map: MaplibreMap | null +): { zoom: number; pitch: number } => + map && typeof map.getZoom === "function" && typeof map.getPitch === "function" + ? { zoom: map.getZoom(), pitch: map.getPitch() } + : { zoom: 0, pitch: 0 }; + +const buildPrimitiveOutlinePlugin = ( + parser: unknown, + options: { + color: THREE.ColorRepresentation; + opacity: number; + } +) => ({ + name: "CARMA_lazy_primitive_outline", + async afterRoot(result: { scene: THREE.Object3D }) { + await new GLTFPrimitiveOutlineExtension( + parser as ConstructorParameters[0], + options + ).afterRoot(result); + }, +}); + +/** Cesium 3D Tiles runtime for the shared local MapLibre Three.js scene. */ + +export type ImageProjector = + | { + kind: "pano"; + position: THREE.Vector3; + headingRad: number; + texture: THREE.Texture; + opacity: number; + } + | { + kind: "frustum"; + viewProj: THREE.Matrix4; + texture: THREE.Texture; + opacity: number; + }; + +export interface ThreeTilesRuntime extends SharedThreeSceneRuntime { + setVisible: (visible: boolean) => void; + setHeightOffset: (offsetMeters: number) => void; + setErrorTarget: (errorTarget: number) => void; + /** Override textures with physically lit clay shading (reversible). */ + setWhiteShading: (white: boolean) => void; + setClayMaterial: (options: ClayMaterialOptions) => void; + setClayColor: (color: string) => void; + setOpacity: (opacity: number) => void; + setWireframe: (enabled: boolean) => void; + setOutlineVisible: (visible: boolean) => void; + /** Restyle loaded outlines and the ones parsed from now on. */ + setOutlineStyle: (style: OutlineStyleOptions) => void; + setTileBoundsVisible: (enabled: boolean) => void; + /** + * Style cache limits; they can only lower the device ceiling. No budget + * restores the device ceiling. + */ + setCacheBudget: (bytes?: number, options?: CacheBudgetOptions) => void; + setRequestConcurrency: (jobs: number) => void; + getRequestDemand: () => number; + getViewElevationRange: ( + camera: THREE.Camera + ) => readonly [minimum: number, maximum: number] | null; + setProjector: (projector: ImageProjector | null) => void; + setShadowView: (view: SharedThreeSceneShadowView | null) => void; + originMerc: MercatorCoordinate; + mScale: number; +} + +export interface ClayMaterialOptions { + color?: string; + roughness?: number; + metalness?: number; +} + +export interface OutlineStyleOptions { + color?: THREE.ColorRepresentation; + opacity?: number; +} + +export interface CacheBudgetOptions { + /** Bytes the style allows beyond its budget before downloads pause. */ + overflowBytes?: number; +} + +export interface ThreeTilesRuntimeOptions { + cacheBudgetBytes?: number; + /** Bytes allowed beyond the eviction budget before downloads pause. */ + cacheOverflowBytes?: number; + requestConcurrency?: number; + onRequestStateChange?: () => void; + onContentChanged?: () => void; + outline?: boolean; + outlineColor?: THREE.ColorRepresentation; + outlineOpacity?: number; + /** The tileset includes the ground surface represented by terrain. */ + providesTerrain?: boolean; + /** Restyle this tileset like a building layer while shadow mode is active. */ + shadowBuildingStyle?: boolean; +} + +export function buildThreeTilesRuntime( + layerId: string, + tilesetUrl: string, + originLngLat: [number, number], + options: ThreeTilesRuntimeOptions = {} +): ThreeTilesRuntime { + const originMerc = MercatorCoordinate.fromLngLat(originLngLat, 0); + const mScale = originMerc.meterInMercatorCoordinateUnits(); + + let map: MaplibreMap | null = null; + let tiles: RuntimeTilesRenderer | null = null; + let dracoLoader: DRACOLoader | null = null; + let tileDebugOverlay: ReturnType | null = + null; + let cameraSet: TilesCameraSet | null = null; + let kickstartTimer = 0; + let requestBackoffTimer = 0; + let hiddenWipeTimer = 0; + let disposed = false; + let lastTraversalFrameCount = -1; + let unsubscribeTerrainLoading: (() => void) | null = null; + let requestedErrorTarget = TILES_ERROR_TARGET_DEFAULT_PIXELS; + let effectiveErrorTarget = requestedErrorTarget; + let errorTargetState: EffectiveErrorTargetState = + createEffectiveErrorTargetState(requestedErrorTarget, Date.now()); + let errorTargetTimer = 0; + let lastProgressAt = 0; + let usedBytesMain = 0; + let lastMainViewConverged = false; + const deviceProfile = readTilesDeviceProfile(); + let styleCacheBudgetBytes = options.cacheBudgetBytes; + let styleCacheOverflowBytes = options.cacheOverflowBytes; + let ceilingBytes = resolveTilesCacheCeiling(deviceProfile, { + cacheBudgetBytes: styleCacheBudgetBytes, + cacheOverflowBytes: styleCacheOverflowBytes, + }); + const bytesPredictor = createTileBytesPredictor(); + /** Displayable siblings outside the view and its prefetch margin (D1). */ + const deferred = new Set(); + let requestConcurrency = Math.max( + 0, + Math.floor( + options.requestConcurrency ?? THREE_TILES_DEFAULT_REQUEST_CONCURRENCY + ) + ); + const payloadAwareConcurrency = createPayloadAwareRequestConcurrency(); + // ReorientationPlugin produces X west / Z north. The MapLibre custom-layer + // matrix below and the other pointcloud layers use X east / Z south, so keep + // the plugin-owned group untouched and correct the horizontal axes in a + // persistent parent (the plugin updates tiles.group asynchronously). + const orientationGroup = new THREE.Group(); + orientationGroup.rotation.y = Math.PI; + const offsetGroup = new THREE.Group(); + orientationGroup.add(offsetGroup); + let whiteShading = false; + let clayColor = new THREE.Color(CLAY_COLOR); + let clayRoughness = 0.92; + let clayMetalness = 0; + let opacity = 1; + let wireframe = false; + let outlineVisible = options.outline ?? true; + let outlineColor: THREE.ColorRepresentation = + options.outlineColor ?? 0x000000; + let outlineOpacity = clamp(options.outlineOpacity ?? 1, 0, 1); + let shadowSimulationStyle: SharedThreeSceneShadowStyle | null = null; + const shadowStylesEqual = ( + first: SharedThreeSceneShadowStyle | null, + second: SharedThreeSceneShadowStyle | null + ) => + first === second || + (first !== null && + second !== null && + first.fullOpacity === second.fullOpacity && + first.uniformColor === second.uniformColor && + first.uniformColorMix === second.uniformColorMix && + first.textureSaturation === second.textureSaturation); + let shadowView: SharedThreeSceneShadowView | null = null; + let shadowViewSignature = ""; + let shadowSelectionEnabled = false; + let shadowSelectionNeedsTraversal = false; + let shadowSelectionRefreshPending = false; + let shadowReceiverMask: ShadowReceiverMask | null = null; + let previousShadowReceiverMask: ShadowReceiverMask | null = null; + let shadowReceiverMaskConverged = false; + let shadowReceiverSourceSignature = ""; + const mainViewSourceTiles = new Set(); + let viewQualityAuditPasses = 0; + const shadowClayColor = new THREE.Color(CLAY_COLOR); + let tileBoundsVisible = false; + const tileDebugIds = new WeakMap(); + let nextTileDebugId = 1; + let runtimeVisible = true; + let activeProjector: ImageProjector | null = null; + const placementMatrix = new THREE.Matrix4(); + const inversePlacementMatrix = new THREE.Matrix4(); + const tileViewProjection = new THREE.Matrix4(); + const tileViewFrustum = new TilesViewFrustum(); + const marginCamera = new THREE.PerspectiveCamera(); + const marginProjection = new THREE.Matrix4(); + const marginFrustum = new TilesViewFrustum(); + let viewFrustumsReady = false; + const tileBoundingSphere = new THREE.Sphere(); + const tileBoundingBox = new THREE.Box3(); + const activeTileBoundingBox = new THREE.Box3(); + const rootTileBoundingBox = new THREE.Box3(); + const rootWorldBoundingBox = new THREE.Box3(); + const sourceWorldBoundingBox = new THREE.Box3(); + const tileViewElevationFrustum = new THREE.Frustum(); + const tileViewElevationProjection = new THREE.Matrix4(); + const tileProjectedCenter = new THREE.Vector3(); + const tilesToShadowView = new THREE.Matrix4(); + const sunwardDirection = new THREE.Vector3(); + const shadowReceiverMatch: ShadowReceiverMatch = { + receiverGeometricError: Number.POSITIVE_INFINITY, + receiverCenterness: 0, + lightFacing: 0, + }; + const identityRotation = new THREE.Quaternion(); + const projectorUniforms = { + uProjKind: { value: 0 }, + uProjOpacity: { value: 0 }, + uProjPos: { value: new THREE.Vector3() }, + uProjHeading: { value: 0 }, + uProjMatrix: { value: new THREE.Matrix4() }, + tProj: { value: null as THREE.Texture | null }, + }; + const shadowAppearanceUniforms = { + uShadowUniformColor: { value: shadowClayColor }, + uShadowUniformColorMix: { value: 0 }, + uShadowTextureSaturation: { value: 1 }, + }; + const flatTerrainNormalMap = options.providesTerrain + ? new THREE.DataTexture( + new Uint8Array([128, 255, 128, 255]), + 1, + 1, + THREE.RGBAFormat, + THREE.UnsignedByteType + ) + : null; + if (flatTerrainNormalMap) { + flatTerrainNormalMap.name = `${layerId}-flat-terrain-normal`; + flatTerrainNormalMap.generateMipmaps = false; + flatTerrainNormalMap.minFilter = THREE.NearestFilter; + flatTerrainNormalMap.magFilter = THREE.NearestFilter; + flatTerrainNormalMap.needsUpdate = true; + } + + const patchMaterialForProjection = (material: THREE.Material) => { + if ((material as { __projPatched?: boolean }).__projPatched) return; + (material as { __projPatched?: boolean }).__projPatched = true; + material.onBeforeCompile = (shader) => { + Object.assign( + shader.uniforms, + projectorUniforms, + shadowAppearanceUniforms + ); + shader.vertexShader = shader.vertexShader + .replace( + "#include ", + "#include \nvarying vec3 vProjWorld;" + ) + .replace( + "#include ", + "#include \nvProjWorld = (modelMatrix * vec4(transformed, 1.0)).xyz;" + ); + shader.fragmentShader = shader.fragmentShader + .replace( + "#include ", + `#include +varying vec3 vProjWorld; +uniform float uProjKind; +uniform float uProjOpacity; +uniform vec3 uProjPos; +uniform float uProjHeading; +uniform mat4 uProjMatrix; +uniform sampler2D tProj; +uniform vec3 uShadowUniformColor; +uniform float uShadowUniformColorMix; +uniform float uShadowTextureSaturation;` + ) + .replace( + "#include ", + `#include +float shadowTextureLuma = dot( + diffuseColor.rgb, + vec3(0.2126, 0.7152, 0.0722) +); +diffuseColor.rgb = mix( + vec3(shadowTextureLuma), + diffuseColor.rgb, + uShadowTextureSaturation +); +diffuseColor.rgb = mix( + diffuseColor.rgb, + uShadowUniformColor, + uShadowUniformColorMix +);` + ) + .replace( + "#include ", + `#include +if (uProjKind > 0.5 && uProjOpacity > 0.001) { + vec3 projColor = vec3(0.0); + float mask = 0.0; + if (uProjKind < 1.5) { + vec3 dir = normalize(vProjWorld - uProjPos); + float theta = atan(dir.x, -dir.z) - uProjHeading; + float u = fract(theta / 6.28318530718 + 0.5); + float v = 0.5 - asin(clamp(dir.y, -1.0, 1.0)) / 3.14159265359; + projColor = texture2D(tProj, vec2(u, v)).rgb; + mask = 1.0; + } else { + vec4 clipPos = uProjMatrix * vec4(vProjWorld, 1.0); + if (clipPos.w > 0.0) { + vec2 uv = clipPos.xy / clipPos.w * 0.5 + 0.5; + if (uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0) { + projColor = texture2D(tProj, uv).rgb; + mask = 1.0; + } + } + } + gl_FragColor.rgb = mix(gl_FragColor.rgb, projColor, uProjOpacity * mask); +}` + ); + }; + material.needsUpdate = true; + }; + + type ClayMaterialState = { + original: THREE.Material | THREE.Material[]; + clay: THREE.Material | THREE.Material[]; + }; + + type LitTextureMaterialState = { + original: THREE.Material | THREE.Material[]; + lit: THREE.Material | THREE.Material[]; + generated: THREE.Material[]; + }; + + const clayMaterialStates = new Map(); + const litTextureMaterialStates = new Map< + THREE.Mesh, + LitTextureMaterialState + >(); + const originalShadowSides = new Map(); + const originalRenderSides = new Map(); + const separatedSurfaceRenderSides = new WeakMap(); + const separatedSurfaceShadowSides = new WeakMap(); + const isSeparatedBuildingSurface = (material: THREE.Material) => { + const surfaceName = material.name.trim().toLowerCase(); + return surfaceName === "roof" || surfaceName === "wall"; + }; + let mapStyleProjectionVersion = 0; + const isRenderedBuildingSurface = (material: THREE.Material) => { + const sourceName = material.name.trim().toLowerCase().split(" · ", 1)[0]; + return sourceName === "roof" || sourceName === "wall"; + }; + // Closed building solids cast from both faces. Back faces alone leave a lit + // gap under a solid that floats above the terrain: the sun-facing wall has + // to cast the shadow beneath and behind the building as well. The shadow + // bias keeps the lit front faces from self-shadowing. + const resolveShadowCastingSide = (material: THREE.Material) => + separatedSurfaceShadowSides.get(material) ?? + (options.providesTerrain === true || isSeparatedBuildingSurface(material) + ? THREE.FrontSide + : THREE.DoubleSide); + const resolveRenderSide = (material: THREE.Material) => + separatedSurfaceRenderSides.get(material) ?? THREE.FrontSide; + const asMaterialArray = ( + material: THREE.Material | THREE.Material[] + ): THREE.Material[] => (Array.isArray(material) ? material : [material]); + const normalizedSeparatedSurfaceGeometries = + new WeakSet(); + const normalizeSeparatedBuildingSurfaces = (root: THREE.Object3D) => { + root.traverse((parent) => { + const surfaceNames = new Set(); + const parts: Array<{ + materials: THREE.Material[]; + geometry: THREE.BufferGeometry; + position: THREE.BufferAttribute | THREE.InterleavedBufferAttribute; + featureId: THREE.BufferAttribute | THREE.InterleavedBufferAttribute; + index: THREE.BufferAttribute; + }> = []; + + for (const child of parent.children) { + const mesh = child as THREE.Mesh; + if (!mesh.isMesh) continue; + const geometry = mesh.geometry as THREE.BufferGeometry; + if (normalizedSeparatedSurfaceGeometries.has(geometry)) continue; + const materials = asMaterialArray(mesh.material); + for (const material of materials) { + const name = material.name.trim().toLowerCase(); + if (name === "roof" || name === "wall") surfaceNames.add(name); + } + if (!materials.some(isSeparatedBuildingSurface)) continue; + const position = geometry.getAttribute("position"); + const featureId = geometry.getAttribute("_feature_id_0"); + const index = geometry.getIndex(); + if (!position || !featureId || !index) continue; + parts.push({ materials, geometry, position, featureId, index }); + } + if (!surfaceNames.has("roof") || !surfaceNames.has("wall")) return; + + type SurfaceTriangle = { + part: number; + offset: number; + featureId: number; + indices: [number, number, number]; + vertexKeys: [string, string, string]; + }; + type SurfaceEdgeReference = { + triangle: number; + forward: boolean; + }; + const coordinateKey = ( + position: THREE.BufferAttribute | THREE.InterleavedBufferAttribute, + vertex: number + ) => { + const precision = 10_000; + return `${Math.round(position.getX(vertex) * precision)},${Math.round( + position.getY(vertex) * precision + )},${Math.round(position.getZ(vertex) * precision)}`; + }; + const triangles: SurfaceTriangle[] = []; + for (let part = 0; part < parts.length; part += 1) { + const { position, featureId, index } = parts[part]; + for (let offset = 0; offset + 2 < index.count; offset += 3) { + const first = index.getX(offset); + const second = index.getX(offset + 1); + const third = index.getX(offset + 2); + const id = featureId.getX(first); + if (featureId.getX(second) !== id || featureId.getX(third) !== id) { + continue; + } + const vertexKeys: [string, string, string] = [ + coordinateKey(position, first), + coordinateKey(position, second), + coordinateKey(position, third), + ]; + if (new Set(vertexKeys).size !== 3) continue; + triangles.push({ + part, + offset, + featureId: id, + indices: [first, second, third], + vertexKeys, + }); + } + } + + const edges = new Map(); + const addEdge = ( + triangle: number, + firstVertex: number, + secondVertex: number + ) => { + const surface = triangles[triangle]; + const first = surface.vertexKeys[firstVertex]; + const second = surface.vertexKeys[secondVertex]; + const forward = first < second; + const key = `${surface.featureId}|${forward ? first : second}|${ + forward ? second : first + }`; + const references = edges.get(key) ?? []; + references.push({ triangle, forward }); + edges.set(key, references); + }; + for (let triangle = 0; triangle < triangles.length; triangle += 1) { + addEdge(triangle, 0, 1); + addEdge(triangle, 1, 2); + addEdge(triangle, 2, 0); + } + + const adjacency = Array.from( + { length: triangles.length }, + (): Array<{ triangle: number; invert: boolean }> => [] + ); + for (const references of edges.values()) { + if (references.length !== 2) continue; + const [first, second] = references; + const invert = first.forward === second.forward; + adjacency[first.triangle].push({ + triangle: second.triangle, + invert, + }); + adjacency[second.triangle].push({ + triangle: first.triangle, + invert, + }); + } + + const triangleFlips: Array = Array( + triangles.length + ).fill(undefined); + const componentByTriangle = new Int32Array(triangles.length).fill(-1); + const components: number[][] = []; + const inconsistentComponents = new Set(); + for (let start = 0; start < triangles.length; start += 1) { + if (triangleFlips[start] !== undefined) continue; + const component = components.length; + const members: number[] = []; + const pending = [start]; + triangleFlips[start] = false; + while (pending.length > 0) { + const triangle = pending.pop(); + if (triangle === undefined) break; + members.push(triangle); + componentByTriangle[triangle] = component; + for (const neighbor of adjacency[triangle]) { + const expected = + (triangleFlips[triangle] as boolean) !== neighbor.invert; + const current = triangleFlips[neighbor.triangle]; + if (current === undefined) { + triangleFlips[neighbor.triangle] = expected; + pending.push(neighbor.triangle); + } else if (current !== expected) { + inconsistentComponents.add(component); + } + } + } + components.push(members); + } + + const openComponents = new Set(inconsistentComponents); + for (const references of edges.values()) { + if (references.length === 2) continue; + for (const reference of references) { + openComponents.add(componentByTriangle[reference.triangle]); + } + } + + const componentVolumes = new Float64Array(components.length); + for (let component = 0; component < components.length; component += 1) { + const members = components[component]; + const firstTriangle = triangles[members[0]]; + const firstPosition = parts[firstTriangle.part].position; + const anchorIndex = firstTriangle.indices[0]; + const anchorX = firstPosition.getX(anchorIndex); + const anchorY = firstPosition.getY(anchorIndex); + const anchorZ = firstPosition.getZ(anchorIndex); + let volume = 0; + for (const triangleIndex of members) { + const triangle = triangles[triangleIndex]; + const position = parts[triangle.part].position; + const [first, sourceSecond, sourceThird] = triangle.indices; + const second = triangleFlips[triangleIndex] + ? sourceThird + : sourceSecond; + const third = triangleFlips[triangleIndex] + ? sourceSecond + : sourceThird; + const ax = position.getX(first) - anchorX; + const ay = position.getY(first) - anchorY; + const az = position.getZ(first) - anchorZ; + const bx = position.getX(second) - anchorX; + const by = position.getY(second) - anchorY; + const bz = position.getZ(second) - anchorZ; + const cx = position.getX(third) - anchorX; + const cy = position.getY(third) - anchorY; + const cz = position.getZ(third) - anchorZ; + volume += + (ax * (by * cz - bz * cy) + + ay * (bz * cx - bx * cz) + + az * (bx * cy - by * cx)) / + 6; + } + componentVolumes[component] = volume; + if (Math.abs(volume) <= 1e-6) openComponents.add(component); + } + + for ( + let triangleIndex = 0; + triangleIndex < triangles.length; + triangleIndex += 1 + ) { + const triangle = triangles[triangleIndex]; + const component = componentByTriangle[triangleIndex]; + const flip = + (triangleFlips[triangleIndex] as boolean) !== + componentVolumes[component] < 0; + if (!flip) continue; + const index = parts[triangle.part].index; + const second = index.getX(triangle.offset + 1); + index.setX(triangle.offset + 1, index.getX(triangle.offset + 2)); + index.setX(triangle.offset + 2, second); + } + + const isClosed = openComponents.size === 0; + for (const { geometry, index, materials } of parts) { + index.needsUpdate = true; + geometry.computeVertexNormals(); + for (const material of materials) { + if (!isSeparatedBuildingSurface(material)) continue; + separatedSurfaceRenderSides.set( + material, + isClosed ? THREE.FrontSide : THREE.DoubleSide + ); + // A closed solid casts from both faces so its sun-facing walls + // shadow the ground beneath a base that floats above the terrain. + separatedSurfaceShadowSides.set( + material, + isClosed ? THREE.DoubleSide : THREE.FrontSide + ); + } + normalizedSeparatedSurfaceGeometries.add(geometry); + } + }); + }; + const buildClayMaterial = (source: THREE.Material) => { + const material = new THREE.MeshStandardMaterial({ + color: shadowSimulationStyle?.uniformColor ? shadowClayColor : clayColor, + roughness: clayRoughness, + metalness: clayMetalness, + // Keep the visible shell outside-facing. The shadow pass uses the side + // appropriate for a closed building solid or an open terrain surface. + side: resolveRenderSide(source), + opacity: source.opacity, + transparent: source.transparent, + depthTest: true, + depthWrite: source.depthWrite, + alphaTest: source.alphaTest, + }); + material.shadowSide = resolveShadowCastingSide(source); + material.name = source.name ? `${source.name} · clay` : "tileset-clay"; + return material; + }; + + const buildLitTextureMaterial = (source: THREE.Material) => { + const basic = source as THREE.MeshBasicMaterial; + if (!basic.isMeshBasicMaterial) return source; + + // Mesh 2024 declares KHR_materials_unlit, which GLTFLoader represents as a + // MeshBasicMaterial. Preserve its source texture and render state, but use + // the same rough non-metallic PBR path as the regular LoD tiles while + // shadow mode is active. No mesh-specific lighting shader is involved. + const material = new THREE.MeshStandardMaterial({ + color: basic.color, + map: basic.map, + alphaMap: basic.alphaMap, + aoMap: basic.aoMap, + aoMapIntensity: basic.aoMapIntensity, + lightMap: basic.lightMap, + lightMapIntensity: basic.lightMapIntensity, + roughness: 1, + metalness: 0, + opacity: basic.opacity, + transparent: basic.transparent, + depthTest: basic.depthTest, + depthWrite: basic.depthWrite, + alphaTest: basic.alphaTest, + side: basic.side, + vertexColors: basic.vertexColors, + fog: basic.fog, + wireframe: basic.wireframe, + }); + material.name = basic.name + ? `${basic.name} · shadow-lit` + : "tileset-shadow-lit"; + material.blending = basic.blending; + material.blendSrc = basic.blendSrc; + material.blendDst = basic.blendDst; + material.blendEquation = basic.blendEquation; + material.colorWrite = basic.colorWrite; + material.depthFunc = basic.depthFunc; + material.polygonOffset = basic.polygonOffset; + material.polygonOffsetFactor = basic.polygonOffsetFactor; + material.polygonOffsetUnits = basic.polygonOffsetUnits; + material.toneMapped = basic.toneMapped; + material.visible = basic.visible; + material.userData = { ...basic.userData }; + if (flatTerrainNormalMap) { + // Keep the baked texture evenly lit without replacing the geometry + // normals that Three.js uses for receiver-side shadow bias. + material.normalMap = flatTerrainNormalMap; + material.normalMapType = THREE.ObjectSpaceNormalMap; + } + delete material.userData.__projPatched; + delete material.userData.__baseOpacity; + delete material.userData.__baseTransparent; + delete material.userData.__baseDepthWrite; + return material; + }; + + const disposeClayState = (mesh: THREE.Mesh, state: ClayMaterialState) => { + mesh.material = state.original; + for (const material of asMaterialArray(state.clay)) material.dispose(); + clayMaterialStates.delete(mesh); + }; + + const restoreClayMaterials = (root: THREE.Object3D) => { + root.traverse((object) => { + const mesh = object as THREE.Mesh; + const state = mesh.isMesh ? clayMaterialStates.get(mesh) : undefined; + if (state) disposeClayState(mesh, state); + }); + }; + + const disposeLitTextureState = ( + mesh: THREE.Mesh, + state: LitTextureMaterialState + ) => { + mesh.material = state.original; + for (const material of state.generated) { + if (originalShadowSides.has(material)) { + material.shadowSide = originalShadowSides.get(material) ?? null; + originalShadowSides.delete(material); + } + if (originalRenderSides.has(material)) { + material.side = originalRenderSides.get(material) ?? material.side; + originalRenderSides.delete(material); + } + material.dispose(); + } + litTextureMaterialStates.delete(mesh); + }; + + const restoreLitTextureMaterials = (root: THREE.Object3D) => { + root.traverse((object) => { + const mesh = object as THREE.Mesh; + const state = mesh.isMesh + ? litTextureMaterialStates.get(mesh) + : undefined; + if (state) disposeLitTextureState(mesh, state); + }); + }; + + const applyShadowCastingSide = (material: THREE.Material) => { + if (shadowSimulationStyle) { + if (!originalShadowSides.has(material)) { + originalShadowSides.set(material, material.shadowSide); + } + const renderSide = separatedSurfaceRenderSides.get(material); + if (renderSide !== undefined && !originalRenderSides.has(material)) { + originalRenderSides.set(material, material.side); + material.side = renderSide; + material.needsUpdate = true; + } + material.shadowSide = resolveShadowCastingSide(material); + return; + } + + if (originalShadowSides.has(material)) { + material.shadowSide = originalShadowSides.get(material) ?? null; + originalShadowSides.delete(material); + } + if (originalRenderSides.has(material)) { + material.side = originalRenderSides.get(material) ?? material.side; + originalRenderSides.delete(material); + material.needsUpdate = true; + } + }; + + const restoreShadowSides = () => { + for (const [material, shadowSide] of originalShadowSides) { + material.shadowSide = shadowSide; + material.needsUpdate = true; + } + originalShadowSides.clear(); + for (const [material, side] of originalRenderSides) { + material.side = side; + material.needsUpdate = true; + } + originalRenderSides.clear(); + }; + + const applyMaterialFlags = (root: THREE.Object3D) => { + normalizeSeparatedBuildingSurfaces(root); + const useClayShading = whiteShading; + const effectiveClayColor = clayColor; + shadowAppearanceUniforms.uShadowUniformColorMix.value = + shadowSimulationStyle?.uniformColor + ? clamp(shadowSimulationStyle.uniformColorMix ?? 1, 0, 1) + : 0; + shadowAppearanceUniforms.uShadowTextureSaturation.value = clamp( + shadowSimulationStyle?.textureSaturation ?? 1, + 0, + 1 + ); + const forceOpaque = shadowSimulationStyle?.fullOpacity === true; + root.traverse((object) => { + const mesh = object as THREE.Mesh; + if (!mesh.isMesh) return; + mesh.castShadow = true; + mesh.receiveShadow = true; + let clayState = clayMaterialStates.get(mesh); + let litTextureState = litTextureMaterialStates.get(mesh); + if (useClayShading) { + if (litTextureState) { + disposeLitTextureState(mesh, litTextureState); + litTextureState = undefined; + } + if (!clayState) { + const original = mesh.material; + const clay = Array.isArray(original) + ? original.map(buildClayMaterial) + : buildClayMaterial(original); + clayState = { original, clay }; + clayMaterialStates.set(mesh, clayState); + mesh.material = clay; + } + } else { + if (clayState) { + disposeClayState(mesh, clayState); + clayState = undefined; + } + const sourceMaterials = asMaterialArray(mesh.material); + const needsLitTextureMaterial = + options.providesTerrain === true && + shadowSimulationStyle !== null && + (litTextureState !== undefined || + sourceMaterials.some( + (material) => + (material as THREE.MeshBasicMaterial).isMeshBasicMaterial + )); + if (needsLitTextureMaterial && !litTextureState) { + const original = mesh.material; + const generated: THREE.Material[] = []; + const buildMaterial = (source: THREE.Material) => { + const lit = buildLitTextureMaterial(source); + if (lit !== source) generated.push(lit); + return lit; + }; + const lit = Array.isArray(original) + ? original.map(buildMaterial) + : buildMaterial(original); + litTextureState = { original, lit, generated }; + litTextureMaterialStates.set(mesh, litTextureState); + mesh.material = lit; + } else if (!needsLitTextureMaterial && litTextureState) { + disposeLitTextureState(mesh, litTextureState); + litTextureState = undefined; + } + } + + const materials = asMaterialArray(mesh.material); + for (const material of materials) { + // Clay materials already enforce the correct casting side. Apply it to + // original textured PBR materials too, then restore their source + // setting when shadow simulation ends. + if (!clayState) applyShadowCastingSide(material); + // The reorientation parent keeps tile coordinates in the same local + // meter frame and projection as the point layers. Write that shared + // depth so later point-cloud layers are hidden by nearer mesh faces. + material.depthTest = true; + if (material.userData.__baseOpacity === undefined) { + material.userData.__baseOpacity = material.opacity; + material.userData.__baseTransparent = material.transparent; + material.userData.__baseDepthWrite = material.depthWrite; + } + const translucent = opacity < 0.999; + material.opacity = forceOpaque + ? 1 + : (material.userData.__baseOpacity as number) * opacity; + material.transparent = forceOpaque + ? false + : (material.userData.__baseTransparent as boolean) || translucent; + material.depthWrite = forceOpaque + ? true + : (material.userData.__baseDepthWrite as boolean) && !translucent; + if ("wireframe" in material) { + (material as THREE.Material & { wireframe: boolean }).wireframe = + wireframe; + } + if (useClayShading && "color" in material) { + (material as THREE.Material & { color: THREE.Color }).color.copy( + effectiveClayColor + ); + } + patchMaterialForProjection(material); + material.needsUpdate = true; + } + }); + }; + const refreshRenderedMaterials = (root: THREE.Object3D) => { + applyMaterialFlags(root); + mapStyleProjectionVersion += 1; + }; + + const applyOutlineVisibility = (root: THREE.Object3D) => { + root.traverse((object) => { + if (object.userData[TILE_OUTLINE_FLAG]) { + object.visible = shadowSimulationStyle ? false : outlineVisible; + } + }); + }; + const applyOutlineStyle = (root: THREE.Object3D) => { + root.traverse((object) => { + if (!object.userData[TILE_OUTLINE_FLAG]) return; + const outline = object as THREE.LineSegments; + for (const material of asMaterialArray(outline.material)) { + if (!(material instanceof THREE.LineBasicMaterial)) continue; + material.color.set(outlineColor); + material.opacity = outlineOpacity; + material.transparent = outlineOpacity < 1; + material.needsUpdate = true; + } + }); + }; + + const requestRender = () => map?.triggerRepaint(); + const getDownloadQueues = (): RuntimePriorityQueue[] => + tiles + ? [...tiles.downloadQueue.originQueues.values()].map( + (queue) => queue as RuntimePriorityQueue + ) + : []; + const runDownloadQueues = () => { + for (const queue of getDownloadQueues()) queue.tryRunJobs(); + }; + const clearErrorTargetTimer = () => { + if (errorTargetTimer) { + window.clearTimeout(errorTargetTimer); + errorTargetTimer = 0; + } + }; + const clearKickstartTimer = () => { + if (kickstartTimer) { + window.clearInterval(kickstartTimer); + kickstartTimer = 0; + } + }; + const clearHiddenWipeTimer = () => { + if (hiddenWipeTimer) { + window.clearTimeout(hiddenWipeTimer); + hiddenWipeTimer = 0; + } + }; + const getRuntimeCache = (): RuntimeLruCache | null => + tiles ? (tiles.lruCache as RuntimeLruCache) : null; + const tileRetries = createThreeTilesRetryController( + () => tiles as unknown as (TilesRenderer & RetryableTilesRenderer) | null, + requestRender + ); + const getRequestDemand = () => { + if (disposed || !runtimeVisible) return 0; + if (!tiles) return 1; + const downloadDemand = getDownloadQueues().reduce( + (total, queue) => total + queue.items.length + queue.currJobs, + 0 + ); + const processNodeQueue = + tiles.processNodeQueue as typeof tiles.processNodeQueue & { + items: unknown[]; + currJobs: number; + }; + const stats = ( + tiles as TilesRenderer & { + stats?: { queued?: number; downloading?: number; parsing?: number }; + } + ).stats; + return ( + downloadDemand + + processNodeQueue.items.length + + processNodeQueue.currJobs + + (stats?.queued ?? 0) + + (stats?.downloading ?? 0) + + (stats?.parsing ?? 0) + + (shadowView && !shadowSelectionEnabled ? 1 : 0) + + (shadowSelectionNeedsTraversal ? 1 : 0) + + (tileRetries.hasPendingRetries() ? 1 : 0) + + viewQualityAuditPasses + + (tiles.group.children.length === 0 && !tileRetries.hasExhaustedRetries() + ? 1 + : 0) + ); + }; + const getViewElevationRange = ( + camera: THREE.Camera + ): readonly [number, number] | null => { + if (!tiles || !runtimeVisible) return null; + const currentTiles = tiles; + camera.updateMatrixWorld(true); + currentTiles.group.updateWorldMatrix(true, false); + tileViewElevationProjection.multiplyMatrices( + camera.projectionMatrix, + camera.matrixWorldInverse + ); + tileViewElevationFrustum.setFromProjectionMatrix( + tileViewElevationProjection, + camera.coordinateSystem, + camera.reversedDepth + ); + let minimum = Number.POSITIVE_INFINITY; + let maximum = Number.NEGATIVE_INFINITY; + currentTiles.forEachLoadedModel((model) => { + model.updateWorldMatrix(true, true); + tileBoundingBox.setFromObject(model); + if (tileBoundingBox.isEmpty()) return; + if (!tileViewElevationFrustum.intersectsBox(tileBoundingBox)) return; + minimum = Math.min(minimum, tileBoundingBox.min.y); + maximum = Math.max(maximum, tileBoundingBox.max.y); + }); + return Number.isFinite(minimum) && Number.isFinite(maximum) + ? [minimum, maximum] + : null; + }; + const getActiveTileVolumes = (): readonly SharedThreeSceneTileVolume[] => { + if (!tiles || !runtimeVisible) return []; + tiles.group.updateWorldMatrix(true, false); + const volumes: SharedThreeSceneTileVolume[] = []; + for (const tile of tiles.activeTiles) { + const activeTile = tile as Tile & { + engineData?: { + boundingVolume?: { getAABB?: (target: THREE.Box3) => void }; + }; + }; + const boundingVolume = activeTile.engineData?.boundingVolume; + if (!boundingVolume?.getAABB) continue; + boundingVolume.getAABB(activeTileBoundingBox); + activeTileBoundingBox.applyMatrix4(tiles.group.matrixWorld); + if (activeTileBoundingBox.isEmpty()) continue; + volumes.push({ + id: getTileDebugId(tile), + kind: options.providesTerrain ? "terrain-tile" : "3d-tile", + loadReason: getTileLoadReason(activeTile as RuntimeTile), + minimum: activeTileBoundingBox.min.toArray(), + maximum: activeTileBoundingBox.max.toArray(), + }); + } + return volumes; + }; + let lastNotifiedRequestDemand = Number.NaN; + const notifyRequestStateChange = () => { + const requestDemand = getRequestDemand(); + if (requestDemand === lastNotifiedRequestDemand) return; + lastNotifiedRequestDemand = requestDemand; + options.onRequestStateChange?.(); + }; + const clearShadowReceiverSources = () => { + shadowReceiverMask = null; + previousShadowReceiverMask = null; + shadowReceiverMaskConverged = false; + shadowReceiverSourceSignature = ""; + mainViewSourceTiles.clear(); + shadowSelectionRefreshPending = false; + }; + const setShadowSelectionEnabled = (enabled: boolean) => { + const nextEnabled = enabled && shadowView !== null; + if (!nextEnabled) clearShadowReceiverSources(); + if (shadowSelectionEnabled === nextEnabled) return; + shadowSelectionEnabled = nextEnabled; + shadowSelectionNeedsTraversal = nextEnabled; + }; + const requestShadowSelectionRefresh = () => { + if (!shadowView) return; + shadowSelectionRefreshPending = true; + }; + const isPipelineIdle = () => + tiles !== null && + !tiles.downloadQueue.running && + !tiles.parseQueue.running && + !tiles.processNodeQueue.running && + tiles.loadingTiles.size === 0 && + !tileRetries.hasPendingRetries() && + payloadAwareConcurrency.getCooldownRemainingMs() <= 0; + const isTileInMainView = (tile: RuntimeTile): boolean => { + const bounds = tile.engineData?.boundingVolume; + if ( + !bounds || + !viewFrustumsReady || + typeof bounds.intersectsFrustum !== "function" + ) { + return tile.traversal?.inFrustum ?? false; + } + return bounds.intersectsFrustum(tileViewFrustum); + }; + const isChildUnloadable = (child: RuntimeTile): boolean => + deferred.has(child) || + tileRetries.isBlocked(child) || + (!child.internal?.hasContent && (child.children?.length ?? 0) === 0); + /** + * The main view converged when every displayed tile inside the main camera + * frustum either meets the effective target or cannot refine any further + * because all of its children are deferred, retry-blocked or empty. + */ + const mainViewWithinErrorFactor = (factor: number) => { + if (!tiles || tiles.visibleTiles.size === 0) return false; + const acceptedError = effectiveErrorTarget * factor; + for (const visible of tiles.visibleTiles) { + const tile = visible as RuntimeTile; + const children = (tile.children ?? []) as RuntimeTile[]; + if (children.length === 0 || tile.traversal?.unconditionallyRefine) { + continue; + } + if (!isTileInMainView(tile)) continue; + if (tile.traversal.error <= acceptedError) continue; + if (!children.every(isChildUnloadable)) return false; + } + return true; + }; + const mainViewConverged = () => mainViewWithinErrorFactor(1); + const currentShadowPathConverged = () => { + if (!tiles || !shadowReceiverMask) return false; + let currentTileCount = 0; + for (const visible of tiles.visibleTiles) { + const tile = visible as RuntimeTile; + if (tile.shadowReceiverCurrent !== true || isTileInMainView(tile)) { + continue; + } + currentTileCount += 1; + const children = (tile.children ?? []) as RuntimeTile[]; + if (children.length === 0 || tile.traversal?.unconditionallyRefine) { + continue; + } + if (tile.traversal.error <= effectiveErrorTarget) continue; + if (!children.every(isChildUnloadable)) return false; + } + return currentTileCount > 0; + }; + const getTileCenterness = ( + bounds: NonNullable["boundingVolume"] + ) => { + if (!bounds) return 0; + bounds.getSphere(tileBoundingSphere); + tileProjectedCenter + .copy(tileBoundingSphere.center) + .applyMatrix4(tileViewProjection); + const centerDistance = Math.min( + Math.SQRT2, + Math.hypot(tileProjectedCenter.x, tileProjectedCenter.y) + ); + return 1 - centerDistance / Math.SQRT2; + }; + const getTileDebugId = (tile: Tile) => { + let sequence = tileDebugIds.get(tile); + if (sequence === undefined) { + sequence = nextTileDebugId++; + tileDebugIds.set(tile, sequence); + } + const uri = tile.content?.uri; + const depth = (tile as Tile & { internal?: { depth?: number } }).internal + ?.depth; + return `${layerId}:${uri ?? `d${depth ?? "?"}:t${sequence}`}`; + }; + const getTileLoadReason = ( + tile: RuntimeTile + ): SharedThreeSceneTileVolume["loadReason"] => { + if (isTileInMainView(tile)) return "viewport"; + return tile.shadowReceiverCenterness === undefined ? undefined : "shadow"; + }; + const captureShadowReceiverSources = () => { + const sourceCamera = shadowView?.camera; + if ( + !tiles || + !(sourceCamera instanceof THREE.OrthographicCamera) || + !viewFrustumsReady || + !tiles.getBoundingBox(rootTileBoundingBox) + ) { + return "empty" as const; + } + + sourceCamera.updateMatrixWorld(true); + tiles.group.updateWorldMatrix(true, false); + rootWorldBoundingBox + .copy(rootTileBoundingBox) + .applyMatrix4(tiles.group.matrixWorld); + sourceCamera.getWorldDirection(sunwardDirection).negate().normalize(); + tilesToShadowView.multiplyMatrices( + sourceCamera.matrixWorldInverse, + tiles.group.matrixWorld + ); + const sources: ShadowReceiverSource[] = []; + const sourceTiles = new Set(); + const sourceKeys: string[] = []; + for (const visible of tiles.visibleTiles) { + const tile = visible as RuntimeTile; + if (!isTileInMainView(tile)) continue; + const bounds = tile.engineData?.boundingVolume; + if (!bounds?.getAABB) continue; + bounds.getAABB(tileBoundingBox); + if (!tileBoundingBox.isEmpty()) { + sourceWorldBoundingBox + .copy(tileBoundingBox) + .applyMatrix4(tiles.group.matrixWorld); + sources.push({ + bounds: tileBoundingBox.clone(), + maximumCasterDistance: maximumSweepDistanceWithinBox( + sourceWorldBoundingBox, + rootWorldBoundingBox, + sunwardDirection + ), + geometricError: tile.geometricError, + centerness: getTileCenterness(bounds), + }); + sourceKeys.push(`${getTileDebugId(tile)}:${tile.geometricError}`); + } + let source: Tile | null = tile; + while (source) { + sourceTiles.add(source); + source = source.parent; + } + } + const nextSignature = [shadowViewSignature, ...sourceKeys.sort()].join("|"); + if (shadowReceiverMask && nextSignature === shadowReceiverSourceSignature) { + return "unchanged" as const; + } + const nextMask = createShadowReceiverMask(sources, tilesToShadowView); + if (!nextMask) { + clearShadowReceiverSources(); + return "empty" as const; + } + if (shadowReceiverMaskConverged) { + previousShadowReceiverMask = shadowReceiverMask; + } + shadowReceiverMask = nextMask; + shadowReceiverMaskConverged = false; + shadowReceiverSourceSignature = nextSignature; + mainViewSourceTiles.clear(); + for (const tile of sourceTiles) mainViewSourceTiles.add(tile); + return "updated" as const; + }; + const measureUsedBytesMain = () => { + if (!tiles) return; + let bytes = 0; + for (const tile of tiles.usedSet) { + bytes += tiles.lruCache.getMemoryUsage(tile); + } + usedBytesMain = bytes; + }; + const applyEffectiveErrorTarget = (nextTarget: number) => { + if (effectiveErrorTarget === nextTarget) return; + effectiveErrorTarget = nextTarget; + if (tiles) tiles.errorTarget = effectiveErrorTarget; + requestShadowSelectionRefresh(); + tiles?.dispatchEvent({ type: "needs-update" }); + requestRender(); + }; + const resetEffectiveErrorTarget = () => { + clearErrorTargetTimer(); + errorTargetState = createEffectiveErrorTargetState( + requestedErrorTarget, + Date.now() + ); + effectiveErrorTarget = requestedErrorTarget; + if (tiles) tiles.errorTarget = effectiveErrorTarget; + }; + const applyErrorTargetPolicy = () => { + const cache = getRuntimeCache(); + if (!tiles || !cache) return; + const { zoom, pitch } = readMapView(map); + const result = nextEffectiveErrorTarget(errorTargetState, { + now: Date.now(), + physicallyFull: cache.isFull(), + pipelineIdle: isPipelineIdle(), + mainConverged: lastMainViewConverged, + usedBytesMain, + cachedBytes: cache.cachedBytes, + ceiling: ceilingBytes, + zoom, + pitch, + unusedEvictable: cache.itemList.length > cache.usedSet.size, + lastProgressAt, + }); + errorTargetState = result.state; + clearErrorTargetTimer(); + if (result.changed) { + applyEffectiveErrorTarget(errorTargetState.effective); + return; + } + if (result.retryInMs !== null) { + errorTargetTimer = window.setTimeout(() => { + errorTargetTimer = 0; + tiles?.dispatchEvent({ type: "needs-update" }); + requestRender(); + }, Math.max(1, Math.ceil(result.retryInMs))); + } + }; + const resetDeferredTiles = () => { + for (const tile of deferred) { + if (tile.internal.loadingState === DEFERRED_TILE_LOADING_STATE) { + tile.internal.loadingState = UNLOADED_LOADING_STATE; + } + } + deferred.clear(); + }; + const evictUnusedCacheItems = () => { + const cache = getRuntimeCache(); + if (!cache) return; + for (const tile of [...cache.itemList]) { + if (!cache.usedSet.has(tile)) cache.remove(tile); + } + }; + /** Full wipe of a tab that stayed hidden: memory back, state reset. */ + const wipeCacheWhileHidden = () => { + hiddenWipeTimer = 0; + if (!tiles) return; + setShadowSelectionEnabled(false); + resetEffectiveErrorTarget(); + resetDeferredTiles(); + tileRetries.reset(); + const cache = getRuntimeCache(); + if (!cache) return; + for (const tile of [...cache.itemSet.keys()]) cache.remove(tile); + }; + const handleVisibilityChange = () => { + if (!tiles) return; + if (document.visibilityState !== "hidden") { + clearHiddenWipeTimer(); + viewQualityAuditPasses = VIEW_QUALITY_AUDIT_PASSES; + tiles.dispatchEvent({ type: "needs-update" }); + requestRender(); + return; + } + // Unused content goes at once; the tiles of the last view stay for a + // quick return before the debounced full wipe. + evictUnusedCacheItems(); + clearHiddenWipeTimer(); + hiddenWipeTimer = window.setTimeout( + wipeCacheWhileHidden, + HIDDEN_TAB_WIPE_DELAY_MS + ); + }; + const maybeEnableShadowSelection = () => { + if ( + !shadowView || + !tiles || + !cameraSet || + !isPipelineIdle() || + (tiles.group.children.length === 0 && !tileRetries.hasExhaustedRetries()) + ) { + return; + } + if (!mainViewWithinErrorFactor(SHADOW_SELECTION_ERROR_FACTOR)) { + return; + } + const receiverUpdate = captureShadowReceiverSources(); + if (receiverUpdate === "empty") { + setShadowSelectionEnabled(false); + return; + } + shadowSelectionRefreshPending = false; + if (receiverUpdate === "unchanged") return; + if (shadowSelectionEnabled) { + shadowSelectionNeedsTraversal = true; + } else { + setShadowSelectionEnabled(true); + } + tiles.dispatchEvent({ type: "needs-update" }); + requestRender(); + }; + const maybeFinalizeShadowSelection = () => { + if ( + !tiles || + !shadowSelectionEnabled || + shadowReceiverMaskConverged || + shadowSelectionNeedsTraversal || + !isPipelineIdle() || + !currentShadowPathConverged() + ) { + return; + } + shadowReceiverMaskConverged = true; + if (!previousShadowReceiverMask) return; + previousShadowReceiverMask = null; + tiles.dispatchEvent({ type: "needs-update" }); + requestRender(); + }; + const handleModelLoad = (event: { + scene?: THREE.Object3D; + tile?: Tile; + url?: string; + }) => { + if (event.tile) tileRetries.handleSuccess(event.tile, event.url); + if (event.scene) { + event.scene.traverse((object) => { + object.frustumCulled = false; + }); + refreshRenderedMaterials(event.scene); + applyOutlineVisibility(event.scene); + } + options.onContentChanged?.(); + lastProgressAt = Date.now(); + if (event.tile && tiles) { + const registeredBytes = tiles.lruCache.getMemoryUsage(event.tile); + bytesPredictor.observe( + { + url: event.url ?? resolveTileContentUrl(event.tile), + geometricError: event.tile.geometricError, + }, + registeredBytes + ); + reapplyCacheBoundsIfDrifted(); + } + payloadAwareConcurrency.observeSuccess(); + applyRequestConcurrency(); + notifyRequestStateChange(); + requestRender(); + }; + const handleModelDispose = (event: { scene?: THREE.Object3D }) => { + if (event.scene) { + restoreClayMaterials(event.scene); + restoreLitTextureMaterials(event.scene); + } + options.onContentChanged?.(); + // Freed space admits waiting tiles only through a new traversal, which + // the change-gated update would otherwise wait for the camera to trigger. + if (tiles && !tiles.lruCache.isFull()) { + tiles.dispatchEvent({ type: "needs-update" }); + requestRender(); + } + }; + const handleTilesetLoad = (event: { url?: string }) => { + clearKickstartTimer(); + tileRetries.handleSuccess(null, event.url); + payloadAwareConcurrency.observeSuccess(); + applyRequestConcurrency(); + requestRender(); + }; + /** + * D8: a failed tile leaves the cache so a later retry can be admitted again; + * it stays UNLOADED and is skipped by `queueTileForDownload` while blocked, + * so its parent keeps rendering as the fallback. + */ + const handleLoadError = (event: { + tile?: Tile | null; + url?: string | URL; + error?: unknown; + }) => { + const failedTile = event.tile ?? null; + if (failedTile && deferred.has(failedTile)) return; + const retryState = tileRetries.handleFailure( + failedTile, + event.url, + event.error + ); + if (failedTile && tiles && retryState !== "ignored") { + const wasFailed = + failedTile.internal.loadingState === FAILED_LOADING_STATE; + const removed = tiles.lruCache.remove(failedTile); + if (!removed && wasFailed) { + failedTile.internal.loadingState = UNLOADED_LOADING_STATE; + } + if (wasFailed) { + tiles.stats.failed = Math.max(0, tiles.stats.failed - 1); + } + if (retryState === "exhausted") { + tiles.dispatchEvent({ type: "needs-update" }); + requestRender(); + } + } + payloadAwareConcurrency.observeFailure(event.error); + applyRequestConcurrency(); + scheduleRequestBackoffRecovery(); + // A failed root is retried by the controller; tile errors keep the + // kickstart running until the root tileset arrives. + if (!failedTile) clearKickstartTimer(); + maybeEnableShadowSelection(); + notifyRequestStateChange(); + }; + const handleTilesLoadEnd = () => { + notifyRequestStateChange(); + maybeEnableShadowSelection(); + }; + const syncProjector = () => { + const projector = activeProjector; + if (!projector) { + projectorUniforms.uProjKind.value = 0; + projectorUniforms.uProjOpacity.value = 0; + projectorUniforms.tProj.value = null; + return; + } + placementMatrix.compose( + orientationGroup.position, + identityRotation, + orientationGroup.scale + ); + inversePlacementMatrix.copy(placementMatrix).invert(); + projectorUniforms.uProjKind.value = projector.kind === "pano" ? 1 : 2; + projectorUniforms.uProjOpacity.value = projector.opacity; + projectorUniforms.tProj.value = projector.texture; + if (projector.kind === "pano") { + projectorUniforms.uProjPos.value + .copy(projector.position) + .applyMatrix4(placementMatrix); + projectorUniforms.uProjHeading.value = projector.headingRad; + } else { + projectorUniforms.uProjMatrix.value + .copy(projector.viewProj) + .multiply(inversePlacementMatrix); + } + }; + const applyCacheBudget = () => { + const cache = getRuntimeCache(); + if (!cache) return; + const ceiling = ceilingBytes; + const bounds = resolveTilesCacheBounds({ + ceilingBytes: ceiling, + estimateBytes: bytesPredictor.globalEstimate(), + }); + // Admission stops at the physical ceiling (tiles register their predicted + // bytes on admission, so `cachedBytes` grows before downloads finish); the + // asynchronous eviction keeps a retention floor below it and only aborts + // in-flight tiles once the real bytes drift far beyond the estimates. + cache.minSize = DEFAULT_CACHE_MIN_ITEMS; + cache.maxSize = DEFAULT_CACHE_MAX_ITEMS; + cache.minBytesSize = bounds.minBytesSize; + cache.maxBytesSize = bounds.maxBytesSize; + cache.unloadPercent = TILES_LOAD_POLICY.cacheUnloadPercent; + cache.isFull = () => + cache.itemSet.size >= cache.maxSize || cache.cachedBytes >= ceiling; + cache.scheduleUnload(); + }; + const reapplyCacheBoundsIfDrifted = () => { + const cache = getRuntimeCache(); + if (!cache) return; + const bounds = resolveTilesCacheBounds({ + ceilingBytes, + estimateBytes: bytesPredictor.globalEstimate(), + }); + if ( + Math.abs(bounds.maxBytesSize - cache.maxBytesSize) > + TILES_LOAD_POLICY.cacheBoundsReapplyBytes + ) { + applyCacheBudget(); + } + }; + const applyRequestConcurrency = () => { + const cache = getRuntimeCache(); + if (!tiles || !cache) return; + const activeConcurrency = resolveRequestConcurrency({ + configured: payloadAwareConcurrency.getConcurrency(requestConcurrency), + ceilingBytes, + cachedBytes: cache.cachedBytes, + estimateBytes: bytesPredictor.globalEstimate(), + }); + tiles.downloadQueue.maxJobsPerOrigin = + map && + options.providesTerrain !== true && + isSharedThreeTerrainLoading(map) + ? Math.min( + TERRAIN_LOADING_CONTENT_BOOTSTRAP_CONCURRENCY, + activeConcurrency + ) + : activeConcurrency; + }; + const handleWireBytes = (_url: string, response: Response) => { + const contentLength = Number(response.headers.get("content-length")); + if (!Number.isFinite(contentLength) || contentLength <= 0) return; + payloadAwareConcurrency.observePayload(contentLength); + applyRequestConcurrency(); + }; + const scheduleRequestBackoffRecovery = () => { + if (!tiles) return; + const delay = payloadAwareConcurrency.getCooldownRemainingMs(); + if (requestBackoffTimer) { + window.clearTimeout(requestBackoffTimer); + requestBackoffTimer = 0; + } + if (delay <= 0) return; + requestBackoffTimer = window.setTimeout(() => { + requestBackoffTimer = 0; + applyRequestConcurrency(); + if (tiles && tiles.downloadQueue.maxJobsPerOrigin > 0) { + runDownloadQueues(); + tiles.dispatchEvent({ type: "needs-update" }); + } + requestRender(); + }, delay); + }; + const handleViewStart = () => { + requestShadowSelectionRefresh(); + tiles?.dispatchEvent({ type: "needs-update" }); + }; + const handleViewEnd = () => { + if (!tiles) return; + requestShadowSelectionRefresh(); + viewQualityAuditPasses = VIEW_QUALITY_AUDIT_PASSES; + tiles.dispatchEvent({ type: "needs-update" }); + }; + /** Main-view and prefetch-margin frustums in the tiles group frame. */ + const prepareViewFrustums = (viewCamera: THREE.Camera) => { + if (!tiles) return; + // Refresh the parents directly, then let TilesGroup recompute its own + // world matrix so its cached inverse (used by the traversal) stays in sync. + offsetGroup.updateWorldMatrix(true, false); + tiles.group.updateMatrixWorld(true); + tileViewProjection + .multiplyMatrices( + viewCamera.projectionMatrix, + viewCamera.matrixWorldInverse + ) + .multiply(tiles.group.matrixWorld); + tileViewFrustum.setFromProjectionMatrix( + tileViewProjection, + viewCamera.coordinateSystem, + viewCamera.reversedDepth + ); + if (viewCamera instanceof THREE.PerspectiveCamera) { + marginCamera.fov = + viewCamera.fov * TILES_LOAD_POLICY.prefetchMarginFovFactor; + marginCamera.aspect = viewCamera.aspect; + marginCamera.near = viewCamera.near; + marginCamera.far = viewCamera.far; + marginCamera.zoom = viewCamera.zoom; + marginCamera.updateProjectionMatrix(); + marginProjection + .multiplyMatrices( + marginCamera.projectionMatrix, + viewCamera.matrixWorldInverse + ) + .multiply(tiles.group.matrixWorld); + marginFrustum.setFromProjectionMatrix( + marginProjection, + viewCamera.coordinateSystem, + viewCamera.reversedDepth + ); + } else { + marginFrustum.copy(tileViewFrustum); + } + viewFrustumsReady = true; + }; + const isTileInPrefetchMargin = (tile: RuntimeTile): boolean => { + const bounds = tile.engineData?.boundingVolume; + if (!bounds || !viewFrustumsReady) return false; + return bounds.intersectsFrustum(marginFrustum); + }; + /** + * D1: displayable REPLACE siblings outside the view and its prefetch margin + * are parked in the FAILED state so upstream's parent gate treats them as + * finished without a download; they are released once they come into view. + */ + const applyTileDeferral = (tile: Tile, inView: boolean) => { + const runtimeTile = tile as RuntimeTile; + const isDeferred = deferred.has(tile); + const displayable = + tile.internal.hasRenderableContent && + tile.refine === "REPLACE" && + !isUnconditionallyRefined(tile); + const decision = shouldDeferTile({ + displayable, + inView, + inMargin: + !inView && (isDeferred || displayable) + ? isTileInPrefetchMargin(runtimeTile) + : false, + loadingState: tile.internal.loadingState, + isDeferred, + }); + if (decision === "defer") { + tile.internal.loadingState = DEFERRED_TILE_LOADING_STATE; + deferred.add(tile); + } else if (decision === "undefer") { + deferred.delete(tile); + if (tile.internal.loadingState === DEFERRED_TILE_LOADING_STATE) { + tile.internal.loadingState = UNLOADED_LOADING_STATE; + } + } + }; + const assignTilePriority = (tile: RuntimeTile) => { + const bounds = tile.engineData?.boundingVolume; + let inMainFrustum = tile.traversal?.inFrustum ?? false; + let centerness = 0; + if (bounds && viewFrustumsReady && tiles) { + inMainFrustum = bounds.intersectsFrustum(tileViewFrustum); + centerness = getTileCenterness(bounds); + } + tile.priority = deriveTilePriority({ + depth: tile.internal?.depth ?? 0, + inMainFrustum, + isExternalTileset: tile.internal?.hasUnrenderableContent ?? false, + centerness, + shadowReceiverCenterness: shadowSelectionEnabled + ? tile.shadowReceiverCenterness + : undefined, + shadowLightFacing: shadowSelectionEnabled + ? tile.shadowLightFacing + : undefined, + }); + }; + /** Refresh the download and parse order for the current view every frame. */ + const prioritizeQueuedTiles = () => { + if (!tiles) return; + for (const queue of getDownloadQueues()) { + for (const tile of queue.items) assignTilePriority(tile as RuntimeTile); + } + const parseQueue = tiles.parseQueue as RuntimePriorityQueue; + for (const tile of parseQueue.items) + assignTilePriority(tile as RuntimeTile); + }; + /** The overlay only exists while tile bounds are shown. */ + const syncTileDebugOverlay = () => { + if (!tiles) return; + if (!tileBoundsVisible) { + tileDebugOverlay?.dispose(); + tileDebugOverlay = null; + return; + } + tileDebugOverlay ??= createThreeTilesDebugOverlay(tiles.group); + const volumes = [...tiles.activeTiles].flatMap((tile) => { + const runtimeTile = tile as RuntimeTile; + const bounds = runtimeTile.engineData?.boundingVolume; + if (!bounds?.getAABB) return []; + const box = new THREE.Box3(); + bounds.getAABB(box); + if (box.isEmpty()) return []; + return [ + { + id: getTileDebugId(tile), + bounds: box, + loadReason: getTileLoadReason(runtimeTile), + }, + ]; + }); + tileDebugOverlay.update(volumes); + }; + const handleUpdateAfter = () => { + const currentTiles = tiles; + if (!currentTiles) return; + const cache = currentTiles.lruCache as RuntimeLruCache; + // A traversal skipped by the change gate never schedules the eviction + // that would bring the cache back to its retention floor. + const traversalRan = currentTiles.frameCount !== lastTraversalFrameCount; + lastTraversalFrameCount = currentTiles.frameCount; + if (!traversalRan && cache.cachedBytes > cache.minBytesSize) { + cache.scheduleUnload(); + } + const entriesBeforeUnload = cache.itemSet.size; + queueMicrotask(() => { + if (tiles !== currentTiles) return; + if (cache.itemSet.size < entriesBeforeUnload) { + currentTiles.dispatchEvent({ type: "needs-update" }); + } + }); + }; + + const layer: ThreeTilesRuntime = { + id: layerId, + originLngLat, + root: orientationGroup, + providesTerrain: options.providesTerrain === true, + receivesMapStyleTexture: + options.providesTerrain === true + ? (material) => !isRenderedBuildingSurface(material) + : false, + // A terrain-providing tileset carries its own texture, so the captured + // style is composited over it instead of replacing it. + mapStyleProjectionBlend: + options.providesTerrain === true ? "overlay" : undefined, + mapStyleProjectionVersion: () => mapStyleProjectionVersion, + originMerc, + mScale, + + onAdd(mapInstance: MaplibreMap) { + map = mapInstance; + if (tiles) return; + + tiles = new TilesRenderer(tilesetUrl) as RuntimeTilesRenderer; + const tileCache = new LRUCache(); + tileCache.unloadPriorityCallback = + tilesCacheUnloadPriorityCallback as typeof tileCache.unloadPriorityCallback; + const downloadQueue = new DownloadPriorityQueue(); + downloadQueue.priorityCallback = tilesQueuePriorityCallback; + const parseQueue = new PriorityQueue(); + parseQueue.priorityCallback = tilesQueuePriorityCallback; + const processNodeQueue = new PriorityQueue(); + processNodeQueue.priorityCallback = tilesNodeQueuePriorityCallback; + tiles.lruCache = tileCache; + tiles.downloadQueue = downloadQueue; + tiles.parseQueue = parseQueue; + tiles.processNodeQueue = processNodeQueue; + // D2: admission registers a predicted size so the cache fills before + // downloads finish; measured content carries the resident overhead. + const calculateBytesUsed = tiles.calculateBytesUsed.bind(tiles); + tiles.calculateBytesUsed = (tile, scene) => { + const measured = calculateBytesUsed(tile, scene); + if (measured !== null && measured > 0) { + return Math.round(measured * TILES_LOAD_POLICY.residentOverhead); + } + return bytesPredictor.predict({ + url: resolveTileContentUrl(tile), + geometricError: tile.geometricError, + isExternalTileset: tile.internal.hasUnrenderableContent, + }); + }; + // D1: the deferral decision rides on upstream's per-frame view error. + const calculateTileViewErrorWithPlugin = + tiles.calculateTileViewErrorWithPlugin.bind(tiles); + tiles.calculateTileViewErrorWithPlugin = (tile, target) => { + calculateTileViewErrorWithPlugin(tile, target); + const runtimeTile = tile as RuntimeTile; + runtimeTile.shadowReceiverCenterness = undefined; + runtimeTile.shadowLightFacing = undefined; + runtimeTile.shadowReceiverCurrent = undefined; + if ( + shadowSelectionEnabled && + shadowReceiverMask && + !mainViewSourceTiles.has(tile) && + !isTileInMainView(runtimeTile) + ) { + const bounds = runtimeTile.engineData?.boundingVolume; + if (bounds?.getAABB) { + bounds.getAABB(tileBoundingBox); + const matchedCurrent = applyShadowReceiverMask( + shadowReceiverMask, + tileBoundingBox, + target, + shadowReceiverMatch, + tile.geometricError, + effectiveErrorTarget + ); + const matchedPrevious = + !matchedCurrent && + previousShadowReceiverMask !== null && + applyShadowReceiverMask( + previousShadowReceiverMask, + tileBoundingBox, + target, + shadowReceiverMatch, + tile.geometricError, + effectiveErrorTarget + ); + if (matchedCurrent || matchedPrevious) { + runtimeTile.shadowReceiverCenterness = + shadowReceiverMatch.receiverCenterness; + runtimeTile.shadowLightFacing = shadowReceiverMatch.lightFacing; + runtimeTile.shadowReceiverCurrent = matchedCurrent; + } + } + } + applyTileDeferral(tile, target.inView); + }; + const queueTileForDownload = tiles.queueTileForDownload.bind(tiles); + tiles.queueTileForDownload = (tile) => { + const runtimeTile = tile as RuntimeTile; + // D8: a pending retry or an exhausted budget keeps the parent as the + // fallback instead of re-requesting the tile every frame. + if (tileRetries.isBlocked(tile)) return; + // Keep the last complete shadow path active across a camera move, but + // do not spend bandwidth extending that stale path. Once the main + // viewport is near its target, the receiver mask is replaced and the + // new offscreen caster traversal may request content. + if ( + shadowSelectionRefreshPending && + runtimeTile.shadowReceiverCenterness !== undefined && + !isTileInMainView(runtimeTile) + ) { + return; + } + if (runtimeTile.shadowReceiverCurrent === false) return; + // D7: REPLACE content that refines unconditionally is never displayed. + if ( + tile.refine === "REPLACE" && + (tile as RuntimeTile).traversal?.unconditionallyRefine === true && + tile.internal.hasRenderableContent + ) { + return; + } + assignTilePriority(runtimeTile); + queueTileForDownload(tile); + }; + // 3D Tiles 1.1 implicit tiling (template URIs) is plugin-based + tiles.registerPlugin(new ImplicitTilingPlugin()); + tiles.registerPlugin(new UpdateOnChangePlugin()); + // Mesh 2020 ships glTF 1.0 b3dm — upgrade payloads on the fly. The raw + // response feeds the wire-size sampling of the request concurrency. + tiles.registerPlugin( + new Gltf1UpgradePlugin({ onResponse: handleWireBytes }) + ); + // Draco-compressed glTF payloads need an explicit decoder + dracoLoader = new DRACOLoader(); + dracoLoader.setDecoderPath( + "https://www.gstatic.com/draco/versioned/decoders/1.5.6/" + ); + tiles.registerPlugin( + new GLTFExtensionsPlugin({ + dracoLoader, + plugins: [ + (parser: unknown) => + buildPrimitiveOutlinePlugin(parser, { + color: outlineColor, + opacity: outlineOpacity, + }), + ], + }) + ); + syncTileDebugOverlay(); + // Reorient the ECEF tileset into the local scene frame at the + // layer origin: ENU with +Y up, north toward -Z — matching the + // point cloud layers (x east, y up, z south). + tiles.registerPlugin( + new ReorientationPlugin({ + lat: degToRadNumeric(originLngLat[1]), + lon: degToRadNumeric(originLngLat[0]), + height: 0, + }) + ); + + tiles.loadSiblings = false; + tiles.loadAncestors = true; + tiles.displayActiveTiles = true; + applyRequestConcurrency(); + tiles.parseQueue.maxJobs = 4; + tiles.processNodeQueue.maxJobs = 48; + tiles.maxTilesProcessed = 1_000; + applyCacheBudget(); + effectiveErrorTarget = requestedErrorTarget; + tiles.errorTarget = effectiveErrorTarget; + offsetGroup.add(tiles.group); + + // Request frames until the root tileset arrived (`load-tileset`) or + // tile work started; a hidden runtime does not ask for frames. + kickstartTimer = window.setInterval(() => { + if (!tiles || tiles.stats.downloading > 0 || tiles.stats.parsing > 0) { + clearKickstartTimer(); + return; + } + if (!runtimeVisible) return; + requestRender(); + }, KICKSTART_INTERVAL_MS); + tiles.addEventListener("needs-update", requestRender); + tiles.addEventListener("load-tileset", handleTilesetLoad); + tiles.addEventListener("update-after", handleUpdateAfter); + tiles.addEventListener("load-model", handleModelLoad); + tiles.addEventListener("dispose-model", handleModelDispose); + tiles.addEventListener("load-error", handleLoadError); + tiles.addEventListener("tiles-load-end", handleTilesLoadEnd); + unsubscribeTerrainLoading = subscribeSharedThreeTerrainLoading( + map, + () => { + applyRequestConcurrency(); + if (tiles && tiles.downloadQueue.maxJobsPerOrigin > 0) { + runDownloadQueues(); + } + tiles?.dispatchEvent({ type: "needs-update" }); + requestRender(); + } + ); + map.on(MAPLIBRE_EVENT.MOVE_START, handleViewStart); + map.on(MAPLIBRE_EVENT.MOVE_END, handleViewEnd); + map.on(MAPLIBRE_EVENT.RESIZE, handleViewEnd); + document.addEventListener("visibilitychange", handleVisibilityChange); + }, + + update(frame: SharedThreeSceneFrame) { + if (!runtimeVisible || !tiles || !map) return; + syncProjector(); + try { + const viewCamera = resolveTilesViewCamera( + frame.renderCamera, + frame.lodCamera + ); + if (!cameraSet) { + cameraSet = createTilesCameraSet(tiles, viewCamera); + } + cameraSet.update(viewCamera, frame.viewport.x, frame.viewport.y); + prepareViewFrustums(viewCamera); + const completingShadowTraversal = shadowSelectionNeedsTraversal; + tiles.update(); + syncTileDebugOverlay(); + if (completingShadowTraversal) shadowSelectionNeedsTraversal = false; + maybeFinalizeShadowSelection(); + if (!shadowSelectionEnabled) measureUsedBytesMain(); + lastMainViewConverged = mainViewConverged(); + applyErrorTargetPolicy(); + applyRequestConcurrency(); + if (viewQualityAuditPasses > 0) { + viewQualityAuditPasses -= 1; + if (viewQualityAuditPasses > 0) { + tiles.dispatchEvent({ type: "needs-update" }); + } + } + maybeEnableShadowSelection(); + } catch (error) { + console.error("[tiles3d] update failed:", error); + } + prioritizeQueuedTiles(); + + // Keep rendering while the tile pipeline has work. Queued downloads + // only count while downloads run; the backoff and terrain listeners + // wake the loop once they may start. + const { stats } = tiles; + const processNodeQueue = + tiles.processNodeQueue as typeof tiles.processNodeQueue & { + items: unknown[]; + currJobs: number; + }; + if ( + (stats.queued > 0 && tiles.downloadQueue.maxJobsPerOrigin > 0) || + stats.downloading > 0 || + stats.parsing > 0 || + processNodeQueue.items.length > 0 || + processNodeQueue.currJobs > 0 || + viewQualityAuditPasses > 0 + ) { + map.triggerRepaint(); + } + notifyRequestStateChange(); + }, + + setVisible(visible: boolean) { + if (runtimeVisible === visible) return; + runtimeVisible = visible; + orientationGroup.visible = visible; + notifyRequestStateChange(); + if (!visible) { + clearErrorTargetTimer(); + viewQualityAuditPasses = 0; + map?.triggerRepaint(); + return; + } + viewQualityAuditPasses = VIEW_QUALITY_AUDIT_PASSES; + tiles?.dispatchEvent({ type: "needs-update" }); + map?.triggerRepaint(); + }, + + setHeightOffset(offsetMeters: number) { + offsetGroup.position.y = offsetMeters; + map?.triggerRepaint(); + }, + + setErrorTarget(errorTarget: number) { + const nextErrorTarget = clamp( + errorTarget, + TILES_ERROR_TARGET_MIN_PIXELS, + TILES_ERROR_TARGET_MAX_PIXELS + ); + // shadow-scene re-applies the same requested target on every content + // change; only a changed request resets a relaxed effective target. + if (requestedErrorTarget === nextErrorTarget) return; + requestedErrorTarget = nextErrorTarget; + resetEffectiveErrorTarget(); + requestShadowSelectionRefresh(); + viewQualityAuditPasses = VIEW_QUALITY_AUDIT_PASSES; + tiles?.dispatchEvent({ type: "needs-update" }); + }, + + setShadowSimulationStyle(style) { + if (!options.shadowBuildingStyle) return; + if (shadowStylesEqual(shadowSimulationStyle, style)) return; + shadowSimulationStyle = style; + // Reapply one bounded cache policy when shadow mode changes. The shadow + // camera may add off-screen casters, but it must not create a separate + // download-admission ceiling or bypass the memory limit. + applyCacheBudget(); + if (style?.uniformColor) shadowClayColor.set(style.uniformColor); + refreshRenderedMaterials(orientationGroup); + applyOutlineVisibility(orientationGroup); + map?.triggerRepaint(); + }, + + setProjector(projector) { + activeProjector = projector; + syncProjector(); + map?.triggerRepaint(); + }, + + setShadowView(view) { + const nextSignature = getSharedThreeShadowViewSignature(view); + if (nextSignature === shadowViewSignature) return; + shadowViewSignature = nextSignature; + shadowView = view; + if (view) { + requestShadowSelectionRefresh(); + } else { + setShadowSelectionEnabled(false); + } + applyRequestConcurrency(); + tiles?.dispatchEvent({ type: "needs-update" }); + notifyRequestStateChange(); + }, + + setWhiteShading(white: boolean) { + whiteShading = white; + refreshRenderedMaterials(orientationGroup); + map?.triggerRepaint(); + }, + + setClayMaterial(options: ClayMaterialOptions) { + if (options.color !== undefined) clayColor.set(options.color); + if (options.roughness !== undefined) { + clayRoughness = clamp(options.roughness, 0, 1); + } + if (options.metalness !== undefined) { + clayMetalness = clamp(options.metalness, 0, 1); + } + for (const state of clayMaterialStates.values()) { + for (const material of asMaterialArray(state.clay)) { + if (!(material instanceof THREE.MeshStandardMaterial)) continue; + material.color.copy(clayColor); + material.roughness = clayRoughness; + material.metalness = clayMetalness; + material.needsUpdate = true; + } + } + refreshRenderedMaterials(orientationGroup); + map?.triggerRepaint(); + }, + + setClayColor(color: string) { + layer.setClayMaterial({ color }); + }, + + setOpacity(nextOpacity: number) { + opacity = clamp(nextOpacity, 0, 1); + refreshRenderedMaterials(orientationGroup); + map?.triggerRepaint(); + }, + + setWireframe(enabled: boolean) { + wireframe = enabled; + refreshRenderedMaterials(orientationGroup); + map?.triggerRepaint(); + }, + + setOutlineVisible(visible: boolean) { + outlineVisible = visible; + applyOutlineVisibility(orientationGroup); + map?.triggerRepaint(); + }, + + setOutlineStyle(style: OutlineStyleOptions) { + if (style.color !== undefined) outlineColor = style.color; + if (style.opacity !== undefined) { + outlineOpacity = clamp(style.opacity, 0, 1); + } + applyOutlineStyle(orientationGroup); + map?.triggerRepaint(); + }, + + setTileBoundsVisible(enabled: boolean) { + if (tileBoundsVisible === enabled) return; + tileBoundsVisible = enabled; + syncTileDebugOverlay(); + tiles?.dispatchEvent({ type: "needs-update" }); + map?.triggerRepaint(); + }, + + setCacheBudget(bytes?: number, cacheOptions?: CacheBudgetOptions) { + styleCacheBudgetBytes = + bytes === undefined ? undefined : Math.max(0, Math.floor(bytes)); + styleCacheOverflowBytes = + cacheOptions?.overflowBytes === undefined + ? undefined + : Math.max(0, Math.floor(cacheOptions.overflowBytes)); + ceilingBytes = resolveTilesCacheCeiling(deviceProfile, { + cacheBudgetBytes: styleCacheBudgetBytes, + cacheOverflowBytes: styleCacheOverflowBytes, + }); + resetEffectiveErrorTarget(); + requestShadowSelectionRefresh(); + applyCacheBudget(); + applyRequestConcurrency(); + tiles?.dispatchEvent({ type: "needs-update" }); + }, + + setRequestConcurrency(jobs: number) { + const nextConcurrency = Math.max(0, Math.floor(jobs)); + const changed = nextConcurrency !== requestConcurrency; + requestConcurrency = nextConcurrency; + if (!tiles) return; + applyRequestConcurrency(); + if (tiles.downloadQueue.maxJobsPerOrigin > 0) { + runDownloadQueues(); + } + if (changed) tiles.dispatchEvent({ type: "needs-update" }); + }, + + getRequestDemand, + getViewElevationRange, + getActiveTileVolumes, + hasRenderableContent: () => { + let renderable = false; + tiles?.group.traverse((object) => { + if ((object as THREE.Mesh).isMesh && object.visible) renderable = true; + }); + return renderable; + }, + + dispose() { + disposed = true; + clearErrorTargetTimer(); + clearHiddenWipeTimer(); + clearKickstartTimer(); + resetDeferredTiles(); + if (requestBackoffTimer) { + window.clearTimeout(requestBackoffTimer); + requestBackoffTimer = 0; + } + map?.off(MAPLIBRE_EVENT.MOVE_START, handleViewStart); + map?.off(MAPLIBRE_EVENT.MOVE_END, handleViewEnd); + map?.off(MAPLIBRE_EVENT.RESIZE, handleViewEnd); + document.removeEventListener("visibilitychange", handleVisibilityChange); + unsubscribeTerrainLoading?.(); + unsubscribeTerrainLoading = null; + tiles?.removeEventListener("needs-update", requestRender); + tiles?.removeEventListener("load-tileset", handleTilesetLoad); + tiles?.removeEventListener("update-after", handleUpdateAfter); + tiles?.removeEventListener("load-model", handleModelLoad); + tiles?.removeEventListener("dispose-model", handleModelDispose); + tiles?.removeEventListener("load-error", handleLoadError); + tiles?.removeEventListener("tiles-load-end", handleTilesLoadEnd); + cameraSet?.dispose(); + cameraSet = null; + tileRetries.dispose(); + // The material states are keyed by mesh, so release them directly + // instead of searching the scene graph for their meshes. + for (const [mesh, state] of clayMaterialStates) { + disposeClayState(mesh, state); + } + for (const [mesh, state] of litTextureMaterialStates) { + disposeLitTextureState(mesh, state); + } + restoreShadowSides(); + tileDebugOverlay?.dispose(); + tileDebugOverlay = null; + tiles?.dispose(); + tiles = null; + dracoLoader?.dispose(); + dracoLoader = null; + orientationGroup.clear(); + flatTerrainNormalMap?.dispose(); + map = null; + }, + }; + + return layer; +} diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-shadow-receiver-mask.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-shadow-receiver-mask.spec.ts new file mode 100644 index 0000000000..f739f67868 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-shadow-receiver-mask.spec.ts @@ -0,0 +1,207 @@ +import * as THREE from "three"; +import { describe, expect, it } from "vitest"; + +import { + applyShadowReceiverMask, + createShadowReceiverMask, + maximumSweepDistanceWithinBox, + receiverMatchedTileError, + type ShadowReceiverMatch, + type ShadowReceiverSource, +} from "./three-tiles-shadow-receiver-mask"; + +const box = ( + minimum: readonly [number, number, number], + maximum: readonly [number, number, number] +) => + new THREE.Box3(new THREE.Vector3(...minimum), new THREE.Vector3(...maximum)); + +const source = ( + bounds: THREE.Box3, + options: Partial> = {} +): ShadowReceiverSource => ({ + bounds, + maximumCasterDistance: options.maximumCasterDistance ?? 50, + geometricError: options.geometricError ?? 4, + centerness: options.centerness ?? 0.5, +}); + +const match = (): ShadowReceiverMatch => ({ + receiverGeometricError: Number.POSITIVE_INFINITY, + receiverCenterness: 0, + lightFacing: 0, +}); + +describe("createShadowReceiverMask", () => { + it("keeps every partial caster between a receiver and the sunward limit", () => { + const mask = createShadowReceiverMask( + [source(box([-10, -10, -110], [10, 10, -100]))], + new THREE.Matrix4() + ); + + expect(mask?.match(box([-5, -5, -95], [5, 5, -85]), match())).toBe(true); + expect(mask?.match(box([-5, -5, -75], [5, 5, -65]), match())).toBe(true); + expect(mask?.match(box([-5, -5, -140], [5, 5, -130]), match())).toBe(false); + expect(mask?.match(box([-5, -5, -45], [5, 5, -35]), match())).toBe(false); + }); + + it("rejects hierarchy branches whose light-space footprint misses all receivers", () => { + const mask = createShadowReceiverMask( + [source(box([-10, -10, -110], [10, 10, -100]))], + new THREE.Matrix4() + ); + + expect(mask?.match(box([20, 20, -95], [30, 30, -85]), match())).toBe(false); + }); + + it("tests the complete 3d cross-section including the receiver height", () => { + const mask = createShadowReceiverMask( + [source(box([-10, 20, -110], [10, 40, -100]))], + new THREE.Matrix4() + ); + + expect(mask?.match(box([-5, 35, -90], [5, 45, -80]), match())).toBe(true); + expect(mask?.match(box([-5, -5, -90], [5, 5, -80]), match())).toBe(false); + }); + + it("uses the finest intersected receiver error and strongest view priority", () => { + const mask = createShadowReceiverMask( + [ + source(box([-20, -10, -110], [5, 10, -100]), { + geometricError: 8, + centerness: 0.9, + }), + source(box([-5, -10, -110], [20, 10, -100]), { + geometricError: 2, + centerness: 0.3, + }), + ], + new THREE.Matrix4() + ); + const result = match(); + + expect(mask?.match(box([-2, -5, -95], [2, 5, -85]), result)).toBe(true); + expect(result.receiverGeometricError).toBe(2); + expect(result.receiverCenterness).toBe(0.9); + }); + + it("applies the tiles-to-light transform before indexing", () => { + const receiver = source(box([-10, -10, -110], [10, 10, -100])); + const caster = box([-30, -5, -108], [-20, 5, -104]); + const identityMask = createShadowReceiverMask( + [receiver], + new THREE.Matrix4() + ); + const rotatedMask = createShadowReceiverMask( + [receiver], + new THREE.Matrix4().makeRotationY(Math.PI / 2) + ); + + expect(identityMask?.match(caster, match())).toBe(false); + expect(rotatedMask?.match(caster, match())).toBe(true); + expect(rotatedMask?.sourceCount).toBe(1); + }); + + it("selects casters only along the receiver-to-sun direction", () => { + const lightCamera = new THREE.OrthographicCamera(-50, 50, 50, -50, 1, 500); + lightCamera.position.set(80, 60, -40); + lightCamera.lookAt(0, 0, 0); + lightCamera.updateMatrixWorld(true); + const towardSun = lightCamera.position.clone().normalize(); + const receiver = box([-5, -5, -5], [5, 5, 5]); + const boxAt = (center: THREE.Vector3) => + new THREE.Box3().setFromCenterAndSize(center, new THREE.Vector3(6, 6, 6)); + const mask = createShadowReceiverMask( + [source(receiver, { maximumCasterDistance: 70 })], + lightCamera.matrixWorldInverse + ); + + expect( + mask?.match(boxAt(towardSun.clone().multiplyScalar(40)), match()) + ).toBe(true); + expect( + mask?.match(boxAt(towardSun.clone().multiplyScalar(-40)), match()) + ).toBe(false); + }); + + it("keeps BVH queries conservative across many receiver leaves", () => { + const receivers = Array.from({ length: 24 }, (_, index) => + source(box([index * 20, 0, -100], [index * 20 + 10, 10, -90]), { + geometricError: index + 1, + }) + ); + const mask = createShadowReceiverMask(receivers, new THREE.Matrix4()); + const result = match(); + + expect(mask?.match(box([401, 1, -80], [409, 9, -70]), result)).toBe(true); + expect(result.receiverGeometricError).toBe(21); + }); +}); + +describe("receiverMatchedTileError", () => { + it("stops at the receiver geometric error and refines coarser casters", () => { + expect(receiverMatchedTileError(4, 4, 1)).toBe(1); + expect(receiverMatchedTileError(8, 4, 1)).toBe(2); + expect(receiverMatchedTileError(2, 4, 1)).toBe(0.5); + }); + + it("requires a leaf when the receiver has zero geometric error", () => { + expect(receiverMatchedTileError(1, 0, 1)).toBe(Number.POSITIVE_INFINITY); + expect(receiverMatchedTileError(0, 0, 1)).toBe(0); + }); +}); + +describe("applyShadowReceiverMask", () => { + it("rescues a sunward hierarchy branch rejected by the camera frustum", () => { + const mask = createShadowReceiverMask( + [source(box([-10, -10, -110], [10, 10, -100]))], + new THREE.Matrix4() + ); + const target = { inView: false, error: 0 }; + const result = match(); + + expect( + applyShadowReceiverMask( + mask!, + box([-5, -5, -80], [5, 5, -70]), + target, + result, + 8, + 1 + ) + ).toBe(true); + expect(target).toEqual({ inView: true, error: 2 }); + }); + + it("removes a shadow-camera tile outside every receiver extrusion", () => { + const mask = createShadowReceiverMask( + [source(box([-10, -10, -110], [10, 10, -100]))], + new THREE.Matrix4() + ); + const target = { inView: true, error: 10 }; + + expect( + applyShadowReceiverMask( + mask!, + box([20, 20, -80], [30, 30, -70]), + target, + match(), + 8, + 1 + ) + ).toBe(false); + expect(target).toEqual({ inView: false, error: 10 }); + }); +}); + +describe("maximumSweepDistanceWithinBox", () => { + it("stops a receiver sweep at the first tileset-bound exit", () => { + const receiver = box([40, 0, 40], [50, 10, 50]); + const tileset = box([0, -20, 0], [100, 80, 100]); + const direction = new THREE.Vector3(1, 1, 0).normalize(); + + expect( + maximumSweepDistanceWithinBox(receiver, tileset, direction) + ).toBeCloseTo(60 * Math.SQRT2); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-shadow-receiver-mask.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-shadow-receiver-mask.ts new file mode 100644 index 0000000000..0362ba253d --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-shadow-receiver-mask.ts @@ -0,0 +1,237 @@ +import { clamp } from "@carma-commons/math"; +import * as THREE from "three"; + +const MAX_RECEIVERS_PER_LEAF = 8; +const DEPTH_EPSILON = 1e-6; +const boxAxes = ["x", "y", "z"] as const; + +export interface ShadowReceiverSource { + readonly bounds: THREE.Box3; + readonly maximumCasterDistance: number; + readonly geometricError: number; + readonly centerness: number; +} + +export interface ShadowReceiverMatch { + receiverGeometricError: number; + receiverCenterness: number; + lightFacing: number; +} + +export interface ShadowReceiverMask { + readonly sourceCount: number; + match: (candidate: THREE.Box3, target: ShadowReceiverMatch) => boolean; +} + +export interface ShadowReceiverTileTarget { + inView: boolean; + error: number; +} + +export const maximumSweepDistanceWithinBox = ( + source: THREE.Box3, + container: THREE.Box3, + direction: THREE.Vector3 +): number => { + let maximumDistance = Number.POSITIVE_INFINITY; + for (const axis of boxAxes) { + const component = direction[axis]; + if (Math.abs(component) <= Number.EPSILON) continue; + const distance = + component > 0 + ? (container.max[axis] - source.min[axis]) / component + : (container.min[axis] - source.max[axis]) / component; + maximumDistance = Math.min(maximumDistance, distance); + } + return Number.isFinite(maximumDistance) ? Math.max(0, maximumDistance) : 0; +}; + +type IndexedReceiver = Readonly<{ + bounds: THREE.Box3; + geometricError: number; + centerness: number; +}>; + +type ReceiverNode = Readonly<{ + bounds: THREE.Box3; + minimumGeometricError: number; + receiverCenterness: number; + receivers?: readonly IndexedReceiver[]; + left?: ReceiverNode; + right?: ReceiverNode; +}>; + +const isFiniteBox = (box: THREE.Box3) => + !box.isEmpty() && + Number.isFinite(box.min.x) && + Number.isFinite(box.min.y) && + Number.isFinite(box.min.z) && + Number.isFinite(box.max.x) && + Number.isFinite(box.max.y) && + Number.isFinite(box.max.z); + +const includeMatch = ( + target: ShadowReceiverMatch, + receiverGeometricError: number, + receiverCenterness: number +) => { + target.receiverGeometricError = Math.min( + target.receiverGeometricError, + receiverGeometricError + ); + target.receiverCenterness = Math.max( + target.receiverCenterness, + receiverCenterness + ); +}; + +const unionBounds = (receivers: readonly IndexedReceiver[]) => { + const bounds = new THREE.Box3().makeEmpty(); + for (const receiver of receivers) bounds.union(receiver.bounds); + return bounds; +}; + +const buildReceiverNode = ( + receivers: readonly IndexedReceiver[] +): ReceiverNode => { + const bounds = unionBounds(receivers); + let minimumGeometricError = Number.POSITIVE_INFINITY; + let receiverCenterness = 0; + for (const receiver of receivers) { + minimumGeometricError = Math.min( + minimumGeometricError, + receiver.geometricError + ); + receiverCenterness = Math.max(receiverCenterness, receiver.centerness); + } + if (receivers.length <= MAX_RECEIVERS_PER_LEAF) { + return { + bounds, + minimumGeometricError, + receiverCenterness, + receivers, + }; + } + + const size = bounds.getSize(new THREE.Vector3()); + const axis = + size.x >= size.y && size.x >= size.z ? "x" : size.y >= size.z ? "y" : "z"; + const sorted = [...receivers].sort( + (first, second) => + (first.bounds.min[axis] + first.bounds.max[axis]) / 2 - + (second.bounds.min[axis] + second.bounds.max[axis]) / 2 + ); + const midpoint = Math.floor(sorted.length / 2); + return { + bounds, + minimumGeometricError, + receiverCenterness, + left: buildReceiverNode(sorted.slice(0, midpoint)), + right: buildReceiverNode(sorted.slice(midpoint)), + }; +}; + +const queryReceiverNode = ( + node: ReceiverNode, + candidate: THREE.Box3, + target: ShadowReceiverMatch +) => { + if (!node.bounds.intersectsBox(candidate)) return; + if (candidate.containsBox(node.bounds)) { + includeMatch(target, node.minimumGeometricError, node.receiverCenterness); + return; + } + if (node.receivers) { + for (const receiver of node.receivers) { + if (receiver.bounds.intersectsBox(candidate)) { + includeMatch(target, receiver.geometricError, receiver.centerness); + } + } + return; + } + if (node.left) queryReceiverNode(node.left, candidate, target); + if (node.right) queryReceiverNode(node.right, candidate, target); +}; + +/** + * Builds a light-space BVH of the camera-visible receiver frontier swept + * toward the sun. Tile hierarchy branches that miss every swept receiver + * volume can be discarded before their payload is requested. + */ +export const createShadowReceiverMask = ( + sources: readonly ShadowReceiverSource[], + tilesToShadowView: THREE.Matrix4 +): ShadowReceiverMask | null => { + const receivers: IndexedReceiver[] = []; + for (const source of sources) { + const bounds = source.bounds.clone().applyMatrix4(tilesToShadowView); + if (!isFiniteBox(bounds)) continue; + bounds.max.z += Math.max(0, source.maximumCasterDistance); + bounds.expandByScalar(DEPTH_EPSILON); + receivers.push({ + bounds, + geometricError: + Number.isFinite(source.geometricError) && source.geometricError >= 0 + ? source.geometricError + : Number.MAX_VALUE, + centerness: clamp(source.centerness, 0, 1), + }); + } + if (receivers.length === 0) return null; + + const root = buildReceiverNode(receivers); + const lightDepthRange = Math.max( + DEPTH_EPSILON, + root.bounds.max.z - root.bounds.min.z + ); + const projectedCandidate = new THREE.Box3(); + return { + sourceCount: receivers.length, + match(candidate, target) { + projectedCandidate.copy(candidate).applyMatrix4(tilesToShadowView); + if (!isFiniteBox(projectedCandidate)) return false; + target.receiverGeometricError = Number.POSITIVE_INFINITY; + target.receiverCenterness = 0; + target.lightFacing = clamp( + (projectedCandidate.max.z - root.bounds.min.z) / lightDepthRange, + 0, + 1 + ); + queryReceiverNode(root, projectedCandidate, target); + return target.receiverGeometricError !== Number.POSITIVE_INFINITY; + }, + }; +}; + +export const receiverMatchedTileError = ( + tileGeometricError: number, + receiverGeometricError: number, + errorTarget: number +): number => { + if (receiverGeometricError <= DEPTH_EPSILON) { + return tileGeometricError <= DEPTH_EPSILON ? 0 : Number.POSITIVE_INFINITY; + } + return ( + errorTarget * (Math.max(0, tileGeometricError) / receiverGeometricError) + ); +}; + +export const applyShadowReceiverMask = ( + mask: ShadowReceiverMask, + candidate: THREE.Box3, + target: ShadowReceiverTileTarget, + match: ShadowReceiverMatch, + tileGeometricError: number, + errorTarget: number +): boolean => { + const matched = mask.match(candidate, match); + target.inView = matched; + if (matched) { + target.error = receiverMatchedTileError( + tileGeometricError, + match.receiverGeometricError, + errorTarget + ); + } + return matched; +}; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-traversal.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-traversal.spec.ts new file mode 100644 index 0000000000..7dbc4a9b89 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-traversal.spec.ts @@ -0,0 +1,559 @@ +// @vitest-environment jsdom + +/** + * D1 traversal behaviour of the 3D Tiles runtime (`loadAncestors=true`, + * `loadSiblings=false`, `displayActiveTiles=true`) against the real + * 3d-tiles-renderer 0.5.2 traversal: displayable REPLACE siblings outside the + * view and its prefetch margin are deferred instead of blocking the parent + * gate, external-tileset stubs still load, margin siblings load at low + * priority, and deferred siblings are released once they enter the view. + * + * The runtime is driven through `buildThreeTilesRuntime` with an in-memory + * REPLACE tileset served by a stubbed `fetch`; frame scheduling is stubbed so + * the pipeline drains deterministically. + */ + +import { TilesRenderer } from "3d-tiles-renderer"; +import type { Tile } from "3d-tiles-renderer/core"; +import type { Map as MaplibreMap } from "maplibre-gl"; +import * as THREE from "three"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { buildThreeTilesRuntime } from "./three-tiles-runtime"; + +vi.hoisted(() => { + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: () => "blob:vitest-maplibre-worker", + }); +}); + +const ORIGIN: [number, number] = [7.15, 51.25]; +const BASE_URL = "https://tiles.test/traversal"; +const TILESET_URL = `${BASE_URL}/tileset.json`; +const FAILED = -1; +const LOADED = 4; + +type TilesetJson = { + asset: { version: string }; + geometricError: number; + root: TileJson; +}; +type TileJson = { + boundingVolume: { box: number[] }; + geometricError: number; + refine: "REPLACE"; + content?: { uri: string }; + children?: TileJson[]; + transform?: number[]; +}; +type HarnessTile = Tile & { + priority?: number; + traversal: Tile["traversal"] & { + active: boolean; + allChildrenLoaded: boolean; + unconditionallyRefine: boolean; + }; + engineData: { + scene: THREE.Object3D | null; + geometry: THREE.BufferGeometry[] | null; + materials: THREE.Material[] | null; + textures: THREE.Texture[] | null; + }; +}; +type HarnessRenderer = TilesRenderer & { + root: HarnessTile | null; + stats: { + failed: number; + queued: number; + downloading: number; + parsing: number; + }; + ellipsoid: { + getObjectFrame: ( + lat: number, + lon: number, + height: number, + az: number, + el: number, + roll: number, + target: THREE.Matrix4 + ) => THREE.Matrix4; + }; +}; + +/** + * Boxes are given in scene coordinates (x east, y up, z south). The runtime + * re-orients the tileset from the ECEF object frame at the layer origin, in + * which the local axes point x west / z north, so mirror x and z. + */ +const sceneBox = ( + cx: number, + cy: number, + cz: number, + hx: number, + hy: number, + hz: number +) => ({ box: [-cx, cy, -cz, hx, 0, 0, 0, hy, 0, 0, 0, hz] }); + +const tileName = (tile: Tile): string => + (tile.content?.uri ?? "").replace(/^.*\//, "").replace(/\.b3dm$/, ""); + +const names = (set: Iterable) => [...set].map(tileName).sort(); + +/** + * root box 400 x 100 x 400 centred on the origin (ge 100) + * children: 2x2 grid of 200 x 100 x 200 boxes (ge 10), "near" = +z, "far" = -z + * grandchildren: 2x2 grid of 100 x 100 x 100 boxes per child (ge 1) + */ +const NEAR = ["near-west", "near-east"]; +const FAR = ["far-west", "far-east"]; +const buildQuadTileset = (): TilesetJson => { + const childLayout: Array<[string, number, number]> = [ + ["near-west", -100, 100], + ["near-east", 100, 100], + ["far-west", -100, -100], + ["far-east", 100, -100], + ]; + const children = childLayout.map(([name, cx, cz]) => ({ + boundingVolume: sceneBox(cx, 0, cz, 100, 50, 100), + geometricError: 10, + refine: "REPLACE" as const, + content: { uri: `${name}.b3dm` }, + children: [ + [-50, -50], + [50, -50], + [-50, 50], + [50, 50], + ].map(([gx, gz], index) => ({ + boundingVolume: sceneBox(cx + gx, 0, cz + gz, 50, 50, 50), + geometricError: 1, + refine: "REPLACE" as const, + content: { uri: `${name}_${index}.b3dm` }, + })), + })); + return { + asset: { version: "1.0" }, + geometricError: 100, + root: { + boundingVolume: sceneBox(0, 0, 0, 200, 50, 200), + geometricError: 100, + refine: "REPLACE", + content: { uri: "root.b3dm" }, + children, + }, + }; +}; + +/** Camera at z=+500 looking along -z; `far` bounds the visible depth. */ +const createViewCamera = (far: number) => { + const camera = new THREE.PerspectiveCamera(40, 800 / 600, 1, far); + camera.position.set(0, 30, 500); + camera.lookAt(0, 30, 0); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(true); + return camera; +}; + +type Harness = { + tiles: HarnessRenderer; + layer: ReturnType; + downloads: string[]; + frame: ( + camera: THREE.PerspectiveCamera, + flushSteps?: number + ) => Promise; + runUntilSettled: ( + camera: THREE.PerspectiveCamera, + options?: { flushSteps?: number; onFrame?: () => void } + ) => Promise; + dispose: () => void; +}; + +const createHarness = ( + buildTilesets: ( + tiles: HarnessRenderer + ) => Record TilesetJson)> +): Harness => { + const frameCallbacks = new Map(); + let nextHandle = 1; + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + const handle = nextHandle++; + frameCallbacks.set(handle, cb); + return handle; + }); + vi.stubGlobal("cancelAnimationFrame", (handle: number) => { + frameCallbacks.delete(handle); + }); + + let captured: HarnessRenderer | undefined; + const registerPlugin = TilesRenderer.prototype.registerPlugin; + vi.spyOn(TilesRenderer.prototype, "registerPlugin").mockImplementation( + function (this: TilesRenderer, plugin: object) { + captured = this as HarnessRenderer; + return registerPlugin.call(this, plugin); + } + ); + const downloads: string[] = []; + let tilesets: Record TilesetJson)> = {}; + vi.stubGlobal("fetch", (input: string | URL) => { + const url = String(input); + const tileset = tilesets[url]; + if (tileset) { + downloads.push(url.replace(`${BASE_URL}/`, "")); + const json = typeof tileset === "function" ? tileset() : tileset; + return Promise.resolve( + new Response(JSON.stringify(json), { + headers: { "content-type": "application/json" }, + }) + ); + } + downloads.push(url.replace(`${BASE_URL}/`, "")); + return Promise.resolve(new Response(new ArrayBuffer(16))); + }); + + const map = { + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + getZoom: () => 17, + getPitch: () => 45, + } as unknown as MaplibreMap; + const layer = buildThreeTilesRuntime("mesh", TILESET_URL, ORIGIN); + layer.onAdd?.(map); + expect(captured).toBeDefined(); + const tiles = captured!; + tilesets = buildTilesets(tiles); + tiles.registerPlugin({ + name: "TEST_PARSE_PLUGIN", + parseTile: ( + _buffer: ArrayBuffer, + tile: Tile, + _extension: string, + _url: string, + signal: AbortSignal + ) => { + if (signal.aborted) return Promise.resolve(); + const geometry = new THREE.BoxGeometry(1, 1, 1); + const material = new THREE.MeshBasicMaterial(); + const scene = new THREE.Group(); + scene.add(new THREE.Mesh(geometry, material)); + const engineData = (tile as HarnessTile).engineData; + engineData.scene = scene; + engineData.geometry = [geometry]; + engineData.materials = [material]; + engineData.textures = []; + return Promise.resolve(); + }, + }); + + const nextTick = () => new Promise((resolve) => setTimeout(resolve, 0)); + const flush = async (steps: number) => { + for (let index = 0; index < steps; index += 1) { + const callbacks = [...frameCallbacks.values()]; + frameCallbacks.clear(); + for (const callback of callbacks) callback(index); + await nextTick(); + } + }; + const frame = async (camera: THREE.PerspectiveCamera, flushSteps = 8) => { + tiles.dispatchEvent({ type: "needs-update" }); + layer.root.updateMatrixWorld(true); + layer.update({ + map, + renderCamera: camera, + lodCamera: camera, + lookTarget: new THREE.Vector3(), + viewport: new THREE.Vector2(800, 600), + }); + await flush(flushSteps); + }; + const runUntilSettled: Harness["runUntilSettled"] = async ( + camera, + options = {} + ) => { + let stableFrames = 0; + let lastCount = -1; + for (let index = 0; index < 80 && stableFrames < 4; index += 1) { + await frame(camera, options.flushSteps); + options.onFrame?.(); + if (downloads.length === lastCount) { + stableFrames += 1; + } else { + stableFrames = 0; + lastCount = downloads.length; + } + } + }; + + return { + tiles, + layer, + downloads, + frame, + runUntilSettled, + dispose: () => { + layer.dispose(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }, + }; +}; + +/** Root transform = object frame at the layer origin, so local == scene frame. */ +const withOriginTransform = ( + tiles: HarnessRenderer, + tileset: TilesetJson +): TilesetJson => { + const frame = tiles.ellipsoid.getObjectFrame( + THREE.MathUtils.degToRad(ORIGIN[1]), + THREE.MathUtils.degToRad(ORIGIN[0]), + 0, + 0, + 0, + 0, + new THREE.Matrix4() + ); + return { ...tileset, root: { ...tileset.root, transform: frame.toArray() } }; +}; + +const childByName = (tiles: HarnessRenderer, name: string) => + tiles.root!.children!.find( + (child) => tileName(child) === name + ) as HarnessTile; + +describe("three tiles traversal (D1 deferral)", () => { + let harness: Harness | null = null; + afterEach(() => { + harness?.dispose(); + harness = null; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("shows the parent first and refines the visible children although two off-frustum siblings never load", async () => { + harness = createHarness((tiles) => ({ + [TILESET_URL]: withOriginTransform(tiles, buildQuadTileset()), + })); + const { tiles, layer, downloads } = harness; + // Serialise the pipeline so the placeholder chain is observable. + layer.setRequestConcurrency(1); + tiles.parseQueue.maxJobs = 1; + const camera = createViewCamera(450); + const visibleTimeline: string[][] = []; + const sampleVisible = () => { + const current = names(tiles.visibleTiles); + const last = visibleTimeline[visibleTimeline.length - 1]; + if (!last || last.join() !== current.join()) + visibleTimeline.push(current); + }; + + await harness.runUntilSettled(camera, { + flushSteps: 2, + onFrame: sampleVisible, + }); + + const near = NEAR.map((name) => childByName(tiles, name)); + const far = FAR.map((name) => childByName(tiles, name)); + expect(near.map((tile) => tile.traversal.inFrustum)).toEqual([true, true]); + expect(far.map((tile) => tile.traversal.inFrustum)).toEqual([false, false]); + + // Everything the camera sees is loaded: root, 2 children, 8 grandchildren. + const grandchildren = NEAR.flatMap((name) => + [0, 1, 2, 3].map((index) => `${name}_${index}`) + ); + expect(downloads.filter((entry) => entry.endsWith(".b3dm")).sort()).toEqual( + [ + "root.b3dm", + ...NEAR.map((n) => `${n}.b3dm`), + ...grandchildren.map((n) => `${n}.b3dm`), + ].sort() + ); + // The off-frustum siblings were marked used by the traversal, deferred + // (parked as finished) and never requested; they are no failures. + for (const tile of far) { + expect(tile.traversal.used).toBe(true); + expect(tile.internal.loadingState).toBe(FAILED); + expect(tile.traversal.active).toBe(false); + expect(tile.traversal.visible).toBe(false); + } + expect(tiles.stats.failed).toBe(0); + + // The parent gate opens: root and near children step aside. + expect(tiles.root!.traversal.allChildrenLoaded).toBe(true); + expect(tiles.root!.traversal.isLeaf).toBe(false); + expect(names(tiles.visibleTiles)).toEqual([...grandchildren].sort()); + expect(names(tiles.activeTiles)).toEqual([...grandchildren].sort()); + + // Progressive placeholder chain: nothing -> root -> near children -> ... -> + // the 8 grandchildren; the deferred siblings never show up. + expect(visibleTimeline[0]).toEqual([]); + expect(visibleTimeline[1]).toEqual(["root"]); + expect(visibleTimeline).toContainEqual([...NEAR].sort()); + expect(visibleTimeline[visibleTimeline.length - 1]).toEqual( + [...grandchildren].sort() + ); + for (const stage of visibleTimeline) { + for (const name of FAR) expect(stage).not.toContain(name); + } + }); + + it("releases, requests and displays a deferred sibling once it enters the frustum", async () => { + harness = createHarness((tiles) => ({ + [TILESET_URL]: withOriginTransform(tiles, buildQuadTileset()), + })); + const { tiles, downloads } = harness; + await harness.runUntilSettled(createViewCamera(450)); + for (const name of FAR) { + expect(childByName(tiles, name).internal.loadingState).toBe(FAILED); + } + const downloadsBefore = downloads.length; + + // Extend the visible depth so the far children enter the frustum. + await harness.runUntilSettled(createViewCamera(1_000)); + + for (const name of FAR) { + const tile = childByName(tiles, name); + expect(tile.traversal.inFrustum).toBe(true); + expect(tile.internal.loadingState).toBe(LOADED); + expect(downloads).toContain(`${name}.b3dm`); + } + const allGrandchildren = [...NEAR, ...FAR].flatMap((name) => + [0, 1, 2, 3].map((index) => `${name}_${index}`) + ); + expect(names(tiles.visibleTiles)).toEqual([...allGrandchildren].sort()); + // Only the far subtree was fetched: 2 children + 8 grandchildren. + expect(downloads.length - downloadsBefore).toBe(10); + expect(new Set(downloads).size).toBe(downloads.length); + }); + + it("requests an off-frustum external-tileset stub but defers its off-frustum root content", async () => { + const stubUrl = `${BASE_URL}/far-east/tileset.json`; + harness = createHarness((tiles) => ({ + [TILESET_URL]: withOriginTransform(tiles, { + asset: { version: "1.0" }, + geometricError: 100, + root: { + boundingVolume: sceneBox(0, 0, 0, 200, 50, 200), + geometricError: 100, + refine: "REPLACE", + content: { uri: "root.b3dm" }, + children: [ + { + boundingVolume: sceneBox(-100, 0, 100, 100, 50, 100), + geometricError: 10, + refine: "REPLACE", + content: { uri: "near-west.b3dm" }, + }, + { + boundingVolume: sceneBox(100, 0, -100, 100, 50, 100), + geometricError: 10, + refine: "REPLACE", + content: { uri: "far-east/tileset.json" }, + }, + ], + }, + }), + [stubUrl]: { + asset: { version: "1.0" }, + geometricError: 10, + root: { + boundingVolume: sceneBox(100, 0, -100, 100, 50, 100), + geometricError: 10, + refine: "REPLACE", + content: { uri: "far-east-root.b3dm" }, + }, + }, + })); + const { tiles, downloads } = harness; + await harness.runUntilSettled(createViewCamera(450)); + + expect(downloads).toContain("far-east/tileset.json"); + expect(downloads).not.toContain("far-east/far-east-root.b3dm"); + const stub = tiles.root!.children!.find( + (child) => child.content?.uri === "far-east/tileset.json" + ) as HarnessTile; + expect(stub.internal.loadingState).toBe(LOADED); + expect(stub.traversal.unconditionallyRefine).toBe(true); + const externalRoot = stub.children![0] as HarnessTile; + expect(externalRoot.traversal.inFrustum).toBe(false); + expect(externalRoot.internal.loadingState).toBe(FAILED); + + // The subtree structure satisfied the parent gate: the visible child shows. + expect(tiles.root!.traversal.allChildrenLoaded).toBe(true); + expect(names(tiles.visibleTiles)).toEqual(["near-west"]); + }); + + it("requests margin siblings at low priority without deferring them", async () => { + harness = createHarness((tiles) => ({ + [TILESET_URL]: withOriginTransform(tiles, { + asset: { version: "1.0" }, + geometricError: 100, + root: { + boundingVolume: sceneBox(300, 0, 100, 400, 50, 100), + geometricError: 100, + refine: "REPLACE", + content: { uri: "root.b3dm" }, + children: [ + // inside the main frustum + { + boundingVolume: sceneBox(0, 0, 100, 100, 50, 100), + geometricError: 10, + refine: "REPLACE", + content: { uri: "in.b3dm" }, + }, + // outside the main frustum, inside the 1.25x fov prefetch margin + { + boundingVolume: sceneBox(280, 0, 100, 20, 50, 100), + geometricError: 10, + refine: "REPLACE", + content: { uri: "margin.b3dm" }, + }, + // outside the margin as well + { + boundingVolume: sceneBox(650, 0, 100, 50, 50, 100), + geometricError: 10, + refine: "REPLACE", + content: { uri: "far.b3dm" }, + }, + ], + }, + }), + })); + const { tiles, layer, downloads } = harness; + layer.setRequestConcurrency(1); + const camera = createViewCamera(1_000); + const priorities = new Map(); + await harness.runUntilSettled(camera, { + onFrame: () => { + const root = tiles.root; + if (!root) return; + for (const tile of [root, ...(root.children ?? [])]) { + const priority = (tile as HarnessTile).priority; + if (priority !== undefined && !priorities.has(tileName(tile))) { + priorities.set(tileName(tile), priority); + } + } + }, + }); + + const inTile = childByName(tiles, "in"); + const marginTile = childByName(tiles, "margin"); + const farTile = childByName(tiles, "far"); + expect(inTile.traversal.inFrustum).toBe(true); + expect(marginTile.traversal.inFrustum).toBe(false); + expect(farTile.traversal.inFrustum).toBe(false); + + expect(downloads).toContain("in.b3dm"); + expect(downloads).toContain("margin.b3dm"); + expect(downloads).not.toContain("far.b3dm"); + expect(marginTile.internal.loadingState).toBe(LOADED); + expect(farTile.internal.loadingState).toBe(FAILED); + expect(priorities.get("margin")).toBeLessThan(priorities.get("in")!); + expect(priorities.get("in")).toBeLessThan(priorities.get("root")!); + + expect(tiles.root!.traversal.allChildrenLoaded).toBe(true); + expect(names(tiles.visibleTiles)).toContain("in"); + expect(names(tiles.visibleTiles)).not.toContain("far"); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-unconditional-refine.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-unconditional-refine.spec.ts new file mode 100644 index 0000000000..5c9222a2c2 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-unconditional-refine.spec.ts @@ -0,0 +1,160 @@ +// @vitest-environment jsdom +// D7: under loadAncestors, upstream toggleTiles queues the content of a +// REPLACE tile whose geometricError is >= its parent's (unconditionallyRefine) +// although such a tile can never become active or visible. The runtime never +// downloads that content, and the parent's readiness is unaffected. + +import { TilesRenderer } from "3d-tiles-renderer"; +import type { Map as MaplibreMap } from "maplibre-gl"; +import * as THREE from "three"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { buildThreeTilesRuntime } from "./three-tiles-runtime"; + +vi.hoisted(() => { + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: () => "blob:vitest-maplibre-worker", + }); +}); + +const LOADED = 4; +const TILESET_URL = "https://example.test/tileset.json"; +const box = { box: [0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1] }; + +type TileJson = { + geometricError: number; + refine: "REPLACE"; + boundingVolume: typeof box; + content: { uri: string }; + children: TileJson[]; +}; +type TraversedTile = TileJson & { + traversal: { + unconditionallyRefine: boolean; + active: boolean; + visible: boolean; + used: boolean; + isLeaf: boolean; + allChildrenLoaded: boolean; + }; + internal: { loadingState: number }; + engineData: { scene: THREE.Object3D | null }; +}; +type RuntimeInternals = { + rootLoadingState: number; + calculateTileViewError: ( + tile: { geometricError: number }, + target: { inView: boolean; error: number; distanceFromCamera: number } + ) => void; + requestTileContents: (tile: TileJson) => void; + preprocessTileset: (json: unknown, url: string) => void; +}; + +const node = ( + geometricError: number, + uri: string, + children: TileJson[] = [] +): TileJson => ({ + geometricError, + refine: "REPLACE", + boundingVolume: box, + content: { uri }, + children, +}); + +const traversalOf = (tile: TileJson) => tile as unknown as TraversedTile; + +describe("three tiles unconditional refine (D7)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("never requests out-of-order content while the leaf still becomes visible", () => { + vi.spyOn(console, "warn").mockImplementation(() => undefined); + let tiles: (TilesRenderer & RuntimeInternals) | undefined; + const registerPlugin = TilesRenderer.prototype.registerPlugin; + vi.spyOn(TilesRenderer.prototype, "registerPlugin").mockImplementation( + function (this: TilesRenderer, plugin: object) { + tiles = this as TilesRenderer & RuntimeInternals; + return registerPlugin.call(this, plugin); + } + ); + const map = { + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + getZoom: () => 17, + getPitch: () => 45, + } as unknown as MaplibreMap; + const layer = buildThreeTilesRuntime("mesh", TILESET_URL, [7.15, 51.25]); + layer.onAdd?.(map); + expect(tiles).toBeDefined(); + const renderer = tiles!; + + const leaf = node(0.45, "leaf.b3dm"); + const outOfOrder = node(11.9, "out-of-order.b3dm", [leaf]); + const parent = node(1.4, "parent.b3dm", [outOfOrder]); + const tileset = { + asset: { version: "1.0" }, + geometricError: 100, + root: node(10, "root.b3dm", [parent]), + }; + // 5 px per metre of geometric error: 0.45 -> 2.25 px (stop), 1.4 -> 7 px + renderer.calculateTileViewError = (tile, target) => { + target.inView = true; + target.error = tile.geometricError * 5; + target.distanceFromCamera = 100; + }; + const requested: string[] = []; + renderer.requestTileContents = (tile) => { + requested.push(tile.content.uri); + }; + renderer.preprocessTileset(tileset, TILESET_URL); + (renderer as unknown as { rootTileset: unknown }).rootTileset = tileset; + renderer.rootLoadingState = LOADED; + + const camera = new THREE.PerspectiveCamera(60, 4 / 3, 1, 1_000); + camera.position.set(0, 0, 50); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(true); + const frame = { + map, + renderCamera: camera, + lodCamera: camera, + lookTarget: new THREE.Vector3(), + viewport: new THREE.Vector2(800, 600), + }; + const update = () => { + renderer.dispatchEvent({ type: "needs-update" }); + layer.update(frame); + }; + + update(); + + expect(traversalOf(outOfOrder).traversal.unconditionallyRefine).toBe(true); + expect(traversalOf(outOfOrder).traversal.used).toBe(true); + expect(requested).toEqual( + expect.arrayContaining(["root.b3dm", "parent.b3dm", "leaf.b3dm"]) + ); + expect(requested).not.toContain("out-of-order.b3dm"); + + for (const tile of [tileset.root, parent, leaf]) { + traversalOf(tile).internal.loadingState = LOADED; + traversalOf(tile).engineData.scene = new THREE.Group(); + } + update(); + update(); + + expect(requested).not.toContain("out-of-order.b3dm"); + expect(traversalOf(parent).traversal.allChildrenLoaded).toBe(true); + expect(traversalOf(parent).traversal.isLeaf).toBe(false); + expect(traversalOf(parent).traversal.active).toBe(false); + expect(traversalOf(outOfOrder).traversal.active).toBe(false); + expect(traversalOf(outOfOrder).traversal.visible).toBe(false); + expect(traversalOf(leaf).traversal.active).toBe(true); + expect(traversalOf(leaf).traversal.visible).toBe(true); + + layer.dispose(); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/tiles-camera-set.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/tiles-camera-set.spec.ts new file mode 100644 index 0000000000..abd4ea50c6 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/tiles-camera-set.spec.ts @@ -0,0 +1,56 @@ +import { TilesRenderer } from "3d-tiles-renderer"; +import * as THREE from "three"; +import { describe, expect, it, vi } from "vitest"; + +import { + createTilesCameraSet, + resolveTilesViewCamera, +} from "./tiles-camera-set"; + +const buildTiles = () => + ({ + setCamera: vi.fn(), + deleteCamera: vi.fn(), + setResolution: vi.fn(), + } as unknown as TilesRenderer); + +describe("createTilesCameraSet", () => { + it("always selects tiles with the true perspective LOD camera", () => { + const renderCamera = new THREE.PerspectiveCamera(); + const lodCamera = new THREE.PerspectiveCamera(); + + // The MapLibre render camera carries the composite scene-to-clip + // projection; its scaled matrix would inflate the screen-space error. + expect(resolveTilesViewCamera(renderCamera, lodCamera)).toBe(lodCamera); + expect(resolveTilesViewCamera(new THREE.Camera(), lodCamera)).toBe( + lodCamera + ); + }); + + it("keeps tile selection on the viewport camera", () => { + const tiles = buildTiles(); + const viewCamera = new THREE.PerspectiveCamera(); + const cameras = createTilesCameraSet(tiles, viewCamera); + + cameras.update(viewCamera, 800, 600); + + expect(tiles.setCamera).toHaveBeenCalledOnce(); + expect(tiles.setCamera).toHaveBeenCalledWith(viewCamera); + expect(tiles.setResolution).toHaveBeenCalledWith(viewCamera, 800, 600); + cameras.dispose(); + expect(tiles.deleteCamera).toHaveBeenCalledWith(viewCamera); + }); + + it("replaces a changed view camera without synthetic coverage cameras", () => { + const tiles = buildTiles(); + const first = new THREE.PerspectiveCamera(); + const second = new THREE.PerspectiveCamera(); + const cameras = createTilesCameraSet(tiles, first); + + cameras.update(second, 0, 0); + + expect(tiles.deleteCamera).toHaveBeenCalledWith(first); + expect(tiles.setCamera).toHaveBeenCalledWith(second); + expect(tiles.setResolution).toHaveBeenCalledWith(second, 1, 1); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/tiles-camera-set.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/tiles-camera-set.ts new file mode 100644 index 0000000000..769c66db99 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/tiles-camera-set.ts @@ -0,0 +1,48 @@ +import { TilesRenderer } from "3d-tiles-renderer"; +import * as THREE from "three"; + +export type TilesCamera = THREE.PerspectiveCamera | THREE.OrthographicCamera; + +export interface TilesCameraSet { + update: (camera: TilesCamera, width: number, height: number) => void; + dispose: () => void; +} + +/** + * Tile selection always uses the true perspective LOD camera. The MapLibre + * render camera carries the composite scene-to-clip projection, whose scaled + * matrix inflates the screen-space error by the metres per pixel of the map. + */ +export const resolveTilesViewCamera = ( + _renderCamera: THREE.Camera, + lodCamera: THREE.PerspectiveCamera +): TilesCamera => lodCamera; + +export const createTilesCameraSet = ( + tiles: TilesRenderer, + initialCamera: TilesCamera +): TilesCameraSet => { + let activeCamera = initialCamera; + + tiles.setCamera(activeCamera); + + const update = (camera: TilesCamera, width: number, height: number) => { + if (camera !== activeCamera) { + tiles.deleteCamera(activeCamera); + activeCamera = camera; + tiles.setCamera(activeCamera); + } + tiles.setResolution( + activeCamera, + Math.max(1, width), + Math.max(1, height) + ); + }; + + return { + update, + dispose() { + tiles.deleteCamera(activeCamera); + }, + }; +}; diff --git a/libraries/mapping/engines/maplibre/src/lib/style-composition/libre-layer-identity.ts b/libraries/mapping/engines/maplibre/src/lib/style-composition/libre-layer-identity.ts new file mode 100644 index 0000000000..66fc718a36 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/style-composition/libre-layer-identity.ts @@ -0,0 +1,50 @@ +import type { LibreLayer } from "../../components/LibreMap"; +import { slugifyUrl } from "../../utils/styleComposer"; + +export const getLibreLayerCompositionKey = ( + layer: LibreLayer, + index: number +): string => { + switch (layer.type) { + case "vector": + return `vector::${layer.name}::${ + typeof layer.style === "string" ? layer.style : "inline" + }`; + case "geojson": + return `geojson::${layer.name}::${layer.data}`; + case "wms": + case "wmts": + return `${layer.type}::${layer.url}::${layer.layers}::${ + layer.nonTiled ? "nt" : "tiled" + }`; + case "tiles": + return `tiles::${layer.name}::${layer.url}`; + case "cog": + return `cog::${layer.name}::${layer.url}`; + default: + return `unknown::${index}`; + } +}; + +export const getLibreLayerSubStyleId = ( + layer: LibreLayer, + index: number +): string => { + switch (layer.type) { + case "vector": + return typeof layer.style === "string" + ? slugifyUrl(layer.style) + : layer.name; + case "geojson": + return `geojson-${layer.name}-${index}`; + case "wms": + case "wmts": + return `raster-${layer.layers.replace(/[^a-zA-Z0-9]/g, "-")}-${index}`; + case "tiles": + return `tiles-${layer.name.replace(/[^a-zA-Z0-9]/g, "-")}-${index}`; + case "cog": + return `cog-${layer.name}-${index}`; + default: + return `layer-${index}`; + } +}; diff --git a/libraries/mapping/engines/maplibre/src/utils/retryTileProtocol.spec.ts b/libraries/mapping/engines/maplibre/src/utils/retryTileProtocol.spec.ts new file mode 100644 index 0000000000..c8b8d70440 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/utils/retryTileProtocol.spec.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + fetchTileWithRetry, + stripRetryTileProtocol, + withRetryTileProtocol, +} from "./retryTileProtocol"; + +const response = (status: number, body = new ArrayBuffer(4)): Response => + ({ + ok: status >= 200 && status < 300, + status, + headers: new Headers({ "Cache-Control": "max-age=60" }), + arrayBuffer: () => Promise.resolve(body), + } as unknown as Response); + +describe("retry tile protocol", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("wraps and unwraps tile URLs", () => { + const url = "https://tiles.test/{z}/{x}/{y}.png"; + expect(withRetryTileProtocol(url)).toBe(`carma-retry://${url}`); + expect(withRetryTileProtocol(withRetryTileProtocol(url))).toBe( + `carma-retry://${url}` + ); + expect(stripRetryTileProtocol(`carma-retry://${url}`)).toBe(url); + }); + + it("retries a broken transfer and an overloaded host until the tile arrives", async () => { + const body = new ArrayBuffer(8); + const fetchImpl = vi + .fn<[string, RequestInit], Promise>() + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockResolvedValueOnce(response(503)) + .mockResolvedValueOnce(response(200, body)); + + const pending = fetchTileWithRetry( + { url: "carma-retry://https://tiles.test/1/2/3.png" }, + new AbortController(), + fetchImpl as unknown as typeof fetch + ); + await vi.runAllTimersAsync(); + const result = await pending; + + expect(result.data).toBe(body); + expect(result.cacheControl).toBe("max-age=60"); + expect(fetchImpl).toHaveBeenCalledTimes(3); + expect(fetchImpl.mock.calls[0][0]).toBe("https://tiles.test/1/2/3.png"); + }); + + it("gives up at once when the server refuses the tile", async () => { + const fetchImpl = vi.fn().mockResolvedValue(response(404)); + + await expect( + fetchTileWithRetry( + { url: "carma-retry://https://tiles.test/1/2/3.png" }, + new AbortController(), + fetchImpl as unknown as typeof fetch + ) + ).rejects.toMatchObject({ status: 404 }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("stops retrying when MapLibre aborts the request", async () => { + const controller = new AbortController(); + const fetchImpl = vi.fn().mockResolvedValue(response(503)); + + const pending = fetchTileWithRetry( + { url: "carma-retry://https://tiles.test/1/2/3.png" }, + controller, + fetchImpl as unknown as typeof fetch + ); + const settled = pending.catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(10); + controller.abort(new Error("stale")); + await expect(settled).resolves.toMatchObject({ message: "stale" }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/utils/retryTileProtocol.ts b/libraries/mapping/engines/maplibre/src/utils/retryTileProtocol.ts new file mode 100644 index 0000000000..9474fe85cc --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/utils/retryTileProtocol.ts @@ -0,0 +1,110 @@ +import type { AddProtocolAction, RequestParameters } from "maplibre-gl"; + +/** + * MapLibre asks for a tile exactly once. A tile whose transfer breaks or whose + * host answers 5xx is marked errored and stays a hole until the source is + * reloaded. Sources that must not go stale (the terrain DEM) route their tile + * URLs through this protocol, which fetches with backoff until the data + * arrives, the server confirms the tile is unavailable, or MapLibre aborts + * the request. + */ +export const RETRY_TILE_PROTOCOL = "carma-retry"; + +const PROTOCOL_PREFIX = `${RETRY_TILE_PROTOCOL}://`; +const RETRY_BASE_DELAY_MS = 250; +const RETRY_MAX_DELAY_MS = 8_000; +/** Tries per tile, the first one included. */ +const RETRY_MAX_ATTEMPTS = 12; + +export const withRetryTileProtocol = (url: string): string => + url.startsWith(PROTOCOL_PREFIX) ? url : `${PROTOCOL_PREFIX}${url}`; + +export const stripRetryTileProtocol = (url: string): string => + url.startsWith(PROTOCOL_PREFIX) ? url.slice(PROTOCOL_PREFIX.length) : url; + +/** A refusal is the server's answer to this tile; only a broken transfer or an overloaded host is worth another try. */ +const isConfirmedServerError = (status: number): boolean => + status >= 400 && status < 500 && status !== 408 && status !== 429; + +const waitWithSignal = (ms: number, signal: AbortSignal): Promise => + new Promise((resolve, reject) => { + if (signal.aborted) { + reject(signal.reason ?? new Error("aborted")); + return; + } + const onAbort = () => { + clearTimeout(timer); + reject(signal.reason ?? new Error("aborted")); + }; + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal.addEventListener("abort", onAbort, { once: true }); + }); + +const buildStatusError = (url: string, status: number): Error => { + const error = new Error( + `Tile request failed with status ${String(status)}: ${url}` + ) as Error & { status: number }; + error.status = status; + return error; +}; + +export const fetchTileWithRetry = async ( + requestParameters: RequestParameters, + abortController: AbortController, + fetchImpl: typeof fetch = fetch +): Promise<{ data: ArrayBuffer; cacheControl?: string; expires?: string }> => { + const url = stripRetryTileProtocol(requestParameters.url); + const { signal } = abortController; + let lastError: unknown; + for (let attempt = 0; ; attempt += 1) { + if (signal.aborted) throw signal.reason ?? new Error("aborted"); + try { + const response = await fetchImpl(url, { + headers: requestParameters.headers, + credentials: requestParameters.credentials, + signal, + }); + if (response.ok) { + // Read the body inside the attempt: a reset stream fails here, after + // the headers already said 200. + const data = await response.arrayBuffer(); + return { + data, + cacheControl: response.headers.get("Cache-Control") ?? undefined, + expires: response.headers.get("Expires") ?? undefined, + }; + } + if (isConfirmedServerError(response.status)) { + throw buildStatusError(url, response.status); + } + lastError = buildStatusError(url, response.status); + } catch (error) { + if (signal.aborted) throw error; + if ((error as { status?: number }).status !== undefined) { + if (isConfirmedServerError((error as { status: number }).status)) { + throw error; + } + } + lastError = error; + } + if (attempt + 1 >= RETRY_MAX_ATTEMPTS) { + throw lastError instanceof Error + ? lastError + : new Error(`Tile request failed: ${url}`); + } + const backoff = Math.min( + RETRY_MAX_DELAY_MS, + RETRY_BASE_DELAY_MS * 2 ** attempt + ); + // Jittered so tiles that failed together do not return as one burst. + await waitWithSignal(backoff * (0.5 + Math.random()), signal); + } +}; + +export const retryTileProtocol: AddProtocolAction = ( + requestParameters, + abortController +) => fetchTileWithRetry(requestParameters, abortController); diff --git a/libraries/mapping/engines/maplibre/src/utils/styleBuilder.spec.ts b/libraries/mapping/engines/maplibre/src/utils/styleBuilder.spec.ts new file mode 100644 index 0000000000..34dbc87ad5 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/utils/styleBuilder.spec.ts @@ -0,0 +1,92 @@ +// @vitest-environment jsdom + +import type { StyleSpecification } from "maplibre-gl"; +import { describe, expect, it, vi } from "vitest"; + +import { vectorStylesToMapLibreStyle } from "./styleBuilder"; + +vi.hoisted(() => { + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: () => "blob:vitest-maplibre-worker", + }); +}); + +describe("vectorStylesToMapLibreStyle 3D terrain metadata", () => { + it("marks a Mesh-tagged 3D tiles layer as the terrain provider", async () => { + const meshStyle = { + version: 8, + metadata: { + carmaConf: { + layerInfo: { tags: ["Basis", "3D", "Mesh"] }, + }, + }, + sources: {}, + layers: [ + { + id: "mesh-carrier", + type: "background", + metadata: { + carmaConf: { + "3d": { + renderMode: "tiles3d", + tilesetUrl: "https://example.test/mesh/tileset.json", + terrainMandatory: true, + }, + }, + }, + paint: { "background-opacity": 0 }, + }, + ], + } as StyleSpecification; + + const { style } = await vectorStylesToMapLibreStyle({ + layers: [{ type: "vector", name: "mesh2024", style: meshStyle }], + backgroundStyle: { version: 8, sources: {}, layers: [] }, + }); + + expect(style.layers?.[0]?.metadata?.carmaConf?.["3d"]).toEqual( + expect.objectContaining({ + renderMode: "tiles3d", + providesTerrain: true, + }) + ); + }); + + it("does not mark a LoD2 building layer as terrain", async () => { + const lod2Style = { + version: 8, + metadata: { + carmaConf: { + layerInfo: { tags: ["Basis", "Gebäude", "LoD2"] }, + }, + }, + sources: {}, + layers: [ + { + id: "lod2-carrier", + type: "background", + metadata: { + carmaConf: { + "3d": { + renderMode: "tiles3d", + tilesetUrl: "https://example.test/lod2/tileset.json", + terrainMandatory: true, + }, + }, + }, + paint: { "background-opacity": 0 }, + }, + ], + } as StyleSpecification; + + const { style } = await vectorStylesToMapLibreStyle({ + layers: [{ type: "vector", name: "lod2", style: lod2Style }], + backgroundStyle: { version: 8, sources: {}, layers: [] }, + }); + + expect( + style.layers?.[0]?.metadata?.carmaConf?.["3d"]?.providesTerrain + ).toBeUndefined(); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/utils/styleBuilder.ts b/libraries/mapping/engines/maplibre/src/utils/styleBuilder.ts index 6a50e108ad..e4377d3d21 100644 --- a/libraries/mapping/engines/maplibre/src/utils/styleBuilder.ts +++ b/libraries/mapping/engines/maplibre/src/utils/styleBuilder.ts @@ -21,6 +21,10 @@ import { createNonTiledImageSource, createNonTiledMetadata, } from "./nonTiledWms"; +import { + styleProvidesTerrain, + withTerrainProviderMetadata, +} from "./terrainProviderMetadata"; // Inlined from @carma-mapping/layers to avoid circular dependency through portals interface WMSLayerLike { @@ -715,6 +719,7 @@ export const vectorStylesToMapLibreStyle = async ({ if (layer.type === "vector") { const additionalStyle = fetched.data; + const providesTerrain = styleProvidesTerrain(additionalStyle); let capabilitiesLayer = ""; if (layer.layer) { @@ -760,6 +765,11 @@ export const vectorStylesToMapLibreStyle = async ({ .userFilter; additionalStyle.layers = additionalStyle.layers.map( (styleLayer: LayerSpecification) => { + const styleLayerMetadata = ( + styleLayer as LayerSpecification & { + metadata?: Record; + } + ).metadata; const src = (styleLayer as { source?: string }).source; const origFilter = (styleLayer as { filter?: unknown[] }).filter ?? null; @@ -777,11 +787,10 @@ export const vectorStylesToMapLibreStyle = async ({ : {}), ...(userFilter ? { filter: bakedFilter as never } : {}), metadata: { - ...( - styleLayer as LayerSpecification & { - metadata?: Record; - } - ).metadata, + ...withTerrainProviderMetadata( + styleLayerMetadata, + providesTerrain + ), "z-index": index, "layer-id": layerId, // What the layer bar's slider asks of this layer. A 2D layer diff --git a/libraries/mapping/engines/maplibre/src/utils/styleComposer.ts b/libraries/mapping/engines/maplibre/src/utils/styleComposer.ts index 0ca6dd5ec0..8d1942da09 100644 --- a/libraries/mapping/engines/maplibre/src/utils/styleComposer.ts +++ b/libraries/mapping/engines/maplibre/src/utils/styleComposer.ts @@ -38,6 +38,10 @@ import { createNonTiledImageSource, createNonTiledMetadata, } from "./nonTiledWms"; +import { + styleProvidesTerrain, + withTerrainProviderMetadata, +} from "./terrainProviderMetadata"; /** * Slugify a URL into a compact ID: strips protocol and .json extension, @@ -340,6 +344,7 @@ export class StyleComposer { // 4. Add layers const remoteLayers = (styleJson.layers || []) as LayerSpecification[]; + const providesTerrain = styleProvidesTerrain(styleJson); const opacity = opts.opacity ?? 1; const markerSymbolSize = opts.markerSymbolSize ?? 35; @@ -373,8 +378,13 @@ export class StyleComposer { // Set metadata for HidingForwardingManager and z-ordering. // "layer-id" is the slugified style URL; "carma-layer-id" is the catalog // id, which is what the layer apis (hasLayer/addLayer) speak. + const styleLayerMetadata = layer.metadata; + // Preserve the terrain-provider marker in imperative composition just + // like styleBuilder does in merged mode. The runtime uses this marker to + // keep MapLibre terrain available for draping while making its base + // surfaces transparent underneath a photogrammetric terrain mesh. layer.metadata = { - ...(layer.metadata || {}), + ...withTerrainProviderMetadata(styleLayerMetadata, providesTerrain), "z-index": opts.zIndex, "layer-id": layerId, ...(vectorLayer.carmaLayerId diff --git a/libraries/mapping/engines/maplibre/src/utils/terrainProviderMetadata.spec.ts b/libraries/mapping/engines/maplibre/src/utils/terrainProviderMetadata.spec.ts new file mode 100644 index 0000000000..81ea3636dd --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/utils/terrainProviderMetadata.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { + styleProvidesTerrain, + withTerrainProviderMetadata, +} from "./terrainProviderMetadata"; + +describe("terrain provider metadata", () => { + it("recognizes a Mesh tag independent of case", () => { + expect( + styleProvidesTerrain({ + metadata: { + carmaConf: { layerInfo: { tags: ["Basis", "mEsH"] } }, + }, + }) + ).toBe(true); + }); + + it("marks only tiles3d carriers of terrain-providing styles", () => { + const carrier = { + carmaConf: { + "3d": { + renderMode: "tiles3d", + tilesetUrl: "https://tiles.example.test/tileset.json", + }, + }, + }; + + expect(withTerrainProviderMetadata(carrier, true)).toMatchObject({ + carmaConf: { + "3d": { + renderMode: "tiles3d", + providesTerrain: true, + }, + }, + }); + expect(withTerrainProviderMetadata(carrier, false)).not.toMatchObject({ + carmaConf: { "3d": { providesTerrain: true } }, + }); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/utils/terrainProviderMetadata.ts b/libraries/mapping/engines/maplibre/src/utils/terrainProviderMetadata.ts new file mode 100644 index 0000000000..6296d0c1e2 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/utils/terrainProviderMetadata.ts @@ -0,0 +1,44 @@ +type StyleLike = { + metadata?: { + carmaConf?: { + layerInfo?: { + tags?: unknown; + }; + }; + }; +}; + +export const styleProvidesTerrain = (style: StyleLike): boolean => { + const tags = style.metadata?.carmaConf?.layerInfo?.tags; + return ( + Array.isArray(tags) && + tags.some( + (tag: unknown) => typeof tag === "string" && tag.toLowerCase() === "mesh" + ) + ); +}; + +export const withTerrainProviderMetadata = ( + metadata: Record | undefined, + providesTerrain: boolean +): Record => { + const carmaConf = metadata?.carmaConf as Record | undefined; + const tiles3dConfig = carmaConf?.["3d"] as + | Record + | undefined; + + if (!providesTerrain || tiles3dConfig?.renderMode !== "tiles3d") { + return { ...metadata }; + } + + return { + ...metadata, + carmaConf: { + ...carmaConf, + "3d": { + ...tiles3dConfig, + providesTerrain: true, + }, + }, + }; +}; diff --git a/libraries/mapping/engines/three/primitives/src/lib/common/terrain-tile-geometry.spec.ts b/libraries/mapping/engines/three/primitives/src/lib/common/terrain-tile-geometry.spec.ts new file mode 100644 index 0000000000..8c0c697966 --- /dev/null +++ b/libraries/mapping/engines/three/primitives/src/lib/common/terrain-tile-geometry.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import { createProjectedTerrainTileGeometry } from "./terrain-tile-geometry"; + +const tile = { + bounds: { west: 0, south: 0, east: 1, north: 1 }, + u: [0, 0, 1, 1], + v: [0, 1, 0, 1], + heightMeters: [10, 10, 10, 10], + indices: [0, 3, 1, 0, 2, 3], +}; + +describe("createProjectedTerrainTileGeometry", () => { + it("projects the native TIN with upward-facing normals", () => { + const geometry = createProjectedTerrainTileGeometry({ + tile, + projectToWorld: (longitude, latitude, height, target) => + target.set(longitude, height, -latitude), + }); + + expect([...(geometry.getIndex()?.array ?? [])]).toEqual([0, 3, 1, 0, 2, 3]); + expect(geometry.getAttribute("position").count).toBe(4); + expect(geometry.getAttribute("uv")).toBeUndefined(); + expect(geometry.getAttribute("normal").getY(0)).toBeCloseTo(1); + }); + + it("does not create skirt geometry from Cesium edge metadata", () => { + const geometry = createProjectedTerrainTileGeometry({ + tile: { + ...tile, + westIndices: [0, 1], + westSkirtHeight: 4, + }, + projectToWorld: (longitude, latitude, height, target) => + target.set(longitude, height, -latitude), + }); + + expect(geometry.getAttribute("position").count).toBe(4); + expect(geometry.getIndex()?.count).toBe(6); + }); + + it("drops zero-area faces and corrects downward winding", () => { + const geometry = createProjectedTerrainTileGeometry({ + tile: { + ...tile, + // First triangle is clockwise in projected X/Z. The second has the + // same horizontal vertex three times and must not reach the GPU. + indices: [0, 1, 3, 0, 0, 0], + }, + projectToWorld: (longitude, latitude, height, target) => + target.set(longitude, height, -latitude), + }); + + expect([...(geometry.getIndex()?.array ?? [])]).toEqual([0, 3, 1]); + for (const index of [0, 1, 3]) { + expect(geometry.getAttribute("normal").getY(index)).toBeGreaterThan(0); + } + }); + + it("rejects out-of-range indices before creating GPU buffers", () => { + expect(() => + createProjectedTerrainTileGeometry({ + tile: { ...tile, indices: [0, 1, 4] }, + projectToWorld: (longitude, latitude, height, target) => + target.set(longitude, height, -latitude), + }) + ).toThrow("outside the vertex array"); + }); +}); diff --git a/libraries/mapping/engines/three/primitives/src/lib/common/terrain-tile-geometry.ts b/libraries/mapping/engines/three/primitives/src/lib/common/terrain-tile-geometry.ts new file mode 100644 index 0000000000..c7df1fc1b8 --- /dev/null +++ b/libraries/mapping/engines/three/primitives/src/lib/common/terrain-tile-geometry.ts @@ -0,0 +1,144 @@ +import { BufferGeometry, Float32BufferAttribute, Vector3 } from "three"; + +export type ProjectedTerrainTileBounds = Readonly<{ + west: number; + south: number; + east: number; + north: number; +}>; + +export type ProjectedTerrainTileSource = Readonly<{ + bounds: ProjectedTerrainTileBounds; + u: ArrayLike; + v: ArrayLike; + heightMeters: ArrayLike; + indices: ArrayLike; +}>; + +export type TerrainTileProjector = ( + longitudeDegrees: number, + latitudeDegrees: number, + heightMeters: number, + target: Vector3 +) => Vector3; + +export type ProjectedTerrainTileGeometryOptions = Readonly<{ + tile: ProjectedTerrainTileSource; + projectToWorld: TerrainTileProjector; +}>; + +const assertTile = ({ tile }: ProjectedTerrainTileGeometryOptions) => { + const vertexCount = tile.u.length; + if ( + vertexCount === 0 || + tile.v.length !== vertexCount || + tile.heightMeters.length !== vertexCount + ) { + throw new RangeError("Terrain tile vertex arrays have different sizes"); + } + if (tile.indices.length % 3 !== 0) { + throw new RangeError("Terrain tile indices must describe triangles"); + } + if ( + ![ + tile.bounds.west, + tile.bounds.south, + tile.bounds.east, + tile.bounds.north, + ].every(Number.isFinite) || + tile.bounds.west >= tile.bounds.east || + tile.bounds.south >= tile.bounds.north + ) { + throw new RangeError("Terrain tile bounds are invalid"); + } + for (let index = 0; index < vertexCount; index += 1) { + if ( + !Number.isFinite(tile.u[index]) || + !Number.isFinite(tile.v[index]) || + !Number.isFinite(tile.heightMeters[index]) + ) { + throw new TypeError("Terrain tile vertices must be finite"); + } + } + for (const index of Array.from(tile.indices)) { + if (!Number.isInteger(index) || index < 0 || index >= vertexCount) { + throw new RangeError("Terrain tile index is outside the vertex array"); + } + } +}; + +const MIN_HORIZONTAL_DOUBLE_AREA_SQUARE_METERS = 1e-10; + +const buildUpwardTriangleIndices = ( + positions: Float32Array, + sourceIndices: ArrayLike +) => { + const indices: number[] = []; + for (let offset = 0; offset < sourceIndices.length; offset += 3) { + const a = sourceIndices[offset]; + const b = sourceIndices[offset + 1]; + const c = sourceIndices[offset + 2]; + const ax = positions[a * 3]; + const az = positions[a * 3 + 2]; + const bx = positions[b * 3]; + const bz = positions[b * 3 + 2]; + const cx = positions[c * 3]; + const cz = positions[c * 3 + 2]; + const normalY = (bz - az) * (cx - ax) - (bx - ax) * (cz - az); + if (Math.abs(normalY) <= MIN_HORIZONTAL_DOUBLE_AREA_SQUARE_METERS) { + continue; + } + if (normalY > 0) { + indices.push(a, b, c); + } else { + indices.push(a, c, b); + } + } + if (indices.length === 0) { + throw new RangeError("Terrain tile does not contain a renderable triangle"); + } + return indices; +}; + +/** Projects one native quantized-mesh TIN tile without synthetic skirts. */ +export const createProjectedTerrainTileGeometry = ( + options: ProjectedTerrainTileGeometryOptions +): BufferGeometry => { + assertTile(options); + const { tile, projectToWorld } = options; + const positions = new Float32Array(tile.u.length * 3); + const projected = new Vector3(); + + const projectVertex = (sourceIndex: number) => { + const longitude = + tile.bounds.west + + tile.u[sourceIndex] * (tile.bounds.east - tile.bounds.west); + const latitude = + tile.bounds.south + + tile.v[sourceIndex] * (tile.bounds.north - tile.bounds.south); + projectToWorld( + longitude, + latitude, + tile.heightMeters[sourceIndex], + projected + ); + if (![projected.x, projected.y, projected.z].every(Number.isFinite)) { + throw new TypeError("Terrain projector returned a non-finite vertex"); + } + positions[sourceIndex * 3] = projected.x; + positions[sourceIndex * 3 + 1] = projected.y; + positions[sourceIndex * 3 + 2] = projected.z; + }; + + for (let index = 0; index < tile.u.length; index += 1) { + projectVertex(index); + } + + const geometry = new BufferGeometry(); + geometry.setAttribute("position", new Float32BufferAttribute(positions, 3)); + geometry.setIndex(buildUpwardTriangleIndices(positions, tile.indices)); + geometry.computeVertexNormals(); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + return geometry; +}; diff --git a/libraries/mapping/engines/three/primitives/src/lib/index.ts b/libraries/mapping/engines/three/primitives/src/lib/index.ts index 3ac902e406..0777d8c911 100644 --- a/libraries/mapping/engines/three/primitives/src/lib/index.ts +++ b/libraries/mapping/engines/three/primitives/src/lib/index.ts @@ -5,3 +5,10 @@ export type { UtmGridSurface, UtmGridSurfaceOptions, } from "./common/utm-grid-surface"; +export { createProjectedTerrainTileGeometry } from "./common/terrain-tile-geometry"; +export type { + ProjectedTerrainTileBounds, + ProjectedTerrainTileGeometryOptions, + ProjectedTerrainTileSource, + TerrainTileProjector, +} from "./common/terrain-tile-geometry"; diff --git a/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/create-view-state-visualizer.ts b/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/create-view-state-visualizer.ts index e6ced6ec6d..47bfff437e 100644 --- a/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/create-view-state-visualizer.ts +++ b/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/create-view-state-visualizer.ts @@ -49,6 +49,7 @@ import { createAngleCues } from "./parts/cues/angle-cues"; import { createMaxPitchRing } from "./parts/cues/max-pitch-ring"; import { createHemisphereSurface } from "./parts/sphere/hemisphere-surface"; import { createWorldAxes } from "./parts/world-axes/world-axes"; +import { createVolumeBoxes } from "./parts/volume-boxes/volume-boxes"; import { createPointToCanvasProjector, projectOrthogonalLineLabelAnchor, @@ -76,6 +77,7 @@ import type { ViewStateVisualizerOverviewOptions, ViewStateVisualizerOptions, ViewStateVisualizerPrimitive, + ViewStateVisualizerVolumeBoxesOptions, ViewStateVisualizerVisualizedOptions, ViewStateVisualizerSize, } from "./view-state-visualizer-types"; @@ -375,6 +377,7 @@ export const createViewStateVisualizerPrimitive = ( currentOverview.orbitPhi ?? GEOMETRY.overviewCamera.orbitPhiRad; let orbitTheta = currentOverview.orbitTheta ?? GEOMETRY.overviewCamera.rotationAroundUpRad; + let orbitScale = 1; // --- Cameras --- // Keep sphere visually constant: distance adjusts with FOV so projected size stays the same. @@ -382,7 +385,8 @@ export const createViewStateVisualizerPrimitive = ( const baseTangentProduct = resolveDefaultFrameHalfExtent(); let currentFovDeg = initialFovDeg; const getOrbitRadius = () => - baseTangentProduct / Math.tan(degToRadNumeric(currentFovDeg)! * 0.5); + (baseTangentProduct * orbitScale) / + Math.tan(degToRadNumeric(currentFovDeg)! * 0.5); const perspectiveCamera = createOverviewPerspectiveCamera(initialFovDeg); @@ -405,6 +409,24 @@ export const createViewStateVisualizerPrimitive = ( const getActiveCamera = (): Camera => useOrthographic ? orthographicCamera : perspectiveCamera; + const syncOrthographicProjection = ( + overview: ResolvedViewStateVisualizerOverviewOptions + ) => { + const aspect = size.widthPx / size.heightPx; + const scaledHalfExtent = baseTangentProduct * orbitScale; + const halfWidth = overview.fitOrthographicWidth + ? scaledHalfExtent + : scaledHalfExtent * aspect; + const halfHeight = overview.fitOrthographicWidth + ? scaledHalfExtent / aspect + : scaledHalfExtent; + orthographicCamera.left = -halfWidth; + orthographicCamera.right = halfWidth; + orthographicCamera.top = halfHeight; + orthographicCamera.bottom = -halfHeight; + orthographicCamera.updateProjectionMatrix(); + }; + const orbitPositionScratch = new Vector3(); const writeOrbitPosition = ({ @@ -558,6 +580,20 @@ export const createViewStateVisualizerPrimitive = ( }, opacity: MATERIALS.axes.opacity, }); + const volumeBoxes = createVolumeBoxes(scene); + let currentVolumeBoxes: ViewStateVisualizerVolumeBoxesOptions = + options.volumeBoxes ?? { boxes: [] }; + const applyVolumeBoxes = () => { + volumeBoxes.update(currentVolumeBoxes.boxes); + volumeBoxes.setDisplay({ + visible: + (currentVolumeBoxes.visible ?? true) && + currentVolumeBoxes.boxes.length > 0, + color: currentVolumeBoxes.color ?? "#0f766e", + opacity: currentVolumeBoxes.opacity ?? 0.55, + }); + }; + applyVolumeBoxes(); const cameraViews: CameraViewPartInstance[] = []; @@ -618,6 +654,7 @@ export const createViewStateVisualizerPrimitive = ( worldAxes.setDisplay({ visible: visibility.showWorldAxes, + showUp: display.worldAxes.showUp, lineWidthPx: lineWidths.worldAxesLineWidthPx, cueColors: { east: cueColors.east, @@ -691,12 +728,7 @@ export const createViewStateVisualizerPrimitive = ( currentFovDeg = clampedFovDeg; perspectiveCamera.fov = clampedFovDeg; perspectiveCamera.updateProjectionMatrix(); - const aspect = size.widthPx / size.heightPx; - orthographicCamera.left = -baseTangentProduct * aspect; - orthographicCamera.right = baseTangentProduct * aspect; - orthographicCamera.top = baseTangentProduct; - orthographicCamera.bottom = -baseTangentProduct; - orthographicCamera.updateProjectionMatrix(); + syncOrthographicProjection(overview); syncCamerasToOrbit(); }; @@ -1111,8 +1143,6 @@ export const createViewStateVisualizerPrimitive = ( const ndcY = 1 - ((e.clientY - rect.top) / rect.height) * 2; raycaster.setFromCamera(new Vector2(ndcX, ndcY), getActiveCamera()); - const sphereHits = raycaster.intersectObject(hemisphereSurface.mesh, false); - const dragTarget = cameraViews .flatMap((cameraView, cameraIndex) => { const dragTargetMesh = cameraView.readDragTargetMesh(); @@ -1162,7 +1192,7 @@ export const createViewStateVisualizerPrimitive = ( ); return; } - if (sphereHits.length > 0 && interactive) { + if (interactive) { beginOrbitDrag(e.pointerId, e.clientX, e.clientY); return; } @@ -1192,10 +1222,21 @@ export const createViewStateVisualizerPrimitive = ( canvas.style.cursor = POINTER_CURSOR.IDLE; }; + const onWheel = (event: WheelEvent) => { + if (!interactive) return; + event.preventDefault(); + orbitScale = clamp(orbitScale * Math.exp(event.deltaY * 0.001), 0.25, 6); + syncOrthographicProjection(currentOverview); + syncCamerasToOrbit(); + const anchors = update(lastViewStates); + options.onInteraction?.(anchors); + }; + canvas.addEventListener("pointerdown", onPointerDown); canvas.addEventListener("pointermove", onPointerMove); canvas.addEventListener("pointerup", onPointerUp); canvas.addEventListener("pointercancel", onPointerUp); + canvas.addEventListener("wheel", onWheel, { passive: false }); canvas.style.cursor = POINTER_CURSOR.IDLE; currentLabelAnchors = update(viewState); @@ -1212,12 +1253,7 @@ export const createViewStateVisualizerPrimitive = ( maxPitchRing.resize(size); altitude.resize(size); cameraViews.forEach((cameraView) => cameraView.resize(size)); - const aspect = size.widthPx / size.heightPx; - orthographicCamera.left = -baseTangentProduct * aspect; - orthographicCamera.right = baseTangentProduct * aspect; - orthographicCamera.top = baseTangentProduct; - orthographicCamera.bottom = -baseTangentProduct; - orthographicCamera.updateProjectionMatrix(); + syncOrthographicProjection(currentOverview); orthographicCamera.updateMatrixWorld(); currentLabelAnchors = update(lastViewStates); @@ -1288,6 +1324,15 @@ export const createViewStateVisualizerPrimitive = ( } }; + const setVolumeBoxes = ( + nextVolumeBoxes: ViewStateVisualizerVolumeBoxesOptions + ) => { + currentVolumeBoxes = nextVolumeBoxes; + applyVolumeBoxes(); + currentLabelAnchors = update(lastViewStates); + return currentLabelAnchors; + }; + // --- Dispose --- const dispose = () => { if (isCameraPoseDragging) { @@ -1302,6 +1347,7 @@ export const createViewStateVisualizerPrimitive = ( canvas.removeEventListener("pointermove", onPointerMove); canvas.removeEventListener("pointerup", onPointerUp); canvas.removeEventListener("pointercancel", onPointerUp); + canvas.removeEventListener("wheel", onWheel); renderer.dispose(); hemisphereSurface.dispose(); @@ -1309,6 +1355,7 @@ export const createViewStateVisualizerPrimitive = ( altitude.dispose(); angleCues.dispose(); worldAxes.dispose(); + volumeBoxes.dispose(); cameraViews.forEach((cameraView) => cameraView.dispose()); }; @@ -1319,6 +1366,7 @@ export const createViewStateVisualizerPrimitive = ( setOverview, setVisualized, setDisplay, + setVolumeBoxes, setInteractive, readLabelAnchors: () => currentLabelAnchors, dispose, diff --git a/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/derived/camera-view-geometry.spec.ts b/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/derived/camera-view-geometry.spec.ts index f6d1002339..0caf5eae1e 100644 --- a/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/derived/camera-view-geometry.spec.ts +++ b/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/derived/camera-view-geometry.spec.ts @@ -1,11 +1,15 @@ -import { Vector3 } from "three"; +import { OrthographicCamera, Quaternion, type Matrix4, Vector3 } from "three"; import { describe, expect, it } from "vitest"; import { buildOrthographicScale, CAMERA_TYPE, } from "@carma-commons/camera/model"; -import { buildViewState } from "@carma-mapping/engines-interop/view-state"; +import { + buildViewState, + buildViewStateFromEcef, + deriveOrbitAngles, +} from "@carma-mapping/engines-interop/view-state"; import type { ViewState } from "@carma-mapping/engines-interop/view-state"; import { degToRadNumeric } from "@carma-units"; @@ -44,7 +48,10 @@ const rotateAroundUp = (point: Vector3, angleRad: number): Vector3 => point.clone().applyAxisAngle(WORLD_UP, angleRad); const expectVectorClose = (actual: Vector3, expected: Vector3) => { - expect(actual.distanceTo(expected)).toBeLessThan(1e-8); + expect( + actual.distanceTo(expected), + `${actual.toArray().join(",")} != ${expected.toArray().join(",")}` + ).toBeLessThan(1e-8); }; const buildOptionalFrustum = ({ @@ -106,6 +113,7 @@ const buildOrthographicViewState = ({ orthographicMetersPerCssPixel, viewportWidthPx, viewportHeightPx, + projectionMatrix, }: { bearingDeg: number; pitchDeg: number; @@ -115,6 +123,7 @@ const buildOrthographicViewState = ({ orthographicMetersPerCssPixel?: number; viewportWidthPx?: number; viewportHeightPx?: number; + projectionMatrix?: Matrix4; }): ViewState => buildViewState({ longitude: SPEC_LONGITUDE_RAD, @@ -125,6 +134,7 @@ const buildOrthographicViewState = ({ range: rangeM, intrinsics: { type: CAMERA_TYPE.ORTHOGRAPHIC, + projectionMatrix, ...(typeof orthographicMetersPerCssPixel === "number" ? { orthographicScale: buildOrthographicScale( @@ -151,12 +161,23 @@ const buildOrthographicViewState = ({ }, }); -const buildGeometry = (viewState: ViewState) => +const buildGeometry = ( + viewState: ViewState, + { + useCameraPosition = false, + worldScaleMeters = null, + }: { + useCameraPosition?: boolean; + worldScaleMeters?: number | null; + } = {} +) => buildImagePlaneGeometry({ viewState, visualized: { maxPitch: null, imagePlaneDistance: null, + useCameraPosition, + worldScaleMeters, }, hemisphereRadius: SPEC_HEMISPHERE_RADIUS, imagePlaneDefaults: SPEC_GEOMETRY_DEFAULTS, @@ -167,6 +188,60 @@ const buildProjectionPolygon = (viewState: ViewState) => buildGeometry(viewState).projectionPlanePolygon; describe("camera-view-geometry ground projection", () => { + it("uses the stored camera position independently of its orientation", () => { + const base = buildPerspectiveViewState({ bearingDeg: 0, pitchDeg: 0 }); + const { longitude, latitude } = base.anchorCartographic; + const east = new Vector3(-Math.sin(longitude), Math.cos(longitude), 0); + const north = new Vector3( + -Math.sin(latitude) * Math.cos(longitude), + -Math.sin(latitude) * Math.sin(longitude), + Math.cos(latitude) + ); + const localUp = new Vector3( + Math.cos(latitude) * Math.cos(longitude), + Math.cos(latitude) * Math.sin(longitude), + Math.sin(latitude) + ); + const cameraPosition = base.anchor + .clone() + .addScaledVector(east, 3) + .addScaledVector(north, 4) + .addScaledVector(localUp, 12); + const viewState = buildViewStateFromEcef({ + anchor: base.anchor, + cameraPosition, + orientation: new Quaternion(), + intrinsics: base.intrinsics, + metadata: base.metadata, + }); + + const geometry = buildGeometry(viewState, { useCameraPosition: true }); + expectVectorClose( + geometry.cameraPosition, + new Vector3(3, 12, -4).normalize() + ); + }); + + it("uses one explicit world scale for camera positions and metric frusta", () => { + const viewState = buildPerspectiveViewState({ + bearingDeg: 0, + pitchDeg: 0, + rangeM: 100, + nearM: 1, + farM: 60, + }); + const geometry = buildGeometry(viewState, { + useCameraPosition: true, + worldScaleMeters: 200, + }); + + expect(geometry.cameraPosition.length()).toBeCloseTo(0.5, 6); + geometry.frustumEdges.forEach((edge) => { + expect(edge).not.toBeNull(); + expect(edge![1]!.distanceTo(geometry.cameraPosition)).toBeLessThan(1); + }); + }); + it("derives the visible camera form in a local bearing-zero frame before rotating it back out", () => { const baseGeometry = buildGeometry( buildPerspectiveViewState({ @@ -174,34 +249,33 @@ describe("camera-view-geometry ground projection", () => { pitchDeg: 63, }) ); - const bearingRad = degToRadNumeric(62); - const rotatedGeometry = buildGeometry( - buildPerspectiveViewState({ - bearingDeg: 62, - pitchDeg: 63, - }) - ); + const rotatedViewState = buildPerspectiveViewState({ + bearingDeg: 62, + pitchDeg: 63, + }); + const bearingRad = deriveOrbitAngles(rotatedViewState).bearing; + const rotatedGeometry = buildGeometry(rotatedViewState); expectVectorClose( - rotateAroundUp(rotatedGeometry.cameraPosition, -bearingRad), + rotateAroundUp(rotatedGeometry.cameraPosition, bearingRad), baseGeometry.cameraPosition ); expectVectorClose( - rotateAroundUp(rotatedGeometry.forward, -bearingRad), + rotateAroundUp(rotatedGeometry.forward, bearingRad), baseGeometry.forward ); expectVectorClose( - rotateAroundUp(rotatedGeometry.right, -bearingRad), + rotateAroundUp(rotatedGeometry.right, bearingRad), baseGeometry.right ); expectVectorClose( - rotateAroundUp(rotatedGeometry.up, -bearingRad), + rotateAroundUp(rotatedGeometry.up, bearingRad), baseGeometry.up ); baseGeometry.imagePlaneCorners.forEach((corner, index) => { expectVectorClose( - rotateAroundUp(rotatedGeometry.imagePlaneCorners[index]!, -bearingRad), + rotateAroundUp(rotatedGeometry.imagePlaneCorners[index]!, bearingRad), corner ); }); @@ -298,6 +372,29 @@ describe("camera-view-geometry ground projection", () => { expect(tangentPlaneHeight).toBeCloseTo(0.4, 8); }); + it("preserves rectangular off-center bounds from an orthographic projection matrix", () => { + const camera = new OrthographicCamera(-80, 120, 60, -40, 1, 4_000); + camera.updateProjectionMatrix(); + const geometry = buildGeometry( + buildOrthographicViewState({ + bearingDeg: 0, + pitchDeg: 0, + rangeM: 2_500, + projectionMatrix: camera.projectionMatrix.clone(), + }) + ); + + const corners = geometry.orthographicTangentPlaneCorners!; + expect(corners[0]!.distanceTo(corners[1]!)).toBeCloseTo(200 / 2_500, 8); + expect(corners[1]!.distanceTo(corners[2]!)).toBeCloseTo(100 / 2_500, 8); + const center = corners + .reduce((sum, corner) => sum.add(corner), new Vector3()) + .multiplyScalar(0.25); + const centerOffset = center.sub(geometry.cameraPosition); + expect(centerOffset.dot(geometry.right)).toBeCloseTo(20 / 2_500, 8); + expect(centerOffset.dot(geometry.up)).toBeCloseTo(10 / 2_500, 8); + }); + it("normalizes orthographic near and far distances from meters into hemisphere units", () => { const polygonShortRange = buildProjectionPolygon( buildOrthographicViewState({ @@ -334,7 +431,7 @@ describe("camera-view-geometry ground projection", () => { pitchDeg: 63, rangeM: 500, nearM: 50, - farM: 250, + farM: 1_000, }) ); const polygonLongRange = buildProjectionPolygon( @@ -343,7 +440,7 @@ describe("camera-view-geometry ground projection", () => { pitchDeg: 63, rangeM: 1000, nearM: 100, - farM: 500, + farM: 2_000, }) ); diff --git a/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/derived/camera-view-geometry.ts b/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/derived/camera-view-geometry.ts index 0d133e347c..ca303e77f3 100644 --- a/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/derived/camera-view-geometry.ts +++ b/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/derived/camera-view-geometry.ts @@ -6,10 +6,6 @@ import { readMetersPerCssPixelFromIntrinsics, readLocalCameraBasis, } from "@carma-commons/camera/model"; -import { - deriveOrbitAngles, - type ViewState, -} from "@carma-mapping/engines-interop/view-state"; import { clamp, intersectRayWithPlane, @@ -18,6 +14,10 @@ import { PI_OVER_THREE, PI_OVER_TWO, } from "@carma-commons/math"; +import { + deriveOrbitAngles, + type ViewState, +} from "@carma-mapping/engines-interop/view-state"; import { zeroToTwoPi } from "@carma-units"; import type { Radians } from "@carma-units"; @@ -299,6 +299,71 @@ const readOrthographicFallbackHalfExtents = ({ }; }; +const readOrthographicBoundsFromProjectionMatrix = ({ + viewState, + rangeMeters, + hemisphereRadius, + epsilon, +}: { + viewState: ViewState; + rangeMeters: number; + hemisphereRadius: number; + epsilon: number; +}): { + left: number; + right: number; + bottom: number; + top: number; +} | null => { + const projectionMatrix = viewState.intrinsics?.projectionMatrix; + if ( + !projectionMatrix || + !projectionMatrix.elements.every(isFiniteNumber) || + !isFiniteNumber(rangeMeters) || + rangeMeters <= epsilon + ) { + return null; + } + + const horizontalScale = projectionMatrix.elements[0]; + const verticalScale = projectionMatrix.elements[5]; + const horizontalOffset = projectionMatrix.elements[12]; + const verticalOffset = projectionMatrix.elements[13]; + if ( + !isFiniteNumber(horizontalScale) || + Math.abs(horizontalScale) <= Number.EPSILON || + !isFiniteNumber(verticalScale) || + Math.abs(verticalScale) <= Number.EPSILON || + !isFiniteNumber(horizontalOffset) || + !isFiniteNumber(verticalOffset) + ) { + return null; + } + + // Orthographic X/Y projection is independent of near/far. Decode its two + // NDC endpoints directly; a determinant threshold would reject perfectly + // valid large-area shadow cameras because their depth scale is very small. + const horizontalEndpoints = [ + (-1 - horizontalOffset) / horizontalScale, + (1 - horizontalOffset) / horizontalScale, + ]; + const verticalEndpoints = [ + (-1 - verticalOffset) / verticalScale, + (1 - verticalOffset) / verticalScale, + ]; + const normalize = (value: number) => (value / rangeMeters) * hemisphereRadius; + const left = normalize(Math.min(...horizontalEndpoints)); + const right = normalize(Math.max(...horizontalEndpoints)); + const bottom = normalize(Math.min(...verticalEndpoints)); + const top = normalize(Math.max(...verticalEndpoints)); + + return [left, right, bottom, top].every(isFiniteNumber) && + right - left > epsilon && + top - bottom > epsilon + ? { left, right, bottom, top } + : null; +}; + const intersectForwardRayWithGroundPlane = ({ origin, direction, @@ -376,11 +441,15 @@ const intersectForwardRayWithSphereBoundary = ({ const resolveFrustumEdgeEndpoint = ({ origin, direction, + forward, + far, hemisphereRadius, epsilon, }: { origin: Vector3; direction: Vector3; + forward?: Vector3; + far?: number; hemisphereRadius: number; epsilon: number; }): Vector3 | null => { @@ -399,8 +468,25 @@ const resolveFrustumEdgeEndpoint = ({ radius: hemisphereRadius, epsilon, }); + const farIntersection = (() => { + if (!forward || !isFiniteNumber(far) || far <= epsilon) return null; + const resolvedForward = forward.clone().normalize(); + const resolvedDirection = direction.clone().normalize(); + const intersection = intersectRayWithPlane( + new Ray(origin.clone(), resolvedDirection), + new Plane().setFromNormalAndCoplanarPoint( + resolvedForward, + origin.clone().addScaledVector(resolvedForward, far) + ), + epsilon + ); + return intersection && + intersection.clone().sub(origin).dot(resolvedDirection) >= -epsilon + ? intersection + : null; + })(); - const candidates = [groundIntersection, sphereIntersection] + const candidates = [groundIntersection, sphereIntersection, farIntersection] .filter((point): point is Vector3 => point !== null) .map((point) => ({ point, @@ -462,16 +548,53 @@ export const computeUnitHemisphereCameraPosition = ({ const resolveCameraBasis = ({ viewState, hemisphereRadius, + useCameraPosition, + worldScaleMeters, }: { viewState: ViewState; hemisphereRadius: number; + useCameraPosition: boolean; + worldScaleMeters: number | null; }) => { - const { bearing, pitch } = deriveOrbitAngles(viewState); - const cameraPosition = viewingBearingPitchToCameraSpherePosition({ - viewingBearing: bearing, - pitch, - hemisphereRadius, - }); + const { bearing, pitch, range } = deriveOrbitAngles(viewState); + const exactCameraPosition = (() => { + if (!useCameraPosition) return null; + const { longitude, latitude } = viewState.anchorCartographic; + const delta = viewState.cameraPosition.clone().sub(viewState.anchor); + const sinLongitude = Math.sin(longitude); + const cosLongitude = Math.cos(longitude); + const sinLatitude = Math.sin(latitude); + const cosLatitude = Math.cos(latitude); + const east = new Vector3(-sinLongitude, cosLongitude, 0); + const north = new Vector3( + -sinLatitude * cosLongitude, + -sinLatitude * sinLongitude, + cosLatitude + ); + const localUp = new Vector3( + cosLatitude * cosLongitude, + cosLatitude * sinLongitude, + sinLatitude + ); + return new Vector3(delta.dot(east), delta.dot(localUp), -delta.dot(north)); + })(); + const cameraPosition = + exactCameraPosition && exactCameraPosition.lengthSq() > Number.EPSILON + ? isFiniteNumber(worldScaleMeters) && worldScaleMeters > Number.EPSILON + ? exactCameraPosition.multiplyScalar( + hemisphereRadius / worldScaleMeters + ) + : exactCameraPosition.normalize().multiplyScalar(hemisphereRadius) + : viewingBearingPitchToCameraSpherePosition({ + viewingBearing: bearing, + pitch, + hemisphereRadius: + isFiniteNumber(worldScaleMeters) && + worldScaleMeters > Number.EPSILON && + isFiniteNumber(range) + ? (range / worldScaleMeters) * hemisphereRadius + : hemisphereRadius, + }); const { forward, right, up } = readLocalCameraBasis(viewState.orientation); return { @@ -614,6 +737,7 @@ const buildImagePlaneGeometryInResolvedFrame = ({ const fovVertical = readVerticalFov(viewState); const fovHorizontal = readHorizontalFov(viewState); const { range } = deriveOrbitAngles(viewState); + const normalizationRangeMeters = visualized.worldScaleMeters ?? range; const imagePlaneDistance = readImagePlaneDistance({ viewState, visualized, @@ -651,20 +775,31 @@ const buildImagePlaneGeometryInResolvedFrame = ({ ? Math.abs(projectionMatrix.elements[5]) : null; const aspect = readAspectRatio(viewState); - const orthographicHalfExtents = + const orthographicProjectionBounds = type === CAMERA_TYPE.ORTHOGRAPHIC - ? readOrthographicHalfExtentsFromScale({ + ? readOrthographicBoundsFromProjectionMatrix({ viewState, - rangeMeters: range, - hemisphereRadius, - epsilon, - }) ?? - readOrthographicFallbackHalfExtents({ - aspect, + rangeMeters: normalizationRangeMeters, hemisphereRadius, epsilon, }) : null; + const orthographicHalfExtents = + type === CAMERA_TYPE.ORTHOGRAPHIC + ? orthographicProjectionBounds + ? null + : readOrthographicHalfExtentsFromScale({ + viewState, + rangeMeters: normalizationRangeMeters, + hemisphereRadius, + epsilon, + }) ?? + readOrthographicFallbackHalfExtents({ + aspect, + hemisphereRadius, + epsilon, + }) + : null; const croppedHalfHeight = orthographicHalfExtents ? orthographicHalfExtents.halfHeight @@ -782,24 +917,37 @@ const buildImagePlaneGeometryInResolvedFrame = ({ .multiplyScalar(offsetPlaneHeightRatio) ) : null; - const frustumBackCorners: [Vector3, Vector3, Vector3, Vector3] = [ - cameraPosition - .clone() - .add(right.clone().multiplyScalar(fullHalfWidth)) - .add(up.clone().multiplyScalar(fullHalfHeight)), - cameraPosition - .clone() - .add(right.clone().multiplyScalar(-fullHalfWidth)) - .add(up.clone().multiplyScalar(fullHalfHeight)), + const buildOrthographicCorner = (horizontal: number, vertical: number) => cameraPosition .clone() - .add(right.clone().multiplyScalar(-fullHalfWidth)) - .add(up.clone().multiplyScalar(-fullHalfHeight)), - cameraPosition - .clone() - .add(right.clone().multiplyScalar(fullHalfWidth)) - .add(up.clone().multiplyScalar(-fullHalfHeight)), - ]; + .add(right.clone().multiplyScalar(horizontal)) + .add(up.clone().multiplyScalar(vertical)); + const frustumBackCorners: [Vector3, Vector3, Vector3, Vector3] = + orthographicProjectionBounds + ? [ + buildOrthographicCorner( + orthographicProjectionBounds.right, + orthographicProjectionBounds.top + ), + buildOrthographicCorner( + orthographicProjectionBounds.left, + orthographicProjectionBounds.top + ), + buildOrthographicCorner( + orthographicProjectionBounds.left, + orthographicProjectionBounds.bottom + ), + buildOrthographicCorner( + orthographicProjectionBounds.right, + orthographicProjectionBounds.bottom + ), + ] + : [ + buildOrthographicCorner(fullHalfWidth, fullHalfHeight), + buildOrthographicCorner(-fullHalfWidth, fullHalfHeight), + buildOrthographicCorner(-fullHalfWidth, -fullHalfHeight), + buildOrthographicCorner(fullHalfWidth, -fullHalfHeight), + ]; const perspectiveImagePlaneCorners: [Vector3, Vector3, Vector3, Vector3] = [ perspectiveTopRight, perspectiveTopLeft, @@ -818,11 +966,17 @@ const buildImagePlaneGeometryInResolvedFrame = ({ : perspectiveImagePlaneCorners; const imagePlaneCenterResolved = type === CAMERA_TYPE.ORTHOGRAPHIC - ? cameraPosition.clone() + ? orthographicProjectionBounds + ? buildOrthographicCorner( + (orthographicProjectionBounds.left + + orthographicProjectionBounds.right) / + 2, + (orthographicProjectionBounds.bottom + + orthographicProjectionBounds.top) / + 2 + ) + : cameraPosition.clone() : perspectiveImagePlaneCenter; - const imagePlaneWidthVector = imagePlaneCorners[0] - .clone() - .sub(imagePlaneCorners[1]); const imagePlaneHeightVector = imagePlaneCorners[2] .clone() .sub(imagePlaneCorners[1]); @@ -863,7 +1017,7 @@ const buildImagePlaneGeometryInResolvedFrame = ({ viewState.intrinsics?.frustum?.near, epsilon ), - rangeMeters: range, + rangeMeters: normalizationRangeMeters, hemisphereRadius, epsilon, }), @@ -872,7 +1026,7 @@ const buildImagePlaneGeometryInResolvedFrame = ({ viewState.intrinsics?.frustum?.far, epsilon ), - rangeMeters: range, + rangeMeters: normalizationRangeMeters, hemisphereRadius, epsilon, }), @@ -885,7 +1039,7 @@ const buildImagePlaneGeometryInResolvedFrame = ({ viewState.intrinsics?.frustum?.near, epsilon ), - rangeMeters: range, + rangeMeters: normalizationRangeMeters, hemisphereRadius, epsilon, }), @@ -897,7 +1051,7 @@ const buildImagePlaneGeometryInResolvedFrame = ({ viewState.intrinsics?.frustum?.far, epsilon ), - rangeMeters: range, + rangeMeters: normalizationRangeMeters, hemisphereRadius, epsilon, }), @@ -938,6 +1092,8 @@ const buildImagePlaneGeometryInResolvedFrame = ({ const endpoint = resolveFrustumEdgeEndpoint({ origin: corner, direction: forward, + forward, + far: orthographicFar, hemisphereRadius, epsilon, }); @@ -948,6 +1104,16 @@ const buildImagePlaneGeometryInResolvedFrame = ({ const endpoint = resolveFrustumEdgeEndpoint({ origin: cameraPosition, direction: corner.clone().sub(cameraPosition), + forward, + far: normalizeFrustumDistanceToHemisphere({ + distanceMeters: readPositiveFrustumDistance( + viewState.intrinsics?.frustum?.far, + epsilon + ), + rangeMeters: normalizationRangeMeters, + hemisphereRadius, + epsilon, + }), hemisphereRadius, epsilon, }); @@ -1028,6 +1194,8 @@ export const buildImagePlaneGeometry = ({ const { bearing, cameraPosition, forward, right, up } = resolveCameraBasis({ viewState, hemisphereRadius, + useCameraPosition: visualized.useCameraPosition, + worldScaleMeters: visualized.worldScaleMeters, }); const inverseBearingRotation = CAMERA_GEOMETRY_SCRATCH.inverseBearingRotation.setFromAxisAngle( diff --git a/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/parts/volume-boxes/volume-boxes.spec.ts b/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/parts/volume-boxes/volume-boxes.spec.ts new file mode 100644 index 0000000000..c9e9da5daa --- /dev/null +++ b/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/parts/volume-boxes/volume-boxes.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { + buildVolumeBoxLineColors, + buildVolumeBoxLinePositions, +} from "./volume-boxes"; + +describe("buildVolumeBoxLinePositions", () => { + it("builds twelve hairline edges for every volume", () => { + const positions = buildVolumeBoxLinePositions([ + { + minimum: [1, 2, 3], + maximum: [4, 5, 6], + }, + ]); + + expect(positions).toHaveLength(12 * 2 * 3); + expect([...positions.slice(0, 6)]).toEqual([1, 2, 3, 4, 2, 3]); + expect([...positions.slice(-6)]).toEqual([4, 5, 3, 4, 5, 6]); + }); + + it("keeps per-volume colors while applying a fallback", () => { + const colors = buildVolumeBoxLineColors( + [ + { + minimum: [0, 0, 0], + maximum: [1, 1, 1], + color: "#ff0000", + }, + { minimum: [1, 1, 1], maximum: [2, 2, 2] }, + ], + "#0000ff" + ); + const colorValuesPerBox = 12 * 2 * 3; + + expect([...colors.slice(0, 3)]).toEqual([1, 0, 0]); + expect([...colors.slice(colorValuesPerBox, colorValuesPerBox + 3)]).toEqual( + [0, 0, 1] + ); + }); +}); diff --git a/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/parts/volume-boxes/volume-boxes.ts b/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/parts/volume-boxes/volume-boxes.ts new file mode 100644 index 0000000000..ce8bd309fe --- /dev/null +++ b/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/parts/volume-boxes/volume-boxes.ts @@ -0,0 +1,135 @@ +import { + BufferGeometry, + Color, + Float32BufferAttribute, + LineBasicMaterial, + LineSegments, + type Scene, +} from "three"; + +import { + createThreePart, + disposeThreePartResources, + removeThreePartObjects, +} from "../../../../common/create-part"; +import type { ViewStateVisualizerVolumeBox } from "../../view-state-visualizer-types"; + +const BOX_EDGES = [ + [0, 1], + [1, 3], + [3, 2], + [2, 0], + [4, 5], + [5, 7], + [7, 6], + [6, 4], + [0, 4], + [1, 5], + [2, 6], + [3, 7], +] as const; + +export const buildVolumeBoxLinePositions = ( + boxes: readonly ViewStateVisualizerVolumeBox[] +): Float32Array => { + const positions = new Float32Array(boxes.length * BOX_EDGES.length * 2 * 3); + let offset = 0; + for (const { minimum, maximum } of boxes) { + const corners = [ + [minimum[0], minimum[1], minimum[2]], + [maximum[0], minimum[1], minimum[2]], + [minimum[0], maximum[1], minimum[2]], + [maximum[0], maximum[1], minimum[2]], + [minimum[0], minimum[1], maximum[2]], + [maximum[0], minimum[1], maximum[2]], + [minimum[0], maximum[1], maximum[2]], + [maximum[0], maximum[1], maximum[2]], + ] as const; + for (const [startIndex, endIndex] of BOX_EDGES) { + positions.set(corners[startIndex], offset); + offset += 3; + positions.set(corners[endIndex], offset); + offset += 3; + } + } + return positions; +}; + +export const buildVolumeBoxLineColors = ( + boxes: readonly ViewStateVisualizerVolumeBox[], + fallbackColor: string +): Float32Array => { + const colors = new Float32Array(boxes.length * BOX_EDGES.length * 2 * 3); + const color = new Color(); + let offset = 0; + for (const box of boxes) { + color.set(box.color ?? fallbackColor); + for (let vertex = 0; vertex < BOX_EDGES.length * 2; vertex += 1) { + colors[offset++] = color.r; + colors[offset++] = color.g; + colors[offset++] = color.b; + } + } + return colors; +}; + +export type VolumeBoxesDisplay = Readonly<{ + visible: boolean; + color: string; + opacity: number; +}>; + +export const createVolumeBoxes = (scene: Scene) => { + const geometry = new BufferGeometry(); + const material = new LineBasicMaterial({ + color: "#ffffff", + vertexColors: true, + transparent: true, + opacity: 0.55, + depthWrite: false, + toneMapped: false, + }); + const lines = new LineSegments(geometry, material); + lines.frustumCulled = false; + lines.renderOrder = 3; + scene.add(lines); + + let boxes: readonly ViewStateVisualizerVolumeBox[] = []; + let fallbackColor = "#0f766e"; + const updateGeometry = () => { + geometry.setAttribute( + "position", + new Float32BufferAttribute(buildVolumeBoxLinePositions(boxes), 3) + ); + geometry.setAttribute( + "color", + new Float32BufferAttribute( + buildVolumeBoxLineColors(boxes, fallbackColor), + 3 + ) + ); + geometry.computeBoundingSphere(); + }; + + return createThreePart< + readonly ViewStateVisualizerVolumeBox[], + VolumeBoxesDisplay + >({ + update: (nextBoxes) => { + boxes = nextBoxes; + updateGeometry(); + }, + setDisplay: ({ visible, color, opacity }) => { + lines.visible = visible; + if (fallbackColor !== color) { + fallbackColor = color; + updateGeometry(); + } + material.opacity = opacity; + }, + dispose: () => { + removeThreePartObjects([lines]); + disposeThreePartResources([geometry, material]); + }, + }); +}; diff --git a/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/parts/world-axes/world-axes.ts b/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/parts/world-axes/world-axes.ts index 8bd077d233..4d9e2e7cfa 100644 --- a/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/parts/world-axes/world-axes.ts +++ b/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/parts/world-axes/world-axes.ts @@ -32,6 +32,7 @@ export type WorldAxisGeometry = { export type WorldAxisDisplay = { visible: boolean; + showUp: boolean; lineWidthPx: number; cueColors: WorldAxisColors; }; @@ -91,7 +92,10 @@ export const createWorldAxes = ( }, setDisplay: (display) => { WORLD_AXIS_KEY_LIST.forEach((key) => { - wideLines.setVisible(key, display.visible); + wideLines.setVisible( + key, + display.visible && (key !== WORLD_AXIS_KEYS.UP || display.showUp) + ); wideLines.setWidth(key, display.lineWidthPx); }); wideLines.setColor(WORLD_AXIS_KEYS.EAST, display.cueColors.east); diff --git a/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/view-state-visualizer-defaults.ts b/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/view-state-visualizer-defaults.ts index b9aac58824..abdbd3050c 100644 --- a/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/view-state-visualizer-defaults.ts +++ b/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/view-state-visualizer-defaults.ts @@ -29,12 +29,15 @@ export const DEFAULT_VIEW_STATE_VISUALIZER_CUE_COLORS = Object.freeze({ export const DEFAULT_VIEW_STATE_VISUALIZER_OVERVIEW_OPTIONS = Object.freeze({ fovDeg: 38, orthographic: false, + fitOrthographicWidth: false, }) satisfies Readonly; export const DEFAULT_VIEW_STATE_VISUALIZER_INTERACTIVE = false; export const DEFAULT_VIEW_STATE_VISUALIZER_VISUALIZED_OPTIONS = Object.freeze({ maxPitch: null, imagePlaneDistance: null, + useCameraPosition: false, + worldScaleMeters: null, }) satisfies Readonly; export const DEFAULT_VIEW_STATE_VISUALIZER_DISPLAY_OPTIONS = Object.freeze({ @@ -47,6 +50,7 @@ export const DEFAULT_VIEW_STATE_VISUALIZER_DISPLAY_OPTIONS = Object.freeze({ }), worldAxes: Object.freeze({ show: true, + showUp: true, lineWidthPx: 0.5, }), angleCues: Object.freeze({ @@ -142,6 +146,15 @@ export const mergeViewStateVisualizerVisualizedOptions = ( Number.isFinite(merged.imagePlaneDistance) ? merged.imagePlaneDistance : DEFAULT_VIEW_STATE_VISUALIZER_VISUALIZED_OPTIONS.imagePlaneDistance, + useCameraPosition: + merged.useCameraPosition ?? + DEFAULT_VIEW_STATE_VISUALIZER_VISUALIZED_OPTIONS.useCameraPosition, + worldScaleMeters: + typeof merged.worldScaleMeters === "number" && + Number.isFinite(merged.worldScaleMeters) && + merged.worldScaleMeters > 0 + ? merged.worldScaleMeters + : DEFAULT_VIEW_STATE_VISUALIZER_VISUALIZED_OPTIONS.worldScaleMeters, }; }; diff --git a/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/view-state-visualizer-types.ts b/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/view-state-visualizer-types.ts index 2eadd862dc..1e2f7d1ab3 100644 --- a/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/view-state-visualizer-types.ts +++ b/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/view-state-visualizer-types.ts @@ -13,6 +13,19 @@ export type ViewStateVisualizerCameraModel = export type ViewStateVisualizerInput = ViewState | readonly ViewState[]; +export type ViewStateVisualizerVolumeBox = Readonly<{ + minimum: readonly [number, number, number]; + maximum: readonly [number, number, number]; + color?: string; +}>; + +export type ViewStateVisualizerVolumeBoxesOptions = Readonly<{ + boxes: readonly ViewStateVisualizerVolumeBox[]; + visible?: boolean; + color?: string; + opacity?: number; +}>; + export type ViewStateVisualizerCueKey = | "bearing" | "pitch" @@ -36,11 +49,17 @@ export type ViewStateVisualizerOverviewOptions = { fovDeg?: number; /** Use orthographic projection for the overview camera. */ orthographic?: boolean; + /** Keep the horizontal orthographic extent fixed instead of the vertical extent. */ + fitOrthographicWidth?: boolean; }; export type ViewStateVisualizerVisualizedOptions = { maxPitch?: Radians; imagePlaneDistance?: number; + /** Place the marker from the stored ECEF camera position, not orbit angles. */ + useCameraPosition?: boolean; + /** Shared number of world meters represented by one visualizer unit. */ + worldScaleMeters?: number; }; export type ViewStateVisualizerSurfaceDisplayOptions = { @@ -62,6 +81,7 @@ export type ViewStateVisualizerSurfaceDisplayOptions = { export type ViewStateVisualizerAxisDisplayOptions = { show?: boolean; + showUp?: boolean; lineWidthPx?: number; }; @@ -135,11 +155,14 @@ export type ResolvedViewStateVisualizerOverviewOptions = { orbitPhi?: number; fovDeg: number; orthographic: boolean; + fitOrthographicWidth: boolean; }; export type ResolvedViewStateVisualizerVisualizedOptions = { maxPitch: Radians | null; imagePlaneDistance: number | null; + useCameraPosition: boolean; + worldScaleMeters: number | null; }; export type ResolvedViewStateVisualizerSurfaceDisplayOptions = { @@ -152,6 +175,7 @@ export type ResolvedViewStateVisualizerSurfaceDisplayOptions = { export type ResolvedViewStateVisualizerAxisDisplayOptions = { show: boolean; + showUp: boolean; lineWidthPx: number; }; @@ -246,6 +270,7 @@ export type ViewStateVisualizerOptions = { interactive?: boolean; visualized?: ViewStateVisualizerVisualizedOptions; display?: ViewStateVisualizerDisplayOptions; + volumeBoxes?: ViewStateVisualizerVolumeBoxesOptions; activeCameraIndex?: number; onInteraction?: (labelAnchors: ViewStateVisualizerLabelAnchors) => void; /** Called when the user drags the camera cube to change bearing/pitch (radians). */ @@ -288,6 +313,9 @@ export type ViewStateVisualizerPrimitive = { setDisplay: ( options: ViewStateVisualizerDisplayOptions ) => ViewStateVisualizerLabelAnchors | null; + setVolumeBoxes: ( + options: ViewStateVisualizerVolumeBoxesOptions + ) => ViewStateVisualizerLabelAnchors | null; setInteractive: (interactive: boolean) => void; readLabelAnchors: () => ViewStateVisualizerLabelAnchors | null; dispose: () => void; diff --git a/libraries/mapping/engines/three/primitives/vite.config.ts b/libraries/mapping/engines/three/primitives/vite.config.ts index c45ac75365..8916341c8e 100644 --- a/libraries/mapping/engines/three/primitives/vite.config.ts +++ b/libraries/mapping/engines/three/primitives/vite.config.ts @@ -33,4 +33,11 @@ export default defineConfig({ external: ["three"], }, }, + test: { + globals: true, + environment: "jsdom", + setupFiles: ["./vitest.setup.ts"], + include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + reporters: ["default"], + }, }); diff --git a/libraries/mapping/engines/three/primitives/vitest.setup.ts b/libraries/mapping/engines/three/primitives/vitest.setup.ts new file mode 100644 index 0000000000..b798e35dc6 --- /dev/null +++ b/libraries/mapping/engines/three/primitives/vitest.setup.ts @@ -0,0 +1,4 @@ +if (typeof window !== "undefined") { + window.URL.createObjectURL ??= () => ""; + window.URL.revokeObjectURL ??= () => undefined; +} diff --git a/libraries/mapping/engines/threejs/src/factories/ExtrusionFactory.spec.ts b/libraries/mapping/engines/threejs/src/factories/ExtrusionFactory.spec.ts new file mode 100644 index 0000000000..c37781d255 --- /dev/null +++ b/libraries/mapping/engines/threejs/src/factories/ExtrusionFactory.spec.ts @@ -0,0 +1,109 @@ +import { MercatorCoordinate } from "maplibre-gl"; +import * as THREE from "three"; +import { describe, expect, it } from "vitest"; + +import { buildExtrusionMeshes } from "./ExtrusionFactory"; + +const counterClockwiseRing = [ + [7.2, 51.2], + [7.2002, 51.2], + [7.2002, 51.2002], + [7.2, 51.2002], +]; + +const getFaceNormal = ( + geometry: THREE.BufferGeometry, + faceIndex: number +): THREE.Vector3 => { + const positions = geometry.getAttribute("position"); + const indices = geometry.getIndex(); + if (!indices) throw new Error("Expected indexed extrusion geometry"); + const offset = faceIndex * 3; + const a = new THREE.Vector3().fromBufferAttribute( + positions, + indices.getX(offset) + ); + const b = new THREE.Vector3().fromBufferAttribute( + positions, + indices.getX(offset + 1) + ); + const c = new THREE.Vector3().fromBufferAttribute( + positions, + indices.getX(offset + 2) + ); + return b.sub(a).cross(c.sub(a)).normalize(); +}; + +describe("buildExtrusionMeshes", () => { + it.each([ + ["counter-clockwise", counterClockwiseRing], + ["clockwise", [...counterClockwiseRing].reverse()], + ])("builds outward, single-sided faces from a %s ring", (_label, ring) => { + const scene = new THREE.Scene(); + const origin = MercatorCoordinate.fromLngLat([7.2, 51.2], 0); + buildExtrusionMeshes( + [ + { + ring, + height: 12, + elevation: 150, + isPublic: false, + sourceIndex: 0, + }, + ], + scene, + origin, + origin.meterInMercatorCoordinateUnits() + ); + + const wall = scene.children.find( + (child) => (child as THREE.Mesh).userData.isBuildingWall + ) as THREE.Mesh; + const roof = scene.children.find( + (child) => + (child as THREE.Mesh).userData.isBuilding && + !(child as THREE.Mesh).userData.isBuildingWall + ) as THREE.Mesh; + + expect((wall.material as THREE.Material).side).toBe(THREE.FrontSide); + expect((roof.material as THREE.Material).side).toBe(THREE.FrontSide); + + const roofIndex = roof.geometry.getIndex(); + expect(roofIndex).not.toBeNull(); + const capFaceCount = (roofIndex?.count ?? 0) / 3; + expect(capFaceCount).toBe(4); + for (let face = 0; face < capFaceCount / 2; face += 1) { + expect(getFaceNormal(roof.geometry, face).y).toBeGreaterThan(0.999); + } + for (let face = capFaceCount / 2; face < capFaceCount; face += 1) { + expect(getFaceNormal(roof.geometry, face).y).toBeLessThan(-0.999); + } + const roofNormals = roof.geometry.getAttribute("normal"); + expect(roofNormals.getY(0)).toBe(1); + expect(roofNormals.getY(roofNormals.count - 1)).toBe(-1); + + const wallPositions = wall.geometry.getAttribute("position"); + const center = new THREE.Vector3(); + for (let index = 0; index < wallPositions.count; index += 1) { + center.add(new THREE.Vector3().fromBufferAttribute(wallPositions, index)); + } + center.divideScalar(wallPositions.count); + const wallIndex = wall.geometry.getIndex(); + expect(wallIndex).not.toBeNull(); + for (let edge = 0; edge < wallPositions.count / 4; edge += 1) { + const edgeBase = edge * 4; + const edgeMidpoint = new THREE.Vector3() + .fromBufferAttribute(wallPositions, edgeBase) + .add( + new THREE.Vector3().fromBufferAttribute(wallPositions, edgeBase + 1) + ) + .multiplyScalar(0.5); + const outward = edgeMidpoint.sub(center).setY(0).normalize(); + for (const face of [edge * 2, edge * 2 + 1]) { + expect(getFaceNormal(wall.geometry, face).dot(outward)).toBeGreaterThan( + 0.999 + ); + } + } + }); +}); diff --git a/libraries/mapping/engines/threejs/src/factories/ExtrusionFactory.ts b/libraries/mapping/engines/threejs/src/factories/ExtrusionFactory.ts index a6fdb530bd..c7643a1eeb 100644 --- a/libraries/mapping/engines/threejs/src/factories/ExtrusionFactory.ts +++ b/libraries/mapping/engines/threejs/src/factories/ExtrusionFactory.ts @@ -41,7 +41,7 @@ export interface VertexRange { /** Group vertex ranges by sourceIndex for O(1) highlight lookup. */ export function buildSourceIndexMap( - ranges: VertexRange[], + ranges: VertexRange[] ): Map { const map = new Map(); for (const r of ranges) { @@ -71,6 +71,19 @@ const WALL_DARKEN = 0.85; const COLOR_DEFAULT_WALL = COLOR_DEFAULT.clone().multiplyScalar(WALL_DARKEN); const COLOR_PUBLIC_WALL = COLOR_PUBLIC.clone().multiplyScalar(WALL_DARKEN); +/** GeoJSON exterior rings are counter-clockwise in longitude/latitude. The + * MapLibre-local X/Z plane flips latitude to south, making that orientation + * clockwise there: roof faces point up and wall faces point out. */ +const orientExteriorRing = (ring: number[][]): number[][] => { + let signedAreaTwice = 0; + for (let index = 0; index < ring.length; index += 1) { + const current = ring[index]; + const next = ring[(index + 1) % ring.length]; + signedAreaTwice += current[0] * next[1] - next[0] * current[1]; + } + return signedAreaTwice < 0 ? [...ring].reverse() : ring; +}; + /** * What a colour resolver is actually allowed to look at. * @@ -143,7 +156,9 @@ const HEX = /^#?(?:[0-9a-f]{3}|[0-9a-f]{6})$/i; */ const colorCache = new Map(); -const parseHexColor = (value: string | null | undefined): THREE.Color | null => { +const parseHexColor = ( + value: string | null | undefined +): THREE.Color | null => { if (!value) { return null; } @@ -154,9 +169,7 @@ const parseHexColor = (value: string | null | undefined): THREE.Color | null => const trimmed = value.trim(); let parsed: THREE.Color | null = null; if (HEX.test(trimmed)) { - parsed = new THREE.Color( - trimmed.startsWith("#") ? trimmed : `#${trimmed}` - ); + parsed = new THREE.Color(trimmed.startsWith("#") ? trimmed : `#${trimmed}`); } else { console.warn( `[3D-BUILDINGS] not a hex colour, falling back: ${JSON.stringify(value)}` @@ -264,7 +277,6 @@ export const wallRunsForRing = ( return walls; }; - /** * The material both building meshes are made of. * @@ -279,7 +291,7 @@ export function createBuildingMaterial(): THREE.MeshLambertMaterial { transparent: true, opacity: DEFAULT_BUILDING_OPACITY, depthWrite: true, - side: THREE.DoubleSide, + side: THREE.FrontSide, }); } @@ -291,7 +303,7 @@ export function removeBuildingMeshes(scene: THREE.Scene): void { const toRemove = scene.children.filter( (c): c is THREE.Mesh => (c as THREE.Mesh).isMesh === true && - (c as THREE.Mesh).userData.isBuilding === true, + (c as THREE.Mesh).userData.isBuilding === true ); for (const m of toRemove) { m.geometry.dispose(); @@ -317,7 +329,7 @@ export function buildExtrusionMeshes( originMerc: MercatorCoordinate, mScale: number, colors: BuildingColors = defaultBuildingColors, - wallAngleThreshold: number = DEFAULT_WALL_ANGLE, + wallAngleThreshold: number = DEFAULT_WALL_ANGLE ): FactoryStats { removeBuildingMeshes(scene); @@ -333,6 +345,7 @@ export function buildExtrusionMeshes( ring = ring.slice(0, -1); } if (ring.length < 3) continue; + ring = orientExteriorRing(ring); validFeatures.push({ f, ring }); } @@ -351,9 +364,9 @@ export function buildExtrusionMeshes( // Walls: 4 verts per edge, 6 indices per edge totalWallVerts += n * 4; totalWallIdx += n * 6; - // Roof: n perimeter verts, earcut produces at most (n-2) triangles - totalRoofVerts += n; - totalRoofIdx += (n - 2) * 3; + // Top and bottom caps: n perimeter verts and at most (n-2) triangles each + totalRoofVerts += n * 2; + totalRoofIdx += (n - 2) * 6; } // Allocate typed arrays @@ -406,19 +419,29 @@ export function buildExtrusionMeshes( // Pre-compute scene-space positions for each vertex at ground and roof height const roofBaseIdx = rv; const flatXZ: number[] = []; // flat [x, z, x, z, ...] for 2D triangulation + const groundPositions: number[] = []; for (let i = 0; i < n; i++) { const [lng, lat] = ring[i]; - const mH = MercatorCoordinate.fromLngLat([lng, lat], f.elevation + f.height); + const mH = MercatorCoordinate.fromLngLat( + [lng, lat], + f.elevation + f.height + ); const rx = (mH.x - originMerc.x) / mScale; const ry = (mH.z - originMerc.z) / mScale; const rz = (mH.y - originMerc.y) / mScale; // Roof vertex let v3 = rv * 3; - rP[v3] = rx; rP[v3 + 1] = ry; rP[v3 + 2] = rz; - rN[v3] = 0; rN[v3 + 1] = 1; rN[v3 + 2] = 0; - rC[v3] = cr; rC[v3 + 1] = cg; rC[v3 + 2] = cb; + rP[v3] = rx; + rP[v3 + 1] = ry; + rP[v3 + 2] = rz; + rN[v3] = 0; + rN[v3 + 1] = 1; + rN[v3 + 2] = 0; + rC[v3] = cr; + rC[v3 + 1] = cg; + rC[v3 + 2] = cb; rv++; // earcut uses 2D coords: we project onto XZ plane @@ -437,11 +460,15 @@ export function buildExtrusionMeshes( const m0 = MercatorCoordinate.fromLngLat([lng, lat], f.elevation); const m1 = MercatorCoordinate.fromLngLat([lng1, lat1], f.elevation); - const mH1 = MercatorCoordinate.fromLngLat([lng1, lat1], f.elevation + f.height); + const mH1 = MercatorCoordinate.fromLngLat( + [lng1, lat1], + f.elevation + f.height + ); const ax = (m0.x - originMerc.x) / mScale; const ay = (m0.z - originMerc.z) / mScale; const az = (m0.y - originMerc.y) / mScale; + groundPositions.push(ax, ay, az); const bx = (m1.x - originMerc.x) / mScale; const by = (m1.z - originMerc.z) / mScale; @@ -466,27 +493,51 @@ export function buildExtrusionMeshes( const wallBase = wv; v3 = wv * 3; - wP[v3] = ax; wP[v3 + 1] = ay; wP[v3 + 2] = az; - wN[v3] = nx; wN[v3 + 1] = 0; wN[v3 + 2] = nz; - wC[v3] = wcr; wC[v3 + 1] = wcg; wC[v3 + 2] = wcb; + wP[v3] = ax; + wP[v3 + 1] = ay; + wP[v3 + 2] = az; + wN[v3] = nx; + wN[v3 + 1] = 0; + wN[v3 + 2] = nz; + wC[v3] = wcr; + wC[v3 + 1] = wcg; + wC[v3 + 2] = wcb; wv++; v3 = wv * 3; - wP[v3] = bx; wP[v3 + 1] = by; wP[v3 + 2] = bz; - wN[v3] = nx; wN[v3 + 1] = 0; wN[v3 + 2] = nz; - wC[v3] = wcr; wC[v3 + 1] = wcg; wC[v3 + 2] = wcb; + wP[v3] = bx; + wP[v3 + 1] = by; + wP[v3 + 2] = bz; + wN[v3] = nx; + wN[v3 + 1] = 0; + wN[v3 + 2] = nz; + wC[v3] = wcr; + wC[v3 + 1] = wcg; + wC[v3 + 2] = wcb; wv++; v3 = wv * 3; - wP[v3] = dx; wP[v3 + 1] = dy; wP[v3 + 2] = dz; - wN[v3] = nx; wN[v3 + 1] = 0; wN[v3 + 2] = nz; - wC[v3] = wcr; wC[v3 + 1] = wcg; wC[v3 + 2] = wcb; + wP[v3] = dx; + wP[v3 + 1] = dy; + wP[v3 + 2] = dz; + wN[v3] = nx; + wN[v3 + 1] = 0; + wN[v3 + 2] = nz; + wC[v3] = wcr; + wC[v3 + 1] = wcg; + wC[v3 + 2] = wcb; wv++; v3 = wv * 3; - wP[v3] = ex; wP[v3 + 1] = ey; wP[v3 + 2] = ez; - wN[v3] = nx; wN[v3 + 1] = 0; wN[v3 + 2] = nz; - wC[v3] = wcr; wC[v3 + 1] = wcg; wC[v3 + 2] = wcb; + wP[v3] = ex; + wP[v3 + 1] = ey; + wP[v3 + 2] = ez; + wN[v3] = nx; + wN[v3 + 1] = 0; + wN[v3 + 2] = nz; + wC[v3] = wcr; + wC[v3 + 1] = wcg; + wC[v3 + 2] = wcb; wv++; // Wall indices: A-B-E, A-E-D @@ -498,33 +549,91 @@ export function buildExtrusionMeshes( wI[wi++] = wallBase + 2; } - // Roof triangulation via earcut (handles concave polygons, winding-insensitive) + const bottomBaseIdx = rv; + for (let i = 0; i < n; i++) { + const source = i * 3; + const target = rv * 3; + rP[target] = groundPositions[source]; + rP[target + 1] = groundPositions[source + 1]; + rP[target + 2] = groundPositions[source + 2]; + rN[target] = 0; + rN[target + 1] = -1; + rN[target + 2] = 0; + rC[target] = cr; + rC[target + 1] = cg; + rC[target + 2] = cb; + rv++; + } + + // Cap triangulation via earcut (handles concave polygons). In Three's + // X/Z plane Earcut's winding faces down, so only the roof is reversed. const roofIndices = Earcut.triangulate(flatXZ, undefined, 2); - for (const idx of roofIndices) { - rI[ri++] = roofBaseIdx + idx; + for (let index = 0; index < roofIndices.length; index += 3) { + rI[ri++] = roofBaseIdx + roofIndices[index]; + rI[ri++] = roofBaseIdx + roofIndices[index + 2]; + rI[ri++] = roofBaseIdx + roofIndices[index + 1]; + } + for (let index = 0; index < roofIndices.length; index += 3) { + rI[ri++] = bottomBaseIdx + roofIndices[index]; + rI[ri++] = bottomBaseIdx + roofIndices[index + 1]; + rI[ri++] = bottomBaseIdx + roofIndices[index + 2]; } // Record face/vertex ranges for this building (selection metadata) const wallFaceEnd = wi / 3; - wallFaceRanges.push({ faceStart: wallFaceStart, faceEnd: wallFaceEnd, sourceIndex: f.sourceIndex }); - wallVertexRanges.push({ vertexStart: wallVertStart, vertexEnd: wv, sourceIndex: f.sourceIndex }); + wallFaceRanges.push({ + faceStart: wallFaceStart, + faceEnd: wallFaceEnd, + sourceIndex: f.sourceIndex, + }); + wallVertexRanges.push({ + vertexStart: wallVertStart, + vertexEnd: wv, + sourceIndex: f.sourceIndex, + }); const roofFaceEnd = ri / 3; - roofFaceRanges.push({ faceStart: roofFaceStart, faceEnd: roofFaceEnd, sourceIndex: f.sourceIndex }); - roofVertexRanges.push({ vertexStart: roofVertStart, vertexEnd: rv, sourceIndex: f.sourceIndex }); + roofFaceRanges.push({ + faceStart: roofFaceStart, + faceEnd: roofFaceEnd, + sourceIndex: f.sourceIndex, + }); + roofVertexRanges.push({ + vertexStart: roofVertStart, + vertexEnd: rv, + sourceIndex: f.sourceIndex, + }); } // Build BufferGeometry objects const wallGeo = new THREE.BufferGeometry(); - wallGeo.setAttribute("position", new THREE.BufferAttribute(wP.subarray(0, wv * 3), 3)); - wallGeo.setAttribute("normal", new THREE.BufferAttribute(wN.subarray(0, wv * 3), 3)); - wallGeo.setAttribute("color", new THREE.BufferAttribute(wC.subarray(0, wv * 3), 3)); + wallGeo.setAttribute( + "position", + new THREE.BufferAttribute(wP.subarray(0, wv * 3), 3) + ); + wallGeo.setAttribute( + "normal", + new THREE.BufferAttribute(wN.subarray(0, wv * 3), 3) + ); + wallGeo.setAttribute( + "color", + new THREE.BufferAttribute(wC.subarray(0, wv * 3), 3) + ); wallGeo.setIndex(new THREE.BufferAttribute(wI.subarray(0, wi), 1)); const roofGeo = new THREE.BufferGeometry(); - roofGeo.setAttribute("position", new THREE.BufferAttribute(rP.subarray(0, rv * 3), 3)); - roofGeo.setAttribute("normal", new THREE.BufferAttribute(rN.subarray(0, rv * 3), 3)); - roofGeo.setAttribute("color", new THREE.BufferAttribute(rC.subarray(0, rv * 3), 3)); + roofGeo.setAttribute( + "position", + new THREE.BufferAttribute(rP.subarray(0, rv * 3), 3) + ); + roofGeo.setAttribute( + "normal", + new THREE.BufferAttribute(rN.subarray(0, rv * 3), 3) + ); + roofGeo.setAttribute( + "color", + new THREE.BufferAttribute(rC.subarray(0, rv * 3), 3) + ); roofGeo.setIndex(new THREE.BufferAttribute(rI.subarray(0, ri), 1)); const wallMat = createBuildingMaterial(); diff --git a/libraries/mapping/engines/threejs/src/tiles3d/Tiles3dLayer.ts b/libraries/mapping/engines/threejs/src/tiles3d/Tiles3dLayer.ts index fdfb234f3f..d2b02b072e 100644 --- a/libraries/mapping/engines/threejs/src/tiles3d/Tiles3dLayer.ts +++ b/libraries/mapping/engines/threejs/src/tiles3d/Tiles3dLayer.ts @@ -244,7 +244,10 @@ export function buildTiles3dLayer( type: "custom", renderingMode: "3d", - onAdd(mapInstance: MaplibreMap, gl: WebGLRenderingContext | WebGL2RenderingContext) { + onAdd( + mapInstance: MaplibreMap, + gl: WebGLRenderingContext | WebGL2RenderingContext + ) { map = mapInstance; // A style rebuild takes every custom layer off the map and the manager @@ -328,7 +331,7 @@ export function buildTiles3dLayer( // about to reach; the library treats it as implied here anyway. tiles.loadSiblings = true; tiles.loadAncestors = true; - tiles.downloadQueue.maxJobs = Math.max( + tiles.downloadQueue.maxJobsPerOrigin = Math.max( 1, Math.floor(options.requestConcurrency ?? 6) ); diff --git a/libraries/mapping/engines/threejs/src/tiles3d/lodCamera.ts b/libraries/mapping/engines/threejs/src/tiles3d/lodCamera.ts index 4d945c95f2..c77c00c1b2 100644 --- a/libraries/mapping/engines/threejs/src/tiles3d/lodCamera.ts +++ b/libraries/mapping/engines/threejs/src/tiles3d/lodCamera.ts @@ -19,6 +19,8 @@ export interface LodCameraFrame { meterScale: number; /** Drawing buffer size in pixels. */ viewport: THREE.Vector2; + /** Optional elevation for the map centre when terrain lives outside MapLibre. */ + centerElevationMeters?: number; } /** @@ -68,7 +70,7 @@ export function synthesizeLodCamera( const centerLngLat = map.getCenter(); const centerMerc = MercatorCoordinate.fromLngLat( centerLngLat, - map.queryTerrainElevation(centerLngLat) ?? 0 + frame.centerElevationMeters ?? map.queryTerrainElevation(centerLngLat) ?? 0 ); lookTarget.set( (centerMerc.x - originMerc.x) / meterScale, diff --git a/libraries/mapping/layers/src/components/LayerCatalog.spec.tsx b/libraries/mapping/layers/src/components/LayerCatalog.spec.tsx index 7f89a8657a..cf3f2c0a0f 100644 --- a/libraries/mapping/layers/src/components/LayerCatalog.spec.tsx +++ b/libraries/mapping/layers/src/components/LayerCatalog.spec.tsx @@ -66,6 +66,9 @@ const routedFetch = (input: RequestInfo | URL): Promise => { if (url.includes("styles/extern.style.json")) { return jsonResponse(buildVectorStyle("Externer Style Titel")); } + if (url.includes("alkis/gebaeude-only.style.json")) { + return jsonResponse(buildVectorStyle("ALKIS Gebäude (schwarz)")); + } if (url.includes("additionalLayerConfig.json")) { return jsonResponse(additionalLayerConfig); @@ -448,6 +451,35 @@ describe("LayerCatalog", () => { expect(headings?.[0]?.textContent).toBe("Externe Dienste"); }); + it("adds a plain-text vector style URL directly to the local map", async () => { + const { props } = renderModal(); + await screen.findByText("Stadtgrundkarte (grau)", undefined, { + timeout: 8000, + }); + + const url = + "https://tiles.cismet.de/alkis/gebaeude-only.style.json?source=drag"; + fireEvent.drop(window, { + dataTransfer: { + files: [], + getData: (type: string) => (type === "text/plain" ? url : ""), + }, + }); + + await waitFor(() => { + expect(props.setAdditionalLayers).toHaveBeenCalledWith( + expect.objectContaining({ + id: `custom:${url}`, + title: "ALKIS Gebäude (schwarz)", + }), + false, + false, + false, + true + ); + }); + }); + it("applies a dropped layer config to the current catalog", async () => { renderModal(); await screen.findByText("Stadtgrundkarte (grau)", undefined, { @@ -554,7 +586,7 @@ describe("LayerCatalog", () => { { timeout: 8000 } ); - const updatedLayer = props.updateActiveLayer.mock.calls[0][0]; + const updatedLayer = vi.mocked(props.updateActiveLayer).mock.calls[0][0]; expect(updatedLayer.id).toBe("zusatzTest:testlayer"); expect(updatedLayer.title).toBe("Zusatz Testlayer"); // the style of the catalog item, with the zoom range and the layer info @@ -612,7 +644,7 @@ describe("LayerCatalog", () => { { timeout: 8000 } ); - const updatedLayer = props.updateActiveLayer.mock.calls[0][0]; + const updatedLayer = vi.mocked(props.updateActiveLayer).mock.calls[0][0]; expect(updatedLayer.id).toBe("extern:testlayer"); expect(updatedLayer.props.style).toBe( "https://example.test/styles/extern.style.json" diff --git a/libraries/mapping/layers/src/helper/resolve-dropped-url.spec.ts b/libraries/mapping/layers/src/helper/resolve-dropped-url.spec.ts new file mode 100644 index 0000000000..e8e5aba5ec --- /dev/null +++ b/libraries/mapping/layers/src/helper/resolve-dropped-url.spec.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + replaceVectorTileServerPlaceholders, + resolveDroppedUrl, +} from "./resolve-dropped-url"; + +const createDataTransfer = (values: Record) => ({ + getData: vi.fn((type: string) => values[type] ?? ""), +}); + +describe("resolveDroppedUrl", () => { + it("reads the browser URL transfer type", () => { + const dataTransfer = createDataTransfer({ + URL: "https://tiles.cismet.de/alkis/gebaeude-only.style.json", + }); + + expect(resolveDroppedUrl(dataTransfer)).toBe( + "https://tiles.cismet.de/alkis/gebaeude-only.style.json" + ); + }); + + it("falls back to a URI list and ignores comments", () => { + const dataTransfer = createDataTransfer({ + "text/uri-list": + "# dragged link\r\nhttps://tiles.cismet.de/alkis/gebaeude-only.style.json\r\n", + }); + + expect(resolveDroppedUrl(dataTransfer)).toBe( + "https://tiles.cismet.de/alkis/gebaeude-only.style.json" + ); + }); + + it("falls back to plain text used by app-to-browser dragging", () => { + const dataTransfer = createDataTransfer({ + "text/plain": + " https://tiles.cismet.de/alkis/gebaeude-only.style.json ", + }); + + expect(resolveDroppedUrl(dataTransfer)).toBe( + "https://tiles.cismet.de/alkis/gebaeude-only.style.json" + ); + }); + + it("reads an HTML-only dragged link", () => { + const dataTransfer = createDataTransfer({ + "text/html": + 'ALKIS', + }); + + expect(resolveDroppedUrl(dataTransfer)).toBe( + "https://tiles.cismet.de/alkis/gebaeude-only.style.json" + ); + }); + + it("rejects non-http transfer values", () => { + const dataTransfer = createDataTransfer({ + "text/plain": "javascript:alert(1)", + }); + + expect(resolveDroppedUrl(dataTransfer)).toBeNull(); + }); +}); + +describe("replaceVectorTileServerPlaceholders", () => { + it("replaces both supported placeholder spellings", () => { + expect( + replaceVectorTileServerPlaceholders( + '{"upper":"__SERVER_URL__","lower":"__server_url__"}', + "https://tiles.example.test" + ) + ).toBe( + '{"upper":"https://tiles.example.test","lower":"https://tiles.example.test"}' + ); + }); +}); diff --git a/libraries/mapping/layers/src/helper/resolve-dropped-url.ts b/libraries/mapping/layers/src/helper/resolve-dropped-url.ts new file mode 100644 index 0000000000..c81c840083 --- /dev/null +++ b/libraries/mapping/layers/src/helper/resolve-dropped-url.ts @@ -0,0 +1,65 @@ +const DROP_URL_TYPES = [ + "URL", + "text/uri-list", + "text/plain", + "text/html", +] as const; + +const parseHttpUrl = (candidate: string): string | null => { + try { + const url = new URL(candidate.trim()); + return url.protocol === "http:" || url.protocol === "https:" + ? url.toString() + : null; + } catch { + return null; + } +}; + +const resolveHtmlUrl = (html: string): string | null => { + const href = new DOMParser() + .parseFromString(html, "text/html") + .querySelector("a[href]")?.href; + + return href ? parseHttpUrl(href) : null; +}; + +export const resolveDroppedUrl = ( + dataTransfer: Pick | null | undefined +): string | null => { + if (!dataTransfer) return null; + + for (const type of DROP_URL_TYPES) { + let value = ""; + try { + value = dataTransfer.getData(type); + } catch { + continue; + } + + for (const line of value.split(/\r?\n/)) { + const candidate = line.trim(); + if (!candidate || candidate.startsWith("#")) continue; + const url = parseHttpUrl(candidate); + if (url) return url; + } + + if (type === "text/html") { + const url = resolveHtmlUrl(value); + if (url) return url; + } + } + + return null; +}; + +export const isJsonUrl = (url: string): boolean => + new URL(url).pathname.toLowerCase().endsWith(".json"); + +export const replaceVectorTileServerPlaceholders = ( + input: string, + vectorTileServerUrl: string +): string => + input + .replaceAll("__SERVER_URL__", vectorTileServerUrl) + .replaceAll("__server_url__", vectorTileServerUrl); diff --git a/libraries/mapping/layers/src/hooks/useHandleDrop.ts b/libraries/mapping/layers/src/hooks/useHandleDrop.ts index aa473671c2..3bbf0e2db7 100644 --- a/libraries/mapping/layers/src/hooks/useHandleDrop.ts +++ b/libraries/mapping/layers/src/hooks/useHandleDrop.ts @@ -12,6 +12,11 @@ import { wmsCapabilitiesToCustomItems } from "../helper/buildCatalog"; import type { CatalogDrop } from "../helper/buildCatalog"; import { parseToMapLayer } from "@carma-mapping/utils"; import { useLiveDeployment } from "@carma-commons/utils"; +import { + isJsonUrl, + replaceVectorTileServerPlaceholders, + resolveDroppedUrl, +} from "../helper/resolve-dropped-url"; // @ts-expect-error tbd const parser = new WMSCapabilities(); @@ -54,12 +59,6 @@ export const useHandleDrop = ({ } }; - const preTransformJson = (input: string) => { - return input - .replaceAll("__SERVER_URL__", vectorTileServerUrl) - .replaceAll("__server_url__", vectorTileServerUrl); - }; - const handleAddToMap = async (newItem: Item, instant = false) => { const existingLayer = activeLayers.find((layer) => layer.id === newItem.id); @@ -99,7 +98,10 @@ export const useHandleDrop = ({ // Attempt to parse the file content as JSON const fileContent = e.target?.result; if (typeof fileContent === "string") { - const processedContent = preTransformJson(fileContent); + const processedContent = replaceVectorTileServerPlaceholders( + fileContent, + vectorTileServerUrl + ); const jsonData = JSON.parse(processedContent); @@ -156,7 +158,7 @@ export const useHandleDrop = ({ .then((data) => { if (data.metadata && data.metadata.carmaConf.layerInfo) { const layerInfo = data.metadata.carmaConf.layerInfo; - instant = instant || (data.metaData?.carmaConf?.instant ?? false); + instant = instant || (data.metadata?.carmaConf?.instant ?? false); newItem = { ...newItem, id: importedId, @@ -193,7 +195,10 @@ export const useHandleDrop = ({ // Attempt to parse the file content as JSON const fileContent = e.target?.result; if (typeof fileContent === "string") { - const processedContent = preTransformJson(fileContent); + const processedContent = replaceVectorTileServerPlaceholders( + fileContent, + vectorTileServerUrl + ); const jsonData = JSON.parse(processedContent); let newItem = { @@ -239,7 +244,7 @@ export const useHandleDrop = ({ .then((data) => { if (data.metadata && data.metadata.carmaConf.layerInfo) { const layerInfo = data.metadata.carmaConf.layerInfo; - instant = data.metaData?.carmaConf?.instant ?? false; + instant = data.metadata?.carmaConf?.instant ?? false; newItem = { ...newItem, ...layerInfo, @@ -270,7 +275,7 @@ export const useHandleDrop = ({ useEffect(() => { const handleDrop = async (event: DragEvent) => { event.preventDefault(); - const url = event.dataTransfer?.getData("URL"); + const url = resolveDroppedUrl(event.dataTransfer); const file = event?.dataTransfer?.files[0]; @@ -280,7 +285,7 @@ export const useHandleDrop = ({ ) { handleTwinFile(file ?? null, url ?? null); } else { - if (url && url.endsWith(".json")) { + if (url && isJsonUrl(url)) { handleJsonStyle(null, url); } else if (url) { fetch(url) @@ -349,12 +354,12 @@ export const useHandleDrop = ({ event.preventDefault(); }; - window.addEventListener("drop", handleDrop); - window.addEventListener("dragover", handleDragOver); + window.addEventListener("drop", handleDrop, true); + window.addEventListener("dragover", handleDragOver, true); return () => { - window.removeEventListener("drop", handleDrop); - window.removeEventListener("dragover", handleDragOver); + window.removeEventListener("drop", handleDrop, true); + window.removeEventListener("dragover", handleDragOver, true); }; }, [ setOpen, diff --git a/libraries/mapping/map-controls-layout/src/lib/components/Control.tsx b/libraries/mapping/map-controls-layout/src/lib/components/Control.tsx index 1eddd32387..d05bab02a9 100644 --- a/libraries/mapping/map-controls-layout/src/lib/components/Control.tsx +++ b/libraries/mapping/map-controls-layout/src/lib/components/Control.tsx @@ -1,5 +1,5 @@ -import { ReactNode, useEffect } from "react"; -import { Positions, useControlContext } from "../map-control"; +import { ReactNode, useEffect, useRef } from "react"; +import { ControlComponent, Positions, useControlContext } from "../map-control"; interface ControlProps { position: Positions; @@ -12,15 +12,32 @@ interface ControlProps { } function Control({ position, children, order }: ControlProps) { - const { addControl, removeControl } = useControlContext(); + const { addControl, updateControl, removeControl } = useControlContext(); + const registeredRef = useRef(null); + // A parent re-render hands over a new `children` element on every pass. + // Replacing the registered entry in place keeps that to one layout update + // instead of a remove-then-add pair per control. useEffect(() => { - addControl({ position, component: children, order }); + const next: ControlComponent = { position, component: children, order }; + const previous = registeredRef.current; + if (previous) { + updateControl(previous, next); + } else { + addControl(next); + } + registeredRef.current = next; + }, [addControl, children, order, position, updateControl]); - return () => { - removeControl({ position, component: children, order }); - }; - }, [children]); + useEffect( + () => () => { + const registered = registeredRef.current; + if (!registered) return; + registeredRef.current = null; + removeControl(registered); + }, + [removeControl] + ); return <>; } diff --git a/libraries/mapping/map-controls-layout/src/lib/map-control.tsx b/libraries/mapping/map-controls-layout/src/lib/map-control.tsx index 62298833a9..b6492de0c2 100644 --- a/libraries/mapping/map-controls-layout/src/lib/map-control.tsx +++ b/libraries/mapping/map-controls-layout/src/lib/map-control.tsx @@ -1,4 +1,11 @@ -import { createContext, ReactNode, useState, useContext } from "react"; +import { + createContext, + ReactNode, + useCallback, + useMemo, + useState, + useContext, +} from "react"; import ControlRenderer from "./components/ControlRenderer"; export type Positions = @@ -17,12 +24,22 @@ export type ControlComponent = { interface ControlContextType { addControl: (component: ControlComponent) => void; + /** + * Swap a registered control for its re-rendered element in one state + * update, instead of a remove followed by an add. + */ + updateControl: (previous: ControlComponent, next: ControlComponent) => void; removeControl: (component: ControlComponent) => void; addCanvas: (component: ReactNode) => void; removeCanvas: () => void; controls: ControlComponent[]; } +const isSameControl = (a: ControlComponent, b: ControlComponent): boolean => + a.position === b.position && + a.order === b.order && + a.component === b.component; + interface ControlLayoutProps { children: ReactNode; ifStorybook?: boolean; @@ -45,41 +62,58 @@ function ControlLayout({ children }: ControlLayoutProps) { const [controls, setControls] = useState([]); const [canvas, setCanvas] = useState(null); - const addControl = (component: ControlComponent) => { + const addControl = useCallback((component: ControlComponent) => { setControls((prev) => [...prev, component]); - }; - - const removeControl = (component: ControlComponent) => { - setControls((prev) => - prev.filter( - (c) => - !( - c.position === component.position && - c.order === component.order && - c.component === component.component - ) - ) - ); - }; - - const addCanvas = (component: ReactNode) => { + }, []); + + const updateControl = useCallback( + (previous: ControlComponent, next: ControlComponent) => { + setControls((prev) => { + const index = prev.findIndex((c) => isSameControl(c, previous)); + if (index < 0) return [...prev, next]; + const updated = [...prev]; + updated[index] = next; + return updated; + }); + }, + [] + ); + + const removeControl = useCallback((component: ControlComponent) => { + setControls((prev) => prev.filter((c) => !isSameControl(c, component))); + }, []); + + const addCanvas = useCallback((component: ReactNode) => { setCanvas(component); - }; + }, []); - const removeCanvas = () => { + const removeCanvas = useCallback(() => { setCanvas(null); - }; + }, []); + + // Every `Control` consumes this context; a fresh value object per render + // would re-render all of them whenever one control re-registers. + const contextValue = useMemo( + () => ({ + addControl, + updateControl, + removeControl, + controls, + addCanvas, + removeCanvas, + }), + [ + addControl, + updateControl, + removeControl, + controls, + addCanvas, + removeCanvas, + ] + ); return ( - + {children} {/* Render ControlRenderer directly when there's no canvas */} {!canvas && controls.length > 0 && ( diff --git a/libraries/mapping/shadow-simulation/project.json b/libraries/mapping/shadow-simulation/project.json new file mode 100644 index 0000000000..fd08738fe1 --- /dev/null +++ b/libraries/mapping/shadow-simulation/project.json @@ -0,0 +1,19 @@ +{ + "name": "mapping-shadow-simulation", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libraries/mapping/shadow-simulation/src", + "projectType": "library", + "tags": ["type:library", "scope:mapping"], + "targets": { + "test": { + "executor": "@nx/vite:test", + "outputs": ["{options.reportsDirectory}"], + "options": { + "reportsDirectory": "../../../coverage/libraries/mapping/shadow-simulation" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libraries/mapping/shadow-simulation/src/index.ts b/libraries/mapping/shadow-simulation/src/index.ts new file mode 100644 index 0000000000..3f81010791 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/index.ts @@ -0,0 +1,13 @@ +export { + clampShadowSimulationSelectionToDaylight, + DEFAULT_SHADOW_SIMULATION_TIME_ZONE, +} from "./lib/core/solar-position"; +export { formatShadowSelection } from "./lib/ui/format-shadow-selection"; +export { ShadowSimulationHeaderControlsView } from "./lib/ui/ShadowSimulationHeaderControlsView"; +export { ShadowSimulationView } from "./lib/ui/ShadowSimulationView"; +export type { + ShadowDateState, + ShadowSimulationConfig, + ShadowSimulationState, +} from "./lib/contracts/shadow-simulation"; +export type { MeshErrorTargetPixels } from "./lib/core/shadow-types"; diff --git a/libraries/mapping/shadow-simulation/src/lib/contracts/shadow-simulation.ts b/libraries/mapping/shadow-simulation/src/lib/contracts/shadow-simulation.ts new file mode 100644 index 0000000000..f55b0ddc05 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/contracts/shadow-simulation.ts @@ -0,0 +1,87 @@ +import type { Positions } from "@carma-mapping/map-controls-layout"; +import type { CesiumTerrainRuntimeOptions } from "@carma-mapping/engines/maplibre"; + +import type { SolarSelection } from "../core/solar-position"; +import type { + MeshErrorTargetPixels, + ShadowQualityMultiplier, +} from "../core/shadow-types"; + +export type ShadowTerrainOptions = Readonly<{ url: string }> & + Omit; + +export type ShadowSceneOptions = { + shadowAreaMeters?: number; + terrain?: ShadowTerrainOptions; +}; + +export const SHADOW_CONTROL_STYLE = { + QUICK: "quick", + CURVE: "curve", +} as const; + +export type ShadowControlStyle = + (typeof SHADOW_CONTROL_STYLE)[keyof typeof SHADOW_CONTROL_STYLE]; + +export const SHADOW_ANIMATION_MODE = { + DAY: "day", + YEAR: "year", +} as const; + +export type ShadowAnimationMode = + (typeof SHADOW_ANIMATION_MODE)[keyof typeof SHADOW_ANIMATION_MODE]; + +export type ShadowAnimationSpeed = 1 | 4 | 12; + +export type ShadowSimulationConfig = { + year?: number; + initialDayOfYear?: number; + initialMinutes?: number; + latitude?: number; + longitude?: number; + timeZone?: string; + shadowAreaMeters?: number; + terrain?: ShadowTerrainOptions; + controlPosition?: Positions; + controlOrder?: number; +}; + +export type ShadowSimulationState = { + enabled: boolean; + terrainColor: string; + buildingsFullOpacity: boolean; + buildingColorMix: number; + meshTextureSaturation?: number; + buildingColor: string; + shadowQuality: ShadowQualityMultiplier; + meshErrorTarget?: MeshErrorTargetPixels; + showSunDebugVector: boolean; + showProjectionDebugView?: boolean; + showTileBounds?: boolean; + softSunShadows?: boolean; + showMapStyleContent?: boolean; + showMapStyleLabels?: boolean; + useTransmittanceLut?: boolean; + useSkyIrradianceLut?: boolean; + controlStyle?: ShadowControlStyle; + animationMode?: ShadowAnimationMode; + animationSpeed?: ShadowAnimationSpeed; + isAnimating?: boolean; + shadowIntensity?: number; +}; + +export type ShadowDateState = SolarSelection; + +export type ShadowSimulationStateAction = + | ShadowSimulationState + | ((previous: ShadowSimulationState | undefined) => ShadowSimulationState); + +export type ShadowSimulationStateSetter = ( + action: ShadowSimulationStateAction +) => void; + +export type ShadowDateStateAction = + | ShadowDateState + | ((previous: ShadowDateState | undefined) => ShadowDateState); + +export type ShadowDateStateSetter = (action: ShadowDateStateAction) => void; diff --git a/libraries/mapping/shadow-simulation/src/lib/core/create-shadow-simulation-state.spec.ts b/libraries/mapping/shadow-simulation/src/lib/core/create-shadow-simulation-state.spec.ts new file mode 100644 index 0000000000..8d1e389eda --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/core/create-shadow-simulation-state.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { + createInitialShadowDateState, + createInitialShadowSimulationState, +} from "./create-shadow-simulation-state"; +import { DEFAULT_SHADOW_SIMULATION_LOCATION } from "./solar-position"; + +describe("initial shadow states", () => { + it("builds stable independent defaults", () => { + const state = createInitialShadowSimulationState(undefined); + const dateState = createInitialShadowDateState( + undefined, + DEFAULT_SHADOW_SIMULATION_LOCATION, + new Date("2026-06-21T10:00:00.000Z") + ); + + expect(state.enabled).toBe(false); + expect(dateState.year).toBe(2026); + expect(dateState.dayOfYear).toBe(172); + expect(dateState.timeZone).toBe("Europe/Berlin"); + expect(state.showMapStyleContent).toBe(true); + }); + + it("honors configured date and terrain material defaults", () => { + const config = { + year: 2024, + initialDayOfYear: 60, + initialMinutes: 12 * 60, + terrain: { + url: "https://example.invalid/terrain.json", + material: { color: "#123456" }, + }, + }; + const state = createInitialShadowSimulationState(config); + const dateState = createInitialShadowDateState( + config, + DEFAULT_SHADOW_SIMULATION_LOCATION + ); + + expect(dateState).toEqual({ + year: 2024, + dayOfYear: 60, + minutes: 12 * 60, + timeZone: "Europe/Berlin", + }); + expect(state.terrainColor).toBe("#123456"); + }); +}); diff --git a/libraries/mapping/shadow-simulation/src/lib/core/create-shadow-simulation-state.ts b/libraries/mapping/shadow-simulation/src/lib/core/create-shadow-simulation-state.ts new file mode 100644 index 0000000000..cd8dc79e69 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/core/create-shadow-simulation-state.ts @@ -0,0 +1,71 @@ +import { + SHADOW_ANIMATION_MODE, + SHADOW_CONTROL_STYLE, + type ShadowDateState, + type ShadowSimulationConfig, + type ShadowSimulationState, +} from "../contracts/shadow-simulation"; +import { + clampSelectionToDaylight, + DEFAULT_SHADOW_SIMULATION_TIME_ZONE, + getSolarSelectionForInstant, + type SolarLocation, +} from "./solar-position"; +import { + DEFAULT_MESH_ERROR_TARGET_PIXELS, + DEFAULT_SHADOW_BUILDING_COLOR, + DEFAULT_SHADOW_BUILDING_COLOR_MIX, + DEFAULT_SHADOW_BUILDING_TEXTURE_SATURATION, + DEFAULT_SHADOW_QUALITY, + resolveShadowSurfaceColor, +} from "./shadow-types"; + +export const createInitialShadowSimulationState = ( + config: ShadowSimulationConfig | undefined +): ShadowSimulationState => { + return { + enabled: false, + terrainColor: resolveShadowSurfaceColor(config?.terrain?.material?.color), + buildingsFullOpacity: true, + buildingColorMix: DEFAULT_SHADOW_BUILDING_COLOR_MIX, + meshTextureSaturation: DEFAULT_SHADOW_BUILDING_TEXTURE_SATURATION, + buildingColor: DEFAULT_SHADOW_BUILDING_COLOR, + shadowQuality: DEFAULT_SHADOW_QUALITY, + meshErrorTarget: DEFAULT_MESH_ERROR_TARGET_PIXELS, + showSunDebugVector: false, + showTileBounds: false, + showProjectionDebugView: false, + softSunShadows: true, + showMapStyleContent: true, + showMapStyleLabels: true, + useTransmittanceLut: true, + useSkyIrradianceLut: true, + controlStyle: SHADOW_CONTROL_STYLE.QUICK, + animationMode: SHADOW_ANIMATION_MODE.DAY, + animationSpeed: 4, + isAnimating: false, + shadowIntensity: 1, + }; +}; + +export const createInitialShadowDateState = ( + config: ShadowSimulationConfig | undefined, + location: SolarLocation, + instant = new Date() +): ShadowDateState => { + const timeZone = config?.timeZone ?? DEFAULT_SHADOW_SIMULATION_TIME_ZONE; + const now = getSolarSelectionForInstant(instant, timeZone); + const candidate = { + year: config?.year ?? now.year, + dayOfYear: config?.initialDayOfYear ?? now.dayOfYear, + minutes: config?.initialMinutes ?? now.minutes, + timeZone, + }; + + return ( + clampSelectionToDaylight(candidate, location) ?? { + ...candidate, + minutes: 12 * 60, + } + ); +}; diff --git a/libraries/mapping/shadow-simulation/src/lib/core/frustum-box-intersection.spec.ts b/libraries/mapping/shadow-simulation/src/lib/core/frustum-box-intersection.spec.ts new file mode 100644 index 0000000000..e79a40a9cf --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/core/frustum-box-intersection.spec.ts @@ -0,0 +1,50 @@ +import { Box3, PerspectiveCamera, Vector3 } from "three"; +import { describe, expect, it } from "vitest"; + +import { getFrustumBoxIntersectionPoints } from "./frustum-box-intersection"; + +const buildCamera = () => { + const camera = new PerspectiveCamera(60, 1, 1, 20); + camera.position.set(0, 0, 5); + camera.lookAt(0, 0, 0); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(true); + return camera; +}; + +describe("getFrustumBoxIntersectionPoints", () => { + it("returns the clipped volume vertices for an intersecting tile box", () => { + const camera = buildCamera(); + const box = new Box3( + new Vector3(-10, -0.5, -0.5), + new Vector3(10, 0.5, 0.5) + ); + + const points = getFrustumBoxIntersectionPoints(camera, box); + + expect(points.length).toBeGreaterThan(0); + expect(points.every((point) => box.containsPoint(point))).toBe(true); + expect(Math.max(...points.map(({ x }) => Math.abs(x)))).toBeLessThan(10); + }); + + it("returns the frustum vertices when a tile volume contains the view", () => { + const camera = buildCamera(); + const box = new Box3( + new Vector3(-100, -100, -100), + new Vector3(100, 100, 100) + ); + + const points = getFrustumBoxIntersectionPoints(camera, box); + + expect(points).toHaveLength(8); + }); + + it("returns no points for a tile outside the view frustum", () => { + const points = getFrustumBoxIntersectionPoints( + buildCamera(), + new Box3(new Vector3(100, 100, 100), new Vector3(101, 101, 101)) + ); + + expect(points).toEqual([]); + }); +}); diff --git a/libraries/mapping/shadow-simulation/src/lib/core/frustum-box-intersection.ts b/libraries/mapping/shadow-simulation/src/lib/core/frustum-box-intersection.ts new file mode 100644 index 0000000000..05ba0de84c --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/core/frustum-box-intersection.ts @@ -0,0 +1,167 @@ +import { + Box3, + Camera, + Frustum, + Matrix4, + Plane, + Vector3, + WebGPUCoordinateSystem, +} from "three"; + +const BOX_EDGE_INDICES = [ + [0, 1], + [1, 3], + [3, 2], + [2, 0], + [4, 5], + [5, 7], + [7, 6], + [6, 4], + [0, 4], + [1, 5], + [2, 6], + [3, 7], +] as const; + +const buildBoxCorners = ({ min, max }: Box3) => [ + new Vector3(min.x, min.y, min.z), + new Vector3(max.x, min.y, min.z), + new Vector3(min.x, max.y, min.z), + new Vector3(max.x, max.y, min.z), + new Vector3(min.x, min.y, max.z), + new Vector3(max.x, min.y, max.z), + new Vector3(min.x, max.y, max.z), + new Vector3(max.x, max.y, max.z), +]; + +const buildFrustumCorners = (camera: Camera) => { + const nearClipZ = camera.coordinateSystem === WebGPUCoordinateSystem ? 0 : -1; + return [nearClipZ, 1].flatMap((z) => + [-1, 1].flatMap((y) => + [-1, 1].map((x) => new Vector3(x, y, z).unproject(camera)) + ) + ); +}; + +const containsWithTolerance = ( + planes: readonly Plane[], + point: Vector3, + tolerance: number +) => planes.every((plane) => plane.distanceToPoint(point) >= -tolerance); + +const boxContainsWithTolerance = ( + box: Box3, + point: Vector3, + tolerance: number +) => + point.x >= box.min.x - tolerance && + point.x <= box.max.x + tolerance && + point.y >= box.min.y - tolerance && + point.y <= box.max.y + tolerance && + point.z >= box.min.z - tolerance && + point.z <= box.max.z + tolerance; + +const appendUniquePoint = ( + points: Vector3[], + candidate: Vector3, + toleranceSquared: number +) => { + if ( + points.some( + (existing) => existing.distanceToSquared(candidate) <= toleranceSquared + ) + ) { + return; + } + points.push(candidate); +}; + +const appendSegmentPlaneIntersections = ( + start: Vector3, + end: Vector3, + planes: readonly Plane[], + accepts: (point: Vector3) => boolean, + points: Vector3[], + toleranceSquared: number +) => { + const delta = end.clone().sub(start); + for (const plane of planes) { + const startDistance = plane.distanceToPoint(start); + const endDistance = plane.distanceToPoint(end); + const denominator = startDistance - endDistance; + if (Math.abs(denominator) <= Number.EPSILON) continue; + const interpolation = startDistance / denominator; + if (interpolation < 0 || interpolation > 1) continue; + const candidate = start.clone().addScaledVector(delta, interpolation); + if (accepts(candidate)) { + appendUniquePoint(points, candidate, toleranceSquared); + } + } +}; + +export const getFrustumBoxIntersectionPoints = ( + camera: Camera, + box: Box3, + tolerance = 1e-6 +): Vector3[] => { + if (box.isEmpty()) return []; + camera.updateMatrixWorld(true); + const frustum = new Frustum().setFromProjectionMatrix( + new Matrix4().multiplyMatrices( + camera.projectionMatrix, + camera.matrixWorldInverse + ), + camera.coordinateSystem, + camera.reversedDepth + ); + if (!frustum.intersectsBox(box)) return []; + + const toleranceSquared = tolerance * tolerance; + const boxCorners = buildBoxCorners(box); + const frustumCorners = buildFrustumCorners(camera); + const points: Vector3[] = []; + for (const point of boxCorners) { + if (containsWithTolerance(frustum.planes, point, tolerance)) { + appendUniquePoint(points, point, toleranceSquared); + } + } + for (const point of frustumCorners) { + if (boxContainsWithTolerance(box, point, tolerance)) { + appendUniquePoint(points, point, toleranceSquared); + } + } + + for (const [startIndex, endIndex] of BOX_EDGE_INDICES) { + appendSegmentPlaneIntersections( + boxCorners[startIndex], + boxCorners[endIndex], + frustum.planes, + (point) => containsWithTolerance(frustum.planes, point, tolerance), + points, + toleranceSquared + ); + } + + const boxPlanes = [ + new Plane(new Vector3(1, 0, 0), -box.min.x), + new Plane(new Vector3(-1, 0, 0), box.max.x), + new Plane(new Vector3(0, 1, 0), -box.min.y), + new Plane(new Vector3(0, -1, 0), box.max.y), + new Plane(new Vector3(0, 0, 1), -box.min.z), + new Plane(new Vector3(0, 0, -1), box.max.z), + ]; + for (const [startIndex, endIndex] of BOX_EDGE_INDICES) { + appendSegmentPlaneIntersections( + frustumCorners[startIndex], + frustumCorners[endIndex], + boxPlanes, + (point) => + boxContainsWithTolerance(box, point, tolerance) && + containsWithTolerance(frustum.planes, point, tolerance), + points, + toleranceSquared + ); + } + + return points; +}; diff --git a/libraries/mapping/shadow-simulation/src/lib/core/shadow-animation.spec.ts b/libraries/mapping/shadow-simulation/src/lib/core/shadow-animation.spec.ts new file mode 100644 index 0000000000..653bbb0b79 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/core/shadow-animation.spec.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; + +import { + SHADOW_ANIMATION_MODE, + type ShadowSimulationState, +} from "../contracts/shadow-simulation"; +import { advanceShadowAnimationFrame } from "./shadow-animation"; +import { + createInitialShadowDateState, + createInitialShadowSimulationState, +} from "./create-shadow-simulation-state"; +import { DEFAULT_SHADOW_SIMULATION_LOCATION } from "./solar-position"; + +const initialDateState = createInitialShadowDateState( + undefined, + DEFAULT_SHADOW_SIMULATION_LOCATION, + new Date("2026-06-21T10:00:00.000Z") +); + +const createState = ( + patch: Partial +): ShadowSimulationState => ({ + ...createInitialShadowSimulationState(undefined), + enabled: true, + isAnimating: true, + ...patch, +}); + +describe("advanceShadowAnimationFrame", () => { + it("advances the daily animation and wraps at sunset", () => { + const state = createState({ animationSpeed: 4 }); + const dateState = { + year: 2026, + dayOfYear: 172, + minutes: 24 * 60, + timeZone: "Europe/Berlin", + }; + const frame = advanceShadowAnimationFrame( + state, + dateState, + initialDateState, + DEFAULT_SHADOW_SIMULATION_LOCATION, + 0 + ); + + expect(frame.dateState.minutes).toBeLessThan(12 * 60); + expect(frame.yearDayProgress).toBe(0); + }); + + it("carries annual animation across year boundaries", () => { + const state = createState({ + animationMode: SHADOW_ANIMATION_MODE.YEAR, + animationSpeed: 4, + }); + const dateState = { + year: 2024, + dayOfYear: 366, + minutes: 12 * 60, + timeZone: "Europe/Berlin", + }; + const frame = advanceShadowAnimationFrame( + state, + dateState, + initialDateState, + DEFAULT_SHADOW_SIMULATION_LOCATION, + 0 + ); + + expect(frame.dateState.year).toBe(2025); + expect(frame.dateState.dayOfYear).toBe(2); + expect(frame.yearDayProgress).toBe(0); + }); + + it("keeps fractional annual progress explicit", () => { + const state = createState({ + animationMode: SHADOW_ANIMATION_MODE.YEAR, + animationSpeed: 1, + }); + const first = advanceShadowAnimationFrame( + state, + initialDateState, + initialDateState, + DEFAULT_SHADOW_SIMULATION_LOCATION, + 0 + ); + const second = advanceShadowAnimationFrame( + state, + first.dateState, + initialDateState, + DEFAULT_SHADOW_SIMULATION_LOCATION, + first.yearDayProgress + ); + + expect(first.dateState).toBe(initialDateState); + expect(first.yearDayProgress).toBe(0.5); + expect(second.dateState.dayOfYear).toBe(initialDateState.dayOfYear + 1); + expect(second.yearDayProgress).toBe(0); + }); + + it("keeps inactive date state unchanged", () => { + const state = createState({ isAnimating: false }); + const frame = advanceShadowAnimationFrame( + state, + initialDateState, + initialDateState, + DEFAULT_SHADOW_SIMULATION_LOCATION, + 0.5 + ); + + expect(frame).toEqual({ + dateState: initialDateState, + yearDayProgress: 0.5, + }); + }); +}); diff --git a/libraries/mapping/shadow-simulation/src/lib/core/shadow-animation.ts b/libraries/mapping/shadow-simulation/src/lib/core/shadow-animation.ts new file mode 100644 index 0000000000..1c8d9a6c34 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/core/shadow-animation.ts @@ -0,0 +1,92 @@ +import { offsetYearDay } from "@carma-commons/utils"; + +import { + SHADOW_ANIMATION_MODE, + type ShadowDateState, + type ShadowSimulationState, +} from "../contracts/shadow-simulation"; +import { + clampSelectionToDaylight, + getDaylightWindow, + type SolarLocation, +} from "./solar-position"; + +export type ShadowAnimationFrame = Readonly<{ + dateState: ShadowDateState; + yearDayProgress: number; +}>; + +type ShadowAnimationState = Pick< + ShadowSimulationState, + "animationMode" | "animationSpeed" | "enabled" | "isAnimating" +>; + +const advanceYearSelection = ( + dateState: ShadowDateState, + animationSpeed: number, + location: SolarLocation, + yearDayProgress: number +): ShadowAnimationFrame => { + const accumulatedDays = yearDayProgress + animationSpeed / 2; + const wholeDays = Math.floor(accumulatedDays); + const remainingProgress = accumulatedDays - wholeDays; + + if (wholeDays === 0) { + return { dateState, yearDayProgress: remainingProgress }; + } + + const nextYearDay = offsetYearDay(dateState, wholeDays); + const nextDateState = clampSelectionToDaylight( + { ...dateState, ...nextYearDay }, + location + ); + + return { + dateState: nextDateState ?? dateState, + yearDayProgress: remainingProgress, + }; +}; + +const advanceDaySelection = ( + dateState: ShadowDateState, + animationSpeed: number, + location: SolarLocation +): ShadowAnimationFrame => { + const daylight = getDaylightWindow(dateState, location); + const firstDaylightMinute = Math.ceil(daylight.sunriseMinutes); + const lastDaylightMinute = Math.floor(daylight.sunsetMinutes); + const nextMinute = dateState.minutes + animationSpeed; + + return { + dateState: { + ...dateState, + minutes: + nextMinute > lastDaylightMinute ? firstDaylightMinute : nextMinute, + }, + yearDayProgress: 0, + }; +}; + +export const advanceShadowAnimationFrame = ( + shadowState: ShadowAnimationState | null | undefined, + dateState: ShadowDateState | null | undefined, + initialDateState: ShadowDateState, + location: SolarLocation, + yearDayProgress: number +): ShadowAnimationFrame => { + const currentDateState = dateState ?? initialDateState; + if (!shadowState?.enabled || !shadowState.isAnimating) { + return { dateState: currentDateState, yearDayProgress }; + } + + const animationSpeed = shadowState.animationSpeed ?? 4; + return (shadowState.animationMode ?? SHADOW_ANIMATION_MODE.DAY) === + SHADOW_ANIMATION_MODE.YEAR + ? advanceYearSelection( + currentDateState, + animationSpeed, + location, + yearDayProgress + ) + : advanceDaySelection(currentDateState, animationSpeed, location); +}; diff --git a/libraries/mapping/shadow-simulation/src/lib/core/shadow-date-state.spec.ts b/libraries/mapping/shadow-simulation/src/lib/core/shadow-date-state.spec.ts new file mode 100644 index 0000000000..79be43ecd3 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/core/shadow-date-state.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { createInitialShadowDateState } from "./create-shadow-simulation-state"; +import { + updateShadowCalendarDate, + updateShadowToCurrentDate, +} from "./shadow-date-state"; +import { DEFAULT_SHADOW_SIMULATION_LOCATION } from "./solar-position"; + +const initialState = createInitialShadowDateState( + undefined, + DEFAULT_SHADOW_SIMULATION_LOCATION, + new Date("2026-06-21T10:00:00.000Z") +); + +describe("shadow date state transitions", () => { + it("updates the calendar date while preserving the time", () => { + const state = updateShadowCalendarDate( + initialState, + 2026, + 64, + DEFAULT_SHADOW_SIMULATION_LOCATION + ); + + expect(state.year).toBe(2026); + expect(state.dayOfYear).toBe(64); + expect(state.minutes).toBe(initialState.minutes); + }); + + it("selects today's date while preserving the chosen time", () => { + const state = updateShadowToCurrentDate( + initialState, + DEFAULT_SHADOW_SIMULATION_LOCATION, + new Date("2025-01-02T10:00:00.000Z") + ); + + expect(state.year).toBe(2025); + expect(state.dayOfYear).toBe(2); + expect(state.minutes).toBe(initialState.minutes); + }); +}); diff --git a/libraries/mapping/shadow-simulation/src/lib/core/shadow-date-state.ts b/libraries/mapping/shadow-simulation/src/lib/core/shadow-date-state.ts new file mode 100644 index 0000000000..937dd7668e --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/core/shadow-date-state.ts @@ -0,0 +1,42 @@ +import type { ShadowDateState } from "../contracts/shadow-simulation"; +import { + clampSelectionToDaylight, + getSolarSelectionForInstant, + type SolarLocation, +} from "./solar-position"; + +export const updateShadowDateState = ( + state: ShadowDateState, + candidate: ShadowDateState, + location: SolarLocation +): ShadowDateState => clampSelectionToDaylight(candidate, location) ?? state; + +export const updateShadowCalendarDate = ( + state: ShadowDateState, + year: number, + dayOfYear: number, + location: SolarLocation +): ShadowDateState => + updateShadowDateState(state, { ...state, year, dayOfYear }, location); + +export const updateShadowToCurrentDate = ( + state: ShadowDateState, + location: SolarLocation, + instant = new Date() +): ShadowDateState => { + const today = getSolarSelectionForInstant(instant, state.timeZone); + return updateShadowDateState( + state, + { ...today, minutes: state.minutes }, + location + ); +}; + +export const resetShadowDateState = ( + state: ShadowDateState, + location: SolarLocation, + instant = new Date() +): ShadowDateState => { + const now = getSolarSelectionForInstant(instant, state.timeZone); + return clampSelectionToDaylight(now, location) ?? state; +}; diff --git a/libraries/mapping/shadow-simulation/src/lib/core/shadow-state.spec.ts b/libraries/mapping/shadow-simulation/src/lib/core/shadow-state.spec.ts new file mode 100644 index 0000000000..c40f8e5984 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/core/shadow-state.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { createInitialShadowSimulationState } from "./create-shadow-simulation-state"; +import { resetShadowSimulationState } from "./shadow-state"; + +const initialState = createInitialShadowSimulationState(undefined); + +describe("shadow state transitions", () => { + it("resets transient display and animation state", () => { + const state = resetShadowSimulationState({ + ...initialState, + isAnimating: true, + showProjectionDebugView: true, + showMapStyleContent: false, + }); + + expect(state.isAnimating).toBe(false); + expect(state.showProjectionDebugView).toBe(false); + expect(state.showMapStyleContent).toBe(true); + }); +}); diff --git a/libraries/mapping/shadow-simulation/src/lib/core/shadow-state.ts b/libraries/mapping/shadow-simulation/src/lib/core/shadow-state.ts new file mode 100644 index 0000000000..cf811a2948 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/core/shadow-state.ts @@ -0,0 +1,23 @@ +import { + SHADOW_ANIMATION_MODE, + type ShadowSimulationState, +} from "../contracts/shadow-simulation"; + +export const resetShadowSimulationState = ( + state: ShadowSimulationState +): ShadowSimulationState => { + return { + ...state, + animationMode: SHADOW_ANIMATION_MODE.DAY, + animationSpeed: 4, + isAnimating: false, + shadowIntensity: 1, + showSunDebugVector: false, + showTileBounds: false, + showProjectionDebugView: false, + showMapStyleContent: true, + showMapStyleLabels: true, + useTransmittanceLut: true, + useSkyIrradianceLut: true, + }; +}; diff --git a/libraries/mapping/shadow-simulation/src/lib/core/shadow-types.ts b/libraries/mapping/shadow-simulation/src/lib/core/shadow-types.ts new file mode 100644 index 0000000000..d3b806f4fd --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/core/shadow-types.ts @@ -0,0 +1,28 @@ +import { clamp } from "@carma-commons/math"; + +export type ShadowQualityMultiplier = 4 | 16 | 64; +export type MeshErrorTargetPixels = 0.25 | 1 | 4; + +export const DEFAULT_SHADOW_QUALITY: ShadowQualityMultiplier = 64; +export const DEFAULT_MESH_ERROR_TARGET_PIXELS: MeshErrorTargetPixels = 4; +export const DEFAULT_SHADOW_SURFACE_COLOR = "#d3d3d3"; +export const DEFAULT_SHADOW_BUILDING_COLOR_MIX = 0.05; +export const DEFAULT_SHADOW_BUILDING_TEXTURE_SATURATION = 1; +export const DEFAULT_SHADOW_BUILDING_COLOR = "#ffffff"; + +export const resolveShadowQuality = ( + quality: number | undefined +): ShadowQualityMultiplier => + quality === 4 || quality === 16 || quality === 64 + ? quality + : DEFAULT_SHADOW_QUALITY; + +export const resolveShadowSurfaceColor = (value: unknown): string => { + if (typeof value === "string" && /^#[\da-f]{6}$/i.test(value)) return value; + if (typeof value === "number" && Number.isFinite(value)) { + return `#${clamp(Math.round(value), 0, 0xffffff) + .toString(16) + .padStart(6, "0")}`; + } + return DEFAULT_SHADOW_SURFACE_COLOR; +}; diff --git a/libraries/mapping/shadow-simulation/src/lib/core/solar-location.spec.ts b/libraries/mapping/shadow-simulation/src/lib/core/solar-location.spec.ts new file mode 100644 index 0000000000..0c0747dcf9 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/core/solar-location.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { areSolarLocationsEqual, resolveSolarLocation } from "./solar-location"; + +describe("solar location", () => { + const fallback = { latitude: 51.256, longitude: 7.15 }; + + it("uses an available geographic position", () => { + expect( + resolveSolarLocation( + { latitude: 51.3, longitude: 7.2 }, + fallback + ) + ).toEqual({ + latitude: 51.3, + longitude: 7.2, + }); + }); + + it("uses the fallback and compares locations by value", () => { + const location = resolveSolarLocation(null, fallback); + expect(location).toEqual(fallback); + expect(areSolarLocationsEqual(location, { ...location })).toBe(true); + }); +}); diff --git a/libraries/mapping/shadow-simulation/src/lib/core/solar-location.ts b/libraries/mapping/shadow-simulation/src/lib/core/solar-location.ts new file mode 100644 index 0000000000..21c7ed7c6a --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/core/solar-location.ts @@ -0,0 +1,21 @@ +import type { SolarLocation } from "./solar-position"; + +export type GeographicPosition = Readonly<{ + latitude: number; + longitude: number; +}>; + +export const resolveSolarLocation = ( + position: GeographicPosition | null, + fallback: GeographicPosition +): SolarLocation => ({ + latitude: position?.latitude ?? fallback.latitude, + longitude: position?.longitude ?? fallback.longitude, +}); + +export const areSolarLocationsEqual = ( + left: SolarLocation, + right: SolarLocation +): boolean => + left.latitude === right.latitude && + left.longitude === right.longitude; diff --git a/libraries/mapping/shadow-simulation/src/lib/core/solar-position.spec.ts b/libraries/mapping/shadow-simulation/src/lib/core/solar-position.spec.ts new file mode 100644 index 0000000000..62df61b11d --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/core/solar-position.spec.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; + +import { getDaysInYear } from "@carma-commons/utils"; + +import { + clampSelectionToDaylight, + getDaylightWindow, + getSolarPosition, + MEAN_SOLAR_ANGULAR_RADIUS_DEGREES, + solarSelectionToInstant, + type SolarLocation, + type SolarSelection, +} from "./solar-position"; + +const BERLIN = "Europe/Berlin"; +const WUPPERTAL: SolarLocation = { + latitude: 51.256, + longitude: 7.15, +}; +const createSelection = ( + dayOfYear: number, + minutes: number, + year = 2026 +): SolarSelection => ({ year, dayOfYear, minutes, timeZone: BERLIN }); + +describe("solar position", () => { + it("models the long Wuppertal summer day in local civil time", () => { + const daylight = getDaylightWindow(createSelection(172, 720), WUPPERTAL); + + expect(daylight.sunriseMinutes).toBeGreaterThan(300); + expect(daylight.sunriseMinutes).toBeLessThan(340); + expect(daylight.sunsetMinutes).toBeGreaterThan(1_290); + expect(daylight.sunsetMinutes).toBeLessThan(1_330); + }); + + it("clamps night input to the daylight curve", () => { + const selection = clampSelectionToDaylight( + createSelection(172, 120), + WUPPERTAL + ); + const daylight = getDaylightWindow(createSelection(172, 120), WUPPERTAL); + + expect(selection).not.toBeNull(); + expect(selection?.minutes).toBe(Math.ceil(daylight.sunriseMinutes)); + }); + + it("limits selection to the lower solar limb touching the horizon", () => { + const daylight = getDaylightWindow(createSelection(64, 720), WUPPERTAL); + const sunrisePosition = getSolarPosition( + createSelection(64, daylight.sunriseMinutes), + WUPPERTAL + ); + const sunsetPosition = getSolarPosition( + createSelection(64, daylight.sunsetMinutes), + WUPPERTAL + ); + + expect(sunrisePosition.elevationDegrees).toBeCloseTo( + MEAN_SOLAR_ANGULAR_RADIUS_DEGREES, + 3 + ); + expect(sunsetPosition.elevationDegrees).toBeCloseTo( + MEAN_SOLAR_ANGULAR_RADIUS_DEGREES, + 3 + ); + + const earliestSelection = clampSelectionToDaylight( + createSelection(64, 0), + WUPPERTAL + ); + const latestSelection = clampSelectionToDaylight( + createSelection(64, 24 * 60 - 1), + WUPPERTAL + ); + + expect(earliestSelection?.minutes).toBe(Math.ceil(daylight.sunriseMinutes)); + expect(latestSelection?.minutes).toBe(Math.floor(daylight.sunsetMinutes)); + expect( + getSolarPosition(earliestSelection!, WUPPERTAL).elevationDegrees + ).toBeGreaterThanOrEqual(MEAN_SOLAR_ANGULAR_RADIUS_DEGREES); + expect( + getSolarPosition(latestSelection!, WUPPERTAL).elevationDegrees + ).toBeGreaterThanOrEqual(MEAN_SOLAR_ANGULAR_RADIUS_DEGREES); + }); + + it("keeps every selectable whole-minute edge above the horizon", () => { + const selectedEdgeElevations: number[] = []; + const excludedEdgeElevations: number[] = []; + + for (let dayOfYear = 1; dayOfYear <= getDaysInYear(2026); dayOfYear += 1) { + const daylight = getDaylightWindow( + createSelection(dayOfYear, 720), + WUPPERTAL + ); + const earliestMinutes = Math.ceil(daylight.sunriseMinutes); + const latestMinutes = Math.floor(daylight.sunsetMinutes); + selectedEdgeElevations.push( + getSolarPosition( + createSelection(dayOfYear, earliestMinutes), + WUPPERTAL + ).elevationDegrees, + getSolarPosition( + createSelection(dayOfYear, latestMinutes), + WUPPERTAL + ).elevationDegrees + ); + excludedEdgeElevations.push( + getSolarPosition( + createSelection(dayOfYear, earliestMinutes - 1), + WUPPERTAL + ).elevationDegrees, + getSolarPosition( + createSelection(dayOfYear, latestMinutes + 1), + WUPPERTAL + ).elevationDegrees + ); + } + + expect(Math.min(...selectedEdgeElevations)).toBeGreaterThanOrEqual( + MEAN_SOLAR_ANGULAR_RADIUS_DEGREES + ); + expect(Math.max(...excludedEdgeElevations)).toBeLessThan( + MEAN_SOLAR_ANGULAR_RADIUS_DEGREES + ); + }); + + it("keeps local wall-clock time stable across the named time zone", () => { + expect( + solarSelectionToInstant(createSelection(172, 12 * 60)).toISOString() + ).toBe("2026-06-21T10:00:00.000Z"); + }); + + it("places the summer-noon sun high in the southern sky", () => { + const daylight = getDaylightWindow(createSelection(172, 720), WUPPERTAL); + const position = getSolarPosition( + createSelection(172, daylight.solarNoonMinutes), + WUPPERTAL + ); + + expect(position.azimuthDegrees).toBeGreaterThan(175); + expect(position.azimuthDegrees).toBeLessThan(185); + expect(position.elevationDegrees).toBeGreaterThan(60); + expect(position.elevationDegrees).toBeLessThan(64); + }); + + it("keeps a March late morning selection above the local horizon", () => { + const position = getSolarPosition( + createSelection(71, 11 * 60 + 3), + WUPPERTAL + ); + + expect(position.azimuthDegrees).toBeGreaterThan(140); + expect(position.azimuthDegrees).toBeLessThan(160); + expect(position.elevationDegrees).toBeGreaterThan(25); + expect(position.elevationDegrees).toBeLessThan(35); + }); +}); diff --git a/libraries/mapping/shadow-simulation/src/lib/core/solar-position.ts b/libraries/mapping/shadow-simulation/src/lib/core/solar-position.ts new file mode 100644 index 0000000000..bb315abd51 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/core/solar-position.ts @@ -0,0 +1,252 @@ +import { + AstroTime, + Body, + Equator, + GeoVector, + Horizon, + Observer, + RotateVector, + Rotation_EQJ_EQD, + SearchAltitude, + SearchHourAngle, + SiderealTime, +} from "astronomy-engine"; + +import { clamp } from "@carma-commons/math"; +import { + getDaysInYear, + instantToZonedYearDayTime, + offsetYearDay, + type YearDayTime, + type ZonedYearDayTime, + zonedYearDayTimeToInstant, +} from "@carma-commons/utils"; + +const MINUTES_PER_DAY = 24 * 60; +const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1_000; +const RADIANS_PER_SIDEREAL_HOUR = Math.PI / 12; + +export const MEAN_SOLAR_ANGULAR_RADIUS_DEGREES = 0.2666; + +export type SolarLocation = { + latitude: number; + longitude: number; +}; + +export const DEFAULT_SHADOW_SIMULATION_LOCATION: SolarLocation = { + latitude: 51.256, + longitude: 7.15, +}; + +export const DEFAULT_SHADOW_SIMULATION_TIME_ZONE = "Europe/Berlin"; + +export type SolarSelection = ZonedYearDayTime; + +export type DaylightWindow = { + sunriseMinutes: number; + solarNoonMinutes: number; + sunsetMinutes: number; + polarDay: boolean; + polarNight: boolean; +}; + +export type SolarPosition = { + instant: Date; + azimuthDegrees: number; + elevationDegrees: number; +}; + +export const solarSelectionToInstant = (selection: SolarSelection): Date => { + const minutes = clamp(selection.minutes, 0, MINUTES_PER_DAY - 1); + const wholeMinutes = Math.floor(minutes); + const wholeMinuteInstant = zonedYearDayTimeToInstant({ + ...selection, + dayOfYear: clamp( + Math.round(selection.dayOfYear), + 1, + getDaysInYear(selection.year) + ), + minutes: wholeMinutes, + }); + return new Date( + wholeMinuteInstant.getTime() + (minutes - wholeMinutes) * 60_000 + ); +}; + +export const getSolarSelectionForInstant = ( + instant: Date, + timeZone: string +): SolarSelection => { + return instantToZonedYearDayTime(instant, timeZone); +}; + +const createObserver = ({ latitude, longitude }: SolarLocation) => + new Observer(latitude, longitude, 0); + +const isOnLocalDay = ( + instant: Date, + selection: Pick +) => { + const local = instantToZonedYearDayTime(instant, selection.timeZone); + return ( + local.year === selection.year && local.dayOfYear === selection.dayOfYear + ); +}; + +const getLocalEventMinutes = ( + event: AstroTime | null, + selection: Pick +): number | null => { + if (!event || !isOnLocalDay(event.date, selection)) return null; + const local = instantToZonedYearDayTime(event.date, selection.timeZone); + return ( + local.minutes + + event.date.getUTCSeconds() / 60 + + event.date.getUTCMilliseconds() / 60_000 + ); +}; + +export const getSolarDirectionECEF = ( + instant: Date +): readonly [number, number, number] => { + const time = new AstroTime(instant); + const equatorialOfDate = RotateVector( + Rotation_EQJ_EQD(time), + GeoVector(Body.Sun, time, true) + ); + const siderealAngle = SiderealTime(time) * RADIANS_PER_SIDEREAL_HOUR; + const cosSiderealAngle = Math.cos(siderealAngle); + const sinSiderealAngle = Math.sin(siderealAngle); + const x = + cosSiderealAngle * equatorialOfDate.x + + sinSiderealAngle * equatorialOfDate.y; + const y = + -sinSiderealAngle * equatorialOfDate.x + + cosSiderealAngle * equatorialOfDate.y; + const inverseLength = 1 / Math.hypot(x, y, equatorialOfDate.z); + + return [ + x * inverseLength, + y * inverseLength, + equatorialOfDate.z * inverseLength, + ]; +}; + +export const getDaylightWindow = ( + selection: Pick, + location: SolarLocation +): DaylightWindow => { + const { year, dayOfYear } = selection; + const safeDay = clamp(Math.round(dayOfYear), 1, getDaysInYear(year)); + const localDay = { ...selection, year, dayOfYear: safeDay }; + const dayStart = solarSelectionToInstant({ ...localDay, minutes: 0 }); + const nextDay = offsetYearDay({ year, dayOfYear: safeDay }, 1); + const dayEnd = solarSelectionToInstant({ + ...localDay, + ...nextDay, + minutes: 0, + }); + const searchDays = + (dayEnd.getTime() - dayStart.getTime()) / MILLISECONDS_PER_DAY; + const observer = createObserver(location); + const sunriseMinutes = getLocalEventMinutes( + SearchAltitude( + Body.Sun, + observer, + 1, + dayStart, + searchDays, + MEAN_SOLAR_ANGULAR_RADIUS_DEGREES + ), + localDay + ); + const sunsetMinutes = getLocalEventMinutes( + SearchAltitude( + Body.Sun, + observer, + -1, + dayStart, + searchDays, + MEAN_SOLAR_ANGULAR_RADIUS_DEGREES + ), + localDay + ); + const solarNoonEvent = SearchHourAngle(Body.Sun, observer, 0, dayStart, 1); + const solarNoonMinutes = + getLocalEventMinutes(solarNoonEvent.time, localDay) ?? MINUTES_PER_DAY / 2; + const startsInDaylight = + getSolarPosition({ ...localDay, minutes: 0 }, location).elevationDegrees >= + MEAN_SOLAR_ANGULAR_RADIUS_DEGREES; + const polarDay = + sunriseMinutes === null && sunsetMinutes === null && startsInDaylight; + const polarNight = + sunriseMinutes === null && sunsetMinutes === null && !startsInDaylight; + + return { + sunriseMinutes: sunriseMinutes ?? (startsInDaylight ? 0 : solarNoonMinutes), + solarNoonMinutes, + sunsetMinutes: + sunsetMinutes ?? (startsInDaylight ? MINUTES_PER_DAY : solarNoonMinutes), + polarDay, + polarNight, + }; +}; + +export const clampSelectionToDaylight = ( + selection: SolarSelection, + location: SolarLocation +): SolarSelection | null => { + const dayOfYear = clamp( + Math.round(selection.dayOfYear), + 1, + getDaysInYear(selection.year) + ); + const daylight = getDaylightWindow({ ...selection, dayOfYear }, location); + if (daylight.polarNight) return null; + const minimum = daylight.polarDay ? 0 : Math.ceil(daylight.sunriseMinutes); + const maximum = daylight.polarDay + ? MINUTES_PER_DAY - 1 + : Math.floor(daylight.sunsetMinutes); + if (minimum > maximum) return null; + return { + year: selection.year, + dayOfYear, + minutes: clamp(Math.round(selection.minutes), minimum, maximum), + timeZone: selection.timeZone, + }; +}; + +export const getSolarPosition = ( + selection: SolarSelection, + location: SolarLocation +): SolarPosition => { + const instant = solarSelectionToInstant(selection); + const observer = createObserver(location); + const equatorial = Equator(Body.Sun, instant, observer, true, true); + const horizontal = Horizon(instant, observer, equatorial.ra, equatorial.dec); + + return { + instant, + azimuthDegrees: horizontal.azimuth, + elevationDegrees: horizontal.altitude, + }; +}; + +const resolveShadowSimulationLocation = ( + location: Partial +): SolarLocation => ({ + latitude: location.latitude ?? DEFAULT_SHADOW_SIMULATION_LOCATION.latitude, + longitude: location.longitude ?? DEFAULT_SHADOW_SIMULATION_LOCATION.longitude, +}); + +export const clampShadowSimulationSelectionToDaylight = ( + selection: YearDayTime, + location: Partial & { timeZone?: string } = {} +): SolarSelection | null => + clampSelectionToDaylight( + { + ...selection, + timeZone: location.timeZone ?? DEFAULT_SHADOW_SIMULATION_TIME_ZONE, + }, + resolveShadowSimulationLocation(location) + ); diff --git a/libraries/mapping/shadow-simulation/src/lib/runtime/ShadowSimulationRuntime.tsx b/libraries/mapping/shadow-simulation/src/lib/runtime/ShadowSimulationRuntime.tsx new file mode 100644 index 0000000000..bbf25b5df0 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/runtime/ShadowSimulationRuntime.tsx @@ -0,0 +1,217 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { Map as MaplibreMap } from "maplibre-gl"; + +import { clamp } from "@carma-commons/math"; +import { + getSharedThreeSceneRuntimes, + MAPLIBRE_EVENT, +} from "@carma-mapping/engines/maplibre"; + +import type { + ShadowDateState, + ShadowSimulationState, + ShadowTerrainOptions, +} from "../contracts/shadow-simulation"; +import { + getSolarPosition, + type SolarLocation, +} from "../core/solar-position"; +import { + DEFAULT_MESH_ERROR_TARGET_PIXELS, + DEFAULT_SHADOW_BUILDING_COLOR, + DEFAULT_SHADOW_BUILDING_COLOR_MIX, + DEFAULT_SHADOW_BUILDING_TEXTURE_SATURATION, + DEFAULT_SHADOW_SURFACE_COLOR, + resolveShadowQuality, +} from "../core/shadow-types"; +import { + buildShadowSimulationScene, + type ShadowSimulationScene, +} from "./shadow-scene"; + +export const ShadowSimulationRuntime = ({ + libreMap, + shadowAreaMeters, + terrain, + location, + state, + dateState, +}: { + libreMap: MaplibreMap | null; + shadowAreaMeters?: number; + terrain?: ShadowTerrainOptions; + location: SolarLocation; + state: ShadowSimulationState; + dateState: ShadowDateState; +}) => { + const shadowScene = useRef(null); + const [sceneRevision, setSceneRevision] = useState(0); + const solarPosition = useMemo( + () => getSolarPosition(dateState, location), + [dateState, location] + ); + + useEffect(() => { + if (!libreMap || !state.enabled) return; + // URL state can enable the simulation before the style is ready. + let scene: ShadowSimulationScene | null = null; + const tryBuild = () => { + if (scene || !libreMap.isStyleLoaded()) return; + libreMap.off(MAPLIBRE_EVENT.STYLE_DATA, tryBuild); + libreMap.off(MAPLIBRE_EVENT.STYLE_LOAD, tryBuild); + scene = buildShadowSimulationScene(libreMap, { + shadowAreaMeters, + terrain, + }); + shadowScene.current = scene; + setSceneRevision((revision) => revision + 1); + }; + tryBuild(); + if (!scene) { + libreMap.on(MAPLIBRE_EVENT.STYLE_DATA, tryBuild); + libreMap.on(MAPLIBRE_EVENT.STYLE_LOAD, tryBuild); + } + return () => { + libreMap.off(MAPLIBRE_EVENT.STYLE_DATA, tryBuild); + libreMap.off(MAPLIBRE_EVENT.STYLE_LOAD, tryBuild); + shadowScene.current = null; + scene?.dispose(); + scene = null; + }; + }, [libreMap, shadowAreaMeters, state.enabled, terrain]); + + useEffect(() => { + if (!state.enabled) return; + shadowScene.current?.updateSolarPosition(solarPosition); + }, [solarPosition, state.enabled, sceneRevision]); + + useEffect(() => { + if (!state.enabled) return; + shadowScene.current?.updateShadowQuality( + resolveShadowQuality(state.shadowQuality) + ); + }, [state.enabled, state.shadowQuality, sceneRevision]); + + useEffect(() => { + if (!state.enabled) return; + shadowScene.current?.updateMeshErrorTarget( + state.meshErrorTarget ?? DEFAULT_MESH_ERROR_TARGET_PIXELS + ); + }, [state.enabled, state.meshErrorTarget, sceneRevision]); + + useEffect(() => { + if (!state.enabled) return; + shadowScene.current?.updateSoftSunShadows(state.softSunShadows ?? true); + }, [state.enabled, state.softSunShadows, sceneRevision]); + + useEffect(() => { + if (!state.enabled) return; + shadowScene.current?.updateTimeAnimating(state.isAnimating ?? false); + }, [state.enabled, state.isAnimating, sceneRevision]); + + useEffect(() => { + if (!state.enabled || !state.showProjectionDebugView) return; + shadowScene.current?.refreshProjectionDebug(); + }, [state.enabled, state.showProjectionDebugView, sceneRevision]); + + useEffect(() => { + if (!state.enabled) return; + shadowScene.current?.updateShadowIntensity(state.shadowIntensity ?? 1); + }, [state.enabled, state.shadowIntensity, sceneRevision]); + + useEffect(() => { + if (!state.enabled) return; + shadowScene.current?.updateMapStyleContentVisibility( + state.showMapStyleContent ?? true + ); + }, [state.enabled, state.showMapStyleContent, sceneRevision]); + + useEffect(() => { + if (!state.enabled) return; + shadowScene.current?.updateMapStyleLabelOverlayVisibility( + (state.showMapStyleContent ?? true) && (state.showMapStyleLabels ?? true) + ); + }, [ + state.enabled, + state.showMapStyleContent, + state.showMapStyleLabels, + sceneRevision, + ]); + + useEffect(() => { + if (!state.enabled) return; + shadowScene.current?.updateSunDebugVectorVisibility( + state.showSunDebugVector ?? false + ); + }, [state.enabled, state.showSunDebugVector, sceneRevision]); + + useEffect(() => { + if (!libreMap) return; + const visible = + state.enabled && + (state.showProjectionDebugView ?? false) && + (state.showTileBounds ?? false); + for (const runtime of getSharedThreeSceneRuntimes(libreMap)) { + runtime.setTileBoundsVisible?.(visible); + } + return () => { + for (const runtime of getSharedThreeSceneRuntimes(libreMap)) { + runtime.setTileBoundsVisible?.(false); + } + }; + }, [ + libreMap, + sceneRevision, + state.enabled, + state.showProjectionDebugView, + state.showTileBounds, + ]); + + useEffect(() => { + if (!state.enabled) return; + shadowScene.current?.updateAtmosphericLutUsage({ + useTransmittanceLut: state.useTransmittanceLut ?? true, + useIrradianceLut: state.useSkyIrradianceLut ?? true, + }); + }, [ + state.enabled, + state.useSkyIrradianceLut, + state.useTransmittanceLut, + sceneRevision, + ]); + + useEffect(() => { + if (!state.enabled) return; + shadowScene.current?.updateTerrainColor( + state.terrainColor ?? DEFAULT_SHADOW_SURFACE_COLOR + ); + }, [state.enabled, state.terrainColor, sceneRevision]); + + useEffect(() => { + if (!state.enabled) return; + shadowScene.current?.updateBuildingAppearance({ + fullOpacity: state.buildingsFullOpacity ?? true, + uniformColor: state.buildingColor ?? DEFAULT_SHADOW_BUILDING_COLOR, + uniformColorMix: clamp( + state.buildingColorMix ?? DEFAULT_SHADOW_BUILDING_COLOR_MIX, + 0, + 1 + ), + textureSaturation: clamp( + state.meshTextureSaturation ?? + DEFAULT_SHADOW_BUILDING_TEXTURE_SATURATION, + 0, + 1 + ), + }); + }, [ + state.buildingColor, + state.buildingColorMix, + state.buildingsFullOpacity, + state.enabled, + state.meshTextureSaturation, + sceneRevision, + ]); + + return null; +}; diff --git a/libraries/mapping/shadow-simulation/src/lib/runtime/atmospheric-sky.spec.ts b/libraries/mapping/shadow-simulation/src/lib/runtime/atmospheric-sky.spec.ts new file mode 100644 index 0000000000..75690c4902 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/runtime/atmospheric-sky.spec.ts @@ -0,0 +1,150 @@ +// @vitest-environment node + +import { SKY_RENDER_ORDER } from "@takram/three-atmosphere"; +import * as THREE from "three"; +import { describe, expect, it, vi } from "vitest"; + +import { + ATMOSPHERIC_DISPLAY_EXPOSURE, + buildAtmosphericSky, +} from "./atmospheric-sky"; + +describe("atmospheric sky", () => { + it("renders Takram's sky and sun disc in the local tangent frame", () => { + const initialAlbedo = new THREE.Color("#d8d1c4"); + const sky = buildAtmosphericSky(initialAlbedo); + const transmittanceTexture = new THREE.DataTexture(); + const irradianceTexture = new THREE.DataTexture(); + const scatteringTexture = new THREE.Data3DTexture(); + const ecefToSceneMatrix = new THREE.Matrix4().makeRotationY(0.3); + + expect(sky.mesh.visible).toBe(false); + expect(sky.mesh.frustumCulled).toBe(false); + expect(sky.mesh.renderOrder).toBe(-SKY_RENDER_ORDER); + expect(sky.mesh.castShadow).toBe(false); + expect(sky.mesh.receiveShadow).toBe(false); + expect(sky.mesh.geometry.getAttribute("position").count).toBe(3); + expect(sky.mesh.material.side).toBe(THREE.DoubleSide); + expect(sky.mesh.material.depthTest).toBe(false); + expect(sky.mesh.material.depthWrite).toBe(false); + + expect( + sky.update( + { + directionToSunECEF: new THREE.Vector3(1, 2, 3).normalize(), + ecefToSceneMatrix, + ellipsoidCenterECEF: new THREE.Vector3(-6_371_000, 0, 0), + }, + { + transmittanceTexture, + irradianceTexture, + scatteringTexture, + } + ) + ).toBe(true); + + expect(sky.mesh.visible).toBe(true); + expect(sky.mesh.material.sun).toBe(true); + expect(sky.mesh.material.moon).toBe(false); + expect(sky.mesh.material.photometric).toBe(true); + expect(sky.mesh.material.fragmentShader).toContain( + "outputColor = carmaLinearToSrgb(outputColor)" + ); + expect(sky.mesh.material.fragmentShader).toContain( + "missing terrain reveals sky rather than a dark plane" + ); + expect(sky.mesh.material.uniforms.carmaDisplayExposure.value).toBe( + ATMOSPHERIC_DISPLAY_EXPOSURE + ); + sky.mesh.onBeforeRender( + { getRenderTarget: () => null } as unknown as THREE.WebGLRenderer, + new THREE.Scene(), + new THREE.PerspectiveCamera(), + sky.mesh.geometry, + sky.mesh, + null + ); + expect(sky.mesh.material.uniforms.carmaOutputToSrgb.value).toBe(true); + sky.mesh.onBeforeRender( + { + getRenderTarget: () => ({}), + } as unknown as THREE.WebGLRenderer, + new THREE.Scene(), + new THREE.PerspectiveCamera(), + sky.mesh.geometry, + sky.mesh, + null + ); + expect(sky.mesh.material.uniforms.carmaOutputToSrgb.value).toBe(false); + expect(sky.mesh.material.transmittanceTexture).toBe(transmittanceTexture); + expect(sky.mesh.material.irradianceTexture).toBe(irradianceTexture); + expect(sky.mesh.material.scatteringTexture).toBe(scatteringTexture); + expect(sky.mesh.material.ellipsoidCenter.toArray()).toEqual([ + -6_371_000, 0, 0, + ]); + expect(sky.mesh.material.ellipsoidMatrix.equals(ecefToSceneMatrix)).toBe( + true + ); + expect(sky.mesh.material.sunDirection.toArray()).toEqual( + new THREE.Vector3(1, 2, 3).normalize().toArray() + ); + sky.updateObserverScenePosition(new THREE.Vector3(40, 475, -20)); + const renderCamera = new THREE.PerspectiveCamera(); + renderCamera.position.set(40, -5_000, -20); + renderCamera.updateMatrixWorld(true); + sky.mesh.material.copyCameraSettings(renderCamera); + expect(sky.mesh.material.uniforms.cameraPosition.value.toArray()).toEqual([ + 40, 475, -20, + ]); + const viewCamera = new THREE.PerspectiveCamera(55, 16 / 9, 2, 50_000); + viewCamera.position.set(10, 500, 30); + viewCamera.lookAt(10, 100, -500); + viewCamera.updateMatrixWorld(true); + sky.updateViewCamera(viewCamera); + sky.mesh.material.onBeforeRender( + {} as THREE.WebGLRenderer, + new THREE.Scene(), + renderCamera, + sky.mesh.geometry, + sky.mesh, + new THREE.Group() + ); + expect( + sky.mesh.material.uniforms.inverseProjectionMatrix.value.equals( + viewCamera.projectionMatrixInverse + ) + ).toBe(true); + expect( + sky.mesh.material.uniforms.inverseViewMatrix.value.equals( + viewCamera.matrixWorld + ) + ).toBe(true); + expect( + sky.update( + { + directionToSunECEF: new THREE.Vector3(Number.NaN, 0, 0), + ecefToSceneMatrix: new THREE.Matrix4().makeScale(2, 0.5, 1), + ellipsoidCenterECEF: new THREE.Vector3(), + }, + { + transmittanceTexture, + irradianceTexture, + scatteringTexture, + } + ) + ).toBe(false); + expect(sky.mesh.material.ellipsoidCenter.toArray()).toEqual([ + -6_371_000, 0, 0, + ]); + + const nextAlbedo = new THREE.Color("#eeeeee"); + sky.updateGroundAlbedo(nextAlbedo); + expect(sky.mesh.material.groundAlbedo.equals(nextAlbedo)).toBe(true); + + const geometryDispose = vi.spyOn(sky.mesh.geometry, "dispose"); + const materialDispose = vi.spyOn(sky.mesh.material, "dispose"); + sky.dispose(); + expect(geometryDispose).toHaveBeenCalledOnce(); + expect(materialDispose).toHaveBeenCalledOnce(); + }); +}); diff --git a/libraries/mapping/shadow-simulation/src/lib/runtime/atmospheric-sky.ts b/libraries/mapping/shadow-simulation/src/lib/runtime/atmospheric-sky.ts new file mode 100644 index 0000000000..3f46301a14 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/runtime/atmospheric-sky.ts @@ -0,0 +1,203 @@ +import { + getAltitudeCorrectionOffset, + SKY_RENDER_ORDER, + SkyMaterial, +} from "@takram/three-atmosphere"; +import * as THREE from "three"; + +import type { + AtmosphericSkyFrame, + AtmosphericSkyTextures, +} from "./atmospheric-sunlight"; +import { getAtmosphericSkyFrameValidationError } from "./atmospheric-sunlight"; + +export const ATMOSPHERIC_SKY_NAME = "shadow-simulation-atmospheric-sky"; +export const ATMOSPHERIC_DISPLAY_EXPOSURE = 2; + +const OUTPUT_ENCODING_UNIFORM = "carmaOutputToSrgb"; +const DISPLAY_EXPOSURE_UNIFORM = "carmaDisplayExposure"; +const cameraPositionECEFScratch = new THREE.Vector3(); + +class ObserverPositionSkyMaterial extends SkyMaterial { + readonly observerScenePosition = new THREE.Vector3(); + hasObserverScenePosition = false; + viewCamera: THREE.Camera | null = null; + + override copyCameraSettings(camera: THREE.Camera): void { + super.copyCameraSettings(camera); + if (!this.hasObserverScenePosition) return; + + const uniforms = this.uniforms; + uniforms.cameraPosition.value.copy(this.observerScenePosition); + if (!this.correctAltitude) return; + + const cameraPositionECEF = cameraPositionECEFScratch + .copy(this.observerScenePosition) + .applyMatrix4(uniforms.inverseEllipsoidMatrix.value) + .sub(uniforms.ellipsoidCenter.value); + getAltitudeCorrectionOffset( + cameraPositionECEF, + this.atmosphere.bottomRadius, + this.ellipsoid, + uniforms.altitudeCorrection.value + ); + } + + override onBeforeRender( + renderer: THREE.WebGLRenderer, + scene: THREE.Scene, + camera: THREE.Camera, + geometry: THREE.BufferGeometry, + object: THREE.Object3D, + group: THREE.Group + ): void { + super.onBeforeRender( + renderer, + scene, + this.viewCamera ?? camera, + geometry, + object, + group + ); + } +} + +const addDisplayTransform = (material: SkyMaterial) => { + material.uniforms[OUTPUT_ENCODING_UNIFORM] = new THREE.Uniform(false); + material.uniforms[DISPLAY_EXPOSURE_UNIFORM] = new THREE.Uniform( + ATMOSPHERIC_DISPLAY_EXPOSURE + ); + material.fragmentShader = material.fragmentShader + .replace( + "precision highp sampler3D;", + `precision highp sampler3D; + +uniform bool ${OUTPUT_ENCODING_UNIFORM}; +uniform float ${DISPLAY_EXPOSURE_UNIFORM}; + +vec4 carmaLinearToSrgb(vec4 value) { + return vec4( + mix( + pow(value.rgb, vec3(0.41666)) * 1.055 - vec3(0.055), + value.rgb * 12.92, + vec3(lessThanEqual(value.rgb, vec3(0.0031308))) + ), + value.a + ); +}` + ) + .replace( + "vec3 rayDirection = normalize(vRayDirection);", + `vec3 rayDirection = normalize(vRayDirection); + + // The actual ground is rendered by streamed Three geometry. Do not let the + // atmosphere shader add a second ellipsoid/zero-ground backdrop below it. + // Rays that would hit that synthetic ground sample the tangent atmosphere + // instead, so missing terrain reveals sky rather than a dark plane. + if (rayIntersectsGround(cameraPosition, rayDirection)) { + vec3 localUp = normalize(cameraPosition); + float radius = max(length(cameraPosition), u_bottom_radius); + float tangentMu = -sqrt(max( + 0.0, + 1.0 - u_bottom_radius * u_bottom_radius / (radius * radius) + )) + 1e-5; + vec3 tangent = rayDirection - localUp * dot(rayDirection, localUp); + if (dot(tangent, tangent) < 1e-8) { + tangent = normalize(cross(localUp, vec3(1.0, 0.0, 0.0))); + if (dot(tangent, tangent) < 1e-8) { + tangent = normalize(cross(localUp, vec3(0.0, 0.0, 1.0))); + } + } else { + tangent = normalize(tangent); + } + rayDirection = normalize( + tangent * sqrt(max(0.0, 1.0 - tangentMu * tangentMu)) + + localUp * tangentMu + ); + }` + ) + .replace( + "outputColor.a = 1.0;", + `outputColor.rgb *= ${DISPLAY_EXPOSURE_UNIFORM}; + outputColor.a = 1.0; + if (${OUTPUT_ENCODING_UNIFORM}) { + outputColor = carmaLinearToSrgb(outputColor); + }` + ); +}; + +export type AtmosphericSky = Readonly<{ + mesh: THREE.Mesh; + update: ( + frame: AtmosphericSkyFrame, + textures: AtmosphericSkyTextures | null + ) => boolean; + updateViewCamera: (camera: THREE.Camera) => void; + updateObserverScenePosition: (position: THREE.Vector3) => void; + updateGroundAlbedo: (color: THREE.Color) => void; + dispose: () => void; +}>; + +export const buildAtmosphericSky = ( + groundAlbedo: THREE.Color +): AtmosphericSky => { + const material = new ObserverPositionSkyMaterial({ + groundAlbedo, + moon: false, + photometric: true, + side: THREE.DoubleSide, + sun: true, + }); + addDisplayTransform(material); + material.depthTest = false; + material.depthWrite = false; + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute( + "position", + new THREE.Float32BufferAttribute([-1, -1, 0, 3, -1, 0, -1, 3, 0], 3) + ); + const mesh = new THREE.Mesh(geometry, material); + mesh.name = ATMOSPHERIC_SKY_NAME; + mesh.visible = false; + mesh.frustumCulled = false; + mesh.renderOrder = -SKY_RENDER_ORDER; + mesh.castShadow = false; + mesh.receiveShadow = false; + mesh.onBeforeRender = (renderer) => { + material.uniforms[OUTPUT_ENCODING_UNIFORM].value = + renderer.getRenderTarget() === null; + }; + + return { + mesh, + update(frame, textures) { + if (!textures) { + mesh.visible = false; + return false; + } + if (getAtmosphericSkyFrameValidationError(frame)) return false; + mesh.visible = true; + material.irradianceTexture = textures.irradianceTexture; + material.scatteringTexture = textures.scatteringTexture; + material.transmittanceTexture = textures.transmittanceTexture; + material.sunDirection.copy(frame.directionToSunECEF); + material.ellipsoidCenter.copy(frame.ellipsoidCenterECEF); + material.ellipsoidMatrix.copy(frame.ecefToSceneMatrix); + return true; + }, + updateViewCamera(camera) { + material.viewCamera = camera; + }, + updateObserverScenePosition(position) { + material.observerScenePosition.copy(position); + material.hasObserverScenePosition = true; + }, + updateGroundAlbedo(color) { + material.groundAlbedo.copy(color); + }, + dispose() { + geometry.dispose(); + material.dispose(); + }, + }; +}; diff --git a/libraries/mapping/shadow-simulation/src/lib/runtime/atmospheric-sunlight.spec.ts b/libraries/mapping/shadow-simulation/src/lib/runtime/atmospheric-sunlight.spec.ts new file mode 100644 index 0000000000..afd15d7df5 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/runtime/atmospheric-sunlight.spec.ts @@ -0,0 +1,542 @@ +// @vitest-environment node + +import { + IRRADIANCE_TEXTURE_HEIGHT, + IRRADIANCE_TEXTURE_WIDTH, + SCATTERING_TEXTURE_DEPTH, + SCATTERING_TEXTURE_HEIGHT, + SCATTERING_TEXTURE_WIDTH, + SkyLightProbe, + TRANSMITTANCE_TEXTURE_HEIGHT, + TRANSMITTANCE_TEXTURE_WIDTH, +} from "@takram/three-atmosphere"; +import { Ellipsoid, Geodetic, radians } from "@takram/three-geospatial"; +import * as THREE from "three"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getSolarPosition } from "../core/solar-position"; +import { + AtmosphericSunlightEvaluator, + ecefDirectionToSceneDirection, + evaluateAtmosphericSkyFrame, + evaluateAtmosphericSunlight, + getAtmosphericInputValidationError, + getAtmosphericSkyFrameValidationError, + getAtmosphericSunlightSampleValidationError, + type AtmosphericObserver, +} from "./atmospheric-sunlight"; + +type TextureRequest = Readonly<{ + url: string; + width?: number; + height?: number; + depth?: number; + onLoad: (texture: THREE.Texture) => void; + onError?: (error: unknown) => void; +}>; + +const textureRequests = vi.hoisted(() => [] as TextureRequest[]); + +vi.mock("@takram/three-geospatial", async (importOriginal) => { + const actual = await importOriginal< + typeof import("@takram/three-geospatial") + >(); + return { + ...actual, + createDataTextureLoader: vi.fn( + ( + _parser: unknown, + parameters?: Readonly<{ width?: number; height?: number }> + ) => ({ + load: ( + url: string, + onLoad: (texture: THREE.DataTexture) => void, + _onProgress?: (event: ProgressEvent) => void, + onError?: (error: unknown) => void + ) => { + textureRequests.push({ + url, + width: parameters?.width, + height: parameters?.height, + onLoad: onLoad as (texture: THREE.Texture) => void, + onError, + }); + }, + }) + ), + createData3DTextureLoader: vi.fn( + ( + _parser: unknown, + parameters?: Readonly<{ + width?: number; + height?: number; + depth?: number; + }> + ) => ({ + load: ( + url: string, + onLoad: (texture: THREE.Data3DTexture) => void, + _onProgress?: (event: ProgressEvent) => void, + onError?: (error: unknown) => void + ) => { + textureRequests.push({ + url, + width: parameters?.width, + height: parameters?.height, + depth: parameters?.depth, + onLoad: onLoad as (texture: THREE.Texture) => void, + onError, + }); + }, + }) + ), + }; +}); + +const WUPPERTAL: AtmosphericObserver = { + longitude: 7.15, + latitude: 51.256, + altitudeMeters: 180, +}; + +const createConstantTexture = ( + width: number, + height: number, + value = 1 +): THREE.DataTexture => { + const data = new Float32Array(width * height * 4); + for (let offset = 0; offset < data.length; offset += 4) { + data[offset] = value; + data[offset + 1] = value; + data[offset + 2] = value; + data[offset + 3] = 1; + } + return new THREE.DataTexture( + data, + width, + height, + THREE.RGBAFormat, + THREE.FloatType + ); +}; + +const createConstant3DTexture = ( + width: number, + height: number, + depth: number +): THREE.Data3DTexture => + new THREE.Data3DTexture( + new Float32Array(width * height * depth * 4), + width, + height, + depth + ); + +const findTextureRequests = (filename: string): TextureRequest[] => + textureRequests.filter(({ url }) => url.endsWith(`/${filename}`)); + +describe("atmospheric sunlight", () => { + beforeEach(() => { + textureRequests.length = 0; + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("maps the local E/N/U basis to the shared E/U/S scene axes", () => { + const ecef = new Geodetic( + radians(WUPPERTAL.longitude), + radians(WUPPERTAL.latitude), + WUPPERTAL.altitudeMeters + ).toECEF(); + const east = new THREE.Vector3(); + const north = new THREE.Vector3(); + const up = new THREE.Vector3(); + Ellipsoid.WGS84.getEastNorthUpVectors(ecef, east, north, up); + + const eastScene = ecefDirectionToSceneDirection(east, WUPPERTAL); + expect(eastScene.x).toBeCloseTo(1, 12); + expect(eastScene.y).toBeCloseTo(0, 12); + expect(eastScene.z).toBeCloseTo(0, 12); + const northScene = ecefDirectionToSceneDirection(north, WUPPERTAL); + expect(northScene.x).toBeCloseTo(0, 12); + expect(northScene.y).toBeCloseTo(0, 12); + expect(northScene.z).toBeCloseTo(-1, 12); + const upScene = ecefDirectionToSceneDirection(up, WUPPERTAL); + expect(upScene.x).toBeCloseTo(0, 12); + expect(upScene.y).toBeCloseTo(1, 12); + expect(upScene.z).toBeCloseTo(0, 12); + }); + + it("uses Takram's date and observer position for a local daytime sun", () => { + const sample = evaluateAtmosphericSunlight( + new Date("2026-06-21T10:00:00.000Z"), + WUPPERTAL, + null + ); + + expect(sample.directionToSun.length()).toBeCloseTo(1, 12); + expect(sample.elevationDegrees).toBeGreaterThan(50); + expect(sample.azimuthDegrees).toBeGreaterThan(90); + expect(sample.azimuthDegrees).toBeLessThan(270); + expect(sample.atmosphericTransmittanceReady).toBe(false); + expect(sample.atmosphericIrradianceReady).toBe(false); + expect(sample.skyIrradianceCoefficients).toBeNull(); + expect(sample.relativeIntensity).toBeGreaterThan(0); + }); + + it("uses the same astronomy model as the solar controls", () => { + const selection = { + year: 2026, + dayOfYear: 172, + minutes: 12 * 60, + timeZone: "Europe/Berlin", + }; + const position = getSolarPosition(selection, WUPPERTAL); + const sample = evaluateAtmosphericSunlight( + position.instant, + WUPPERTAL, + null + ); + const azimuthDelta = Math.abs( + ((position.azimuthDegrees - sample.azimuthDegrees + 540) % 360) - 180 + ); + + expect(azimuthDelta).toBeLessThan(0.02); + expect(position.elevationDegrees).toBeCloseTo(sample.elevationDegrees, 2); + }); + + it("keeps the sky ellipsoid fixed while the observer moves inside the AOI", () => { + const instant = new Date("2026-06-21T10:00:00.000Z"); + const skyReference = { + observer: { + longitude: WUPPERTAL.longitude, + latitude: WUPPERTAL.latitude, + altitudeMeters: 0, + }, + scenePosition: new THREE.Vector3(80, 0, -120), + }; + const first = evaluateAtmosphericSunlight( + instant, + WUPPERTAL, + null, + null, + skyReference + ); + const second = evaluateAtmosphericSunlight( + instant, + { + longitude: WUPPERTAL.longitude + 0.03, + latitude: WUPPERTAL.latitude + 0.02, + altitudeMeters: 420, + }, + null, + null, + skyReference + ); + + expect( + second.skyFrame.ecefToSceneMatrix.equals(first.skyFrame.ecefToSceneMatrix) + ).toBe(true); + expect( + second.skyFrame.ellipsoidCenterECEF.equals( + first.skyFrame.ellipsoidCenterECEF + ) + ).toBe(true); + expect(second.directionToSun.equals(first.directionToSun)).toBe(false); + }); + + it("rejects malformed atmosphere units and matrices", () => { + const instant = new Date("2026-06-21T10:00:00.000Z"); + expect( + getAtmosphericInputValidationError(instant, { + ...WUPPERTAL, + latitude: Number.NaN, + }) + ).toContain("latitude"); + expect( + getAtmosphericInputValidationError(instant, WUPPERTAL, { + observer: { ...WUPPERTAL, altitudeMeters: 0 }, + scenePosition: new THREE.Vector3(Number.POSITIVE_INFINITY, 0, 0), + }) + ).toContain("scenePosition"); + + const validFrame = evaluateAtmosphericSkyFrame(instant, WUPPERTAL); + expect(getAtmosphericSkyFrameValidationError(validFrame)).toBeNull(); + const malformedMatrix = validFrame.ecefToSceneMatrix.clone(); + malformedMatrix.elements[0] *= 2; + expect( + getAtmosphericSkyFrameValidationError({ + ...validFrame, + ecefToSceneMatrix: malformedMatrix, + }) + ).toContain("orthonormal"); + + const sample = evaluateAtmosphericSunlight(instant, WUPPERTAL, null); + expect(getAtmosphericSunlightSampleValidationError(sample)).toBeNull(); + expect( + getAtmosphericSunlightSampleValidationError({ + ...sample, + relativeIntensity: Number.NaN, + }) + ).toContain("invalid values"); + }); + + it("moves the local sky ellipsoid with its zero-altitude map anchor", () => { + const instant = new Date("2026-06-21T10:00:00.000Z"); + const observer = { + ...WUPPERTAL, + altitudeMeters: 420, + }; + const referenceObserver = { + ...WUPPERTAL, + altitudeMeters: 0, + }; + const firstPosition = new THREE.Vector3(80, 0, -120); + const secondPosition = new THREE.Vector3(200, 0, -360); + const first = evaluateAtmosphericSkyFrame(instant, observer, { + observer: referenceObserver, + scenePosition: firstPosition, + }); + const second = evaluateAtmosphericSkyFrame(instant, observer, { + observer: referenceObserver, + scenePosition: secondPosition, + }); + + expect(second.ecefToSceneMatrix.equals(first.ecefToSceneMatrix)).toBe(true); + const sceneCenterDelta = second.ellipsoidCenterECEF + .clone() + .sub(first.ellipsoidCenterECEF) + .applyMatrix4(second.ecefToSceneMatrix); + expect( + sceneCenterDelta.distanceTo(secondPosition.sub(firstPosition)) + ).toBeLessThan(1e-8); + }); + + it("orients Takram sky irradiance in the local East/Up/South frame", () => { + const irradianceTexture = createConstantTexture( + IRRADIANCE_TEXTURE_WIDTH, + IRRADIANCE_TEXTURE_HEIGHT + ); + const skyLightProbe = new SkyLightProbe({ + irradianceTexture, + ellipsoid: Ellipsoid.WGS84, + correctAltitude: false, + photometric: false, + }); + + const sample = evaluateAtmosphericSunlight( + new Date("2026-06-21T10:00:00.000Z"), + WUPPERTAL, + null, + skyLightProbe + ); + + expect(sample.atmosphericIrradianceReady).toBe(true); + expect(sample.skyIrradianceCoefficients).toHaveLength(9); + const coefficients = sample.skyIrradianceCoefficients ?? []; + expect(coefficients[0]?.length()).toBeGreaterThan(0); + expect(coefficients[1]?.length()).toBeGreaterThan(0); + expect(coefficients[2]?.length()).toBeCloseTo(0, 10); + expect(coefficients[3]?.length()).toBeCloseTo(0, 10); + for (const coefficient of coefficients.slice(4)) { + expect(coefficient.length()).toBeCloseTo(0, 12); + } + + irradianceTexture.dispose(); + }); + + it("loads LUTs independently and retries failed transmittance", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const evaluator = new AtmosphericSunlightEvaluator(); + const onReady = vi.fn(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + evaluator.ensure(onReady); + + expect(textureRequests).toMatchObject([ + { + width: TRANSMITTANCE_TEXTURE_WIDTH, + height: TRANSMITTANCE_TEXTURE_HEIGHT, + }, + { + width: IRRADIANCE_TEXTURE_WIDTH, + height: IRRADIANCE_TEXTURE_HEIGHT, + }, + ]); + const firstTransmittanceRequest = + findTextureRequests("transmittance.bin")[0]; + const irradianceRequest = findTextureRequests("irradiance.bin")[0]; + expect(firstTransmittanceRequest).toBeDefined(); + expect(irradianceRequest).toBeDefined(); + + firstTransmittanceRequest?.onError?.(new Error("transmittance failed")); + const irradianceTexture = createConstantTexture( + IRRADIANCE_TEXTURE_WIDTH, + IRRADIANCE_TEXTURE_HEIGHT + ); + const irradianceDispose = vi.spyOn(irradianceTexture, "dispose"); + irradianceRequest?.onLoad(irradianceTexture); + + expect(onReady).toHaveBeenCalledTimes(1); + let sample = evaluator.evaluate( + new Date("2026-06-21T10:00:00.000Z"), + WUPPERTAL + ); + expect(sample.atmosphericTransmittanceReady).toBe(false); + expect(sample.atmosphericIrradianceReady).toBe(true); + expect(evaluator.ready).toBe(false); + + evaluator.ensure(onReady); + expect(findTextureRequests("transmittance.bin")).toHaveLength(1); + vi.advanceTimersByTime(30_000); + evaluator.ensure(onReady); + expect(findTextureRequests("irradiance.bin")).toHaveLength(1); + expect(findTextureRequests("transmittance.bin")).toHaveLength(2); + + const transmittanceTexture = createConstantTexture( + TRANSMITTANCE_TEXTURE_WIDTH, + TRANSMITTANCE_TEXTURE_HEIGHT + ); + const transmittanceDispose = vi.spyOn(transmittanceTexture, "dispose"); + findTextureRequests("transmittance.bin")[1]?.onLoad(transmittanceTexture); + + expect(onReady).toHaveBeenCalledTimes(2); + sample = evaluator.evaluate( + new Date("2026-06-21T10:00:00.000Z"), + WUPPERTAL + ); + expect(sample.atmosphericTransmittanceReady).toBe(true); + expect(sample.atmosphericIrradianceReady).toBe(true); + expect(sample.skyIrradianceCoefficients).toHaveLength(9); + expect(evaluator.ready).toBe(true); + + evaluator.dispose(); + evaluator.dispose(); + expect(transmittanceDispose).toHaveBeenCalledTimes(1); + expect(irradianceDispose).toHaveBeenCalledTimes(1); + errorSpy.mockRestore(); + }); + + it("loads and shares all three LUTs needed by the sky material", () => { + const evaluator = new AtmosphericSunlightEvaluator(); + const onReady = vi.fn(); + + evaluator.ensureSky(onReady); + + expect(textureRequests).toMatchObject([ + { + width: TRANSMITTANCE_TEXTURE_WIDTH, + height: TRANSMITTANCE_TEXTURE_HEIGHT, + }, + { + width: IRRADIANCE_TEXTURE_WIDTH, + height: IRRADIANCE_TEXTURE_HEIGHT, + }, + { + width: SCATTERING_TEXTURE_WIDTH, + height: SCATTERING_TEXTURE_HEIGHT, + depth: SCATTERING_TEXTURE_DEPTH, + }, + ]); + const transmittanceTexture = createConstantTexture( + TRANSMITTANCE_TEXTURE_WIDTH, + TRANSMITTANCE_TEXTURE_HEIGHT + ); + const irradianceTexture = createConstantTexture( + IRRADIANCE_TEXTURE_WIDTH, + IRRADIANCE_TEXTURE_HEIGHT + ); + const scatteringTexture = createConstant3DTexture( + SCATTERING_TEXTURE_WIDTH, + SCATTERING_TEXTURE_HEIGHT, + SCATTERING_TEXTURE_DEPTH + ); + findTextureRequests("transmittance.bin")[0]?.onLoad(transmittanceTexture); + findTextureRequests("irradiance.bin")[0]?.onLoad(irradianceTexture); + expect(onReady).not.toHaveBeenCalled(); + findTextureRequests("scattering.bin")[0]?.onLoad(scatteringTexture); + + expect(onReady).toHaveBeenCalledOnce(); + expect(evaluator.skyReady).toBe(true); + expect(evaluator.skyTextures).toEqual({ + transmittanceTexture, + irradianceTexture, + scatteringTexture, + }); + + evaluator.dispose(); + }); + + it("retries failed irradiance without reloading transmittance", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const evaluator = new AtmosphericSunlightEvaluator(); + const onReady = vi.fn(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + evaluator.ensure(onReady); + + const transmittanceTexture = createConstantTexture( + TRANSMITTANCE_TEXTURE_WIDTH, + TRANSMITTANCE_TEXTURE_HEIGHT + ); + findTextureRequests("transmittance.bin")[0]?.onLoad(transmittanceTexture); + findTextureRequests("irradiance.bin")[0]?.onError?.( + new Error("irradiance failed") + ); + + expect(onReady).toHaveBeenCalledTimes(1); + expect( + evaluator.evaluate(new Date("2026-06-21T10:00:00.000Z"), WUPPERTAL) + .atmosphericIrradianceReady + ).toBe(false); + evaluator.ensure(onReady); + expect(findTextureRequests("transmittance.bin")).toHaveLength(1); + expect(findTextureRequests("irradiance.bin")).toHaveLength(1); + vi.advanceTimersByTime(30_000); + evaluator.ensure(onReady); + expect(findTextureRequests("irradiance.bin")).toHaveLength(2); + + const irradianceTexture = createConstantTexture( + IRRADIANCE_TEXTURE_WIDTH, + IRRADIANCE_TEXTURE_HEIGHT + ); + findTextureRequests("irradiance.bin")[1]?.onLoad(irradianceTexture); + expect(onReady).toHaveBeenCalledTimes(2); + expect(evaluator.ready).toBe(true); + + evaluator.dispose(); + errorSpy.mockRestore(); + }); + + it("disposes late texture results without publishing readiness", () => { + const evaluator = new AtmosphericSunlightEvaluator(); + const onReady = vi.fn(); + evaluator.ensure(onReady); + evaluator.dispose(); + + const transmittanceTexture = createConstantTexture( + TRANSMITTANCE_TEXTURE_WIDTH, + TRANSMITTANCE_TEXTURE_HEIGHT + ); + const irradianceTexture = createConstantTexture( + IRRADIANCE_TEXTURE_WIDTH, + IRRADIANCE_TEXTURE_HEIGHT + ); + const transmittanceDispose = vi.spyOn(transmittanceTexture, "dispose"); + const irradianceDispose = vi.spyOn(irradianceTexture, "dispose"); + findTextureRequests("transmittance.bin")[0]?.onLoad(transmittanceTexture); + findTextureRequests("irradiance.bin")[0]?.onLoad(irradianceTexture); + + expect(onReady).not.toHaveBeenCalled(); + expect(evaluator.ready).toBe(false); + expect(transmittanceDispose).toHaveBeenCalledTimes(1); + expect(irradianceDispose).toHaveBeenCalledTimes(1); + const requestCount = textureRequests.length; + evaluator.ensure(onReady); + expect(textureRequests).toHaveLength(requestCount); + }); +}); diff --git a/libraries/mapping/shadow-simulation/src/lib/runtime/atmospheric-sunlight.ts b/libraries/mapping/shadow-simulation/src/lib/runtime/atmospheric-sunlight.ts new file mode 100644 index 0000000000..d29f553a75 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/runtime/atmospheric-sunlight.ts @@ -0,0 +1,670 @@ +import { + DEFAULT_PRECOMPUTED_TEXTURES_URL, + getSunLightColor, + IRRADIANCE_TEXTURE_HEIGHT, + IRRADIANCE_TEXTURE_WIDTH, + SCATTERING_TEXTURE_DEPTH, + SCATTERING_TEXTURE_HEIGHT, + SCATTERING_TEXTURE_WIDTH, + SkyLightProbe, + TRANSMITTANCE_TEXTURE_HEIGHT, + TRANSMITTANCE_TEXTURE_WIDTH, +} from "@takram/three-atmosphere"; +import { + createData3DTextureLoader, + createDataTextureLoader, + Ellipsoid, + Geodetic, + parseFloat16Array, + radians, +} from "@takram/three-geospatial"; +import * as THREE from "three"; + +import { clamp } from "@carma-commons/math"; +import { radToDegNumeric } from "@carma-units"; + +import { getSolarDirectionECEF } from "../core/solar-position"; + +const FALLBACK_SUN_COLOR = new THREE.Color("#fff2d8"); +const MIN_RADIANCE = 1e-8; +const LUT_RETRY_DELAY_MS = 30_000; +const MIN_OBSERVER_ALTITUDE_METERS = -1_000; +const MAX_OBSERVER_ALTITUDE_METERS = 10_000_000; +const MIN_EARTH_CENTER_DISTANCE_METERS = 5_000_000; +const MAX_EARTH_CENTER_DISTANCE_METERS = 8_000_000; + +export type AtmosphericSunlightSample = Readonly<{ + directionToSun: THREE.Vector3; + color: THREE.Color; + relativeIntensity: number; + radiance: THREE.Color; + atmosphericTransmittanceReady: boolean; + atmosphericIrradianceReady: boolean; + skyIrradianceCoefficients: readonly THREE.Vector3[] | null; + azimuthDegrees: number; + elevationDegrees: number; + skyFrame: AtmosphericSkyFrame; +}>; + +export type AtmosphericSkyFrame = Readonly<{ + directionToSunECEF: THREE.Vector3; + ecefToSceneMatrix: THREE.Matrix4; + ellipsoidCenterECEF: THREE.Vector3; +}>; + +export type AtmosphericSkyReference = Readonly<{ + observer: AtmosphericObserver; + scenePosition: THREE.Vector3; +}>; + +export type AtmosphericSkyTextures = Readonly<{ + irradianceTexture: THREE.DataTexture; + scatteringTexture: THREE.Data3DTexture; + transmittanceTexture: THREE.DataTexture; +}>; + +export type AtmosphericObserver = Readonly<{ + longitude: number; + latitude: number; + altitudeMeters: number; +}>; + +export type AtmosphericSunlightOptions = Readonly<{ + useTransmittanceLut: boolean; + useIrradianceLut: boolean; +}>; + +const isFiniteVector3 = (value: THREE.Vector3) => + Number.isFinite(value.x) && + Number.isFinite(value.y) && + Number.isFinite(value.z); + +export const getAtmosphericObserverValidationError = ( + observer: AtmosphericObserver +): string | null => { + if ( + !Number.isFinite(observer.longitude) || + Math.abs(observer.longitude) > 180 + ) + return "longitude must be a finite value in degrees within [-180, 180]"; + if (!Number.isFinite(observer.latitude) || Math.abs(observer.latitude) > 90) + return "latitude must be a finite value in degrees within [-90, 90]"; + if ( + !Number.isFinite(observer.altitudeMeters) || + observer.altitudeMeters < MIN_OBSERVER_ALTITUDE_METERS || + observer.altitudeMeters > MAX_OBSERVER_ALTITUDE_METERS + ) + return `altitudeMeters must be within [${MIN_OBSERVER_ALTITUDE_METERS}, ${MAX_OBSERVER_ALTITUDE_METERS}]`; + return null; +}; + +export const getAtmosphericInputValidationError = ( + instant: Date, + observer: AtmosphericObserver, + skyReference?: AtmosphericSkyReference +): string | null => { + if (!Number.isFinite(instant.getTime())) + return "instant must be a valid Date"; + const observerError = getAtmosphericObserverValidationError(observer); + if (observerError) return `observer ${observerError}`; + if (!skyReference) return null; + const referenceObserverError = getAtmosphericObserverValidationError( + skyReference.observer + ); + if (referenceObserverError) + return `sky reference observer ${referenceObserverError}`; + if (!isFiniteVector3(skyReference.scenePosition)) + return "sky reference scenePosition must contain finite metre coordinates"; + return null; +}; + +export const getAtmosphericSkyFrameValidationError = ( + frame: AtmosphericSkyFrame +): string | null => { + if (!isFiniteVector3(frame.directionToSunECEF)) + return "sun direction contains a non-finite component"; + if (Math.abs(frame.directionToSunECEF.length() - 1) > 1e-6) + return "sun direction is not normalized"; + if (!frame.ecefToSceneMatrix.elements.every(Number.isFinite)) + return "ECEF-to-scene matrix contains a non-finite component"; + const elements = frame.ecefToSceneMatrix.elements; + const rows = [ + new THREE.Vector3(elements[0], elements[4], elements[8]), + new THREE.Vector3(elements[1], elements[5], elements[9]), + new THREE.Vector3(elements[2], elements[6], elements[10]), + ]; + if ( + rows.some((row) => Math.abs(row.length() - 1) > 1e-6) || + Math.abs(rows[0].dot(rows[1])) > 1e-6 || + Math.abs(rows[0].dot(rows[2])) > 1e-6 || + Math.abs(rows[1].dot(rows[2])) > 1e-6 || + Math.abs(elements[3]) > 1e-12 || + Math.abs(elements[7]) > 1e-12 || + Math.abs(elements[11]) > 1e-12 || + Math.abs(elements[12]) > 1e-12 || + Math.abs(elements[13]) > 1e-12 || + Math.abs(elements[14]) > 1e-12 || + Math.abs(elements[15] - 1) > 1e-12 + ) + return "ECEF-to-scene matrix is not an affine orthonormal rotation"; + const determinant = frame.ecefToSceneMatrix.determinant(); + if (!Number.isFinite(determinant) || Math.abs(determinant - 1) > 1e-6) + return "ECEF-to-scene matrix is not a proper orthonormal rotation"; + if (!isFiniteVector3(frame.ellipsoidCenterECEF)) + return "ellipsoid center contains a non-finite component"; + const centerDistance = frame.ellipsoidCenterECEF.length(); + if ( + centerDistance < MIN_EARTH_CENTER_DISTANCE_METERS || + centerDistance > MAX_EARTH_CENTER_DISTANCE_METERS + ) + return "ellipsoid center is outside the plausible WGS84 distance range"; + return null; +}; + +export const getAtmosphericSunlightSampleValidationError = ( + sample: AtmosphericSunlightSample +): string | null => { + const frameError = getAtmosphericSkyFrameValidationError(sample.skyFrame); + if (frameError) return frameError; + if (!isFiniteVector3(sample.directionToSun)) + return "scene sun direction contains a non-finite component"; + if (Math.abs(sample.directionToSun.length() - 1) > 1e-6) + return "scene sun direction is not normalized"; + if ( + ![sample.color.r, sample.color.g, sample.color.b].every(Number.isFinite) || + ![sample.radiance.r, sample.radiance.g, sample.radiance.b].every( + Number.isFinite + ) || + !Number.isFinite(sample.relativeIntensity) || + sample.relativeIntensity < 0 || + sample.relativeIntensity > 1 || + !Number.isFinite(sample.azimuthDegrees) || + !Number.isFinite(sample.elevationDegrees) + ) + return "sunlight color, intensity, or angles contain invalid values"; + if ( + sample.skyIrradianceCoefficients?.some( + (coefficient) => !isFiniteVector3(coefficient) + ) + ) + return "sky irradiance contains a non-finite coefficient"; + return null; +}; + +const DEFAULT_ATMOSPHERIC_SUNLIGHT_OPTIONS: AtmosphericSunlightOptions = { + useTransmittanceLut: true, + useIrradianceLut: true, +}; + +type ObserverFrame = ReturnType; + +const getEcefToSceneMatrix = ({ east, north, up }: ObserverFrame) => + new THREE.Matrix4().set( + east.x, + east.y, + east.z, + 0, + up.x, + up.y, + up.z, + 0, + -north.x, + -north.y, + -north.z, + 0, + 0, + 0, + 0, + 1 + ); + +function getObserverFrame({ + longitude, + latitude, + altitudeMeters, +}: AtmosphericObserver) { + const observerECEF = new Geodetic( + radians(longitude), + radians(latitude), + altitudeMeters + ).toECEF(); + const east = new THREE.Vector3(); + const north = new THREE.Vector3(); + const up = new THREE.Vector3(); + Ellipsoid.WGS84.getEastNorthUpVectors(observerECEF, east, north, up); + return { observerECEF, east, north, up }; +} + +const ecefDirectionToSceneDirectionWithFrame = ( + directionECEF: THREE.Vector3, + { east, north, up }: ObserverFrame, + target: THREE.Vector3 +): THREE.Vector3 => + target + .set( + directionECEF.dot(east), + directionECEF.dot(up), + -directionECEF.dot(north) + ) + .normalize(); + +const buildAtmosphericSkyFrame = ( + sunDirectionECEF: THREE.Vector3, + observerFrame: ObserverFrame, + skyReference?: AtmosphericSkyReference +): AtmosphericSkyFrame => { + const skyReferenceFrame = skyReference + ? getObserverFrame(skyReference.observer) + : observerFrame; + const ecefToSceneMatrix = getEcefToSceneMatrix(skyReferenceFrame); + const sceneToEcefMatrix = ecefToSceneMatrix.clone().invert(); + const ellipsoidCenterECEF = ( + skyReference?.scenePosition ?? new THREE.Vector3() + ) + .clone() + .applyMatrix4(sceneToEcefMatrix) + .sub(skyReferenceFrame.observerECEF); + return { + directionToSunECEF: sunDirectionECEF.clone(), + ecefToSceneMatrix, + ellipsoidCenterECEF, + }; +}; + +export const evaluateAtmosphericSkyFrame = ( + instant: Date, + observer: AtmosphericObserver, + skyReference?: AtmosphericSkyReference +): AtmosphericSkyFrame => + buildAtmosphericSkyFrame( + new THREE.Vector3(...getSolarDirectionECEF(instant)), + getObserverFrame(observer), + skyReference + ); + +/** Convert an ECEF direction into the shared scene's E/U/S axes. */ +export const ecefDirectionToSceneDirection = ( + directionECEF: THREE.Vector3, + observer: AtmosphericObserver, + target = new THREE.Vector3() +): THREE.Vector3 => { + return ecefDirectionToSceneDirectionWithFrame( + directionECEF, + getObserverFrame(observer), + target + ); +}; + +const evaluateSkyIrradiance = ( + skyLightProbe: SkyLightProbe | null, + sunDirectionECEF: THREE.Vector3, + { observerECEF, east, north, up }: ObserverFrame +): readonly THREE.Vector3[] | null => { + if (!skyLightProbe?.irradianceTexture) return null; + + // SkyLightProbe treats ellipsoidMatrix as ECEF-to-world orientation and + // ellipsoidCenter as the ECEF offset subtracted after that inverse transform. + // This rotation maps ECEF into the shared local +East/+Up/-North scene frame; + // negating the observer position makes the probe's local origin evaluate at + // the observer without putting translation into the normal transform. + skyLightProbe.ellipsoidMatrix.set( + east.x, + east.y, + east.z, + 0, + up.x, + up.y, + up.z, + 0, + -north.x, + -north.y, + -north.z, + 0, + 0, + 0, + 0, + 1 + ); + skyLightProbe.ellipsoidCenter.copy(observerECEF).negate(); + skyLightProbe.sunDirection.copy(sunDirectionECEF); + skyLightProbe.position.set(0, 0, 0); + skyLightProbe.updateMatrixWorld(true); + skyLightProbe.update(); + return skyLightProbe.sh.coefficients.map((coefficient) => + coefficient.clone() + ); +}; + +const getSceneAngles = (direction: THREE.Vector3) => { + const elevationDegrees = radToDegNumeric( + Math.asin(clamp(direction.y, -1, 1)) + ); + const azimuthDegrees = + (radToDegNumeric(Math.atan2(direction.x, -direction.z)) + 360) % 360; + return { azimuthDegrees, elevationDegrees }; +}; + +export const evaluateAtmosphericSunlight = ( + instant: Date, + observer: AtmosphericObserver, + transmittanceTexture: THREE.DataTexture | null, + skyLightProbe: SkyLightProbe | null = null, + skyReference?: AtmosphericSkyReference +): AtmosphericSunlightSample => { + const observerFrame = getObserverFrame(observer); + const { observerECEF, up } = observerFrame; + const sunDirectionECEF = new THREE.Vector3(...getSolarDirectionECEF(instant)); + const directionToSun = ecefDirectionToSceneDirectionWithFrame( + sunDirectionECEF, + observerFrame, + new THREE.Vector3() + ); + const skyFrame = buildAtmosphericSkyFrame( + sunDirectionECEF, + observerFrame, + skyReference + ); + const skyIrradianceCoefficients = evaluateSkyIrradiance( + skyLightProbe, + sunDirectionECEF, + observerFrame + ); + const { azimuthDegrees, elevationDegrees } = getSceneAngles(directionToSun); + if (!transmittanceTexture) { + const relativeIntensity = Math.sqrt(clamp(directionToSun.y, 0, 1)); + return { + directionToSun, + color: FALLBACK_SUN_COLOR.clone(), + relativeIntensity, + radiance: FALLBACK_SUN_COLOR.clone().multiplyScalar(relativeIntensity), + atmosphericTransmittanceReady: false, + atmosphericIrradianceReady: skyIrradianceCoefficients !== null, + skyIrradianceCoefficients, + azimuthDegrees, + elevationDegrees, + skyFrame, + }; + } + + const radiance = getSunLightColor( + transmittanceTexture, + observerECEF, + sunDirectionECEF, + new THREE.Color(), + { + ellipsoid: Ellipsoid.WGS84, + correctAltitude: true, + photometric: true, + } + ); + const zenithRadiance = getSunLightColor( + transmittanceTexture, + observerECEF, + up, + new THREE.Color(), + { + ellipsoid: Ellipsoid.WGS84, + correctAltitude: true, + photometric: true, + } + ); + const radiancePeak = Math.max(radiance.r, radiance.g, radiance.b, 0); + const zenithPeak = Math.max( + zenithRadiance.r, + zenithRadiance.g, + zenithRadiance.b, + MIN_RADIANCE + ); + const color = + radiancePeak > MIN_RADIANCE + ? radiance.clone().multiplyScalar(1 / radiancePeak) + : new THREE.Color(0, 0, 0); + return { + directionToSun, + color, + relativeIntensity: clamp(radiancePeak / zenithPeak, 0, 1), + radiance, + atmosphericTransmittanceReady: true, + atmosphericIrradianceReady: skyIrradianceCoefficients !== null, + skyIrradianceCoefficients, + azimuthDegrees, + elevationDegrees, + skyFrame, + }; +}; + +/** + * Owns Takram's CPU-readable direct-light and sky-irradiance LUTs. Per-time + * evaluation is synchronous and never reads back the shared MapLibre framebuffer. + */ +export class AtmosphericSunlightEvaluator { + private transmittanceTexture: THREE.DataTexture | null = null; + private irradianceTexture: THREE.DataTexture | null = null; + private scatteringTexture: THREE.Data3DTexture | null = null; + private readonly skyLightProbe = new SkyLightProbe({ + ellipsoid: Ellipsoid.WGS84, + correctAltitude: true, + photometric: true, + }); + private transmittanceLoading = false; + private irradianceLoading = false; + private scatteringLoading = false; + private transmittanceRetryAt = 0; + private irradianceRetryAt = 0; + private scatteringRetryAt = 0; + private disposed = false; + + get ready(): boolean { + return ( + this.transmittanceTexture !== null && this.irradianceTexture !== null + ); + } + + get skyReady(): boolean { + return this.skyTextures !== null; + } + + get skyTextures(): AtmosphericSkyTextures | null { + if ( + !this.transmittanceTexture || + !this.irradianceTexture || + !this.scatteringTexture + ) { + return null; + } + return { + transmittanceTexture: this.transmittanceTexture, + irradianceTexture: this.irradianceTexture, + scatteringTexture: this.scatteringTexture, + }; + } + + get isSkyLoading(): boolean { + return ( + this.transmittanceLoading || + this.irradianceLoading || + this.scatteringLoading + ); + } + + isLoadingFor( + options: AtmosphericSunlightOptions = DEFAULT_ATMOSPHERIC_SUNLIGHT_OPTIONS + ): boolean { + return ( + (options.useTransmittanceLut && this.transmittanceLoading) || + (options.useIrradianceLut && this.irradianceLoading) + ); + } + + ensure( + onReady: () => void, + options: AtmosphericSunlightOptions = DEFAULT_ATMOSPHERIC_SUNLIGHT_OPTIONS + ): void { + if (this.disposed) return; + const notifyWhenSettled = () => { + if (!this.isLoadingFor(options)) onReady(); + }; + if (options.useTransmittanceLut) { + this.ensureTransmittance(notifyWhenSettled); + } + if (options.useIrradianceLut) this.ensureIrradiance(notifyWhenSettled); + } + + ensureSky(onReady: () => void): void { + if (this.disposed || this.skyReady) return; + let notified = false; + const notifyWhenSettled = () => { + if (!notified && !this.isSkyLoading) { + notified = true; + onReady(); + } + }; + this.ensureTransmittance(notifyWhenSettled); + this.ensureIrradiance(notifyWhenSettled); + this.ensureScattering(notifyWhenSettled); + } + + private ensureTransmittance(onReady: () => void): void { + if ( + this.transmittanceTexture || + this.transmittanceLoading || + Date.now() < this.transmittanceRetryAt + ) { + return; + } + this.transmittanceLoading = true; + createDataTextureLoader(parseFloat16Array, { + width: TRANSMITTANCE_TEXTURE_WIDTH, + height: TRANSMITTANCE_TEXTURE_HEIGHT, + }).load( + `${DEFAULT_PRECOMPUTED_TEXTURES_URL}/transmittance.bin`, + (transmittanceTexture) => { + this.transmittanceLoading = false; + if (this.disposed) { + transmittanceTexture.dispose(); + return; + } + this.transmittanceRetryAt = 0; + this.transmittanceTexture = transmittanceTexture; + onReady(); + }, + undefined, + (error: unknown) => { + this.transmittanceLoading = false; + if (!this.disposed) { + this.transmittanceRetryAt = Date.now() + LUT_RETRY_DELAY_MS; + console.error("[SHADOW] Takram transmittance LUT failed", error); + onReady(); + } + } + ); + } + + private ensureIrradiance(onReady: () => void): void { + if ( + this.irradianceTexture || + this.irradianceLoading || + Date.now() < this.irradianceRetryAt + ) { + return; + } + this.irradianceLoading = true; + createDataTextureLoader(parseFloat16Array, { + width: IRRADIANCE_TEXTURE_WIDTH, + height: IRRADIANCE_TEXTURE_HEIGHT, + }).load( + `${DEFAULT_PRECOMPUTED_TEXTURES_URL}/irradiance.bin`, + (irradianceTexture) => { + this.irradianceLoading = false; + if (this.disposed) { + irradianceTexture.dispose(); + return; + } + this.irradianceRetryAt = 0; + this.irradianceTexture = irradianceTexture; + this.skyLightProbe.irradianceTexture = irradianceTexture; + onReady(); + }, + undefined, + (error: unknown) => { + this.irradianceLoading = false; + if (!this.disposed) { + this.irradianceRetryAt = Date.now() + LUT_RETRY_DELAY_MS; + console.error("[SHADOW] Takram irradiance LUT failed", error); + onReady(); + } + } + ); + } + + private ensureScattering(onReady: () => void): void { + if ( + this.scatteringTexture || + this.scatteringLoading || + Date.now() < this.scatteringRetryAt + ) { + return; + } + this.scatteringLoading = true; + createData3DTextureLoader(parseFloat16Array, { + width: SCATTERING_TEXTURE_WIDTH, + height: SCATTERING_TEXTURE_HEIGHT, + depth: SCATTERING_TEXTURE_DEPTH, + }).load( + `${DEFAULT_PRECOMPUTED_TEXTURES_URL}/scattering.bin`, + (scatteringTexture) => { + this.scatteringLoading = false; + if (this.disposed) { + scatteringTexture.dispose(); + return; + } + this.scatteringRetryAt = 0; + this.scatteringTexture = scatteringTexture; + onReady(); + }, + undefined, + (error: unknown) => { + this.scatteringLoading = false; + if (!this.disposed) { + this.scatteringRetryAt = Date.now() + LUT_RETRY_DELAY_MS; + console.error("[SHADOW] Takram scattering LUT failed", error); + onReady(); + } + } + ); + } + + evaluate( + instant: Date, + observer: AtmosphericObserver, + options: AtmosphericSunlightOptions = DEFAULT_ATMOSPHERIC_SUNLIGHT_OPTIONS, + skyReference?: AtmosphericSkyReference + ): AtmosphericSunlightSample { + return evaluateAtmosphericSunlight( + instant, + observer, + options.useTransmittanceLut ? this.transmittanceTexture : null, + options.useIrradianceLut ? this.skyLightProbe : null, + skyReference + ); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.transmittanceLoading = false; + this.irradianceLoading = false; + this.scatteringLoading = false; + this.transmittanceRetryAt = 0; + this.irradianceRetryAt = 0; + this.scatteringRetryAt = 0; + this.transmittanceTexture?.dispose(); + this.irradianceTexture?.dispose(); + this.scatteringTexture?.dispose(); + this.transmittanceTexture = null; + this.irradianceTexture = null; + this.scatteringTexture = null; + this.skyLightProbe.irradianceTexture = null; + this.skyLightProbe.sh.zero(); + } +} diff --git a/libraries/mapping/shadow-simulation/src/lib/runtime/hooks/use-map-center-solar-location.ts b/libraries/mapping/shadow-simulation/src/lib/runtime/hooks/use-map-center-solar-location.ts new file mode 100644 index 0000000000..24d9327006 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/runtime/hooks/use-map-center-solar-location.ts @@ -0,0 +1,41 @@ +import { useEffect, useState } from "react"; +import type { Map as MaplibreMap } from "maplibre-gl"; + +import { areSolarLocationsEqual } from "../../core/solar-location"; +import type { SolarLocation } from "../../core/solar-position"; +import { readMapCenterSolarLocation } from "../map-center-solar-location"; + +export const useMapCenterSolarLocation = ( + libreMap: MaplibreMap | null, + fallbackLatitude: number, + fallbackLongitude: number +): SolarLocation => { + const [location, setLocation] = useState(() => + readMapCenterSolarLocation( + libreMap, + fallbackLatitude, + fallbackLongitude + ) + ); + + useEffect(() => { + const updateLocation = () => { + const next = readMapCenterSolarLocation( + libreMap, + fallbackLatitude, + fallbackLongitude + ); + setLocation((current) => + areSolarLocationsEqual(current, next) ? current : next + ); + }; + updateLocation(); + if (!libreMap) return; + libreMap.on("moveend", updateLocation); + return () => { + libreMap.off("moveend", updateLocation); + }; + }, [fallbackLatitude, fallbackLongitude, libreMap]); + + return location; +}; diff --git a/libraries/mapping/shadow-simulation/src/lib/runtime/hooks/use-shadow-animation.ts b/libraries/mapping/shadow-simulation/src/lib/runtime/hooks/use-shadow-animation.ts new file mode 100644 index 0000000000..4ced69480b --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/runtime/hooks/use-shadow-animation.ts @@ -0,0 +1,59 @@ +import { useEffect, useRef } from "react"; + +import { + type ShadowDateState, + type ShadowDateStateSetter, + type ShadowSimulationState, +} from "../../contracts/shadow-simulation"; +import { advanceShadowAnimationFrame } from "../../core/shadow-animation"; +import type { SolarLocation } from "../../core/solar-position"; + +const SHADOW_ANIMATION_INTERVAL_MS = 1000 / 30; + +export const useShadowAnimation = ({ + initialDateState, + setDateState, + location, + shadowState, +}: { + initialDateState: ShadowDateState; + setDateState: ShadowDateStateSetter; + location: SolarLocation; + shadowState: ShadowSimulationState; +}): void => { + const yearAnimationDayProgress = useRef(0); + const { animationMode, animationSpeed, enabled, isAnimating } = shadowState; + + useEffect(() => { + if (!enabled || !isAnimating) return; + const animationState = { + animationMode, + animationSpeed, + enabled, + isAnimating, + }; + yearAnimationDayProgress.current = 0; + const interval = window.setInterval(() => { + setDateState((previous) => { + const frame = advanceShadowAnimationFrame( + animationState, + previous, + initialDateState, + location, + yearAnimationDayProgress.current + ); + yearAnimationDayProgress.current = frame.yearDayProgress; + return frame.dateState; + }); + }, SHADOW_ANIMATION_INTERVAL_MS); + return () => window.clearInterval(interval); + }, [ + initialDateState, + animationMode, + animationSpeed, + enabled, + isAnimating, + location, + setDateState, + ]); +}; diff --git a/libraries/mapping/shadow-simulation/src/lib/runtime/map-center-solar-location.ts b/libraries/mapping/shadow-simulation/src/lib/runtime/map-center-solar-location.ts new file mode 100644 index 0000000000..b1bcecbaa3 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/runtime/map-center-solar-location.ts @@ -0,0 +1,16 @@ +import type { Map as MaplibreMap } from "maplibre-gl"; + +import { resolveSolarLocation } from "../core/solar-location"; +import type { SolarLocation } from "../core/solar-position"; + +export const readMapCenterSolarLocation = ( + libreMap: MaplibreMap | null, + fallbackLatitude: number, + fallbackLongitude: number +): SolarLocation => { + const center = libreMap?.getCenter(); + return resolveSolarLocation( + center ? { latitude: center.lat, longitude: center.lng } : null, + { latitude: fallbackLatitude, longitude: fallbackLongitude } + ); +}; diff --git a/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-controller.spec.ts b/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-controller.spec.ts new file mode 100644 index 0000000000..52a0beea80 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-controller.spec.ts @@ -0,0 +1,214 @@ +import * as THREE from "three"; +import { describe, expect, it } from "vitest"; + +import { ShadowController, type ShadowUpdate } from "./shadow-controller"; + +const receiverWorldPoints = [ + new THREE.Vector3(-50, 100, -25), + new THREE.Vector3(50, 100, -25), + new THREE.Vector3(50, 140, 25), + new THREE.Vector3(-50, 140, 25), +]; +const SUN_DISC_TEST_RADIUS_RAD = ((0.53 / 2) * Math.PI) / 180 + 1e-12; + +const buildUpdate = (overrides: Partial = {}): ShadowUpdate => ({ + receiverWorldPoints, + receiverAnchorWorldPosition: new THREE.Vector3(0, 100, 0), + minimumElevationMeters: 100, + maximumElevationMeters: 140, + directionToSun: new THREE.Vector3(0.5, 0.7, -0.5).normalize(), + color: 0xffffff, + intensity: 2, + shadowIntensity: 0.8, + quality: 4, + ...overrides, +}); + +describe("ShadowController", () => { + it("fits one orthographic buffer to the receiver area", () => { + const scene = new THREE.Scene(); + const controller = new ShadowController(scene); + + const snapshot = controller.update(buildUpdate()); + + expect(snapshot?.sampleCount).toBe(1); + expect(snapshot?.camera.shadowMapWidth).toBe(4_096); + expect(snapshot?.camera.shadowMapHeight).toBe(4_096); + expect(snapshot?.camera.rightMeters).toBeGreaterThan( + snapshot?.camera.leftMeters ?? Infinity + ); + expect(snapshot?.camera.topMeters).toBeGreaterThan( + snapshot?.camera.bottomMeters ?? Infinity + ); + expect(snapshot?.camera.farMeters).toBeGreaterThan( + snapshot?.camera.nearMeters ?? Infinity + ); + expect(controller.lights[0].visible).toBe(true); + expect(controller.lights).toHaveLength(1); + }); + + it("uses one full-resolution buffer for sun-disc sampling", () => { + const controller = new ShadowController(new THREE.Scene()); + controller.setSoftSun(true); + + const snapshot = controller.update(buildUpdate()); + + expect(snapshot?.sampleCount).toBe(1); + expect(snapshot?.camera.shadowMapWidth).toBe(4_096); + expect(controller.lights).toHaveLength(1); + }); + + it("increases normal bias for grazing sunlight", () => { + const controller = new ShadowController(new THREE.Scene()); + controller.update( + buildUpdate({ + directionToSun: new THREE.Vector3(0.2, 0.95, -0.2).normalize(), + }) + ); + const highSunBias = controller.lights[0].shadow.normalBias; + + controller.update( + buildUpdate({ + directionToSun: new THREE.Vector3(0.8, 0.08, -0.6).normalize(), + }) + ); + + expect(controller.lights[0].shadow.normalBias).toBeGreaterThan(highSunBias); + }); + + it("scales negative depth bias from the fitted shadow texel size", () => { + const controller = new ShadowController(new THREE.Scene()); + + const snapshot = controller.update(buildUpdate()); + + expect(snapshot).not.toBeNull(); + expect(controller.lights[0].shadow.bias).toBeLessThan(0); + + const compactBias = Math.abs(controller.lights[0].shadow.bias); + controller.update( + buildUpdate({ + receiverWorldPoints: receiverWorldPoints.map((point) => + point.clone().multiplyScalar(100) + ), + }) + ); + + expect(Math.abs(controller.lights[0].shadow.bias)).toBeGreaterThan( + compactBias + ); + }); + + it("moves the one light across the sun disc for accumulation rounds", () => { + const controller = new ShadowController(new THREE.Scene()); + controller.setSoftSun(true); + controller.update(buildUpdate()); + const before = controller.lights[0].position.clone(); + + controller.applySunDiscSample(1, 32); + + expect(controller.lights[0].position.equals(before)).toBe(false); + expect(controller.lights[0].shadow.needsUpdate).toBe(true); + + controller.restoreSunDiscCenter(); + + expect(controller.lights[0].position.distanceTo(before)).toBeLessThan(1e-9); + }); + + it("uses distinct tangent-plane offsets across the whole sun disc", () => { + const controller = new ShadowController(new THREE.Scene()); + const update = buildUpdate(); + controller.setSoftSun(true); + controller.update(update); + const directionToSun = update.directionToSun.clone().normalize(); + const tangentA = new THREE.Vector3(0, 1, 0) + .cross(directionToSun) + .normalize(); + const tangentB = directionToSun.clone().cross(tangentA); + const offsets = Array.from({ length: 32 }, (_, round) => { + controller.applySunDiscSample(round, 32); + const sampledDirection = controller.lights[0].position + .clone() + .sub(update.receiverAnchorWorldPosition) + .normalize(); + return [ + sampledDirection.dot(tangentA).toFixed(12), + sampledDirection.dot(tangentB).toFixed(12), + ] as const; + }); + + expect(new Set(offsets.map(([x]) => x)).size).toBe(offsets.length); + expect(new Set(offsets.map(([, y]) => y)).size).toBe(offsets.length); + }); + + it("rotates the sun direction around the terrain anchor", () => { + const controller = new ShadowController(new THREE.Scene()); + const anchor = new THREE.Vector3(20, 123, -40); + controller.setSoftSun(true); + controller.update(buildUpdate({ receiverAnchorWorldPosition: anchor })); + + controller.applySunDiscSample(3, 32); + + expect(controller.lights[0].target.position.equals(anchor)).toBe(true); + const sampledDirection = controller.lights[0].position + .clone() + .sub(anchor) + .normalize(); + expect( + sampledDirection.angleTo(buildUpdate().directionToSun) + ).toBeLessThanOrEqual(SUN_DISC_TEST_RADIUS_RAD); + }); + + it("keeps every receiver inside the shadow map for every sun-disc sample", () => { + const controller = new ShadowController(new THREE.Scene()); + controller.setSoftSun(true); + controller.update( + buildUpdate({ + directionToSun: new THREE.Vector3(0.8, 0.05, -0.6).normalize(), + }) + ); + + for (let round = 0; round < 8; round += 1) { + controller.applySunDiscSample(round, 32); + const camera = controller.lights[0].shadow.camera; + for (const point of receiverWorldPoints) { + const clip = point + .clone() + .applyMatrix4(camera.matrixWorldInverse) + .applyMatrix4(camera.projectionMatrix); + expect(Math.abs(clip.x)).toBeLessThanOrEqual(1); + expect(Math.abs(clip.y)).toBeLessThanOrEqual(1); + expect(Math.abs(clip.z)).toBeLessThanOrEqual(1); + } + } + }); + + it.each([16, 64] as const)( + "uses the renderer texture limit at quality %i", + (quality) => { + const controller = new ShadowController(new THREE.Scene()); + controller.setMaxShadowMapSize(32_768); + + const snapshot = controller.update(buildUpdate({ quality })); + + expect(snapshot?.camera.shadowMapWidth).toBe(32_768); + } + ); + + it("disables lights without receivers and removes them on disposal", () => { + const scene = new THREE.Scene(); + const controller = new ShadowController(scene); + controller.update(buildUpdate()); + + expect( + controller.update(buildUpdate({ receiverWorldPoints: [] })) + ).toBeNull(); + expect(controller.lights.every(({ visible }) => !visible)).toBe(true); + + controller.dispose(); + expect( + scene.children.some(({ name }) => + name.startsWith("shadow-simulation-sun") + ) + ).toBe(false); + }); +}); diff --git a/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-controller.ts b/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-controller.ts new file mode 100644 index 0000000000..f0d64fc6aa --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-controller.ts @@ -0,0 +1,415 @@ +import * as THREE from "three"; + +import { clamp } from "@carma-commons/math"; +import { degToRadNumeric } from "@carma-units"; + +import type { ShadowQualityMultiplier } from "../core/shadow-types"; + +const BASE_SHADOW_MAP_SIZE = 2_048; +const DEFAULT_MAX_SHADOW_MAP_SIZE = 8_192; +const SHADOW_FILTER_GUARD_TEXELS = 3; +const MIN_SHADOW_AREA_METERS = 2; +const MIN_CASTER_REACH_METERS = 50; +const MAX_CASTER_REACH_METERS = 10_000; +const CASTER_REACH_ELEVATION_EPSILON = 0.04; +const LIGHT_CAMERA_SAFETY_METERS = 25; +const SHADOW_DEPTH_BIAS_TEXELS = 4; +const SHADOW_NORMAL_BIAS_TEXELS = 1.2; +const MIN_SHADOW_BIAS_ELEVATION_SINE = 0.2; +const MIN_SHADOW_NORMAL_BIAS_METERS = 0.05; +const MAX_SHADOW_NORMAL_BIAS_METERS = 8; +const SUN_ANGULAR_RADIUS_RAD = degToRadNumeric(0.53 / 2); +const GOLDEN_ANGLE_RAD = Math.PI * (3 - Math.sqrt(5)); + +export const CASTER_RELIEF_MARGIN_METERS = 300; + +type LightSpaceBounds = Readonly<{ + left: number; + right: number; + bottom: number; + top: number; + near: number; + far: number; +}>; + +export type ShadowCameraSnapshot = Readonly<{ + receiverPointCount: number; + receiverLeftMeters: number; + receiverRightMeters: number; + receiverBottomMeters: number; + receiverTopMeters: number; + leftMeters: number; + rightMeters: number; + bottomMeters: number; + topMeters: number; + nearMeters: number; + farMeters: number; + shadowMapWidth: number; + shadowMapHeight: number; + viewMatrixElements: readonly number[]; + projectionMatrixElements: readonly number[]; + guardMeters: number; + metersPerTexel: number; +}>; + +export type ShadowSnapshot = Readonly<{ + sampleCount: number; + totalShadowTexels: number; + casterReachMeters: number; + camera: ShadowCameraSnapshot; +}>; + +export type ShadowUpdate = Readonly<{ + receiverWorldPoints: readonly THREE.Vector3[]; + receiverAnchorWorldPosition: THREE.Vector3; + minimumElevationMeters: number; + maximumElevationMeters: number; + directionToSun: THREE.Vector3; + color: THREE.ColorRepresentation; + intensity: number; + shadowIntensity: number; + quality: ShadowQualityMultiplier; +}>; + +const getReceiverBoundsInLightCamera = ( + points: readonly THREE.Vector3[], + camera: THREE.OrthographicCamera +): LightSpaceBounds | null => { + if (points.length === 0) return null; + camera.updateMatrixWorld(true); + const projected = points.map((point) => + point.clone().applyMatrix4(camera.matrixWorldInverse) + ); + const left = Math.min(...projected.map(({ x }) => x)); + const right = Math.max(...projected.map(({ x }) => x)); + const bottom = Math.min(...projected.map(({ y }) => y)); + const top = Math.max(...projected.map(({ y }) => y)); + const depths = projected.map(({ z }) => -z); + const near = Math.max(0, Math.min(...depths)); + const far = Math.max(near + 0.01, Math.max(...depths)); + const centerX = (left + right) / 2; + const centerY = (bottom + top) / 2; + const halfWidth = Math.max((right - left) / 2, MIN_SHADOW_AREA_METERS / 2); + const halfHeight = Math.max((top - bottom) / 2, MIN_SHADOW_AREA_METERS / 2); + return { + left: centerX - halfWidth, + right: centerX + halfWidth, + bottom: centerY - halfHeight, + top: centerY + halfHeight, + near, + far, + }; +}; + +const restingShadowMapSize = ( + quality: ShadowQualityMultiplier, + maxShadowMapSize = DEFAULT_MAX_SHADOW_MAP_SIZE +): number => + quality >= 16 + ? maxShadowMapSize + : Math.min(maxShadowMapSize, BASE_SHADOW_MAP_SIZE * Math.sqrt(quality)); + +export class ShadowController { + readonly lights: readonly THREE.DirectionalLight[]; + + private softSun = false; + private lastSoftFit: { + directionToSun: THREE.Vector3; + tangentA: THREE.Vector3; + tangentB: THREE.Vector3; + anchorPosition: THREE.Vector3; + lightDistance: number; + } | null = null; + private maxShadowMapSize = DEFAULT_MAX_SHADOW_MAP_SIZE; + private disposed = false; + + constructor(private readonly hostScene: THREE.Scene) { + this.lights = Array.from({ length: 1 }, () => { + const light = new THREE.DirectionalLight(0xffffff, 0); + light.name = "shadow-simulation-sun"; + light.visible = false; + light.castShadow = false; + light.shadow.camera.name = "shadow-simulation-shadow-camera"; + light.shadow.autoUpdate = false; + light.shadow.radius = 0; + light.shadow.bias = 0; + light.shadow.normalBias = MIN_SHADOW_NORMAL_BIAS_METERS; + hostScene.add(light, light.target); + return light; + }); + } + + setMaxShadowMapSize(size: number): void { + const next = Math.max(256, Math.floor(size)); + if (this.disposed || this.maxShadowMapSize === next) return; + this.maxShadowMapSize = next; + } + + setSoftSun(enabled: boolean): void { + if (this.disposed || this.softSun === enabled) return; + this.softSun = enabled; + if (!enabled) this.lastSoftFit = null; + } + + applySunDiscSample(round: number, sampleCount: number): void { + if (this.disposed) return; + const fit = this.lastSoftFit; + if (!fit) return; + const count = Math.max(1, Math.floor(sampleCount)); + const sampleIndex = ((Math.floor(round) % count) + count) % count; + const angularOffset = + SUN_ANGULAR_RADIUS_RAD * Math.sqrt((sampleIndex + 0.5) / count); + const sampleAngle = sampleIndex * GOLDEN_ANGLE_RAD; + const offsetA = Math.cos(sampleAngle) * angularOffset; + const offsetB = Math.sin(sampleAngle) * angularOffset; + const tangentDirection = fit.tangentA + .clone() + .multiplyScalar(offsetA) + .addScaledVector(fit.tangentB, offsetB) + .normalize(); + const direction = fit.directionToSun + .clone() + .multiplyScalar(Math.cos(angularOffset)) + .addScaledVector(tangentDirection, Math.sin(angularOffset)) + .normalize(); + const light = this.lights[0]; + light.position + .copy(direction) + .multiplyScalar(fit.lightDistance) + .add(fit.anchorPosition); + light.updateMatrixWorld(true); + light.target.updateMatrixWorld(true); + light.shadow.updateMatrices(light); + light.shadow.needsUpdate = true; + } + + restoreSunDiscCenter(): void { + if (this.disposed || !this.lastSoftFit) return; + const fit = this.lastSoftFit; + const light = this.lights[0]; + light.position + .copy(fit.directionToSun) + .multiplyScalar(fit.lightDistance) + .add(fit.anchorPosition); + light.updateMatrixWorld(true); + light.target.updateMatrixWorld(true); + light.shadow.updateMatrices(light); + light.shadow.needsUpdate = true; + } + + invalidate(): void { + this.lights[0].shadow.needsUpdate = true; + } + + update({ + receiverWorldPoints, + receiverAnchorWorldPosition, + minimumElevationMeters, + maximumElevationMeters, + directionToSun, + color, + intensity, + shadowIntensity, + quality, + }: ShadowUpdate): ShadowSnapshot | null { + if (this.disposed) return null; + if (receiverWorldPoints.length === 0) { + for (const light of this.lights) { + light.visible = false; + light.castShadow = false; + light.intensity = 0; + light.shadow.needsUpdate = false; + } + return null; + } + + const normalizedDirectionToSun = directionToSun.clone().normalize(); + const reliefMeters = Math.max( + 0, + maximumElevationMeters - minimumElevationMeters + ); + const elevationSine = Math.max( + CASTER_REACH_ELEVATION_EPSILON, + normalizedDirectionToSun.y + ); + const casterReachMeters = clamp( + (reliefMeters + CASTER_RELIEF_MARGIN_METERS) / elevationSine + + MIN_CASTER_REACH_METERS, + MIN_CASTER_REACH_METERS, + MAX_CASTER_REACH_METERS + ); + const lightMargin = + casterReachMeters + reliefMeters + LIGHT_CAMERA_SAFETY_METERS; + const restingMapSize = restingShadowMapSize(quality, this.maxShadowMapSize); + const mapSize = Math.floor(restingMapSize); + const resolvedColor = new THREE.Color(color); + const targetPosition = receiverAnchorWorldPosition.clone(); + const receiverRadius = receiverWorldPoints.reduce( + (radius, point) => + Math.max(radius, point.distanceTo(receiverAnchorWorldPosition)), + 0 + ); + const lightDistance = receiverRadius + lightMargin; + const primaryLight = this.lights[0]; + primaryLight.position + .copy(normalizedDirectionToSun) + .multiplyScalar(lightDistance) + .add(targetPosition); + primaryLight.target.position.copy(targetPosition); + primaryLight.updateMatrixWorld(true); + primaryLight.target.updateMatrixWorld(true); + primaryLight.shadow.updateMatrices(primaryLight); + + const receiverBounds = getReceiverBoundsInLightCamera( + receiverWorldPoints, + primaryLight.shadow.camera + ); + if (!receiverBounds) return null; + + const sampleMapSize = mapSize; + const sunDiscGuardMeters = this.softSun + ? Math.tan(SUN_ANGULAR_RADIUS_RAD) * lightDistance + : 0; + const usableMapDimension = Math.max( + 1, + sampleMapSize - SHADOW_FILTER_GUARD_TEXELS * 2 + ); + const metersPerTexel = Math.max( + (receiverBounds.right - receiverBounds.left + sunDiscGuardMeters * 2) / + usableMapDimension, + (receiverBounds.top - receiverBounds.bottom + sunDiscGuardMeters * 2) / + usableMapDimension, + Number.EPSILON + ); + const guardMeters = metersPerTexel * SHADOW_FILTER_GUARD_TEXELS; + const fittedSize = metersPerTexel * sampleMapSize; + const centerX = + Math.round( + (receiverBounds.left + receiverBounds.right) / 2 / metersPerTexel + ) * metersPerTexel; + const centerY = + Math.round( + (receiverBounds.bottom + receiverBounds.top) / 2 / metersPerTexel + ) * metersPerTexel; + const shadowBounds = { + left: centerX - fittedSize / 2, + right: centerX + fittedSize / 2, + bottom: centerY - fittedSize / 2, + top: centerY + fittedSize / 2, + near: Math.max( + 0.01, + receiverBounds.near - + casterReachMeters - + reliefMeters - + LIGHT_CAMERA_SAFETY_METERS + ), + far: Math.max( + 1, + receiverBounds.far + reliefMeters + LIGHT_CAMERA_SAFETY_METERS + ), + }; + shadowBounds.far = Math.max(shadowBounds.near + 1, shadowBounds.far); + const normalBias = clamp( + (metersPerTexel * SHADOW_NORMAL_BIAS_TEXELS) / + Math.max(MIN_SHADOW_BIAS_ELEVATION_SINE, normalizedDirectionToSun.y), + MIN_SHADOW_NORMAL_BIAS_METERS, + MAX_SHADOW_NORMAL_BIAS_METERS + ); + const depthBias = -clamp( + (metersPerTexel * SHADOW_DEPTH_BIAS_TEXELS) / + Math.max(shadowBounds.far - shadowBounds.near, 1), + Number.EPSILON, + 0.01 + ); + const tangentA = new THREE.Vector3(); + if (Math.abs(normalizedDirectionToSun.y) > 0.99) { + tangentA.set(1, 0, 0); + } else { + tangentA + .crossVectors(new THREE.Vector3(0, 1, 0), normalizedDirectionToSun) + .normalize(); + } + const tangentB = new THREE.Vector3().crossVectors( + normalizedDirectionToSun, + tangentA + ); + + const light = this.lights[0]; + light.visible = true; + light.castShadow = true; + light.intensity = intensity; + light.color.copy(resolvedColor); + light.shadow.intensity = clamp(shadowIntensity, 0, 1); + light.shadow.needsUpdate = true; + if ( + light.shadow.mapSize.x !== sampleMapSize || + light.shadow.mapSize.y !== sampleMapSize + ) { + light.shadow.map?.dispose(); + light.shadow.map = null; + light.shadow.mapSize.set(sampleMapSize, sampleMapSize); + } + light.position + .copy(normalizedDirectionToSun) + .multiplyScalar(lightDistance) + .add(targetPosition); + light.target.position.copy(targetPosition); + light.shadow.bias = depthBias; + light.shadow.normalBias = normalBias; + const camera = light.shadow.camera; + camera.left = shadowBounds.left; + camera.right = shadowBounds.right; + camera.bottom = shadowBounds.bottom; + camera.top = shadowBounds.top; + camera.near = shadowBounds.near; + camera.far = shadowBounds.far; + camera.updateProjectionMatrix(); + light.updateMatrixWorld(true); + light.target.updateMatrixWorld(true); + light.shadow.updateMatrices(light); + + this.lastSoftFit = this.softSun + ? { + directionToSun: normalizedDirectionToSun.clone(), + tangentA, + tangentB, + anchorPosition: targetPosition.clone(), + lightDistance, + } + : null; + const primaryCamera = primaryLight.shadow.camera; + return { + sampleCount: 1, + totalShadowTexels: sampleMapSize * sampleMapSize, + casterReachMeters, + camera: { + receiverPointCount: receiverWorldPoints.length, + receiverLeftMeters: receiverBounds.left, + receiverRightMeters: receiverBounds.right, + receiverBottomMeters: receiverBounds.bottom, + receiverTopMeters: receiverBounds.top, + leftMeters: primaryCamera.left, + rightMeters: primaryCamera.right, + bottomMeters: primaryCamera.bottom, + topMeters: primaryCamera.top, + nearMeters: primaryCamera.near, + farMeters: primaryCamera.far, + shadowMapWidth: sampleMapSize, + shadowMapHeight: sampleMapSize, + viewMatrixElements: [...primaryCamera.matrixWorldInverse.elements], + projectionMatrixElements: [...primaryCamera.projectionMatrix.elements], + guardMeters, + metersPerTexel, + }, + }; + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + for (const light of this.lights) { + light.shadow.map?.dispose(); + this.hostScene.remove(light.target, light); + } + } +} diff --git a/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-projection-debug-model.spec.ts b/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-projection-debug-model.spec.ts new file mode 100644 index 0000000000..4e8f36a583 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-projection-debug-model.spec.ts @@ -0,0 +1,209 @@ +import type { Map as MaplibreMap } from "maplibre-gl"; +import { + Matrix4, + OrthographicCamera, + PerspectiveCamera, + Quaternion, + Vector3, +} from "three"; +import { describe, expect, it } from "vitest"; + +import { CAMERA_TYPE } from "@carma-commons/camera/model"; +import { ecefToEnuOffset } from "@carma-geo/utils"; + +import type { ShadowSnapshot } from "./shadow-controller"; +import { buildShadowProjectionDebugModel } from "./shadow-projection-debug-model"; + +const buildMap = () => + ({ + getBearing: () => 0, + getCenter: () => ({ lng: 7.15, lat: 51.25 }), + getCanvas: () => ({ + clientWidth: 1_000, + clientHeight: 500, + width: 1_000, + height: 500, + }), + getPitch: () => 45, + getZoom: () => 16, + unproject: ([x, y]: [number, number]) => ({ + lng: 7.15 + (x - 500) * 0.00001, + lat: 51.25 - (y - 250) * 0.00001, + }), + } as unknown as MaplibreMap); + +const buildSnapshot = () => { + const mainCamera = new PerspectiveCamera(50, 2, 0.5, 5_000); + mainCamera.position.set(100, 200, -50); + mainCamera.lookAt(new Vector3(10, 20, 30)); + mainCamera.updateMatrixWorld(true); + mainCamera.updateProjectionMatrix(); + const camera = new OrthographicCamera(-80, 120, 60, -40, 1, 4_000); + camera.position.set(80, 240, -110); + camera.lookAt(new Vector3(-15, 40, 0)); + camera.updateMatrixWorld(true); + camera.updateProjectionMatrix(); + const shadow: ShadowSnapshot = { + sampleCount: 8, + totalShadowTexels: 8 * 4_096 ** 2, + casterReachMeters: 875, + camera: { + receiverPointCount: 8, + receiverLeftMeters: -70, + receiverRightMeters: 110, + receiverBottomMeters: -30, + receiverTopMeters: 50, + leftMeters: camera.left, + rightMeters: camera.right, + bottomMeters: camera.bottom, + topMeters: camera.top, + nearMeters: camera.near, + farMeters: camera.far, + shadowMapWidth: 4_096, + shadowMapHeight: 4_096, + viewMatrixElements: [...camera.matrixWorldInverse.elements], + projectionMatrixElements: [...camera.projectionMatrix.elements], + guardMeters: 3, + metersPerTexel: 0.05, + }, + }; + return { + cameraRangeMeters: 2_500, + leftMeters: camera.left, + rightMeters: camera.right, + bottomMeters: camera.bottom, + topMeters: camera.top, + nearMeters: camera.near, + farMeters: camera.far, + projectionMatrixElements: [...camera.projectionMatrix.elements], + shadowMapWidth: 4_096, + shadowMapHeight: 4_096, + minimumElevationMeters: 120, + maximumElevationMeters: 320, + sceneAnchorPositionElements: [10, 20, 30] as const, + mainCamera: { + viewMatrixElements: [...mainCamera.matrixWorldInverse.elements], + projectionMatrixElements: [...mainCamera.projectionMatrix.elements], + nearMeters: mainCamera.near, + farMeters: mainCamera.far, + viewportWidth: 1_000, + viewportHeight: 500, + }, + shadow, + }; +}; + +describe("buildShadowProjectionDebugModel", () => { + const instant = new Date("2026-06-21T10:00:00.000Z"); + + it("represents the map and fitted shadow cameras as separate frusta", () => { + const model = buildShadowProjectionDebugModel( + buildMap(), + { instant, azimuthDegrees: 120, elevationDegrees: 30 }, + buildSnapshot() + ); + + expect(model?.viewStates).toHaveLength(2); + expect(model?.viewStates[1].intrinsics.type).toBe(CAMERA_TYPE.ORTHOGRAPHIC); + expect(model?.receiverCoverageWidthMeters).toBe(180); + expect(model?.receiverCoverageHeightMeters).toBe(80); + expect(model?.shadowTexelWidthMeters).toBe(0.05); + expect(model?.shadowSampleCount).toBe(8); + expect(model?.casterReachMeters).toBe(875); + expect(model?.horizontalProjectionPerHeight).toBeCloseTo(Math.sqrt(3), 5); + expect(model?.viewStates[0].anchorCartographic.altitude).toBeCloseTo(20); + }); + + it("uses the actual shadow-camera pose independently of display angles", () => { + const snapshot = buildSnapshot(); + const model = buildShadowProjectionDebugModel( + buildMap(), + { instant, azimuthDegrees: 15, elevationDegrees: 8 }, + snapshot + ); + const changedSolarModel = buildShadowProjectionDebugModel( + buildMap(), + { instant, azimuthDegrees: 290, elevationDegrees: 65 }, + snapshot + ); + const viewState = model!.viewStates[1]!; + const matrixWorld = new Matrix4() + .fromArray([...snapshot.shadow.camera.viewMatrixElements]) + .invert(); + const expectedOrientation = new Quaternion() + .setFromRotationMatrix(matrixWorld) + .normalize(); + + expect(viewState.orientation.angleTo(expectedOrientation)).toBeLessThan( + 1e-8 + ); + expect( + changedSolarModel!.viewStates[1]!.orientation.angleTo( + viewState.orientation + ) + ).toBeLessThan(1e-8); + + const offset = ecefToEnuOffset(viewState.cameraPosition, viewState.anchor); + const expectedPosition = new Vector3() + .setFromMatrixPosition(matrixWorld) + .sub(new Vector3(...snapshot.sceneAnchorPositionElements)); + expect(offset.east).toBeCloseTo(expectedPosition.x, 6); + expect(offset.north).toBeCloseTo(-expectedPosition.z, 6); + expect(offset.up).toBeCloseTo(expectedPosition.y, 6); + }); + + it("uses the exact render-camera pose in the scene-anchor frame", () => { + const snapshot = buildSnapshot(); + const model = buildShadowProjectionDebugModel( + buildMap(), + { instant, azimuthDegrees: 120, elevationDegrees: 30 }, + snapshot + ); + const viewState = model!.viewStates[0]!; + const matrixWorld = new Matrix4() + .fromArray([...snapshot.mainCamera.viewMatrixElements]) + .invert(); + const expectedPosition = new Vector3() + .setFromMatrixPosition(matrixWorld) + .sub(new Vector3(...snapshot.sceneAnchorPositionElements)); + const offset = ecefToEnuOffset(viewState.cameraPosition, viewState.anchor); + + expect(offset.east).toBeCloseTo(expectedPosition.x, 6); + expect(offset.north).toBeCloseTo(-expectedPosition.z, 6); + expect(offset.up).toBeCloseTo(expectedPosition.y, 6); + }); + + it("normalizes loaded tile volumes around the scene anchor", () => { + const snapshot = { + ...buildSnapshot(), + tileVolumes: [ + { + id: "terrain:10/532/218", + loadReason: "shadow" as const, + minimum: [0, 10, 20] as const, + maximum: [20, 30, 40] as const, + }, + ], + }; + const model = buildShadowProjectionDebugModel( + buildMap(), + { instant, azimuthDegrees: 120, elevationDegrees: 30 }, + snapshot + ); + + expect(model?.tileVolumes).toHaveLength(1); + const worldScaleMeters = model!.visualizationWorldScaleMeters; + expect(model?.tileVolumes[0]?.minimum).toEqual([ + expect.closeTo(-10 / worldScaleMeters), + expect.closeTo(-10 / worldScaleMeters), + expect.closeTo(-10 / worldScaleMeters), + ]); + expect(model?.tileVolumes[0]?.maximum).toEqual([ + expect.closeTo(10 / worldScaleMeters), + expect.closeTo(10 / worldScaleMeters), + expect.closeTo(10 / worldScaleMeters), + ]); + expect(model?.tileVolumes[0]?.color).toBe("#ea580c"); + expect(model?.viewStates[0].intrinsics.frustum?.far).toBeGreaterThan(0); + }); +}); diff --git a/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-projection-debug-model.ts b/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-projection-debug-model.ts new file mode 100644 index 0000000000..48cc9dadf2 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-projection-debug-model.ts @@ -0,0 +1,426 @@ +import type { Map as MaplibreMap } from "maplibre-gl"; +import { Matrix4, Quaternion, Vector3 } from "three"; + +import { CAMERA_TYPE, readLocalCameraBasis } from "@carma-commons/camera/model"; +import { + distanceMeters, + ecefToEnuOffset, + enuOffsetToEcef, +} from "@carma-geo/utils"; +import { + buildViewState, + buildViewStateFromEcef, + readFromMaplibre, + type ViewState, +} from "@carma-mapping/engines-interop/view-state"; +import { degToRadNumeric, type Meters } from "@carma-units"; + +import type { SolarPosition } from "../core/solar-position"; +import type { ShadowProjectionDebugSnapshot } from "./shadow-projection-debug-store"; + +const DEBUG_SOURCE_ID = "shadow-simulation-projection-debug"; + +type GeographicPoint = readonly [longitude: number, latitude: number]; +type DistancePoint = Parameters[0]; + +export type ShadowProjectionDebugBuffer = Readonly<{ + receiverLeftMeters: number; + receiverRightMeters: number; + receiverBottomMeters: number; + receiverTopMeters: number; + leftMeters: number; + rightMeters: number; + bottomMeters: number; + topMeters: number; + widthMeters: number; + heightMeters: number; + guardMeters: number; + texelMeters: number; + shadowMapWidth: number; + shadowMapHeight: number; +}>; + +export type ShadowProjectionDebugModel = { + viewStates: readonly ViewState[]; + tileVolumes: readonly Readonly<{ + minimum: readonly [number, number, number]; + maximum: readonly [number, number, number]; + color?: string; + }>[]; + viewportWidthMeters: number; + viewportHeightMeters: number; + receiverCoverageWidthMeters: number; + receiverCoverageHeightMeters: number; + shadowTexelWidthMeters: number; + shadowTexelHeightMeters: number; + horizontalProjectionPerHeight: number; + elevationSpanMeters: number; + shadowBuffer: ShadowProjectionDebugBuffer; + shadowSampleCount: number; + totalShadowTexels: number; + casterReachMeters: number; + visualizationWorldScaleMeters: number; +}; + +const isFiniteMatrix = (matrix: Matrix4) => { + const determinant = matrix.determinant(); + return ( + matrix.elements.every(Number.isFinite) && + Number.isFinite(determinant) && + determinant !== 0 + ); +}; + +const buildExactCameraViewState = ({ + referenceViewState, + sceneAnchorPosition, + cameraType, + viewMatrixElements, + projectionMatrixElements, + nearMeters, + farMeters, + shadowMapWidth, + shadowMapHeight, + sourceSuffix, +}: { + referenceViewState: ViewState; + sceneAnchorPosition: Vector3; + cameraType: (typeof CAMERA_TYPE)[keyof typeof CAMERA_TYPE]; + viewMatrixElements: readonly number[]; + projectionMatrixElements: readonly number[]; + nearMeters: number; + farMeters: number; + shadowMapWidth: number; + shadowMapHeight: number; + sourceSuffix: string; +}): ViewState | null => { + if ( + viewMatrixElements.length !== 16 || + projectionMatrixElements.length !== 16 + ) { + return null; + } + const viewMatrix = new Matrix4().fromArray([...viewMatrixElements]); + const projectionMatrix = new Matrix4().fromArray([ + ...projectionMatrixElements, + ]); + if (!isFiniteMatrix(viewMatrix) || !isFiniteMatrix(projectionMatrix)) { + return null; + } + const matrixWorld = viewMatrix.clone().invert(); + const relativePosition = new Vector3() + .setFromMatrixPosition(matrixWorld) + .sub(sceneAnchorPosition); + const cameraPosition = enuOffsetToEcef( + relativePosition.x, + -relativePosition.z, + relativePosition.y, + referenceViewState.anchor + ); + const orientation = new Quaternion() + .setFromRotationMatrix(matrixWorld) + .normalize(); + + return buildViewStateFromEcef({ + anchor: referenceViewState.anchor.clone(), + cameraPosition, + orientation, + intrinsics: { + type: cameraType, + projectionMatrix, + frustum: { + near: nearMeters as Meters, + far: farMeters as Meters, + }, + }, + metadata: { + frameId: referenceViewState.metadata.frameId, + timestampMs: Date.now(), + sourceId: `${DEBUG_SOURCE_ID}-${sourceSuffix}`, + source: "sync", + viewport: { + widthPx: shadowMapWidth, + heightPx: shadowMapHeight, + }, + }, + }); +}; + +const measureGeographicDistance = ( + [firstLongitude, firstLatitude]: GeographicPoint, + [secondLongitude, secondLatitude]: GeographicPoint +) => + distanceMeters( + { + longitude: firstLongitude, + latitude: firstLatitude, + } as DistancePoint, + { + longitude: secondLongitude, + latitude: secondLatitude, + } as DistancePoint + ); + +const readViewportFootprint = (map: MaplibreMap) => { + const canvas = map.getCanvas(); + const widthPixels = Math.max(1, canvas.clientWidth || canvas.width); + const heightPixels = Math.max(1, canvas.clientHeight || canvas.height); + const [southWest, northWest, southEast, northEast] = [ + [0, heightPixels], + [0, 0], + [widthPixels, heightPixels], + [widthPixels, 0], + ].map((point) => { + const lngLat = map.unproject(point as [number, number]); + return [lngLat.lng, lngLat.lat] as GeographicPoint; + }); + return { + widthMeters: + (measureGeographicDistance(southWest, southEast) + + measureGeographicDistance(northWest, northEast)) / + 2, + heightMeters: + (measureGeographicDistance(southWest, northWest) + + measureGeographicDistance(southEast, northEast)) / + 2, + }; +}; + +type RelativeTileVolume = Readonly<{ + minimum: Vector3; + maximum: Vector3; + loadReason?: "viewport" | "shadow"; +}>; + +const readRelativeTileVolumes = ( + snapshot: ShadowProjectionDebugSnapshot, + sceneAnchorPosition: Vector3 +) => + (snapshot.tileVolumes ?? []).map(({ minimum, maximum, loadReason }) => ({ + minimum: new Vector3(...minimum).sub(sceneAnchorPosition), + maximum: new Vector3(...maximum).sub(sceneAnchorPosition), + loadReason, + })); + +const readLocalCameraPosition = (viewState: ViewState) => { + const offset = ecefToEnuOffset(viewState.cameraPosition, viewState.anchor); + return new Vector3(offset.east, offset.up, -offset.north); +}; + +const readVisualizationWorldScaleMeters = ( + relativeVolumes: readonly RelativeTileVolume[], + viewStates: readonly ViewState[] +) => { + const maximumAbsoluteCoordinate = [ + ...relativeVolumes.flatMap(({ minimum, maximum }) => [minimum, maximum]), + ...viewStates.map(readLocalCameraPosition), + ].reduce( + (maximumValue, point) => + Math.max(maximumValue, ...point.toArray().map(Math.abs)), + 1 + ); + return maximumAbsoluteCoordinate / 0.82; +}; + +const normalizeTileVolumes = ( + relativeVolumes: readonly RelativeTileVolume[], + worldScaleMeters: number +) => { + const scale = 1 / worldScaleMeters; + return relativeVolumes.map(({ minimum, maximum, loadReason }) => ({ + minimum: minimum.multiplyScalar(scale).toArray() as [ + number, + number, + number + ], + maximum: maximum.multiplyScalar(scale).toArray() as [ + number, + number, + number + ], + color: + loadReason === "viewport" + ? "#0284c7" + : loadReason === "shadow" + ? "#ea580c" + : "#64748b", + })); +}; + +const withTileVolumeDepthRange = ( + cameraViewState: ViewState, + relativeVolumes: readonly RelativeTileVolume[] +): ViewState => { + const viewportVolumes = relativeVolumes.filter( + ({ loadReason }) => loadReason === "viewport" + ); + const volumes = + viewportVolumes.length > 0 ? viewportVolumes : relativeVolumes; + if (volumes.length === 0) return cameraViewState; + + const cameraPosition = readLocalCameraPosition(cameraViewState); + const { forward } = readLocalCameraBasis(cameraViewState.orientation); + const maximumDepthMeters = volumes.reduce((maximumDepth, volume) => { + const { minimum, maximum } = volume; + return Math.max( + maximumDepth, + ...[ + new Vector3(minimum.x, minimum.y, minimum.z), + new Vector3(maximum.x, minimum.y, minimum.z), + new Vector3(minimum.x, maximum.y, minimum.z), + new Vector3(maximum.x, maximum.y, minimum.z), + new Vector3(minimum.x, minimum.y, maximum.z), + new Vector3(maximum.x, minimum.y, maximum.z), + new Vector3(minimum.x, maximum.y, maximum.z), + new Vector3(maximum.x, maximum.y, maximum.z), + ].map((corner) => corner.sub(cameraPosition).dot(forward)) + ); + }, 0); + if (!Number.isFinite(maximumDepthMeters) || maximumDepthMeters <= 0) { + return cameraViewState; + } + + return { + ...cameraViewState, + intrinsics: { + ...cameraViewState.intrinsics, + frustum: { + near: 0.1 as Meters, + far: (maximumDepthMeters * 1.02) as Meters, + }, + }, + }; +}; + +export const buildShadowProjectionDebugModel = ( + map: MaplibreMap, + solarPosition: SolarPosition, + snapshot: ShadowProjectionDebugSnapshot +): ShadowProjectionDebugModel | null => { + const sceneAnchorPosition = new Vector3().fromArray( + snapshot.sceneAnchorPositionElements ?? [0, 0, 0] + ); + const initialCameraViewState = readFromMaplibre(map, DEBUG_SOURCE_ID, { + altitudeM: sceneAnchorPosition.y, + }); + const shadow = snapshot.shadow; + if (!initialCameraViewState || !shadow) return null; + const relativeTileVolumes = readRelativeTileVolumes( + snapshot, + sceneAnchorPosition + ); + const exactMainCameraViewState = snapshot.mainCamera + ? buildExactCameraViewState({ + referenceViewState: initialCameraViewState, + sceneAnchorPosition, + cameraType: CAMERA_TYPE.PERSPECTIVE, + viewMatrixElements: snapshot.mainCamera.viewMatrixElements, + projectionMatrixElements: snapshot.mainCamera.projectionMatrixElements, + nearMeters: snapshot.mainCamera.nearMeters, + farMeters: snapshot.mainCamera.farMeters, + shadowMapWidth: snapshot.mainCamera.viewportWidth, + shadowMapHeight: snapshot.mainCamera.viewportHeight, + sourceSuffix: "main", + }) + : null; + const cameraViewState = withTileVolumeDepthRange( + exactMainCameraViewState ?? initialCameraViewState, + relativeTileVolumes + ); + + const footprint = readViewportFootprint(map); + const camera = shadow.camera; + const receiverCoverageWidthMeters = + camera.receiverRightMeters - camera.receiverLeftMeters; + const receiverCoverageHeightMeters = + camera.receiverTopMeters - camera.receiverBottomMeters; + const azimuthDegrees = + snapshot.atmosphericSunlight?.azimuthDegrees ?? + solarPosition.azimuthDegrees; + const elevationDegrees = + snapshot.atmosphericSunlight?.elevationDegrees ?? + solarPosition.elevationDegrees; + const elevationRadians = degToRadNumeric(Math.max(0.01, elevationDegrees)); + const shadowViewState = buildExactCameraViewState({ + referenceViewState: cameraViewState, + sceneAnchorPosition, + cameraType: CAMERA_TYPE.ORTHOGRAPHIC, + viewMatrixElements: camera.viewMatrixElements, + projectionMatrixElements: camera.projectionMatrixElements, + nearMeters: camera.nearMeters, + farMeters: camera.farMeters, + shadowMapWidth: camera.shadowMapWidth, + shadowMapHeight: camera.shadowMapHeight, + sourceSuffix: "sun", + }); + const fallbackShadowViewState = buildViewState({ + longitude: cameraViewState.anchorCartographic.longitude, + latitude: cameraViewState.anchorCartographic.latitude, + altitude: cameraViewState.anchorCartographic.altitude, + bearing: degToRadNumeric((azimuthDegrees + 180) % 360), + pitch: degToRadNumeric(90 - elevationDegrees), + range: Math.max(snapshot.cameraRangeMeters, 1), + intrinsics: { + type: CAMERA_TYPE.ORTHOGRAPHIC, + projectionMatrix: new Matrix4().fromArray([ + ...camera.projectionMatrixElements, + ]), + frustum: { + near: camera.nearMeters as Meters, + far: camera.farMeters as Meters, + }, + }, + metadata: { + frameId: cameraViewState.metadata.frameId, + timestampMs: Date.now(), + sourceId: `${DEBUG_SOURCE_ID}-sun`, + source: "sync", + viewport: { + widthPx: camera.shadowMapWidth, + heightPx: camera.shadowMapHeight, + }, + }, + }); + const resolvedShadowViewState = shadowViewState ?? fallbackShadowViewState; + const visualizationWorldScaleMeters = readVisualizationWorldScaleMeters( + relativeTileVolumes, + [cameraViewState, resolvedShadowViewState] + ); + + return { + viewStates: [cameraViewState, resolvedShadowViewState], + tileVolumes: normalizeTileVolumes( + relativeTileVolumes, + visualizationWorldScaleMeters + ), + viewportWidthMeters: footprint.widthMeters, + viewportHeightMeters: footprint.heightMeters, + receiverCoverageWidthMeters, + receiverCoverageHeightMeters, + shadowTexelWidthMeters: camera.metersPerTexel, + shadowTexelHeightMeters: camera.metersPerTexel, + horizontalProjectionPerHeight: 1 / Math.tan(elevationRadians), + elevationSpanMeters: + snapshot.maximumElevationMeters - snapshot.minimumElevationMeters, + shadowBuffer: { + receiverLeftMeters: camera.receiverLeftMeters, + receiverRightMeters: camera.receiverRightMeters, + receiverBottomMeters: camera.receiverBottomMeters, + receiverTopMeters: camera.receiverTopMeters, + leftMeters: camera.leftMeters, + rightMeters: camera.rightMeters, + bottomMeters: camera.bottomMeters, + topMeters: camera.topMeters, + widthMeters: camera.rightMeters - camera.leftMeters, + heightMeters: camera.topMeters - camera.bottomMeters, + guardMeters: camera.guardMeters, + texelMeters: camera.metersPerTexel, + shadowMapWidth: camera.shadowMapWidth, + shadowMapHeight: camera.shadowMapHeight, + }, + shadowSampleCount: shadow.sampleCount, + totalShadowTexels: shadow.totalShadowTexels, + casterReachMeters: shadow.casterReachMeters, + visualizationWorldScaleMeters, + }; +}; diff --git a/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-projection-debug-store.ts b/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-projection-debug-store.ts new file mode 100644 index 0000000000..627d68520b --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-projection-debug-store.ts @@ -0,0 +1,93 @@ +import type { Map as MaplibreMap } from "maplibre-gl"; + +import type { ShadowSnapshot } from "./shadow-controller"; + +export type ShadowProjectionDebugSnapshot = Readonly<{ + cameraRangeMeters: number; + leftMeters: number; + rightMeters: number; + bottomMeters: number; + topMeters: number; + nearMeters: number; + farMeters: number; + projectionMatrixElements: readonly number[]; + shadowMapWidth: number; + shadowMapHeight: number; + minimumElevationMeters: number; + maximumElevationMeters: number; + sceneAnchorPositionElements?: readonly [number, number, number]; + mainCamera?: Readonly<{ + viewMatrixElements: readonly number[]; + projectionMatrixElements: readonly number[]; + nearMeters: number; + farMeters: number; + viewportWidth: number; + viewportHeight: number; + }>; + tileVolumes?: readonly Readonly<{ + id: string; + loadReason?: "viewport" | "shadow"; + minimum: readonly [number, number, number]; + maximum: readonly [number, number, number]; + }>[]; + shadow?: ShadowSnapshot | null; + atmosphericSunlight?: Readonly<{ + azimuthDegrees: number; + elevationDegrees: number; + relativeIntensity: number; + color: string; + transmittanceReady: boolean; + irradianceReady: boolean; + }> | null; +}>; + +type ShadowProjectionDebugEntry = { + snapshot: ShadowProjectionDebugSnapshot | null; + listeners: Set<() => void>; +}; + +const entries = new WeakMap(); + +const getOrCreateEntry = (map: MaplibreMap) => { + let entry = entries.get(map); + if (!entry) { + entry = { snapshot: null, listeners: new Set() }; + entries.set(map, entry); + } + return entry; +}; + +export const readShadowProjectionDebugSnapshot = (map: MaplibreMap) => + entries.get(map)?.snapshot ?? null; + +export const subscribeShadowProjectionDebugSnapshot = ( + map: MaplibreMap, + listener: () => void +) => { + const entry = getOrCreateEntry(map); + entry.listeners.add(listener); + return () => { + entry.listeners.delete(listener); + }; +}; + +/** Whether anything is listening; publishing without a reader is waste. */ +export const hasShadowProjectionDebugListeners = (map: MaplibreMap): boolean => + (entries.get(map)?.listeners.size ?? 0) > 0; + +export const publishShadowProjectionDebugSnapshot = ( + map: MaplibreMap, + snapshot: ShadowProjectionDebugSnapshot +) => { + const entry = getOrCreateEntry(map); + entry.snapshot = snapshot; + for (const listener of entry.listeners) listener(); +}; + +export const clearShadowProjectionDebugSnapshot = (map: MaplibreMap) => { + const entry = entries.get(map); + if (!entry) return; + entry.snapshot = null; + for (const listener of entry.listeners) listener(); + if (entry.listeners.size === 0) entries.delete(map); +}; diff --git a/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-resource-limits.spec.ts b/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-resource-limits.spec.ts new file mode 100644 index 0000000000..25b5f59be3 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-resource-limits.spec.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { resolveShadowResourceLimits } from "./shadow-resource-limits"; + +describe("resolveShadowResourceLimits", () => { + it("caps iPhone shadow and accumulation targets before allocation", () => { + expect( + resolveShadowResourceLimits(16_384, { + userAgent: + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148", + platform: "iPhone", + maxTouchPoints: 5, + }) + ).toEqual({ + maxShadowMapSize: 2_048, + maxAccumulationPixels: 1_000_000, + }); + }); + + it("recognizes iPadOS desktop-style user agents", () => { + expect( + resolveShadowResourceLimits(16_384, { + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 Safari/605.1.15", + platform: "MacIntel", + maxTouchPoints: 5, + }) + ).toEqual({ + maxShadowMapSize: 4_096, + maxAccumulationPixels: 2_000_000, + }); + }); + + it("caps oversized desktop shadow targets at a safe HQ size", () => { + expect( + resolveShadowResourceLimits(16_384, { + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + platform: "MacIntel", + maxTouchPoints: 0, + }) + ).toEqual({ + maxShadowMapSize: 4_096, + maxAccumulationPixels: Number.POSITIVE_INFINITY, + }); + }); + + it("keeps a smaller renderer limit on desktop", () => { + expect( + resolveShadowResourceLimits(4_096, { + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + platform: "Linux x86_64", + maxTouchPoints: 0, + }).maxShadowMapSize + ).toBe(4_096); + }); +}); diff --git a/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-resource-limits.ts b/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-resource-limits.ts new file mode 100644 index 0000000000..ec2b998f00 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-resource-limits.ts @@ -0,0 +1,55 @@ +const PHONE_MAX_SHADOW_MAP_SIZE = 2_048; +const TABLET_MAX_SHADOW_MAP_SIZE = 4_096; +const DESKTOP_MAX_SHADOW_MAP_SIZE = 4_096; +const PHONE_MAX_ACCUMULATION_PIXELS = 1_000_000; +const TABLET_MAX_ACCUMULATION_PIXELS = 2_000_000; + +export type ShadowResourceEnvironment = Readonly<{ + userAgent: string; + platform: string; + maxTouchPoints: number; +}>; + +export type ShadowResourceLimits = Readonly<{ + maxShadowMapSize: number; + maxAccumulationPixels: number; +}>; + +const readEnvironment = (): ShadowResourceEnvironment => { + if (typeof navigator === "undefined") { + return { userAgent: "", platform: "", maxTouchPoints: 0 }; + } + return { + userAgent: navigator.userAgent, + platform: navigator.platform, + maxTouchPoints: navigator.maxTouchPoints, + }; +}; + +export const resolveShadowResourceLimits = ( + reportedMaxTextureSize: number, + environment = readEnvironment() +): ShadowResourceLimits => { + const maxTextureSize = Math.max(256, Math.floor(reportedMaxTextureSize)); + const phone = /iPhone|iPod|Android.+Mobile/i.test(environment.userAgent); + const tablet = + /iPad|Android(?!.*Mobile)/i.test(environment.userAgent) || + (environment.platform === "MacIntel" && environment.maxTouchPoints > 1); + + if (phone) { + return { + maxShadowMapSize: Math.min(maxTextureSize, PHONE_MAX_SHADOW_MAP_SIZE), + maxAccumulationPixels: PHONE_MAX_ACCUMULATION_PIXELS, + }; + } + if (tablet) { + return { + maxShadowMapSize: Math.min(maxTextureSize, TABLET_MAX_SHADOW_MAP_SIZE), + maxAccumulationPixels: TABLET_MAX_ACCUMULATION_PIXELS, + }; + } + return { + maxShadowMapSize: Math.min(maxTextureSize, DESKTOP_MAX_SHADOW_MAP_SIZE), + maxAccumulationPixels: Number.POSITIVE_INFINITY, + }; +}; diff --git a/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-scene.spec.ts b/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-scene.spec.ts new file mode 100644 index 0000000000..39f17e92be --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-scene.spec.ts @@ -0,0 +1,1573 @@ +// @vitest-environment node + +import * as THREE from "three"; +import { SkyMaterial } from "@takram/three-atmosphere"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mapLibreEventMock = vi.hoisted(() => ({ + MOVE: "move", + MOVE_END: "moveend", + MOVE_START: "movestart", + RESIZE: "resize", + STYLE_DATA: "styledata", + STYLE_LOAD: "style.load", + TERRAIN: "terrain", +})); + +vi.mock("@carma-mapping/engines/maplibre", () => { + return { + MAPLIBRE_EVENT: mapLibreEventMock, + WUPPERTAL_TERRAIN_SOURCE_ID: "terrain-source", + acquireSharedThreeScene: vi.fn(), + buildCesiumTerrainRuntime: vi.fn(), + getGenericThreeLayers: vi.fn(() => []), + getSharedThreeShadowViewSignature: vi.fn(({ camera, shadowMapSize }) => + [ + ...camera.matrixWorld.elements, + ...camera.projectionMatrix.elements, + shadowMapSize.width, + shadowMapSize.height, + ].join(",") + ), + getSharedThreeSceneRuntimes: vi.fn(() => []), + subscribeGenericThreeLayers: vi.fn(() => vi.fn()), + subscribeSharedThreeSceneContent: vi.fn(() => vi.fn()), + isMapStyleContourLineLayer: (layer: { + type?: string; + id?: string; + "source-layer"?: string; + }) => + layer.type === "line" && + /hoehenlinie/i.test(`${layer.id}:${layer["source-layer"]}`), + suppressMapLibreRegularStyleLayers: vi.fn(() => vi.fn()), + }; +}); + +import { + acquireSharedThreeScene, + buildCesiumTerrainRuntime, + getGenericThreeLayers, + getSharedThreeSceneRuntimes, + MAPLIBRE_EVENT, + subscribeGenericThreeLayers, + subscribeSharedThreeSceneContent, + suppressMapLibreRegularStyleLayers, +} from "@carma-mapping/engines/maplibre"; + +import { + acquireShadowMapLibreTerrain, + buildShadowSimulationScene, + solarPositionToSceneDirection, +} from "./shadow-scene"; +import { + AtmosphericSunlightEvaluator, + evaluateAtmosphericSunlight, +} from "./atmospheric-sunlight"; +import { ATMOSPHERIC_SKY_NAME } from "./atmospheric-sky"; +import { + readShadowProjectionDebugSnapshot, + subscribeShadowProjectionDebugSnapshot, +} from "./shadow-projection-debug-store"; +import { getDaylightWindow, getSolarPosition } from "../core/solar-position"; +import { DEFAULT_MESH_ERROR_TARGET_PIXELS } from "../core/shadow-types"; + +describe("shadow scene sun direction", () => { + const position = (azimuthDegrees: number, elevationDegrees: number) => ({ + instant: new Date("2026-06-21T10:00:00Z"), + azimuthDegrees, + elevationDegrees, + }); + + it("maps north to the shared scene's negative Z axis", () => { + const direction = solarPositionToSceneDirection(position(0, 0)); + + expect(direction.x).toBeCloseTo(0); + expect(direction.y).toBeCloseTo(0); + expect(direction.z).toBeCloseTo(-1); + }); + + it("maps east and zenith into the shared local frame", () => { + expect(solarPositionToSceneDirection(position(90, 0)).x).toBeCloseTo(1); + expect(solarPositionToSceneDirection(position(180, 90)).y).toBeCloseTo(1); + }); + + it("maps Berlin civil time at Wuppertal into the local tangent plane", () => { + const location = { + latitude: 51.256, + longitude: 7.15, + }; + const selection = { + year: 2026, + dayOfYear: 172, + minutes: 12 * 60, + timeZone: "Europe/Berlin", + }; + const daylight = getDaylightWindow(selection, location); + const solarPosition = getSolarPosition( + { + ...selection, + minutes: daylight.solarNoonMinutes, + }, + location + ); + const direction = solarPositionToSceneDirection(solarPosition); + + expect(solarPosition.instant.toISOString()).toContain("T11:"); + expect(direction.y).toBeGreaterThan(0.85); + expect(direction.z).toBeGreaterThan(0); + expect(Math.abs(direction.x)).toBeLessThan(0.05); + }); +}); + +describe("shadow scene MapLibre terrain", () => { + it("leaves the label drape to the shared scene for a textured terrain mesh", () => { + type Handler = () => void; + const handlers = new Map>(); + let terrain: { source: string; exaggeration: number } | null = null; + const layers = [ + { id: "background", type: "background" }, + { id: "basemap", type: "raster", source: "basemap-source" }, + { id: "landcover", type: "fill", source: "vector-source" }, + { id: "roads", type: "line", source: "vector-source" }, + { + id: "bg-basemap_relief-Hoehenlinie_10er", + type: "line", + source: "vector-source", + "source-layer": "Hoehenlinie", + }, + { id: "road-labels", type: "symbol", source: "vector-source" }, + { id: "three", type: "custom" }, + ]; + const paint = new Map([["basemap:raster-opacity", 0.9]]); + const layout = new Map([["roads:visibility", "visible"]]); + const map = { + terrain: null, + getTerrain: vi.fn(() => terrain), + getSource: vi.fn((sourceId: string) => + sourceId === "terrain-source" ? { id: sourceId } : undefined + ), + setTerrain: vi.fn((next: typeof terrain) => { + terrain = next; + }), + getStyle: vi.fn(() => ({ layers })), + getLayer: vi.fn((layerId: string) => + layers.find(({ id }) => id === layerId) + ), + addLayer: vi.fn((layer: (typeof layers)[number]) => { + layers.unshift(layer); + }), + removeLayer: vi.fn((layerId: string) => { + const index = layers.findIndex(({ id }) => id === layerId); + if (index >= 0) layers.splice(index, 1); + }), + getPaintProperty: vi.fn((layerId: string, property: string) => + paint.get(`${layerId}:${property}`) + ), + setPaintProperty: vi.fn( + (layerId: string, property: string, value: unknown) => { + paint.set(`${layerId}:${property}`, value); + } + ), + getLayoutProperty: vi.fn((layerId: string, property: string) => + layout.get(`${layerId}:${property}`) + ), + setLayoutProperty: vi.fn( + (layerId: string, property: string, value: unknown) => { + if (value == null) layout.delete(`${layerId}:${property}`); + else layout.set(`${layerId}:${property}`, value); + } + ), + on: vi.fn((event: string, handler: Handler) => { + const listeners = handlers.get(event) ?? new Set(); + listeners.add(handler); + handlers.set(event, listeners); + }), + off: vi.fn(), + }; + + let drapeMode: "opaque" | "labels" = "labels"; + const release = acquireShadowMapLibreTerrain( + map as never, + "terrain-source", + () => true, + () => drapeMode + ); + + expect(terrain).toEqual({ source: "terrain-source", exaggeration: 1 }); + expect(layers.some(({ id }) => id === "carma-shadow-map-style-base")).toBe( + false + ); + // Hiding fills and strokes is the registry's job; the scene leaves the + // authored visibilities and opacities alone in labels mode. + expect(layout.has("basemap:visibility")).toBe(false); + expect(layout.get("roads:visibility")).toBe("visible"); + expect(layout.has("road-labels:visibility")).toBe(false); + expect(layout.has("three:visibility")).toBe(false); + expect(paint.get("basemap:raster-opacity")).toBe(0.9); + + // The mesh leaves the scene: the opaque basemap drape takes over again. + drapeMode = "opaque"; + release.refresh(); + expect(layout.get("roads:visibility")).toBe("visible"); + expect(layout.has("basemap:visibility")).toBe(false); + expect(paint.get("basemap:raster-opacity")).toBe(1); + expect(layers[0]).toMatchObject({ id: "carma-shadow-map-style-base" }); + + drapeMode = "labels"; + release.refresh(); + expect(layers.some(({ id }) => id === "carma-shadow-map-style-base")).toBe( + false + ); + expect(paint.get("basemap:raster-opacity")).toBe(0.9); + expect(layout.get("roads:visibility")).toBe("visible"); + + release(); + expect(layout.get("roads:visibility")).toBe("visible"); + expect(layout.has("basemap:visibility")).toBe(false); + }); + it("keeps the native terrain enabled and restores the previous setting", () => { + type Handler = () => void; + const handlers = new Map>(); + const previousTerrain = { source: "previous-terrain", exaggeration: 0.75 }; + let terrain: typeof previousTerrain | null = previousTerrain; + const terrainRuntime = { + getMeshFrameDelta: vi.fn(() => 42), + }; + const sources = new Set(["terrain-source", "previous-terrain"]); + const layers = [ + { + id: "basemap", + type: "raster", + source: "basemap-source", + }, + { id: "landcover", type: "fill", source: "vector-source" }, + { id: "terrain-shading", type: "hillshade", source: "terrain-source" }, + { id: "terrain-relief", type: "color-relief", source: "terrain-source" }, + { + id: "bg-basemap_relief::Schummerung_Col", + type: "raster", + source: "bg-basemap_relief::schummerung_col", + }, + { + id: "bg-basemap_relief::Schummerung_Comb", + type: "raster", + source: "bg-basemap_relief::schummerung_comb", + }, + { id: "roads", type: "line", source: "vector-source" }, + ]; + const paint = new Map([ + ["basemap:raster-opacity", 0.9], + ["landcover:fill-opacity", 0.6], + ["bg-basemap_relief::Schummerung_Col:raster-opacity", 0.8], + ["bg-basemap_relief::Schummerung_Comb:raster-opacity", 0.5], + ]); + const layout = new Map([ + ["terrain-shading:visibility", "visible"], + ["terrain-relief:visibility", "visible"], + ["bg-basemap_relief::Schummerung_Col:visibility", "visible"], + ["bg-basemap_relief::Schummerung_Comb:visibility", "visible"], + ]); + const map = { + terrain: terrainRuntime, + getTerrain: vi.fn(() => terrain), + getSource: vi.fn((sourceId: string) => + sources.has(sourceId) ? { id: sourceId } : undefined + ), + setTerrain: vi.fn((nextTerrain: typeof terrain) => { + terrain = nextTerrain; + for (const handler of handlers.get("terrain") ?? []) handler(); + }), + getStyle: vi.fn(() => ({ layers })), + getLayer: vi.fn((layerId: string) => + layers.find(({ id }) => id === layerId) + ), + addLayer: vi.fn((layer: (typeof layers)[number], beforeId?: string) => { + const index = beforeId + ? layers.findIndex(({ id }) => id === beforeId) + : layers.length; + layers.splice(index < 0 ? layers.length : index, 0, layer); + }), + removeLayer: vi.fn((layerId: string) => { + const index = layers.findIndex(({ id }) => id === layerId); + if (index >= 0) layers.splice(index, 1); + }), + getPaintProperty: vi.fn((layerId: string, property: string) => + paint.get(`${layerId}:${property}`) + ), + setPaintProperty: vi.fn( + (layerId: string, property: string, value: unknown) => { + paint.set(`${layerId}:${property}`, value as number); + } + ), + getLayoutProperty: vi.fn((layerId: string, property: string) => + layout.get(`${layerId}:${property}`) + ), + setLayoutProperty: vi.fn( + (layerId: string, property: string, value: unknown) => { + layout.set(`${layerId}:${property}`, value as string); + } + ), + on: vi.fn((event: string, handler: Handler) => { + const listeners = handlers.get(event) ?? new Set(); + listeners.add(handler); + handlers.set(event, listeners); + }), + off: vi.fn((event: string, handler: Handler) => { + handlers.get(event)?.delete(handler); + }), + }; + + let mapStyleContentVisible = true; + const release = acquireShadowMapLibreTerrain( + map as never, + "terrain-source", + () => mapStyleContentVisible + ); + + expect(terrain).toEqual({ source: "terrain-source", exaggeration: 1 }); + expect(terrainRuntime.getMeshFrameDelta(15)).toBe(0); + expect(layers[0]).toMatchObject({ + id: "carma-shadow-map-style-base", + type: "background", + }); + expect(paint.get("basemap:raster-opacity")).toBe(1); + expect(paint.get("landcover:fill-opacity")).toBe(1); + expect(layout.get("terrain-shading:visibility")).toBe("none"); + expect(layout.get("terrain-relief:visibility")).toBe("none"); + expect(layout.get("bg-basemap_relief::Schummerung_Col:visibility")).toBe( + "none" + ); + expect(layout.get("bg-basemap_relief::Schummerung_Comb:visibility")).toBe( + "none" + ); + expect(paint.has("roads:line-opacity")).toBe(false); + terrain = null; + for (const handler of handlers.get("terrain") ?? []) handler(); + expect(terrain).toEqual({ source: "terrain-source", exaggeration: 1 }); + mapStyleContentVisible = false; + paint.set("basemap:raster-opacity", 0.75); + const paintUpdateCount = map.setPaintProperty.mock.calls.length; + for (const handler of handlers.get("styledata") ?? []) handler(); + expect(paint.get("basemap:raster-opacity")).toBe(0.75); + expect(map.setPaintProperty).toHaveBeenCalledTimes(paintUpdateCount); + expect(terrain).toEqual({ source: "terrain-source", exaggeration: 1 }); + mapStyleContentVisible = true; + for (const handler of handlers.get("styledata") ?? []) handler(); + expect(paint.get("basemap:raster-opacity")).toBe(1); + + release(); + + expect(terrain).toEqual(previousTerrain); + expect(terrainRuntime.getMeshFrameDelta(15)).toBe(42); + expect(layers.some(({ id }) => id === "carma-shadow-map-style-base")).toBe( + false + ); + expect(paint.get("basemap:raster-opacity")).toBe(0.75); + expect(paint.get("landcover:fill-opacity")).toBe(0.6); + expect(layout.get("terrain-shading:visibility")).toBe("visible"); + expect(layout.get("terrain-relief:visibility")).toBe("visible"); + expect(layout.get("bg-basemap_relief::Schummerung_Col:visibility")).toBe( + "visible" + ); + expect(layout.get("bg-basemap_relief::Schummerung_Comb:visibility")).toBe( + "visible" + ); + expect(map.off).toHaveBeenCalledWith("styledata", expect.any(Function)); + expect(map.off).toHaveBeenCalledWith("terrain", expect.any(Function)); + }); +}); + +describe("shadow scene lighting integration", () => { + const releaseScene = vi.fn(); + const setLocationLabelColor = vi.fn(); + const setPointLabelOverlayVisible = vi.fn(); + const setMapStyleProjectionVisible = vi.fn(); + let scene: THREE.Scene; + type SharedRuntimeFixture = { + id: string; + root: THREE.Object3D; + providesTerrain?: boolean; + hasRenderableContent?: () => boolean; + getActiveTileVolumes?: () => readonly { + id: string; + kind: "terrain-tile" | "tiles3d"; + minimum: readonly [number, number, number]; + maximum: readonly [number, number, number]; + }[]; + updatePriority?: number; + update?: (frame: unknown) => void; + dispose: () => void; + }; + let sharedRuntimes: Map; + let accumulationController: { + active: () => boolean; + retainSettledFrame: () => boolean; + prepareRound: (round: number) => void; + finishRound?: () => void; + rounds: number; + } | null; + let sharedLayer: { + getScene: () => THREE.Scene; + addRuntime: (runtime: SharedRuntimeFixture) => void; + hasRuntime: (runtimeId: string) => boolean; + removeRuntime: (runtimeId: string) => void; + getRenderer: () => THREE.WebGLRenderer | null; + setAccumulationController: ( + controller: { + active: () => boolean; + retainSettledFrame: () => boolean; + prepareRound: (round: number) => void; + finishRound?: () => void; + rounds: number; + } | null + ) => void; + setMapStyleProjectionVisible: (visible: boolean) => void; + projectLngLatToScene?: ( + lngLat: [number, number], + altitude?: number + ) => THREE.Vector3; + }; + + beforeEach(() => { + vi.clearAllMocks(); + scene = new THREE.Scene(); + sharedRuntimes = new Map(); + accumulationController = null; + sharedLayer = { + getScene: () => scene, + getRenderer: () => null, + addRuntime: vi.fn((runtime) => { + sharedRuntimes.set(runtime.id, runtime); + scene.add(runtime.root); + }), + hasRuntime: vi.fn((runtimeId) => sharedRuntimes.has(runtimeId)), + removeRuntime: vi.fn((runtimeId) => { + const runtime = sharedRuntimes.get(runtimeId); + if (!runtime) return; + scene.remove(runtime.root); + runtime.dispose(); + sharedRuntimes.delete(runtimeId); + }), + setAccumulationController: vi.fn((controller) => { + accumulationController = controller; + }), + setMapStyleProjectionVisible, + }; + vi.mocked(getGenericThreeLayers).mockReturnValue([]); + vi.mocked(getSharedThreeSceneRuntimes).mockReturnValue([]); + vi.mocked(subscribeGenericThreeLayers).mockReturnValue(vi.fn()); + vi.mocked(subscribeSharedThreeSceneContent).mockReturnValue(vi.fn()); + vi.mocked(acquireSharedThreeScene).mockReturnValue({ + layer: sharedLayer as never, + setLocationLabelColor, + setPointLabelOverlayVisible, + setMeshLabelStyle: vi.fn(), + release: releaseScene, + }); + }); + + const updateShadows = ( + map: unknown, + camera: THREE.PerspectiveCamera, + lookTarget = new THREE.Vector3() + ) => { + const runtime = sharedRuntimes.get("shadow-simulation-controller"); + expect(runtime?.update).toBeTypeOf("function"); + runtime?.update?.({ + map, + renderCamera: camera, + lodCamera: camera, + lookTarget, + viewport: new THREE.Vector2(800, 600), + }); + }; + + it("drives MapLibre and the Three.js sun from the same solar position", () => { + const performanceNow = vi.spyOn(performance, "now").mockReturnValue(100); + const evaluateAtmosphere = vi.spyOn( + AtmosphericSunlightEvaluator.prototype, + "evaluate" + ); + sharedLayer.projectLngLatToScene = ([lng, lat], altitude = 0) => + new THREE.Vector3(lng * 1_000, altitude, lat * 1_000); + const setLight = vi.fn(); + let mapCenter = { lng: 7.15, lat: 51.256 }; + const map = { + getCenter: vi.fn(() => mapCenter), + getCanvas: vi.fn(() => ({ clientWidth: 800, clientHeight: 600 })), + unproject: vi.fn(([x, y]: [number, number]) => ({ + lng: 7.15 + (x / 800 - 0.5) * 0.02, + lat: 51.256 + (0.5 - y / 600) * 0.02, + })), + getLight: vi.fn(() => ({ anchor: "viewport" })), + isStyleLoaded: vi.fn(() => true), + setLight, + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + }; + const controller = buildShadowSimulationScene(map as never); + const solarPosition = { + instant: new Date("2026-06-21T10:00:00Z"), + azimuthDegrees: 135, + elevationDegrees: 45, + }; + + controller.updateSolarPosition(solarPosition); + const camera = new THREE.PerspectiveCamera(60, 4 / 3, 1, 20_000); + camera.position.set(7_150, 4_000, 51_256); + camera.lookAt(7_150, 0, 51_256); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(true); + updateShadows(map, camera); + + expect(evaluateAtmosphere.mock.lastCall?.[1].altitudeMeters).toBe(4_100); + expect(evaluateAtmosphere.mock.lastCall?.[3]?.observer).toEqual({ + longitude: 7.15, + latitude: 51.256, + altitudeMeters: 0, + }); + expect( + evaluateAtmosphere.mock.lastCall?.[3]?.scenePosition.toArray() + ).toEqual([7_150, 100, 51_256]); + const atmosphericSky = scene.getObjectByName( + ATMOSPHERIC_SKY_NAME + ) as THREE.Mesh; + atmosphericSky.material.copyCameraSettings(camera); + expect( + ( + atmosphericSky.material.uniforms.cameraPosition.value as THREE.Vector3 + ).toArray() + ).toEqual([7_150, 4_100, 51_256]); + + camera.position.set(9_000, 4_500, 48_000); + camera.lookAt(9_000, 500, 48_000); + camera.updateMatrixWorld(true); + updateShadows(map, camera, new THREE.Vector3(9_000, 500, 48_000)); + atmosphericSky.material.copyCameraSettings(camera); + expect( + ( + atmosphericSky.material.uniforms.cameraPosition.value as THREE.Vector3 + ).toArray() + ).toEqual([7_150, 4_100, 51_256]); + + const atmosphere = evaluateAtmosphericSunlight( + solarPosition.instant, + { longitude: 7.15, latitude: 51.256, altitudeMeters: 4_100 }, + null + ); + + expect(acquireSharedThreeScene).toHaveBeenCalledWith(map); + expect(setLight).toHaveBeenLastCalledWith( + expect.objectContaining({ + anchor: "map", + position: [ + 1.5, + atmosphere.azimuthDegrees, + 90 - atmosphere.elevationDegrees, + ], + color: `#${atmosphere.color.getHexString()}`, + }) + ); + expect(setLocationLabelColor).toHaveBeenLastCalledWith( + `#${atmosphere.color.getHexString()}` + ); + const sun = scene.getObjectByName( + "shadow-simulation-sun" + ) as THREE.DirectionalLight; + const sunVector = scene.getObjectByName( + "shadow-simulation-sun-vector" + ) as THREE.ArrowHelper; + const defaultSunIntensity = sun.intensity; + const defaultMapIntensity = setLight.mock.lastCall?.[0].intensity as number; + const mapLightUpdateCount = setLight.mock.calls.length; + controller.updateShadowIntensity(1); + expect(sun.intensity).toBe(defaultSunIntensity); + expect(setLight).toHaveBeenCalledTimes(mapLightUpdateCount); + expect(setLight.mock.lastCall?.[0].intensity).toBe(defaultMapIntensity); + expect(sun.isDirectionalLight).toBe(true); + expect(sun.shadow.camera).toBeInstanceOf(THREE.OrthographicCamera); + expect(sun.shadow.camera.projectionMatrix.elements[11]).toBe(0); + expect(sun.shadow.camera.projectionMatrix.elements[15]).toBe(1); + expect(sun.castShadow).toBe(true); + expect(sun.shadow.autoUpdate).toBe(false); + expect(sun.shadow.radius).toBe(0); + const shadowLights = scene.children.filter( + (object): object is THREE.DirectionalLight => + (object as THREE.DirectionalLight).isDirectionalLight && + object.name.startsWith("shadow-simulation-sun") + ); + expect(shadowLights).toHaveLength(1); + expect(shadowLights.every((light) => light.shadow.intensity === 1)).toBe( + true + ); + expect( + sun.position + .clone() + .sub(sun.target.position) + .normalize() + .dot(atmosphere.directionToSun) + ).toBeCloseTo(1, 10); + const vectorDirection = new THREE.Vector3(0, 1, 0) + .applyQuaternion(sunVector.quaternion) + .normalize(); + const lightDirection = sun.position + .clone() + .sub(sun.target.position) + .normalize(); + const translatedRay = sun.position + .clone() + .add(new THREE.Vector3(1_000, -300, 500)) + .sub(sun.target.position.clone().add(new THREE.Vector3(1_000, -300, 500))) + .normalize(); + expect(translatedRay.dot(lightDirection)).toBeCloseTo(1); + const shadowRayDirections = [ + [-1, -1], + [-1, 1], + [1, -1], + [1, 1], + ].map(([x, y]) => { + const near = new THREE.Vector3(x, y, -1).unproject(sun.shadow.camera); + const far = new THREE.Vector3(x, y, 1).unproject(sun.shadow.camera); + return far.sub(near).normalize(); + }); + for (const shadowRayDirection of shadowRayDirections.slice(1)) { + expect(shadowRayDirection.dot(shadowRayDirections[0])).toBeCloseTo(1); + } + expect(sunVector.visible).toBe(false); + expect(map.on).toHaveBeenCalledWith( + MAPLIBRE_EVENT.STYLE_LOAD, + expect.any(Function) + ); + controller.updateSunDebugVectorVisibility(true); + expect(sunVector.visible).toBe(true); + expect(sunVector.position).toEqual(sun.target.position); + expect(vectorDirection.dot(lightDirection)).toBeCloseTo(1); + expect(sunVector.cone.castShadow).toBe(false); + expect(sunVector.cone.receiveShadow).toBe(false); + expect((sunVector.cone.material as THREE.Material).depthTest).toBe(false); + expect((sunVector.cone.material as THREE.Material).depthWrite).toBe(false); + expect((sunVector.cone.material as THREE.Material).transparent).toBe(true); + expect( + scene.getObjectByName("shadow-simulation-sun-vector-shaft") + ).toBeDefined(); + expect( + scene.getObjectByName("shadow-simulation-sun-vector-ground-ray") + ).toBeDefined(); + expect( + scene.getObjectByName("shadow-simulation-sun-vector-elevation-arc") + ).toBeDefined(); + const centeredSunPosition = sun.position.clone(); + accumulationController?.prepareRound(1); + expect(sun.position.equals(centeredSunPosition)).toBe(false); + + mapCenter = { lng: 7.2, lat: 51.3 }; + controller.updateSolarPosition({ + ...solarPosition, + instant: new Date("2026-06-21T10:01:00Z"), + }); + expect( + evaluateAtmosphere.mock.lastCall?.[3]?.scenePosition.toArray() + ).toEqual([7_150, 100, 51_256]); + + const animatedMapStyleUpdateCount = setLight.mock.calls.length; + controller.updateTimeAnimating(true); + controller.updateSolarPosition({ + ...solarPosition, + instant: new Date("2026-06-21T10:02:00Z"), + }); + controller.updateSolarPosition({ + ...solarPosition, + instant: new Date("2026-06-21T10:03:00Z"), + }); + expect(setLight).toHaveBeenCalledTimes(animatedMapStyleUpdateCount); + controller.updateTimeAnimating(false); + expect(setLight).toHaveBeenCalledTimes(animatedMapStyleUpdateCount + 1); + + const restoreMapContent = vi.fn(); + vi.mocked(suppressMapLibreRegularStyleLayers).mockReturnValueOnce( + restoreMapContent + ); + controller.updateMapStyleContentVisibility(false); + expect(suppressMapLibreRegularStyleLayers).toHaveBeenCalledWith(map); + expect(setMapStyleProjectionVisible).toHaveBeenLastCalledWith(false); + controller.updateMapStyleContentVisibility(true); + expect(restoreMapContent).toHaveBeenCalledOnce(); + expect(setMapStyleProjectionVisible).toHaveBeenLastCalledWith(true); + controller.updateMapStyleLabelOverlayVisibility(false); + expect(setPointLabelOverlayVisible).toHaveBeenLastCalledWith(false); + controller.updateMapStyleLabelOverlayVisibility(true); + expect(setPointLabelOverlayVisible).toHaveBeenLastCalledWith(true); + + controller.dispose(); + expect(scene.getObjectByName("shadow-simulation-sun")).toBeUndefined(); + expect( + scene.getObjectByName("shadow-simulation-sun-vector") + ).toBeUndefined(); + expect(releaseScene).toHaveBeenCalledOnce(); + evaluateAtmosphere.mockRestore(); + performanceNow.mockRestore(); + }); + + it("keeps sun-disc accumulation active while tiles are streaming", () => { + sharedLayer.projectLngLatToScene = ([lng, lat], altitude = 0) => + new THREE.Vector3(lng * 1_000, altitude, lat * 1_000); + const setShadowView = vi.fn(); + vi.mocked(getSharedThreeSceneRuntimes).mockReturnValue([ + { + id: "buildings", + originLngLat: [7.15, 51.256], + root: new THREE.Group(), + update: vi.fn(), + setShadowView, + getRequestDemand: () => 1, + dispose: vi.fn(), + }, + ]); + const mapHandlers = new Map void>(); + let mapCenter = { lng: 7.15, lat: 51.256 }; + const map = { + getCenter: vi.fn(() => mapCenter), + getCanvas: vi.fn(() => ({ clientWidth: 800, clientHeight: 600 })), + unproject: vi.fn(([x, y]: [number, number]) => ({ + lng: mapCenter.lng + (x / 800 - 0.5) * 0.02, + lat: mapCenter.lat + (0.5 - y / 600) * 0.02, + })), + getLight: vi.fn(() => ({ anchor: "viewport" })), + isStyleLoaded: vi.fn(() => true), + setLight: vi.fn(), + on: vi.fn((event: string, handler: () => void) => { + mapHandlers.set(event, handler); + }), + off: vi.fn(), + triggerRepaint: vi.fn(), + }; + const controller = buildShadowSimulationScene(map as never); + controller.updateAtmosphericLutUsage({ + useTransmittanceLut: false, + useIrradianceLut: false, + }); + controller.updateSolarPosition({ + instant: new Date("2026-06-21T10:00:00Z"), + azimuthDegrees: 135, + elevationDegrees: 45, + }); + const camera = new THREE.PerspectiveCamera(60, 4 / 3, 1, 20_000); + camera.position.set(7_150, 4_000, 51_256); + camera.lookAt(7_150, 0, 51_256); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(true); + updateShadows(map, camera); + + expect( + sharedRuntimes.get("shadow-simulation-controller")?.updatePriority + ).toBe(200); + const accumulation = accumulationController!; + expect(accumulation.active()).toBe(true); + expect(accumulation.retainSettledFrame()).toBe(true); + + const settledShadowViewCallCount = setShadowView.mock.calls.length; + mapHandlers.get("movestart")?.(); + mapCenter = { lng: 7.16, lat: 51.256 }; + camera.position.x += 100; + camera.updateMatrixWorld(true); + updateShadows(map, camera); + expect(setShadowView).toHaveBeenCalledTimes(settledShadowViewCallCount); + mapHandlers.get("moveend")?.(); + updateShadows(map, camera); + expect(setShadowView.mock.calls.length).toBeGreaterThan( + settledShadowViewCallCount + ); + + const shadowViewBeforeAnimation = setShadowView.mock.lastCall?.[0]; + const shadowViewCallCount = setShadowView.mock.calls.length; + controller.updateTimeAnimating(true); + expect(accumulation.active()).toBe(false); + expect(shadowViewBeforeAnimation).not.toBeNull(); + expect(setShadowView).toHaveBeenCalledTimes(shadowViewCallCount); + controller.dispose(); + }); + + it("moves ALKIS buildings into the shared terrain shadow scene", () => { + const terrain = new THREE.Mesh( + new THREE.PlaneGeometry(100, 100), + new THREE.MeshLambertMaterial() + ); + terrain.name = "terrain"; + terrain.userData.isShadowTerrainSurface = true; + scene.add(terrain); + const openSurfaceMaterial = new THREE.MeshLambertMaterial(); + openSurfaceMaterial.shadowSide = THREE.FrontSide; + const openSurface = new THREE.Mesh( + new THREE.PlaneGeometry(10, 10), + openSurfaceMaterial + ); + scene.add(openSurface); + const alkisScene = new THREE.Scene(); + const sourceBuildingMaterial = new THREE.MeshLambertMaterial({ + color: 0xffffff, + opacity: 0.45, + transparent: true, + vertexColors: true, + }); + const building = new THREE.Mesh( + new THREE.BoxGeometry(10, 20, 10), + sourceBuildingMaterial + ); + building.name = "alkis-building"; + building.userData.isBuilding = true; + alkisScene.add(building); + vi.mocked(getGenericThreeLayers).mockReturnValue([ + { + id: "3d-extrusion-alkis", + scene: alkisScene, + _originMerc: { toLngLat: () => ({ lng: 7.15, lat: 51.256 }) }, + } as never, + ]); + const map = { + getCenter: vi.fn(() => ({ lng: 7.15, lat: 51.256 })), + getLight: vi.fn(() => ({ anchor: "viewport" })), + isStyleLoaded: vi.fn(() => true), + setLight: vi.fn(), + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + }; + + const controller = buildShadowSimulationScene(map as never); + controller.updateSolarPosition({ + instant: new Date("2026-06-21T10:00:00Z"), + azimuthDegrees: 135, + elevationDegrees: 45, + }); + controller.updateSunDebugVectorVisibility(true); + + const buildingCopy = scene.getObjectByName( + "alkis-building-shadow-simulation-copy" + ) as THREE.Mesh; + expect(building.visible).toBe(false); + expect(buildingCopy.castShadow).toBe(true); + expect(buildingCopy.receiveShadow).toBe(true); + expect(buildingCopy.material).not.toBe(sourceBuildingMaterial); + expect((buildingCopy.material as THREE.Material).opacity).toBe(1); + expect((buildingCopy.material as THREE.Material).transparent).toBe(false); + expect((buildingCopy.material as THREE.Material).shadowSide).toBe( + THREE.DoubleSide + ); + expect(terrain.castShadow).toBe(true); + expect(terrain.receiveShadow).toBe(true); + expect((terrain.material as THREE.Material).shadowSide).toBeNull(); + expect(openSurfaceMaterial.shadowSide).toBe(THREE.FrontSide); + expect(terrain.customDepthMaterial).toBeUndefined(); + expect(buildingCopy.parent?.parent).toBe(scene); + expect(terrain.parent).toBe(scene); + + controller.updateBuildingAppearance({ + fullOpacity: true, + uniformColor: "#8c7a66", + }); + const uniformCopy = scene.getObjectByName( + "alkis-building-shadow-simulation-copy" + ) as THREE.Mesh; + const uniformMaterial = uniformCopy.material as THREE.MeshLambertMaterial; + expect(uniformMaterial.color.getHexString()).toBe("8c7a66"); + expect(uniformMaterial.vertexColors).toBe(false); + + controller.updateBuildingAppearance({ + fullOpacity: false, + uniformColor: null, + }); + const styledCopy = scene.getObjectByName( + "alkis-building-shadow-simulation-copy" + ) as THREE.Mesh; + const styledMaterial = styledCopy.material as THREE.MeshLambertMaterial; + expect(styledMaterial.opacity).toBe(0.45); + expect(styledMaterial.transparent).toBe(true); + expect(styledMaterial.vertexColors).toBe(true); + + controller.dispose(); + expect(building.visible).toBe(true); + expect( + scene.getObjectByName("alkis-building-shadow-simulation-copy") + ).toBeUndefined(); + }); + + it("restyles registered building tiles only while shadow mode is active", () => { + const setShadowSimulationStyle = vi.fn(); + const setErrorTarget = vi.fn(); + vi.mocked(getSharedThreeSceneRuntimes).mockReturnValue([ + { + providesTerrain: true, + setErrorTarget, + setShadowSimulationStyle, + } as never, + ]); + const map = { + getCenter: vi.fn(() => ({ lng: 7.15, lat: 51.256 })), + getLight: vi.fn(() => ({ anchor: "viewport" })), + isStyleLoaded: vi.fn(() => true), + setLight: vi.fn(), + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + }; + + const controller = buildShadowSimulationScene(map as never); + expect(setShadowSimulationStyle).toHaveBeenLastCalledWith({ + fullOpacity: true, + uniformColor: null, + uniformColorMix: 0, + textureSaturation: 1, + }); + expect(setErrorTarget).toHaveBeenLastCalledWith( + DEFAULT_MESH_ERROR_TARGET_PIXELS + ); + + controller.updateMeshErrorTarget(0.25); + expect(setErrorTarget).toHaveBeenLastCalledWith(0.25); + + controller.updateBuildingAppearance({ + fullOpacity: true, + uniformColor: "#d8d1c4", + }); + expect(setShadowSimulationStyle).toHaveBeenLastCalledWith({ + fullOpacity: true, + uniformColor: "#d8d1c4", + }); + + controller.dispose(); + expect(setShadowSimulationStyle).toHaveBeenLastCalledWith(null); + }); + + it("keeps the full map viewport inside the shadow camera", () => { + sharedLayer.projectLngLatToScene = ([lng, lat], altitude = 0) => + new THREE.Vector3(lng * 1_000, altitude, lat * 1_000); + let mapCenter = { lng: 0, lat: 0 }; + let viewportHalfWidth = 1; + let viewportHalfHeight = 2; + const viewportWidth = 800; + const viewportHeight = 600; + const getBounds = vi.fn(() => ({ + getWest: () => mapCenter.lng - viewportHalfWidth * 4, + getSouth: () => mapCenter.lat - viewportHalfHeight * 4, + getEast: () => mapCenter.lng + viewportHalfWidth * 4, + getNorth: () => mapCenter.lat + viewportHalfHeight * 4, + })); + const map = { + getCenter: vi.fn(() => mapCenter), + getBounds, + getCanvas: vi.fn(() => ({ + clientWidth: viewportWidth, + clientHeight: viewportHeight, + })), + unproject: vi.fn(([x, y]: [number, number]) => ({ + lng: mapCenter.lng + (x / viewportWidth - 0.5) * viewportHalfWidth * 2, + lat: + mapCenter.lat + (0.5 - y / viewportHeight) * viewportHalfHeight * 2, + })), + getLight: vi.fn(() => ({ anchor: "viewport" })), + isStyleLoaded: vi.fn(() => true), + setLight: vi.fn(), + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + }; + + const controller = buildShadowSimulationScene(map as never); + subscribeShadowProjectionDebugSnapshot(map as never, () => undefined); + controller.updateSolarPosition({ + instant: new Date("2026-06-21T10:00:00Z"), + azimuthDegrees: 135, + elevationDegrees: 45, + }); + const sunVector = scene.getObjectByName( + "shadow-simulation-sun-vector" + ) as THREE.ArrowHelper; + const updateAndExpectViewportInsideBuffer = () => { + const camera = new THREE.PerspectiveCamera(60, 4 / 3, 1, 20_000); + const cameraRange = + Math.max(viewportHalfWidth, viewportHalfHeight) * 2_000; + camera.position.set( + mapCenter.lng * 1_000, + cameraRange, + mapCenter.lat * 1_000 + cameraRange + ); + camera.lookAt(mapCenter.lng * 1_000, 0, mapCenter.lat * 1_000); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(true); + updateShadows(map, camera); + const lights = scene.children.filter( + (object): object is THREE.DirectionalLight => + (object as THREE.DirectionalLight).isDirectionalLight && + object.name.startsWith("shadow-simulation-sun") + ); + for (const [lng, lat] of [ + [mapCenter.lng - viewportHalfWidth, mapCenter.lat - viewportHalfHeight], + [mapCenter.lng - viewportHalfWidth, mapCenter.lat + viewportHalfHeight], + [mapCenter.lng + viewportHalfWidth, mapCenter.lat - viewportHalfHeight], + [mapCenter.lng + viewportHalfWidth, mapCenter.lat + viewportHalfHeight], + ]) { + const worldPoint = new THREE.Vector3(lng * 1_000, 0, lat * 1_000); + const contained = lights.some((light) => { + const shadowCamera = light.shadow.camera; + const cameraPoint = worldPoint + .clone() + .applyMatrix4(shadowCamera.matrixWorldInverse); + return ( + cameraPoint.x >= shadowCamera.left - 1e-6 && + cameraPoint.x <= shadowCamera.right + 1e-6 && + cameraPoint.y >= shadowCamera.bottom - 1e-6 && + cameraPoint.y <= shadowCamera.top + 1e-6 + ); + }); + expect(contained).toBe(true); + } + const snapshot = readShadowProjectionDebugSnapshot(map as never); + expect(snapshot?.shadow?.sampleCount).toBe(1); + return snapshot?.shadow?.camera; + }; + + const wideViewportBuffer = updateAndExpectViewportInsideBuffer(); + expect(getBounds).not.toHaveBeenCalled(); + expect(sunVector.position.toArray()).toEqual([0, 0, 0]); + expect(sunVector.cone.position.y).toBeCloseTo(1_000); + expect(map.on).toHaveBeenCalledWith("move", expect.any(Function)); + + mapCenter = { lng: 0.5, lat: 1 }; + const moveHandler = map.on.mock.calls.find( + ([eventName]) => eventName === "move" + )?.[1] as () => void; + moveHandler(); + updateAndExpectViewportInsideBuffer(); + + expect(sunVector.position.toArray()).toEqual([500, 0, 1_000]); + expect( + ( + scene.getObjectByName("shadow-simulation-sun") as THREE.DirectionalLight + ).target.position.toArray() + ).toEqual([500, 0, 1_000]); + + viewportHalfWidth = 0.05; + viewportHalfHeight = 0.1; + moveHandler(); + + const zoomedViewportBuffer = updateAndExpectViewportInsideBuffer(); + expect( + (zoomedViewportBuffer?.rightMeters ?? 0) - + (zoomedViewportBuffer?.leftMeters ?? 0) + ).toBeLessThan( + (wideViewportBuffer?.rightMeters ?? 0) - + (wideViewportBuffer?.leftMeters ?? 0) + ); + + controller.dispose(); + }); + + it("fits loaded tile volumes instead of distant fallback ground points", () => { + sharedLayer.projectLngLatToScene = ([lng, lat], altitude = 0) => + new THREE.Vector3(lng * 1_000, altitude, lat * 1_000); + vi.mocked(getSharedThreeSceneRuntimes).mockReturnValue([ + { + id: "mesh", + root: new THREE.Group(), + getActiveTileVolumes: () => [ + { + id: "visible", + kind: "tiles3d", + minimum: [-100, 0, -100], + maximum: [100, 200, 100], + }, + ], + dispose: vi.fn(), + }, + ] as never); + const map = { + getCenter: vi.fn(() => ({ lng: 0, lat: 0 })), + getCanvas: vi.fn(() => ({ clientWidth: 800, clientHeight: 600 })), + unproject: vi.fn(([x, y]: [number, number]) => ({ + lng: (x / 800 - 0.5) * 20, + lat: (0.5 - y / 600) * 20, + })), + getLight: vi.fn(() => ({ anchor: "viewport" })), + isStyleLoaded: vi.fn(() => true), + setLight: vi.fn(), + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + }; + + const controller = buildShadowSimulationScene(map as never); + subscribeShadowProjectionDebugSnapshot(map as never, () => undefined); + controller.updateSolarPosition({ + instant: new Date("2026-06-21T10:00:00Z"), + azimuthDegrees: 135, + elevationDegrees: 45, + }); + const camera = new THREE.PerspectiveCamera(60, 4 / 3, 1, 20_000); + camera.position.set(0, 500, 500); + camera.lookAt(0, 100, 0); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(true); + + updateShadows(map, camera); + + const shadowCamera = readShadowProjectionDebugSnapshot(map as never)?.shadow + ?.camera; + expect( + (shadowCamera?.rightMeters ?? 0) - (shadowCamera?.leftMeters ?? 0) + ).toBeLessThan(1_000); + expect( + (shadowCamera?.topMeters ?? 0) - (shadowCamera?.bottomMeters ?? 0) + ).toBeLessThan(1_000); + + controller.dispose(); + }); + + it("fits the render-camera rays at both terrain elevation limits", () => { + sharedLayer.projectLngLatToScene = ([lng, lat], altitude = 0) => + new THREE.Vector3(lng * 1_000, altitude, lat * 1_000); + const map = { + getCenter: vi.fn(() => ({ lng: 0, lat: 0 })), + getCanvas: vi.fn(() => ({ clientWidth: 800, clientHeight: 600 })), + unproject: vi.fn(() => ({ lng: 0, lat: 0 })), + getLight: vi.fn(() => ({ anchor: "viewport" })), + isStyleLoaded: vi.fn(() => true), + setLight: vi.fn(), + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + }; + const terrain = new THREE.Mesh( + new THREE.BoxGeometry(1_000, 200, 1_000), + new THREE.MeshLambertMaterial() + ); + terrain.position.y = 100; + scene.add(terrain); + + const controller = buildShadowSimulationScene(map as never); + controller.updateSolarPosition({ + instant: new Date("2026-06-21T10:00:00Z"), + azimuthDegrees: 135, + elevationDegrees: 45, + }); + const camera = new THREE.PerspectiveCamera(55, 4 / 3, 1, 20_000); + camera.position.set(0, 500, 500); + camera.lookAt(0, 100, 0); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(true); + + updateShadows(map, camera); + + const shadowCamera = ( + scene.getObjectByName("shadow-simulation-sun") as THREE.DirectionalLight + ).shadow.camera; + for (const [x, y] of [ + [-1, -1], + [-1, 1], + [1, -1], + [1, 1], + ] as const) { + const nearPoint = new THREE.Vector3(x, y, -1).unproject(camera); + const rayDirection = new THREE.Vector3(x, y, 1) + .unproject(camera) + .sub(nearPoint); + for (const elevation of [0, 200]) { + const receiver = nearPoint + .clone() + .addScaledVector( + rayDirection, + (elevation - nearPoint.y) / rayDirection.y + ); + const clip = receiver + .applyMatrix4(shadowCamera.matrixWorldInverse) + .applyMatrix4(shadowCamera.projectionMatrix); + expect(Math.abs(clip.x)).toBeLessThanOrEqual(1); + expect(Math.abs(clip.y)).toBeLessThanOrEqual(1); + expect(Math.abs(clip.z)).toBeLessThanOrEqual(1); + } + } + + controller.dispose(); + terrain.geometry.dispose(); + (terrain.material as THREE.Material).dispose(); + }); + + it("keeps valid lower viewport rays when upper rays point above the terrain", () => { + sharedLayer.projectLngLatToScene = ([lng, lat], altitude = 0) => + new THREE.Vector3(lng * 1_000, altitude, lat * 1_000); + const map = { + getCenter: vi.fn(() => ({ lng: 0, lat: 0 })), + getCanvas: vi.fn(() => ({ clientWidth: 800, clientHeight: 600 })), + unproject: vi.fn(() => ({ lng: 0, lat: 0 })), + getLight: vi.fn(() => ({ anchor: "viewport" })), + isStyleLoaded: vi.fn(() => true), + setLight: vi.fn(), + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + }; + const terrain = new THREE.Mesh( + new THREE.BoxGeometry(5_000, 200, 5_000), + new THREE.MeshLambertMaterial() + ); + terrain.position.y = 100; + scene.add(terrain); + + const controller = buildShadowSimulationScene(map as never); + controller.updateSolarPosition({ + instant: new Date("2026-06-21T10:00:00Z"), + azimuthDegrees: 135, + elevationDegrees: 45, + }); + const camera = new THREE.PerspectiveCamera(60, 4 / 3, 1, 20_000); + camera.position.set(0, 500, 500); + camera.lookAt(0, 400, 0); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(true); + + updateShadows(map, camera); + + const shadowCamera = ( + scene.getObjectByName("shadow-simulation-sun") as THREE.DirectionalLight + ).shadow.camera; + for (const x of [-1, 1]) { + const nearPoint = new THREE.Vector3(x, -1, -1).unproject(camera); + const rayDirection = new THREE.Vector3(x, -1, 1) + .unproject(camera) + .sub(nearPoint); + for (const elevation of [0, 200]) { + const receiver = nearPoint + .clone() + .addScaledVector( + rayDirection, + (elevation - nearPoint.y) / rayDirection.y + ); + const clip = receiver + .applyMatrix4(shadowCamera.matrixWorldInverse) + .applyMatrix4(shadowCamera.projectionMatrix); + expect(Math.abs(clip.x)).toBeLessThanOrEqual(1); + expect(Math.abs(clip.y)).toBeLessThanOrEqual(1); + expect(Math.abs(clip.z)).toBeLessThanOrEqual(1); + } + } + + controller.dispose(); + terrain.geometry.dispose(); + (terrain.material as THREE.Material).dispose(); + }); + + it("refits the direct shadow pass immediately when streamed content changes", () => { + vi.stubGlobal("window", { + clearTimeout, + setTimeout, + }); + sharedLayer.projectLngLatToScene = ([lng, lat], altitude = 0) => + new THREE.Vector3(lng * 1_000, altitude, lat * 1_000); + let contentChanged = () => undefined; + vi.mocked(subscribeSharedThreeSceneContent).mockImplementation( + (_map, listener) => { + contentChanged = listener; + return vi.fn(); + } + ); + const map = { + getCenter: vi.fn(() => ({ lng: 0, lat: 0 })), + getCanvas: vi.fn(() => ({ clientWidth: 800, clientHeight: 600 })), + unproject: vi.fn(([x, y]: [number, number]) => ({ + lng: (x / 800 - 0.5) * 0.1, + lat: (0.5 - y / 600) * 0.1, + })), + getLight: vi.fn(() => ({ anchor: "viewport" })), + isStyleLoaded: vi.fn(() => true), + setLight: vi.fn(), + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + }; + const controller = buildShadowSimulationScene(map as never); + subscribeShadowProjectionDebugSnapshot(map as never, () => undefined); + controller.updateSolarPosition({ + instant: new Date("2026-06-21T10:00:00Z"), + azimuthDegrees: 135, + elevationDegrees: 45, + }); + const camera = new THREE.PerspectiveCamera(60, 4 / 3, 1, 20_000); + camera.position.set(0, 1_000, 1_000); + camera.lookAt(0, 0, 0); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(true); + updateShadows(map, camera); + expect( + readShadowProjectionDebugSnapshot(map as never)?.maximumElevationMeters + ).toBeCloseTo(0); + + const buildingVolume = new THREE.Mesh( + new THREE.BoxGeometry(100, 300, 100), + new THREE.MeshLambertMaterial() + ); + buildingVolume.position.y = 150; + scene.add(buildingVolume); + contentChanged(); + updateShadows(map, camera); + + expect( + readShadowProjectionDebugSnapshot(map as never)?.maximumElevationMeters + ).toBeGreaterThanOrEqual(300); + + controller.dispose(); + buildingVolume.geometry.dispose(); + (buildingVolume.material as THREE.Material).dispose(); + vi.unstubAllGlobals(); + }); + + it("includes visible elevation relief when fitting the viewport", () => { + sharedLayer.projectLngLatToScene = ([lng, lat], altitude = 0) => + new THREE.Vector3(lng * 1_000, altitude, lat * 1_000); + const elevatedReceiver = new THREE.Mesh( + new THREE.BoxGeometry(1, 300, 1), + new THREE.MeshLambertMaterial() + ); + elevatedReceiver.position.y = 150; + scene.add(elevatedReceiver); + const map = { + getCenter: vi.fn(() => ({ lng: 0, lat: 0 })), + getCanvas: vi.fn(() => ({ clientWidth: 800, clientHeight: 600 })), + unproject: vi.fn(([x, y]: [number, number]) => ({ + lng: (x / 800 - 0.5) * 0.1, + lat: (0.5 - y / 600) * 0.2, + })), + getLight: vi.fn(() => ({ anchor: "viewport" })), + isStyleLoaded: vi.fn(() => true), + setLight: vi.fn(), + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + }; + + const controller = buildShadowSimulationScene(map as never); + subscribeShadowProjectionDebugSnapshot(map as never, () => undefined); + controller.updateSolarPosition({ + instant: new Date("2026-06-21T10:00:00Z"), + azimuthDegrees: 135, + elevationDegrees: 45, + }); + const camera = new THREE.PerspectiveCamera(60, 4 / 3, 1, 20_000); + camera.position.set(0, 4_000, 4_000); + camera.lookAt(0, 0, 0); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(true); + updateShadows(map, camera); + const snapshot = readShadowProjectionDebugSnapshot(map as never); + + expect(snapshot?.maximumElevationMeters).toBeGreaterThanOrEqual(300); + expect(snapshot?.minimumElevationMeters).toBeLessThanOrEqual(0); + expect(snapshot?.shadow?.casterReachMeters).toBeGreaterThan(300); + + controller.dispose(); + elevatedReceiver.geometry.dispose(); + (elevatedReceiver.material as THREE.Material).dispose(); + }); + + it("adds configured Cesium terrain to the shared scene", async () => { + const terrainRoot = new THREE.Group(); + let resolveTerrainReady!: (loaded: boolean) => void; + const terrainReady = new Promise((resolve) => { + resolveTerrainReady = resolve; + }); + const terrainRuntime = { + id: "terrain", + originLngLat: [7.15, 51.256] as [number, number], + root: terrainRoot, + ready: terrainReady, + update: vi.fn(), + setShadowView: vi.fn(), + setMaterialColor: vi.fn(), + getElevation: vi.fn(() => 150), + dispose: vi.fn(), + }; + vi.mocked(buildCesiumTerrainRuntime).mockReturnValue(terrainRuntime); + const addRuntime = vi.fn(); + const removeRuntime = vi.fn(); + vi.mocked(acquireSharedThreeScene).mockReturnValue({ + layer: { + getScene: () => scene, + getRenderer: () => + ({ + capabilities: { maxTextureSize: 16_384 }, + } as THREE.WebGLRenderer), + addRuntime, + hasRuntime: vi.fn(() => true), + removeRuntime, + setAccumulationController: sharedLayer.setAccumulationController, + projectLngLatToScene: ( + [longitude, latitude]: [number, number], + altitude = 0 + ) => new THREE.Vector3(longitude * 1_000, altitude, latitude * 1_000), + } as never, + setLocationLabelColor, + setMeshLabelStyle: vi.fn(), + release: releaseScene, + }); + const map = { + getCenter: vi.fn(() => ({ lng: 7.15, lat: 51.256 })), + getCanvas: vi.fn(() => ({ clientWidth: 800, clientHeight: 600 })), + unproject: vi.fn(([x, y]: [number, number]) => ({ + lng: 7.15 + (x / 800 - 0.5) * 0.02, + lat: 51.256 + (0.5 - y / 600) * 0.02, + })), + getLight: vi.fn(() => ({ anchor: "viewport" })), + isStyleLoaded: vi.fn(() => true), + setLight: vi.fn(), + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + }; + + const controller = buildShadowSimulationScene(map as never, { + shadowAreaMeters: 600, + terrain: { + url: "https://example.test/terrain", + minimumLevel: 10, + maximumLevel: 16, + }, + }); + expect(buildCesiumTerrainRuntime).toHaveBeenCalledWith( + "shadow-simulation-cesium-terrain", + "https://example.test/terrain", + [7.15, 51.256], + expect.objectContaining({ minimumLevel: 10, maximumLevel: 16 }) + ); + expect(addRuntime).toHaveBeenCalledWith(terrainRuntime); + expect(map.setTerrain).toBeUndefined(); + + controller.updateTerrainColor("#8c7a66"); + expect(terrainRuntime.setMaterialColor).toHaveBeenCalledWith("#8c7a66"); + + const evaluateAtmosphere = vi.spyOn( + AtmosphericSunlightEvaluator.prototype, + "evaluate" + ); + controller.updateSolarPosition({ + instant: new Date("2026-06-21T10:00:00Z"), + azimuthDegrees: 135, + elevationDegrees: 45, + }); + const shadowRuntime = addRuntime.mock.calls.find( + ([runtime]) => runtime.id === "shadow-simulation-controller" + )?.[0] as SharedRuntimeFixture; + const camera = new THREE.PerspectiveCamera(60, 4 / 3, 1, 20_000); + camera.position.set(7_150, 475, 55_256); + camera.lookAt(7_150, 150, 51_256); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(true); + shadowRuntime.update?.({ + map, + renderCamera: camera, + lodCamera: camera, + lookTarget: new THREE.Vector3(7_150, 150, 51_256), + viewport: new THREE.Vector2(800, 600), + }); + expect(accumulationController?.active()).toBe(false); + + resolveTerrainReady(true); + await terrainRuntime.ready; + expect(accumulationController?.active()).toBe(true); + + const shadowView = terrainRuntime.setShadowView.mock.lastCall?.[0]; + expect(shadowView.camera.name).toBe("shadow-simulation-shadow-camera"); + expect( + (shadowView.camera as THREE.OrthographicCamera).isOrthographicCamera + ).toBe(true); + expect(shadowView.shadowMapSize.width).toBe(4_096); + expect(shadowView.shadowMapSize.width).not.toBe(800); + expect(evaluateAtmosphere.mock.lastCall?.[1].altitudeMeters).toBe(425); + + controller.dispose(); + expect(removeRuntime).toHaveBeenCalledWith("terrain"); + }); + + it("replaces shadow terrain while a Mesh tiles runtime provides terrain", async () => { + vi.stubGlobal("window", { clearTimeout, setTimeout }); + sharedLayer.projectLngLatToScene = ([lng, lat], altitude = 0) => + new THREE.Vector3(lng * 1_000, altitude, lat * 1_000); + const makeTerrainRuntime = () => ({ + id: "shadow-simulation-cesium-terrain", + originLngLat: [7.15, 51.256] as [number, number], + root: new THREE.Group(), + ready: Promise.resolve(true), + update: vi.fn(), + setShadowView: vi.fn(), + setMaterialColor: vi.fn(), + getElevation: vi.fn(() => 150), + dispose: vi.fn(), + }); + const initialTerrain = makeTerrainRuntime(); + const restoredTerrain = makeTerrainRuntime(); + vi.mocked(buildCesiumTerrainRuntime) + .mockReturnValueOnce(initialTerrain) + .mockReturnValueOnce(restoredTerrain); + let contentChanged = () => undefined; + vi.mocked(subscribeSharedThreeSceneContent).mockImplementation( + (_map, listener) => { + contentChanged = listener; + return vi.fn(); + } + ); + const activeContentRuntimes: SharedRuntimeFixture[] = []; + vi.mocked(getSharedThreeSceneRuntimes).mockImplementation( + () => activeContentRuntimes as never + ); + const map = { + getCenter: vi.fn(() => ({ lng: 7.15, lat: 51.256 })), + getCanvas: vi.fn(() => ({ clientWidth: 800, clientHeight: 600 })), + unproject: vi.fn(([x, y]: [number, number]) => ({ + lng: 7.15 + (x / 800 - 0.5) * 0.02, + lat: 51.256 + (0.5 - y / 600) * 0.02, + })), + getLight: vi.fn(() => ({ anchor: "viewport" })), + isStyleLoaded: vi.fn(() => true), + setLight: vi.fn(), + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + }; + + const controller = buildShadowSimulationScene(map as never, { + terrain: { url: "https://example.test/terrain" }, + }); + await initialTerrain.ready; + expect(sharedRuntimes.has(initialTerrain.id)).toBe(true); + + let meshRenderable = false; + activeContentRuntimes.push({ + id: "mesh2024", + root: new THREE.Group(), + providesTerrain: true, + hasRenderableContent: () => meshRenderable, + dispose: vi.fn(), + }); + contentChanged(); + + expect(sharedRuntimes.has(initialTerrain.id)).toBe(false); + expect(initialTerrain.dispose).toHaveBeenCalledOnce(); + + meshRenderable = true; + contentChanged(); + + expect(sharedRuntimes.has(initialTerrain.id)).toBe(false); + expect(initialTerrain.dispose).toHaveBeenCalledOnce(); + + controller.updateTerrainColor("#8c7a66"); + + activeContentRuntimes.length = 0; + contentChanged(); + await restoredTerrain.ready; + + expect(buildCesiumTerrainRuntime).toHaveBeenCalledTimes(2); + expect(sharedRuntimes.get(restoredTerrain.id)).toBe(restoredTerrain); + expect(restoredTerrain.setMaterialColor).toHaveBeenCalled(); + + controller.dispose(); + vi.unstubAllGlobals(); + }); +}); diff --git a/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-scene.ts b/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-scene.ts new file mode 100644 index 0000000000..f0355a3e05 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/runtime/shadow-scene.ts @@ -0,0 +1,2051 @@ +import type { Map as MaplibreMap } from "maplibre-gl"; +import * as THREE from "three"; + +import { clamp } from "@carma-commons/math"; +import { + acquireSharedThreeScene, + buildCesiumTerrainRuntime, + getGenericThreeLayers, + MAPLIBRE_EVENT, + getSharedThreeShadowViewSignature, + getSharedThreeSceneRuntimes, + subscribeSharedThreeSceneContent, + subscribeGenericThreeLayers, + suppressMapLibreRegularStyleLayers, + WUPPERTAL_TERRAIN_SOURCE_ID, +} from "@carma-mapping/engines/maplibre"; +import type { + SharedThreeSceneLayer, + SharedThreeSceneRuntime, + SharedThreeSceneShadowView, + SharedThreeSceneTileVolume, +} from "@carma-mapping/engines/maplibre"; +import { degToRadNumeric } from "@carma-units"; + +import type { ShadowSceneOptions } from "../contracts/shadow-simulation"; +import type { SolarPosition } from "../core/solar-position"; +import { getFrustumBoxIntersectionPoints } from "../core/frustum-box-intersection"; +import { + DEFAULT_MESH_ERROR_TARGET_PIXELS, + DEFAULT_SHADOW_QUALITY, + DEFAULT_SHADOW_SURFACE_COLOR, + type MeshErrorTargetPixels, + type ShadowQualityMultiplier, +} from "../core/shadow-types"; +import { + AtmosphericSunlightEvaluator, + getAtmosphericInputValidationError, + getAtmosphericSunlightSampleValidationError, + type AtmosphericSunlightSample, + type AtmosphericSunlightOptions, + type AtmosphericSkyReference, +} from "./atmospheric-sunlight"; +import { + ATMOSPHERIC_DISPLAY_EXPOSURE, + buildAtmosphericSky, +} from "./atmospheric-sky"; +import { ShadowController } from "./shadow-controller"; +import { resolveShadowResourceLimits } from "./shadow-resource-limits"; +import { + clearShadowProjectionDebugSnapshot, + hasShadowProjectionDebugListeners, + publishShadowProjectionDebugSnapshot, +} from "./shadow-projection-debug-store"; + +const FALLBACK_SHADOW_AREA_METERS = 900; +/** Maximum radius represented by the fitted shadow buffer. */ +const MAX_RECEIVER_DISTANCE_METERS = 4_000; +const MIN_VIEWPORT_SHADOW_AREA_METERS = 10; +const DEFAULT_SHADOW_CAMERA_OFFSET_METERS = 2_500; +const SHADOW_SIMULATION_SUN_VECTOR_NAME = "shadow-simulation-sun-vector"; +const SHADOW_SIMULATION_TERRAIN_RUNTIME_ID = "shadow-simulation-cesium-terrain"; +const SHADOW_CONTROLLER_UPDATE_PRIORITY = 200; +const SUN_VECTOR_COLOR = 0xf59e0b; +const SUN_VECTOR_HEAD_LENGTH_FACTOR = 0.18; +const SUN_VECTOR_HEAD_WIDTH_FACTOR = 0.07; +const SUN_VECTOR_VIEWPORT_LENGTH_FACTOR = 0.5; +const SUN_VECTOR_ANGLE_RADIUS_FACTOR = 0.22; +const SUN_VECTOR_ANGLE_SEGMENTS = 24; +const SHADOW_OVERLAY_MARKER = "isShadowSimulationOverlay"; +const SUN_DISC_ACCUMULATION_ROUNDS = 32; +const MAX_SUN_DISC_ACCUMULATION_ROUNDS = 64; +const SHADOW_SIMULATION_SKY_LIGHT_NAME = "shadow-simulation-sky-light"; +const LOCAL_ATMOSPHERE_GROUND_ELEVATION_METERS = 100; +const MOTION_SHADOW_UPDATE_INTERVAL_MS = 1000 / 30; +const MAPLIBRE_STYLE_ANIMATION_UPDATE_INTERVAL_MS = 1_000; +const SHADOW_MAP_STYLE_BASE_LAYER_ID = "carma-shadow-map-style-base"; +const OPAQUE_DRAPE_PROPERTIES = new Map([ + ["background", "background-opacity"], + ["fill", "fill-opacity"], + ["raster", "raster-opacity"], +]); + +type GenericThreeLayer = ReturnType[number]; + +type SunVectorGizmo = { + root: THREE.ArrowHelper; + shaft: THREE.Mesh; + origin: THREE.Mesh; + groundRay: THREE.Line; + elevationArc: THREE.Line; + dispose: () => void; +}; + +type ShadowLightBinding = { + scene: THREE.Scene; + controller: ShadowController; + skyLight: THREE.LightProbe; + atmosphericSky: ReturnType; + ambientLightIntensities: Map; + lightTarget: THREE.Object3D; + sunVector: SunVectorGizmo; + center: THREE.Vector3; + shadowCameraOffsetMeters: number; + shadowAreaMeters: number; + sunVectorLengthMeters: number; + sunVectorVisible: boolean; + shadowQuality: ShadowQualityMultiplier; + shadowIntensity: number; + directionToSun: THREE.Vector3; + sunColor: THREE.Color; + sunIntensity: number; + receiverWorldPoints: THREE.Vector3[]; + minimumElevationMeters: number; + maximumElevationMeters: number; + dirty: boolean; +}; + +type GenericThreeShadowBridge = { + runtime: SharedThreeSceneRuntime; + sync: () => void; + updateBuildingAppearance: (appearance: ShadowBuildingAppearance) => void; +}; + +/** + * What the MapLibre pass below Three contributes to the projected drape: + * `opaque` paints the complete basemap onto bare terrain, `labels` keeps only + * the symbol layers so a textured mesh takes draped street names and nothing + * else. + */ +export type ShadowMapStyleDrapeMode = "opaque" | "labels"; + +export type ShadowMapLibreTerrainRelease = (() => void) & { + /** Re-evaluate the drape mode after the shared scene's runtimes changed. */ + refresh: () => void; +}; + +export type ShadowBuildingAppearance = Readonly<{ + fullOpacity: boolean; + uniformColor: string | null; + uniformColorMix?: number; + textureSaturation?: number; +}>; + +export type ShadowSimulationScene = { + updateSolarPosition: (position: SolarPosition) => void; + updateTerrainColor: (color: string) => void; + updateMeshErrorTarget: (errorTarget: MeshErrorTargetPixels) => void; + updateBuildingAppearance: (appearance: ShadowBuildingAppearance) => void; + updateShadowQuality: (quality: ShadowQualityMultiplier) => void; + updateSoftSunShadows: (enabled: boolean) => void; + updateTimeAnimating: (animating: boolean) => void; + refreshProjectionDebug: () => void; + updateShadowIntensity: (intensity: number) => void; + updateMapStyleContentVisibility: (visible: boolean) => void; + updateMapStyleLabelOverlayVisibility: (visible: boolean) => void; + updateSunDebugVectorVisibility: (visible: boolean) => void; + updateAtmosphericLutUsage: (options: AtmosphericSunlightOptions) => void; + dispose: () => void; +}; + +/** + * Keep MapLibre's highest native DEM active while its styled framebuffer is + * captured for projection onto the shared Three scene. Style replacement can + * temporarily drop terrain, so re-apply it once the source becomes available. + */ +export const acquireShadowMapLibreTerrain = ( + map: MaplibreMap, + sourceId = WUPPERTAL_TERRAIN_SOURCE_ID, + isMapStyleContentVisible: () => boolean = () => true, + getDrapeMode: () => ShadowMapStyleDrapeMode = () => "opaque" +): ShadowMapLibreTerrainRelease => { + type InternalTerrain = { + getMeshFrameDelta?: (zoom: number) => number; + }; + type DrapeLayer = { + id: string; + type: string; + source?: unknown; + "source-layer"?: unknown; + }; + const terrainMap = map as MaplibreMap & { + getTerrain?: MaplibreMap["getTerrain"]; + getSource?: MaplibreMap["getSource"]; + setTerrain?: MaplibreMap["setTerrain"]; + terrain?: InternalTerrain | null; + }; + if ( + typeof terrainMap.getTerrain !== "function" || + typeof terrainMap.getSource !== "function" || + typeof terrainMap.setTerrain !== "function" + ) { + return Object.assign(() => undefined, { refresh: () => undefined }); + } + const previousTerrain = terrainMap.getTerrain(); + const savedDrapeOpacities = new Map< + string, + { signature: string; property: string; value: unknown } + >(); + const savedTerrainShadingVisibilities = new Map< + string, + { signature: string; value: unknown } + >(); + let disposed = false; + let applying = false; + let createdBaseLayer = false; + let patchedTerrain: InternalTerrain | null = null; + let inheritedFrameDelta = false; + let originalFrameDelta: InternalTerrain["getMeshFrameDelta"]; + + const restoreTerrainFrame = () => { + if (!patchedTerrain) return; + if (inheritedFrameDelta) { + delete patchedTerrain.getMeshFrameDelta; + } else { + patchedTerrain.getMeshFrameDelta = originalFrameDelta; + } + patchedTerrain = null; + originalFrameDelta = undefined; + inheritedFrameDelta = false; + }; + + const suppressTerrainFrame = () => { + const terrain = terrainMap.terrain; + if (!terrain || terrain === patchedTerrain) return; + restoreTerrainFrame(); + if (typeof terrain.getMeshFrameDelta !== "function") return; + inheritedFrameDelta = !Object.prototype.hasOwnProperty.call( + terrain, + "getMeshFrameDelta" + ); + originalFrameDelta = terrain.getMeshFrameDelta; + terrain.getMeshFrameDelta = () => 0; + patchedTerrain = terrain; + }; + + const getLayerSignature = (layer: DrapeLayer) => + `${layer.type}:${String(layer.source)}:${String(layer["source-layer"])}`; + + const isTerrainShadingLayer = (layer: DrapeLayer) => { + if (layer.type === "hillshade" || layer.type === "color-relief") { + return true; + } + if (layer.type !== "raster") return false; + // basemap.de's relief style models its DEM shading as ordinary raster + // layers (Schummerung_Col / Schummerung_Comb), so filtering by MapLibre's + // dedicated hillshade type alone does not remove the baked relief pass. + return /(?:schummerung|hillshade|combshade|colordem|shaded[-_ ]?relief)/i.test( + [layer.id, layer.source, layer["source-layer"]].join(":") + ); + }; + + const ensureOpaqueDrape = () => { + const style = map.getStyle(); + const layers = (style.layers ?? []) as DrapeLayer[]; + for (const layer of layers) { + if (!isTerrainShadingLayer(layer)) continue; + const signature = getLayerSignature(layer); + let saved = savedTerrainShadingVisibilities.get(layer.id); + const currentVisibility = map.getLayoutProperty(layer.id, "visibility"); + if (!saved || saved.signature !== signature) { + saved = { signature, value: currentVisibility }; + savedTerrainShadingVisibilities.set(layer.id, saved); + } else if (currentVisibility !== "none") { + // Adopt a style-composer replacement as the newest teardown value. + saved.value = currentVisibility; + } + if (currentVisibility !== "none") { + map.setLayoutProperty(layer.id, "visibility", "none"); + } + } + if (!isMapStyleContentVisible()) return; + if (!map.getLayer(SHADOW_MAP_STYLE_BASE_LAYER_ID)) { + map.addLayer( + { + id: SHADOW_MAP_STYLE_BASE_LAYER_ID, + type: "background", + paint: { + "background-color": "#ffffff", + "background-opacity": 1, + }, + }, + layers[0]?.id + ); + createdBaseLayer = true; + } + + for (const layer of layers) { + if ( + layer.id === SHADOW_MAP_STYLE_BASE_LAYER_ID || + layer.type === "custom" + ) { + continue; + } + const property = OPAQUE_DRAPE_PROPERTIES.get(layer.type); + if (!property) continue; + const signature = getLayerSignature(layer); + let saved = savedDrapeOpacities.get(layer.id); + const currentOpacity = map.getPaintProperty(layer.id, property); + if (!saved || saved.signature !== signature) { + saved = { + signature, + property, + value: currentOpacity, + }; + savedDrapeOpacities.set(layer.id, saved); + } else if (currentOpacity !== 1) { + // StyleComposer and opacity controls may replace the authored value + // while shadows are active. Preserve the newest value for teardown. + saved.value = currentOpacity; + } + if (currentOpacity !== 1) { + map.setPaintProperty(layer.id, property, 1); + } + } + }; + + const restoreSavedVisibilities = ( + saved: Map + ) => { + for (const [layerId, entry] of saved) { + try { + const layer = map + .getStyle() + .layers?.find(({ id }) => id === layerId) as DrapeLayer | undefined; + if ( + layer && + getLayerSignature(layer) === entry.signature && + map.getLayoutProperty(layerId, "visibility") === "none" + ) { + map.setLayoutProperty( + layerId, + "visibility", + entry.value === undefined ? null : entry.value + ); + } + } catch { + // A style replacement may already have removed the layer. + } + } + saved.clear(); + }; + + const restoreOpaqueDrape = () => { + for (const [layerId, saved] of savedDrapeOpacities) { + try { + const layer = map + .getStyle() + .layers?.find(({ id }) => id === layerId) as DrapeLayer | undefined; + if ( + layer && + getLayerSignature(layer) === saved.signature && + map.getPaintProperty(layerId, saved.property) === 1 + ) { + map.setPaintProperty( + layerId, + saved.property, + saved.value === undefined ? null : saved.value + ); + } + } catch { + // A style replacement may already have removed the layer. + } + } + savedDrapeOpacities.clear(); + if (createdBaseLayer) { + createdBaseLayer = false; + try { + if (map.getLayer(SHADOW_MAP_STYLE_BASE_LAYER_ID)) { + map.removeLayer(SHADOW_MAP_STYLE_BASE_LAYER_ID); + } + } catch { + // The style may already be gone during map teardown. + } + } + }; + + const apply = () => { + if (disposed || applying) return; + applying = true; + try { + if (getDrapeMode() === "labels") { + // The shared scene registry owns the label drape (it also runs + // without the shadow simulation); only hand the opaque pass back. + restoreOpaqueDrape(); + restoreSavedVisibilities(savedTerrainShadingVisibilities); + } else { + ensureOpaqueDrape(); + } + if (terrainMap.getSource(sourceId)) { + const current = terrainMap.getTerrain(); + if (current?.source !== sourceId || (current.exaggeration ?? 1) !== 1) { + terrainMap.setTerrain({ source: sourceId, exaggeration: 1 }); + } + suppressTerrainFrame(); + } + } catch { + // Style replacement briefly exposes an incomplete style. Its next + // styledata event retries both the opaque drape and terrain setup. + } finally { + applying = false; + } + }; + const handleTerrainChange = () => { + if (!applying) apply(); + }; + + map.on(MAPLIBRE_EVENT.STYLE_DATA, apply); + map.on(MAPLIBRE_EVENT.TERRAIN, handleTerrainChange); + apply(); + + const release = () => { + if (disposed) return; + disposed = true; + map.off(MAPLIBRE_EVENT.STYLE_DATA, apply); + map.off(MAPLIBRE_EVENT.TERRAIN, handleTerrainChange); + restoreTerrainFrame(); + restoreOpaqueDrape(); + restoreSavedVisibilities(savedTerrainShadingVisibilities); + try { + if ( + previousTerrain && + terrainMap.getSource(previousTerrain.source) !== undefined + ) { + terrainMap.setTerrain(previousTerrain); + } else { + terrainMap.setTerrain(null); + } + } catch { + // The style may already be gone during map teardown. + } + }; + return Object.assign(release, { + refresh: () => { + if (!disposed && !applying) apply(); + }, + }); +}; + +export const solarPositionToSceneDirection = ({ + azimuthDegrees, + elevationDegrees, +}: SolarPosition): THREE.Vector3 => { + const azimuth = degToRadNumeric(azimuthDegrees); + const elevation = degToRadNumeric(elevationDegrees); + const horizontal = Math.cos(elevation); + // Shared scene axes: +X east, +Y up, -Z north. + return new THREE.Vector3( + Math.sin(azimuth) * horizontal, + Math.sin(elevation), + -Math.cos(azimuth) * horizontal + ).normalize(); +}; + +const makeMeshShadeable = (mesh: THREE.Mesh) => { + if (mesh.userData[SHADOW_OVERLAY_MARKER]) return; + mesh.castShadow = mesh.userData.disableShadowCasting !== true; + mesh.receiveShadow = true; + const materials = Array.isArray(mesh.material) + ? mesh.material + : [mesh.material]; + // Closed solids default to casting from both faces, so a sun-facing wall + // shadows the ground under a solid that does not sit flush on the terrain. + // Tile runtimes can provide a topology-derived side before they join the + // shared scene; keep that ground truth instead of replacing it here. + if (!mesh.userData.isShadowTerrainSurface) { + for (const material of materials) { + material.shadowSide ??= THREE.DoubleSide; + } + } +}; + +const buildSunVector = () => { + const helper = new THREE.ArrowHelper( + new THREE.Vector3(0, 1, 0), + new THREE.Vector3(), + 1, + SUN_VECTOR_COLOR + ); + helper.name = SHADOW_SIMULATION_SUN_VECTOR_NAME; + helper.visible = false; + helper.frustumCulled = false; + helper.line.visible = false; + const overlayMaterial = new THREE.MeshBasicMaterial({ + color: SUN_VECTOR_COLOR, + depthTest: false, + depthWrite: false, + toneMapped: false, + transparent: true, + }); + const shaft = new THREE.Mesh( + new THREE.CylinderGeometry(1, 1, 1, 12), + overlayMaterial + ); + shaft.name = `${SHADOW_SIMULATION_SUN_VECTOR_NAME}-shaft`; + shaft.matrixAutoUpdate = false; + const origin = new THREE.Mesh( + new THREE.SphereGeometry(1, 16, 8), + overlayMaterial + ); + origin.name = `${SHADOW_SIMULATION_SUN_VECTOR_NAME}-origin`; + origin.matrixAutoUpdate = false; + const lineMaterial = new THREE.LineBasicMaterial({ + color: SUN_VECTOR_COLOR, + depthTest: false, + depthWrite: false, + toneMapped: false, + transparent: true, + }); + const groundRay = new THREE.Line(new THREE.BufferGeometry(), lineMaterial); + groundRay.name = `${SHADOW_SIMULATION_SUN_VECTOR_NAME}-ground-ray`; + const elevationArc = new THREE.Line(new THREE.BufferGeometry(), lineMaterial); + elevationArc.name = `${SHADOW_SIMULATION_SUN_VECTOR_NAME}-elevation-arc`; + helper.add(shaft, origin, groundRay, elevationArc); + for (const part of [ + helper.line, + helper.cone, + shaft, + origin, + groundRay, + elevationArc, + ]) { + part.userData[SHADOW_OVERLAY_MARKER] = true; + part.castShadow = false; + part.receiveShadow = false; + part.frustumCulled = false; + part.renderOrder = 10_000; + const material = part.material as THREE.Material; + material.depthTest = false; + material.depthWrite = false; + material.toneMapped = false; + material.transparent = true; + } + return { + root: helper, + shaft, + origin, + groundRay, + elevationArc, + dispose: () => { + helper.dispose(); + shaft.geometry.dispose(); + origin.geometry.dispose(); + overlayMaterial.dispose(); + groundRay.geometry.dispose(); + elevationArc.geometry.dispose(); + lineMaterial.dispose(); + }, + }; +}; + +const makeSceneMeshesShadeable = (scene: THREE.Scene) => { + scene.traverseVisible((object) => { + const mesh = object as THREE.Mesh; + if (!mesh.isMesh && !(mesh as THREE.InstancedMesh).isInstancedMesh) return; + makeMeshShadeable(mesh); + }); +}; + +const materialIsVisible = (material: THREE.Material): boolean => + material.visible && material.opacity > 0; + +const meshIsVisible = (mesh: THREE.Mesh, scene: THREE.Scene): boolean => { + let current: THREE.Object3D | null = mesh; + while (current && current !== scene) { + if (!current.visible) return false; + current = current.parent; + } + const materials = Array.isArray(mesh.material) + ? mesh.material + : [mesh.material]; + return materials.some(materialIsVisible); +}; + +const disposeCopiedMaterials = (root: THREE.Object3D) => { + root.traverse((object) => { + const mesh = object as THREE.Mesh; + if (!mesh.isMesh && !(mesh as THREE.InstancedMesh).isInstancedMesh) return; + const materials = Array.isArray(mesh.material) + ? mesh.material + : [mesh.material]; + for (const material of materials) { + material.dispose(); + } + }); +}; + +const getVisibleSceneElevationRange = ( + scene: THREE.Scene, + fallbackElevation: number, + viewCamera?: THREE.Camera +): readonly [number, number] => { + scene.updateMatrixWorld(true); + viewCamera?.updateMatrixWorld(true); + const viewFrustum = viewCamera + ? new THREE.Frustum().setFromProjectionMatrix( + new THREE.Matrix4().multiplyMatrices( + viewCamera.projectionMatrix, + viewCamera.matrixWorldInverse + ), + viewCamera.coordinateSystem, + viewCamera.reversedDepth + ) + : null; + let minimum = fallbackElevation; + let maximum = fallbackElevation; + const worldBounds = new THREE.Box3(); + scene.traverseVisible((object) => { + const mesh = object as THREE.Mesh; + if ( + mesh.userData[SHADOW_OVERLAY_MARKER] || + (!mesh.isMesh && !(mesh as THREE.InstancedMesh).isInstancedMesh) || + !mesh.geometry?.getAttribute("position")?.count + ) { + return; + } + if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox(); + if (!mesh.geometry.boundingBox) return; + worldBounds.copy(mesh.geometry.boundingBox).applyMatrix4(mesh.matrixWorld); + if (viewFrustum && !viewFrustum.intersectsBox(worldBounds)) return; + minimum = Math.min(minimum, worldBounds.min.y); + maximum = Math.max(maximum, worldBounds.max.y); + }); + return [minimum, maximum]; +}; + +const getViewElevationRange = ( + scene: THREE.Scene, + runtimes: readonly SharedThreeSceneRuntime[], + camera: THREE.Camera, + fallbackElevation: number +): readonly [number, number] => { + let [minimum, maximum] = getVisibleSceneElevationRange( + scene, + fallbackElevation, + camera + ); + for (const runtime of runtimes) { + const range = runtime.getViewElevationRange?.(camera); + if (!range) continue; + minimum = Math.min(minimum, range[0]); + maximum = Math.max(maximum, range[1]); + } + return [minimum, maximum]; +}; + +const VIEWPORT_NDC_CORNERS = [ + [-1, -1], + [-1, 1], + [1, -1], + [1, 1], +] as const; + +const FRUSTUM_EDGE_VERTEX_INDICES = [ + [0, 1], + [1, 3], + [3, 2], + [2, 0], + [4, 5], + [5, 7], + [7, 6], + [6, 4], + [0, 4], + [1, 5], + [2, 6], + [3, 7], +] as const; + +const getViewportElevationEnvelopePoints = ( + camera: THREE.Camera, + minimumElevationMeters: number, + maximumElevationMeters: number, + anchor: THREE.Vector3 +): THREE.Vector3[] => { + camera.updateMatrixWorld(true); + const minimumElevation = Math.min( + minimumElevationMeters, + maximumElevationMeters + ); + const maximumElevation = Math.max( + minimumElevationMeters, + maximumElevationMeters + ); + const frustumVertices = [-1, 1].flatMap((z) => + VIEWPORT_NDC_CORNERS.map(([x, y]) => + new THREE.Vector3(x, y, z).unproject(camera) + ) + ); + const points = frustumVertices + .filter( + (point) => point.y >= minimumElevation && point.y <= maximumElevation + ) + .map((point) => point.clone()); + + for (const [startIndex, endIndex] of FRUSTUM_EDGE_VERTEX_INDICES) { + const start = frustumVertices[startIndex]; + const end = frustumVertices[endIndex]; + const elevationDelta = end.y - start.y; + if (Math.abs(elevationDelta) <= Number.EPSILON) continue; + for (const elevation of [minimumElevation, maximumElevation]) { + const interpolation = (elevation - start.y) / elevationDelta; + if (interpolation < 0 || interpolation > 1) continue; + points.push(start.clone().lerp(end, interpolation)); + } + } + + for (const point of points) { + const offset = point.clone().sub(anchor); + const horizontalDistance = Math.hypot(offset.x, offset.z); + if (horizontalDistance <= MAX_RECEIVER_DISTANCE_METERS) continue; + const scale = MAX_RECEIVER_DISTANCE_METERS / horizontalDistance; + offset.x *= scale; + offset.z *= scale; + point.copy(anchor).add(offset); + } + + return points; +}; + +const applyBuildingAppearance = ( + root: THREE.Object3D, + appearance: ShadowBuildingAppearance +) => { + const useUniformColor = + appearance.uniformColor !== null && + clamp(appearance.uniformColorMix ?? 1, 0, 1) >= 1; + root.traverse((object) => { + const mesh = object as THREE.Mesh; + if (!mesh.userData.isBuilding) return; + const materials = Array.isArray(mesh.material) + ? mesh.material + : [mesh.material]; + for (const material of materials) { + if (appearance.fullOpacity) { + material.opacity = 1; + material.transparent = false; + material.depthWrite = true; + } + const colorMaterial = material as THREE.Material & { + color?: THREE.Color; + vertexColors?: boolean; + }; + if (useUniformColor && appearance.uniformColor && colorMaterial.color) { + colorMaterial.color.set(appearance.uniformColor); + colorMaterial.vertexColors = false; + } + material.needsUpdate = true; + } + }); +}; + +const buildGenericThreeShadowBridge = ( + sharedLayer: SharedThreeSceneLayer, + layer: GenericThreeLayer, + initialBuildingAppearance: ShadowBuildingAppearance +): GenericThreeShadowBridge | null => { + const origin = layer._originMerc?.toLngLat(); + if (!origin) return null; + + const root = new THREE.Group(); + root.name = `shadow-simulation-copy-${layer.id}`; + const originalVisibility = new Map(); + let buildingAppearance = initialBuildingAppearance; + let disposed = false; + + const restoreOriginals = () => { + for (const [object, visible] of originalVisibility) { + object.visible = visible; + } + originalVisibility.clear(); + }; + + const sync = () => { + if (disposed) return; + restoreOriginals(); + disposeCopiedMaterials(root); + root.clear(); + layer.scene.updateMatrixWorld(true); + const sourceMeshes: THREE.Mesh[] = []; + layer.scene.traverse((object) => { + const mesh = object as THREE.Mesh; + if (!mesh.isMesh && !(mesh as THREE.InstancedMesh).isInstancedMesh) { + return; + } + if (!mesh.geometry?.getAttribute("position")?.count) return; + if (!meshIsVisible(mesh, layer.scene)) return; + sourceMeshes.push(mesh); + }); + for (const source of sourceMeshes) { + const copy = source.clone(false) as THREE.Mesh; + copy.name = `${source.name || "mesh"}-shadow-simulation-copy`; + copy.visible = true; + copy.matrixAutoUpdate = false; + copy.matrix.copy(source.matrixWorld); + copy.material = Array.isArray(source.material) + ? source.material.map((material) => material.clone()) + : source.material.clone(); + makeMeshShadeable(copy); + originalVisibility.set(source, source.visible); + source.visible = false; + root.add(copy); + } + root.visible = root.children.length > 0; + applyBuildingAppearance(root, buildingAppearance); + }; + + const runtime: SharedThreeSceneRuntime = { + id: `shadow-simulation-generic-${layer.id}`, + originLngLat: [origin.lng, origin.lat], + root, + update: () => undefined, + dispose: () => { + if (disposed) return; + disposed = true; + restoreOriginals(); + disposeCopiedMaterials(root); + root.clear(); + }, + }; + sync(); + if (!root.visible) { + runtime.dispose(); + return null; + } + sharedLayer.addRuntime(runtime); + return { + runtime, + sync, + updateBuildingAppearance(appearance) { + buildingAppearance = appearance; + sync(); + }, + }; +}; + +const updateBindingCenter = (binding: ShadowLightBinding) => { + const bounds = new THREE.Box3().setFromObject(binding.scene); + if (bounds.isEmpty()) binding.center.set(0, 0, 0); + else bounds.getCenter(binding.center); +}; + +const buildShadowLightBinding = ( + scene: THREE.Scene, + shadowAreaMeters: number, + groundAlbedo: THREE.Color +): ShadowLightBinding => { + const controller = new ShadowController(scene); + const sunLight = controller.lights[0]; + const lightTarget = sunLight.target; + const sunVector = buildSunVector(); + const skyLight = new THREE.LightProbe(undefined, 0); + skyLight.name = SHADOW_SIMULATION_SKY_LIGHT_NAME; + const atmosphericSky = buildAtmosphericSky(groundAlbedo); + atmosphericSky.mesh.userData[SHADOW_OVERLAY_MARKER] = true; + const ambientLightIntensities = new Map(); + scene.traverse((object) => { + const light = object as THREE.AmbientLight; + if (light.isAmbientLight) { + ambientLightIntensities.set(light, light.intensity); + } + }); + const binding: ShadowLightBinding = { + scene, + controller, + skyLight, + atmosphericSky, + ambientLightIntensities, + lightTarget, + sunVector, + center: new THREE.Vector3(), + shadowCameraOffsetMeters: Math.max( + DEFAULT_SHADOW_CAMERA_OFFSET_METERS, + shadowAreaMeters * 1.5 + ), + shadowAreaMeters, + sunVectorLengthMeters: shadowAreaMeters * SUN_VECTOR_VIEWPORT_LENGTH_FACTOR, + sunVectorVisible: false, + shadowQuality: DEFAULT_SHADOW_QUALITY, + shadowIntensity: 1, + directionToSun: new THREE.Vector3(0, 1, 0), + sunColor: new THREE.Color(0xfff2d8), + sunIntensity: ATMOSPHERIC_DISPLAY_EXPOSURE, + receiverWorldPoints: [], + minimumElevationMeters: 0, + maximumElevationMeters: 0, + dirty: true, + }; + makeSceneMeshesShadeable(scene); + updateBindingCenter(binding); + scene.add(skyLight); + scene.add(atmosphericSky.mesh); + scene.add(sunVector.root); + return binding; +}; + +const applyAtmosphericSkyLightToBinding = ( + binding: ShadowLightBinding, + sample: AtmosphericSunlightSample +) => { + binding.scene.traverse((object) => { + const light = object as THREE.AmbientLight; + if (light.isAmbientLight && !binding.ambientLightIntensities.has(light)) { + binding.ambientLightIntensities.set(light, light.intensity); + } + }); + const coefficients = sample.skyIrradianceCoefficients; + if (coefficients?.length === binding.skyLight.sh.coefficients.length) { + coefficients.forEach((coefficient, index) => { + binding.skyLight.sh.coefficients[index].copy(coefficient); + }); + binding.skyLight.intensity = ATMOSPHERIC_DISPLAY_EXPOSURE; + for (const ambientLight of binding.ambientLightIntensities.keys()) { + ambientLight.intensity = 0; + } + return; + } + binding.skyLight.sh.zero(); + binding.skyLight.intensity = 0; + for (const [ambientLight, intensity] of binding.ambientLightIntensities) { + ambientLight.intensity = intensity; + } +}; + +const applySolarPositionToBinding = ( + binding: ShadowLightBinding, + direction: THREE.Vector3, + color: THREE.ColorRepresentation = 0xfff2d8, + intensity?: number +) => { + const normalizedDirection = direction.clone().normalize(); + binding.directionToSun.copy(normalizedDirection); + binding.sunColor.set(color); + binding.lightTarget.position.copy(binding.center); + for (const sunLight of binding.controller.lights) { + sunLight.target.position.copy(binding.center); + sunLight.position + .copy(normalizedDirection) + .multiplyScalar(binding.shadowCameraOffsetMeters) + .add(binding.center); + sunLight.color.copy(binding.sunColor); + } + const vectorLength = binding.sunVectorLengthMeters; + const headLength = vectorLength * SUN_VECTOR_HEAD_LENGTH_FACTOR; + const shaftLength = vectorLength - headLength; + binding.sunVector.root.position.copy(binding.center); + binding.sunVector.root.setDirection(normalizedDirection); + binding.sunVector.root.setLength( + vectorLength, + headLength, + vectorLength * SUN_VECTOR_HEAD_WIDTH_FACTOR + ); + binding.sunVector.shaft.position.set(0, shaftLength / 2, 0); + binding.sunVector.shaft.scale.set( + vectorLength * 0.006, + shaftLength, + vectorLength * 0.006 + ); + binding.sunVector.shaft.updateMatrix(); + binding.sunVector.origin.scale.setScalar(vectorLength * 0.012); + binding.sunVector.origin.updateMatrix(); + const horizontalDirection = new THREE.Vector3( + normalizedDirection.x, + 0, + normalizedDirection.z + ); + if (horizontalDirection.lengthSq() < Number.EPSILON) { + horizontalDirection.set(0, 0, -1); + } else { + horizontalDirection.normalize(); + } + const angleRadius = vectorLength * SUN_VECTOR_ANGLE_RADIUS_FACTOR; + const inverseArrowRotation = binding.sunVector.root.quaternion + .clone() + .invert(); + binding.sunVector.groundRay.geometry.setFromPoints([ + new THREE.Vector3(), + horizontalDirection + .clone() + .multiplyScalar(angleRadius) + .applyQuaternion(inverseArrowRotation), + ]); + const elevationRadians = Math.asin(clamp(normalizedDirection.y, -1, 1)); + binding.sunVector.elevationArc.geometry.setFromPoints( + Array.from({ length: SUN_VECTOR_ANGLE_SEGMENTS + 1 }, (_, index) => { + const angle = (elevationRadians * index) / SUN_VECTOR_ANGLE_SEGMENTS; + return horizontalDirection + .clone() + .multiplyScalar(Math.cos(angle) * angleRadius) + .add(new THREE.Vector3(0, Math.sin(angle) * angleRadius, 0)) + .applyQuaternion(inverseArrowRotation); + }) + ); + binding.sunVector.root.visible = binding.sunVectorVisible; + binding.sunVector.root.updateMatrixWorld(true); + binding.sunIntensity = intensity ?? ATMOSPHERIC_DISPLAY_EXPOSURE; + for (const sunLight of binding.controller.lights) { + sunLight.intensity = binding.sunIntensity; + } + binding.lightTarget.updateMatrixWorld(true); + for (const sunLight of binding.controller.lights) { + sunLight.updateMatrixWorld(true); + } + binding.controller.invalidate(); + binding.dirty = true; +}; + +const disposeShadowLightBinding = (binding: ShadowLightBinding) => { + for (const [ambientLight, intensity] of binding.ambientLightIntensities) { + ambientLight.intensity = intensity; + } + binding.scene.remove(binding.skyLight); + binding.scene.remove(binding.atmosphericSky.mesh); + binding.scene.remove(binding.sunVector.root); + binding.sunVector.dispose(); + binding.atmosphericSky.dispose(); + binding.controller.dispose(); +}; + +export const buildShadowSimulationScene = ( + map: MaplibreMap, + options: ShadowSceneOptions = {} +): ShadowSimulationScene => { + const { shadowAreaMeters: configuredShadowAreaMeters, terrain } = options; + const initialShadowAreaMeters = + configuredShadowAreaMeters ?? FALLBACK_SHADOW_AREA_METERS; + const previousLight = map.getLight(); + let mapStyleContentVisible = true; + // A mesh that supplies the terrain carries its own texture: the basemap + // pass then only contributes draped labels. Bare Cesium terrain, or no + // terrain-providing content at all, keeps the opaque basemap drape. + const getMapStyleDrapeMode = (): ShadowMapStyleDrapeMode => { + const providers = getSharedThreeSceneRuntimes(map).filter( + (runtime) => runtime.providesTerrain === true + ); + return providers.length > 0 && + providers.every( + (runtime) => runtime.mapStyleProjectionBlend === "overlay" + ) + ? "labels" + : "opaque"; + }; + const releaseMapLibreTerrain = acquireShadowMapLibreTerrain( + map, + WUPPERTAL_TERRAIN_SOURCE_ID, + () => mapStyleContentVisible, + getMapStyleDrapeMode + ); + const syncMeshLabelStyle = () => { + sceneLease.setMeshLabelStyle(getMapStyleDrapeMode() === "labels"); + }; + let latestSolarPosition: SolarPosition | null = null; + let latestBuildingAppearance: ShadowBuildingAppearance = { + fullOpacity: true, + uniformColor: null, + uniformColorMix: 0, + textureSaturation: 1, + }; + let latestShadowIntensity = 1; + let latestMeshErrorTarget = DEFAULT_MESH_ERROR_TARGET_PIXELS; + let latestAtmosphericSunlight: AtmosphericSunlightSample | null = null; + let atmosphericSunlightOptions: AtmosphericSunlightOptions = { + useTransmittanceLut: true, + useIrradianceLut: true, + }; + let disposed = false; + let timeAnimating = false; + let lastMapLibreStyleUpdateMs = Number.NEGATIVE_INFINITY; + let pendingMapLibreLightSample: AtmosphericSunlightSample | null = null; + let mapLibreStyleUpdateTimer: ReturnType | null = null; + let restoreMapLibreStyleLayers: (() => void) | null = null; + let terrainColor = new THREE.Color( + terrain?.material?.color ?? DEFAULT_SHADOW_SURFACE_COLOR + ); + const syncMapStyleContentVisibility = () => { + if (mapStyleContentVisible) { + restoreMapLibreStyleLayers?.(); + restoreMapLibreStyleLayers = null; + sceneLease.layer.setMapStyleProjectionVisible?.(true); + return; + } + sceneLease.layer.setMapStyleProjectionVisible?.(false); + restoreMapLibreStyleLayers ??= suppressMapLibreRegularStyleLayers(map); + }; + const atmosphereReferenceCenter = map.getCenter(); + const sceneLease = acquireSharedThreeScene(map); + const atmosphereSkyObserver: AtmosphericSkyReference["observer"] = { + longitude: atmosphereReferenceCenter.lng, + latitude: atmosphereReferenceCenter.lat, + altitudeMeters: 0, + }; + const atmosphereSkyScenePosition = + sceneLease.layer.projectLngLatToScene?.( + [atmosphereReferenceCenter.lng, atmosphereReferenceCenter.lat], + LOCAL_ATMOSPHERE_GROUND_ELEVATION_METERS + ) ?? new THREE.Vector3(0, LOCAL_ATMOSPHERE_GROUND_ELEVATION_METERS, 0); + const atmosphereSkyReference: AtmosphericSkyReference = { + observer: atmosphereSkyObserver, + scenePosition: atmosphereSkyScenePosition, + }; + const atmosphericSunlight = new AtmosphericSunlightEvaluator(); + let invalidateShadowMap = () => undefined; + let refreshTerrainShadowState = () => invalidateShadowMap(); + const buildTerrainRuntime = () => { + if (!terrain) return null; + const mapCenter = map.getCenter(); + const { url, ...runtimeOptions } = terrain; + return buildCesiumTerrainRuntime( + SHADOW_SIMULATION_TERRAIN_RUNTIME_ID, + url, + [mapCenter.lng, mapCenter.lat], + { + ...runtimeOptions, + receivesMapStyleTexture: true, + onContentChanged: () => refreshTerrainShadowState(), + } + ); + }; + const sharedSceneProvidesTerrain = () => + getSharedThreeSceneRuntimes(map).some( + (runtime) => runtime.providesTerrain === true + ); + let terrainRuntime = sharedSceneProvidesTerrain() + ? null + : buildTerrainRuntime(); + let initialTerrainStageReady = terrainRuntime === null; + if (terrainRuntime) sceneLease.layer.addRuntime(terrainRuntime); + const sharedBinding = buildShadowLightBinding( + sceneLease.layer.getScene(), + initialShadowAreaMeters, + terrainColor + ); + const atmosphereCameraPosition = new THREE.Vector3(); + let shadowStateEpoch = 0; + let shadowVisualEpoch = 0; + let cameraAltitudeMeters = LOCAL_ATMOSPHERE_GROUND_ELEVATION_METERS; + let cachedElevationRange: readonly [number, number] | null = null; + let lastAtmosphereErrorSignature = ""; + const rejectAtmosphereUpdate = ( + phase: string, + reason: string, + details: Readonly> + ) => { + const signature = `${phase}:${reason}`; + if (signature === lastAtmosphereErrorSignature) return; + lastAtmosphereErrorSignature = signature; + console.error( + `[SHADOW] Atmospheric update rejected; retaining last valid frame (${phase}: ${reason})`, + { + phase, + reason, + ...details, + } + ); + }; + const acceptAtmosphereUpdate = () => { + lastAtmosphereErrorSignature = ""; + }; + const invalidateShadowPresentation = () => { + shadowStateEpoch += 1; + shadowVisualEpoch += 1; + }; + const applyMapLibreLightSampleImmediately = ( + sample: AtmosphericSunlightSample + ) => { + pendingMapLibreLightSample = null; + lastMapLibreStyleUpdateMs = performance.now(); + const nextColor = `#${sample.color.getHexString()}`; + sceneLease.setLocationLabelColor(nextColor); + if (!map.isStyleLoaded()) return; + const nextPosition: [number, number, number] = [ + 1.5, + sample.azimuthDegrees, + 90 - sample.elevationDegrees, + ]; + const nextIntensity = clamp(sample.relativeIntensity, 0, 1); + const currentLight = map.getLight(); + const currentPosition = currentLight.position; + if ( + currentLight.anchor === "map" && + Array.isArray(currentPosition) && + currentPosition.length === nextPosition.length && + currentPosition.every((value, index) => value === nextPosition[index]) && + currentLight.color === nextColor && + currentLight.intensity === nextIntensity + ) { + return; + } + map.setLight({ + anchor: "map", + position: nextPosition, + color: nextColor, + intensity: nextIntensity, + }); + }; + const flushMapLibreLightSample = () => { + if (mapLibreStyleUpdateTimer !== null) { + globalThis.clearTimeout(mapLibreStyleUpdateTimer); + mapLibreStyleUpdateTimer = null; + } + const sample = pendingMapLibreLightSample; + if (sample) applyMapLibreLightSampleImmediately(sample); + }; + const applyMapLibreLightSample = (sample: AtmosphericSunlightSample) => { + pendingMapLibreLightSample = sample; + if (!timeAnimating) { + flushMapLibreLightSample(); + return; + } + + const elapsedMs = performance.now() - lastMapLibreStyleUpdateMs; + if (elapsedMs >= MAPLIBRE_STYLE_ANIMATION_UPDATE_INTERVAL_MS) { + flushMapLibreLightSample(); + return; + } + if (mapLibreStyleUpdateTimer !== null) return; + mapLibreStyleUpdateTimer = globalThis.setTimeout(() => { + mapLibreStyleUpdateTimer = null; + const latestSample = pendingMapLibreLightSample; + if (latestSample) applyMapLibreLightSampleImmediately(latestSample); + }, MAPLIBRE_STYLE_ANIMATION_UPDATE_INTERVAL_MS - elapsedMs); + }; + const evaluateAtmosphericSunlightForMap = (position: SolarPosition) => { + const mapCenter = map.getCenter(); + const observer = { + longitude: mapCenter.lng, + latitude: mapCenter.lat, + altitudeMeters: cameraAltitudeMeters, + }; + const inputError = getAtmosphericInputValidationError( + position.instant, + observer, + atmosphereSkyReference + ); + if (inputError) { + rejectAtmosphereUpdate("sunlight input", inputError, { + observer, + skyReference: atmosphereSkyReference, + }); + return latestAtmosphericSunlight; + } + atmosphericSunlight.ensure(() => { + if (disposed || !latestSolarPosition) return; + invalidateShadowPresentation(); + const sample = evaluateAtmosphericSunlightForMap(latestSolarPosition); + if (sample) applyMapLibreLightSample(sample); + map.triggerRepaint(); + }, atmosphericSunlightOptions); + atmosphericSunlight.ensureSky(() => { + if (disposed || !latestSolarPosition) return; + invalidateShadowPresentation(); + evaluateAtmosphericSunlightForMap(latestSolarPosition); + map.triggerRepaint(); + }); + let sample: AtmosphericSunlightSample; + try { + sample = atmosphericSunlight.evaluate( + position.instant, + observer, + atmosphericSunlightOptions, + atmosphereSkyReference + ); + } catch (error) { + rejectAtmosphereUpdate("sunlight generation", "generator threw", { + observer, + error, + }); + return latestAtmosphericSunlight; + } + const outputError = getAtmosphericSunlightSampleValidationError(sample); + if (outputError) { + rejectAtmosphereUpdate("sunlight output", outputError, { + observer, + sample, + }); + return latestAtmosphericSunlight; + } + acceptAtmosphereUpdate(); + latestAtmosphericSunlight = sample; + sharedBinding.atmosphericSky.update( + sample.skyFrame, + atmosphericSunlight.skyTextures + ); + applyAtmosphericSkyLightToBinding(sharedBinding, sample); + applySolarPositionToBinding( + sharedBinding, + sample.directionToSun, + sample.radiance, + ATMOSPHERIC_DISPLAY_EXPOSURE + ); + return sample; + }; + invalidateShadowMap = () => { + if (disposed) return; + sharedBinding.controller.invalidate(); + sharedBinding.dirty = true; + }; + const genericBridges = new Map(); + const getCoverageRuntimes = (): readonly SharedThreeSceneRuntime[] => { + const runtimes = getSharedThreeSceneRuntimes(map); + return terrainRuntime && !runtimes.includes(terrainRuntime) + ? [terrainRuntime, ...runtimes] + : runtimes; + }; + const getActiveTileVolumes = (): readonly SharedThreeSceneTileVolume[] => + getCoverageRuntimes().flatMap( + (runtime) => runtime.getActiveTileVolumes?.() ?? [] + ); + let mapInMotion = false; + let latestShadowView: SharedThreeSceneShadowView | null = null; + let appliedRuntimeShadowView: SharedThreeSceneShadowView | null = null; + const applyRuntimeShadowView = (view: SharedThreeSceneShadowView | null) => { + if (view === null && appliedRuntimeShadowView === null) return; + appliedRuntimeShadowView = view; + terrainRuntime?.setShadowView(view); + for (const runtime of getSharedThreeSceneRuntimes(map)) { + if (runtime === terrainRuntime) continue; + runtime.setShadowView?.(view); + } + }; + const setRuntimeShadowView = (view: SharedThreeSceneShadowView | null) => { + latestShadowView = view; + if (!mapInMotion) applyRuntimeShadowView(view); + }; + + let lastDebugPublishMs = 0; + let softSunShadowsEnabled = true; + let contentChangeTimer = 0; + let coverageNeedsCameraReevaluation = true; + let fallbackReceiverWorldPoints: THREE.Vector3[] = []; + let renderCameraSignature = ""; + let lastMotionShadowUpdateMs = Number.NEGATIVE_INFINITY; + let maxAccumulationPixels = Number.POSITIVE_INFINITY; + sharedBinding.controller.setSoftSun(softSunShadowsEnabled); + + const updateSharedShadowCoverage = (reevaluateSun = true) => { + const mapCenter = map.getCenter(); + const centerElevation = + terrainRuntime?.getElevation(mapCenter.lng, mapCenter.lat) ?? 0; + const center = sceneLease.layer.projectLngLatToScene?.( + [mapCenter.lng, mapCenter.lat], + centerElevation + ); + if (!center) { + if (latestSolarPosition) { + evaluateAtmosphericSunlightForMap(latestSolarPosition); + } + map.triggerRepaint(); + return; + } + sharedBinding.center.copy(center); + const canvas = map.getCanvas(); + const viewportWidth = canvas.clientWidth || canvas.width; + const viewportHeight = canvas.clientHeight || canvas.height; + let radiusMeters = 0; + const projectedCorners: THREE.Vector3[] = []; + // Fit the actual screen corners rather than their geographic AABB. + const viewportLngLats = [ + [0, viewportHeight], + [0, 0], + [viewportWidth, viewportHeight], + [viewportWidth, 0], + ].map((point) => { + const lngLat = map.unproject(point as [number, number]); + return [lngLat.lng, lngLat.lat] as [number, number]; + }); + // Clamp near-horizon projections to the supported receiver radius. + const clampToReceiverRadius = (point: THREE.Vector3) => { + const offset = point.clone().sub(center); + const horizontal = Math.hypot(offset.x, offset.z); + if (horizontal <= MAX_RECEIVER_DISTANCE_METERS) return point; + const scale = MAX_RECEIVER_DISTANCE_METERS / horizontal; + offset.x *= scale; + offset.z *= scale; + return offset.add(center); + }; + for (const lngLat of viewportLngLats) { + const corner = sceneLease.layer.projectLngLatToScene?.( + lngLat, + terrainRuntime?.getElevation(lngLat[0], lngLat[1]) ?? centerElevation + ); + if (corner) { + const clamped = clampToReceiverRadius(corner); + projectedCorners.push(clamped); + radiusMeters = Math.max(radiusMeters, clamped.distanceTo(center)); + } + } + if (projectedCorners.length === 4) { + const [southWest, northWest, southEast, northEast] = projectedCorners; + const viewportWidthMeters = + (southWest.distanceTo(southEast) + northWest.distanceTo(northEast)) / 2; + const viewportHeightMeters = + (southWest.distanceTo(northWest) + southEast.distanceTo(northEast)) / 2; + sharedBinding.sunVectorLengthMeters = + Math.min(viewportWidthMeters, viewportHeightMeters) * + SUN_VECTOR_VIEWPORT_LENGTH_FACTOR; + } + let minimumElevation = center.y; + let maximumElevation = center.y; + if (projectedCorners.length > 0) { + cachedElevationRange ??= getVisibleSceneElevationRange( + sharedBinding.scene, + center.y + ); + [minimumElevation, maximumElevation] = cachedElevationRange; + const elevationRadiusMeters = Math.max( + Math.abs(minimumElevation - center.y), + Math.abs(maximumElevation - center.y) + ); + // Include the scene's vertical span in the receiver radius. + const viewportRadiusMeters = Math.hypot( + radiusMeters, + elevationRadiusMeters + ); + sharedBinding.shadowAreaMeters = Math.max( + configuredShadowAreaMeters ?? 0, + MIN_VIEWPORT_SHADOW_AREA_METERS, + viewportRadiusMeters * 2 + ); + } + sharedBinding.shadowCameraOffsetMeters = Math.max( + DEFAULT_SHADOW_CAMERA_OFFSET_METERS, + sharedBinding.shadowAreaMeters * 1.5 + ); + const coveragePoints = viewportLngLats.flatMap((lngLat) => + [minimumElevation, maximumElevation].flatMap((elevation) => { + const point = sceneLease.layer.projectLngLatToScene?.( + lngLat, + elevation + ); + return point ? [clampToReceiverRadius(point)] : []; + }) + ); + fallbackReceiverWorldPoints = coveragePoints; + sharedBinding.receiverWorldPoints = coveragePoints; + sharedBinding.minimumElevationMeters = minimumElevation; + sharedBinding.maximumElevationMeters = maximumElevation; + sharedBinding.dirty = true; + // During movement, move the anchor without resampling the atmosphere. + if (latestSolarPosition && (reevaluateSun || !latestAtmosphericSunlight)) { + evaluateAtmosphericSunlightForMap(latestSolarPosition); + } else { + sharedBinding.lightTarget.position.copy(sharedBinding.center); + for (const sunLight of sharedBinding.controller.lights) { + sunLight.target.position.copy(sharedBinding.center); + sunLight.target.updateMatrixWorld(true); + } + sharedBinding.sunVector.root.position.copy(sharedBinding.center); + sharedBinding.sunVector.root.updateMatrixWorld(true); + } + map.triggerRepaint(); + }; + + const shadowControllerRuntime: SharedThreeSceneRuntime = { + id: "shadow-simulation-controller", + originLngLat: [map.getCenter().lng, map.getCenter().lat], + root: new THREE.Group(), + updatePriority: SHADOW_CONTROLLER_UPDATE_PRIORITY, + update(frame) { + const cameraHeightAboveTargetMeters = Math.max( + 0, + frame.lodCamera.position.y - frame.lookTarget.y + ); + const nextCameraAltitudeMeters = + LOCAL_ATMOSPHERE_GROUND_ELEVATION_METERS + + cameraHeightAboveTargetMeters; + const nextAtmosphereSceneHeight = + atmosphereSkyScenePosition.y + cameraHeightAboveTargetMeters; + const atmosphereCameraMatricesValid = [ + ...frame.lodCamera.matrixWorld.elements, + ...frame.lodCamera.projectionMatrix.elements, + ].every(Number.isFinite); + if ( + !atmosphereCameraMatricesValid || + !Number.isFinite(nextCameraAltitudeMeters) || + !Number.isFinite(nextAtmosphereSceneHeight) + ) { + rejectAtmosphereUpdate( + "render camera", + "local Three.js camera matrix or altitude is invalid", + { + altitudeMeters: nextCameraAltitudeMeters, + cameraHeightAboveTargetMeters, + matrixWorld: frame.lodCamera.matrixWorld.elements, + projectionMatrix: frame.lodCamera.projectionMatrix.elements, + } + ); + } else { + sharedBinding.atmosphericSky.updateViewCamera(frame.lodCamera); + sharedBinding.atmosphericSky.updateObserverScenePosition( + atmosphereCameraPosition.set( + atmosphereSkyScenePosition.x, + nextAtmosphereSceneHeight, + atmosphereSkyScenePosition.z + ) + ); + const altitudeChanged = + Math.abs(nextCameraAltitudeMeters - cameraAltitudeMeters) >= 0.25; + cameraAltitudeMeters = nextCameraAltitudeMeters; + if (altitudeChanged && latestSolarPosition) { + const sample = evaluateAtmosphericSunlightForMap(latestSolarPosition); + if (sample) applyMapLibreLightSample(sample); + } + } + const nextRenderCameraSignature = getSharedThreeShadowViewSignature({ + camera: frame.renderCamera, + shadowMapSize: { + width: frame.viewport.x, + height: frame.viewport.y, + }, + }); + if ( + coverageNeedsCameraReevaluation || + nextRenderCameraSignature !== renderCameraSignature + ) { + const nowMs = performance.now(); + if ( + mapInMotion && + nowMs - lastMotionShadowUpdateMs < MOTION_SHADOW_UPDATE_INTERVAL_MS + ) { + return; + } + lastMotionShadowUpdateMs = nowMs; + renderCameraSignature = nextRenderCameraSignature; + cachedElevationRange = mapInMotion + ? cachedElevationRange ?? [ + sharedBinding.minimumElevationMeters, + sharedBinding.maximumElevationMeters, + ] + : getViewElevationRange( + sharedBinding.scene, + getSharedThreeSceneRuntimes(map), + frame.renderCamera, + sharedBinding.center.y + ); + updateSharedShadowCoverage(false); + coverageNeedsCameraReevaluation = false; + } + if (!sharedBinding.dirty) return; + shadowStateEpoch += 1; + const cameraCoveragePoints = getViewportElevationEnvelopePoints( + frame.renderCamera, + sharedBinding.minimumElevationMeters, + sharedBinding.maximumElevationMeters, + sharedBinding.center + ); + const visibleTileVolumePoints = mapInMotion + ? [] + : getActiveTileVolumes().flatMap(({ minimum, maximum }) => + getFrustumBoxIntersectionPoints( + frame.renderCamera, + new THREE.Box3( + new THREE.Vector3(...minimum), + new THREE.Vector3(...maximum) + ) + ) + ); + sharedBinding.receiverWorldPoints = + visibleTileVolumePoints.length > 0 + ? visibleTileVolumePoints + : cameraCoveragePoints.length > 0 + ? cameraCoveragePoints + : fallbackReceiverWorldPoints; + if ( + sharedBinding.receiverWorldPoints.length === 0 || + !latestSolarPosition + ) { + setRuntimeShadowView(null); + clearShadowProjectionDebugSnapshot(map); + return; + } + const rendererCaps = sceneLease.layer.getRenderer?.()?.capabilities; + if (rendererCaps?.maxTextureSize) { + const resourceLimits = resolveShadowResourceLimits( + rendererCaps.maxTextureSize + ); + sharedBinding.controller.setMaxShadowMapSize( + resourceLimits.maxShadowMapSize + ); + maxAccumulationPixels = resourceLimits.maxAccumulationPixels; + } + const snapshot = sharedBinding.controller.update({ + receiverWorldPoints: sharedBinding.receiverWorldPoints, + receiverAnchorWorldPosition: sharedBinding.center, + minimumElevationMeters: sharedBinding.minimumElevationMeters, + maximumElevationMeters: sharedBinding.maximumElevationMeters, + directionToSun: sharedBinding.directionToSun, + color: sharedBinding.sunColor, + intensity: sharedBinding.sunIntensity, + shadowIntensity: sharedBinding.shadowIntensity, + quality: sharedBinding.shadowQuality, + }); + sharedBinding.dirty = false; + if (!snapshot) { + setRuntimeShadowView(null); + clearShadowProjectionDebugSnapshot(map); + return; + } + const primary = snapshot.camera; + const primaryCamera = sharedBinding.controller.lights[0].shadow.camera; + setRuntimeShadowView({ + camera: primaryCamera, + shadowMapSize: { + width: primary.shadowMapWidth, + height: primary.shadowMapHeight, + }, + }); + const nowMs = performance.now(); + const publishDue = !mapInMotion || nowMs - lastDebugPublishMs >= 100; + const publishWanted = hasShadowProjectionDebugListeners(map); + if (primary && publishWanted && publishDue) { + lastDebugPublishMs = nowMs; + frame.lodCamera.updateMatrixWorld(true); + frame.lodCamera.updateProjectionMatrix(); + const tileVolumes = getCoverageRuntimes().flatMap((runtime) => + (runtime.getActiveTileVolumes?.() ?? []).map( + ({ id, loadReason, minimum, maximum }) => ({ + id, + loadReason, + minimum, + maximum, + }) + ) + ); + publishShadowProjectionDebugSnapshot(map, { + cameraRangeMeters: primaryCamera.position.distanceTo( + sharedBinding.controller.lights[0].target.position + ), + leftMeters: primary.leftMeters, + rightMeters: primary.rightMeters, + bottomMeters: primary.bottomMeters, + topMeters: primary.topMeters, + nearMeters: primary.nearMeters, + farMeters: primary.farMeters, + projectionMatrixElements: primary.projectionMatrixElements, + shadowMapWidth: primary.shadowMapWidth, + shadowMapHeight: primary.shadowMapHeight, + minimumElevationMeters: sharedBinding.minimumElevationMeters, + maximumElevationMeters: sharedBinding.maximumElevationMeters, + sceneAnchorPositionElements: sharedBinding.center.toArray(), + mainCamera: { + viewMatrixElements: [ + ...frame.lodCamera.matrixWorldInverse.elements, + ], + projectionMatrixElements: [ + ...frame.lodCamera.projectionMatrix.elements, + ], + nearMeters: frame.lodCamera.near, + farMeters: frame.lodCamera.far, + viewportWidth: frame.viewport.x, + viewportHeight: frame.viewport.y, + }, + tileVolumes, + shadow: snapshot, + atmosphericSunlight: latestAtmosphericSunlight + ? { + azimuthDegrees: latestAtmosphericSunlight.azimuthDegrees, + elevationDegrees: latestAtmosphericSunlight.elevationDegrees, + relativeIntensity: latestAtmosphericSunlight.relativeIntensity, + color: `#${latestAtmosphericSunlight.color.getHexString()}`, + transmittanceReady: + latestAtmosphericSunlight.atmosphericTransmittanceReady, + irradianceReady: + latestAtmosphericSunlight.atmosphericIrradianceReady, + } + : null, + }); + } + }, + dispose: () => undefined, + }; + sceneLease.layer.addRuntime(shadowControllerRuntime); + + const getSunDiscAccumulationRounds = () => + sharedBinding.shadowQuality === 64 + ? MAX_SUN_DISC_ACCUMULATION_ROUNDS + : SUN_DISC_ACCUMULATION_ROUNDS; + const accumulationController = { + get maxRenderTargetPixels() { + return maxAccumulationPixels; + }, + get rounds() { + return getSunDiscAccumulationRounds(); + }, + epoch: () => shadowStateEpoch, + visualEpoch: () => shadowVisualEpoch, + active: () => + softSunShadowsEnabled && + initialTerrainStageReady && + !mapInMotion && + !timeAnimating && + contentChangeTimer === 0 && + latestSolarPosition !== null && + latestShadowView !== null && + sharedBinding.receiverWorldPoints.length > 0, + retainSettledFrame: () => + softSunShadowsEnabled && + latestSolarPosition !== null && + latestShadowView !== null && + sharedBinding.receiverWorldPoints.length > 0, + prepareRound: (round: number) => { + sharedBinding.controller.applySunDiscSample( + round, + getSunDiscAccumulationRounds() + ); + }, + finishRound: () => sharedBinding.controller.restoreSunDiscCenter(), + }; + sceneLease.layer.setAccumulationController?.(accumulationController); + const refreshSharedShadowCoverage = () => { + cachedElevationRange = null; + coverageNeedsCameraReevaluation = true; + updateSharedShadowCoverage(); + }; + refreshTerrainShadowState = () => { + invalidateShadowMap(); + refreshSharedShadowCoverage(); + }; + + const handleMoveStart = () => { + mapInMotion = true; + coverageNeedsCameraReevaluation = true; + sharedBinding.dirty = true; + }; + const handleMove = () => { + coverageNeedsCameraReevaluation = true; + sharedBinding.dirty = true; + }; + const handleMoveEnd = () => { + mapInMotion = false; + refreshSharedShadowCoverage(); + if (appliedRuntimeShadowView !== latestShadowView) { + applyRuntimeShadowView(latestShadowView); + } + }; + const handleResize = () => { + handleMove(); + map.triggerRepaint(); + }; + map.on(MAPLIBRE_EVENT.MOVE_START, handleMoveStart); + map.on(MAPLIBRE_EVENT.MOVE, handleMove); + map.on(MAPLIBRE_EVENT.MOVE_END, handleMoveEnd); + map.on(MAPLIBRE_EVENT.RESIZE, handleResize); + const watchTerrainRuntime = (runtime: NonNullable) => { + void runtime.ready.then((loaded) => { + if (!loaded || disposed || terrainRuntime !== runtime) return; + initialTerrainStageReady = true; + refreshSharedShadowCoverage(); + map.triggerRepaint(); + }); + }; + const syncTerrainRuntime = () => { + const meshProvidesTerrain = sharedSceneProvidesTerrain(); + if (!terrain) return; + if (meshProvidesTerrain) { + initialTerrainStageReady = true; + const runtime = terrainRuntime; + terrainRuntime = null; + if (runtime && sceneLease.layer.hasRuntime(runtime.id)) { + sceneLease.layer.removeRuntime(runtime.id); + } + cachedElevationRange = null; + coverageNeedsCameraReevaluation = true; + return; + } + if (terrainRuntime) return; + + const runtime = buildTerrainRuntime(); + if (!runtime) return; + initialTerrainStageReady = false; + terrainRuntime = runtime; + runtime.setMaterialColor(`#${terrainColor.getHexString()}`); + runtime.setShadowView(appliedRuntimeShadowView); + sceneLease.layer.addRuntime(runtime); + watchTerrainRuntime(runtime); + cachedElevationRange = null; + coverageNeedsCameraReevaluation = true; + }; + if (terrainRuntime) { + watchTerrainRuntime(terrainRuntime); + } + updateSharedShadowCoverage(); + + const syncGenericBridges = () => { + if (disposed) return; + const currentLayers = new Set(getGenericThreeLayers(map)); + for (const [layer, bridge] of genericBridges) { + if (currentLayers.has(layer)) continue; + sceneLease.layer.removeRuntime(bridge.runtime.id); + genericBridges.delete(layer); + } + for (const layer of currentLayers) { + const bridge = genericBridges.get(layer); + if (bridge) { + bridge.sync(); + continue; + } + if (!layer.scene) continue; + const nextBridge = buildGenericThreeShadowBridge( + sceneLease.layer, + layer, + latestBuildingAppearance + ); + if (nextBridge) genericBridges.set(layer, nextBridge); + } + makeSceneMeshesShadeable(sceneLease.layer.getScene()); + refreshSharedShadowCoverage(); + map.triggerRepaint(); + }; + + const unsubscribeGenericLayers = subscribeGenericThreeLayers( + map, + syncGenericBridges + ); + syncGenericBridges(); + syncMeshLabelStyle(); + + const handleSharedSceneContentChanged = () => { + if (disposed) return; + syncTerrainRuntime(); + releaseMapLibreTerrain.refresh(); + syncMeshLabelStyle(); + for (const runtime of getSharedThreeSceneRuntimes(map)) { + if (runtime.providesTerrain) { + runtime.setErrorTarget?.(latestMeshErrorTarget); + } + runtime.setShadowSimulationStyle?.(latestBuildingAppearance); + runtime.setShadowView?.(appliedRuntimeShadowView); + } + makeSceneMeshesShadeable(sceneLease.layer.getScene()); + sharedBinding.controller.invalidate(); + sharedBinding.dirty = true; + coverageNeedsCameraReevaluation = true; + refreshSharedShadowCoverage(); + }; + const scheduleSharedSceneContentChanged = () => { + if (disposed) return; + syncTerrainRuntime(); + cachedElevationRange = null; + coverageNeedsCameraReevaluation = true; + sharedBinding.controller.invalidate(); + sharedBinding.dirty = true; + map.triggerRepaint(); + if (contentChangeTimer) window.clearTimeout(contentChangeTimer); + contentChangeTimer = window.setTimeout(() => { + contentChangeTimer = 0; + handleSharedSceneContentChanged(); + }, 120); + }; + const unsubscribeSharedSceneContent = subscribeSharedThreeSceneContent( + map, + scheduleSharedSceneContentChanged + ); + handleSharedSceneContentChanged(); + + const applyMapLibreLight = (position: SolarPosition) => { + const sample = + latestAtmosphericSunlight ?? evaluateAtmosphericSunlightForMap(position); + if (sample) applyMapLibreLightSample(sample); + }; + + const updateSolarPosition = (position: SolarPosition) => { + if ( + latestSolarPosition?.instant.getTime() === position.instant.getTime() && + latestSolarPosition.azimuthDegrees === position.azimuthDegrees && + latestSolarPosition.elevationDegrees === position.elevationDegrees + ) { + return; + } + invalidateShadowPresentation(); + latestSolarPosition = position; + updateSharedShadowCoverage(); + applyMapLibreLight(position); + }; + + const restoreLighting = () => { + if (disposed) return; + if (latestSolarPosition) applyMapLibreLight(latestSolarPosition); + }; + + map.on(MAPLIBRE_EVENT.STYLE_LOAD, restoreLighting); + + return { + updateSolarPosition, + updateTerrainColor(color) { + const nextColor = new THREE.Color(color); + if (terrainColor.equals(nextColor)) return; + invalidateShadowPresentation(); + terrainRuntime?.setMaterialColor(color); + sharedBinding.atmosphericSky.updateGroundAlbedo(nextColor); + terrainColor = nextColor; + }, + updateMeshErrorTarget(errorTarget) { + if (latestMeshErrorTarget === errorTarget) return; + latestMeshErrorTarget = errorTarget; + for (const runtime of getSharedThreeSceneRuntimes(map)) { + if (runtime.providesTerrain) runtime.setErrorTarget?.(errorTarget); + } + map.triggerRepaint(); + }, + updateBuildingAppearance(appearance) { + if ( + latestBuildingAppearance.fullOpacity === appearance.fullOpacity && + latestBuildingAppearance.uniformColor === appearance.uniformColor && + (latestBuildingAppearance.uniformColorMix ?? 1) === + (appearance.uniformColorMix ?? 1) && + (latestBuildingAppearance.textureSaturation ?? 1) === + (appearance.textureSaturation ?? 1) + ) { + return; + } + invalidateShadowPresentation(); + latestBuildingAppearance = appearance; + for (const bridge of genericBridges.values()) { + bridge.updateBuildingAppearance(appearance); + } + for (const runtime of getSharedThreeSceneRuntimes(map)) { + runtime.setShadowSimulationStyle?.(appearance); + } + makeSceneMeshesShadeable(sceneLease.layer.getScene()); + sharedBinding.controller.invalidate(); + sharedBinding.dirty = true; + map.triggerRepaint(); + }, + updateShadowQuality(quality) { + if (sharedBinding.shadowQuality === quality) return; + invalidateShadowPresentation(); + sharedBinding.shadowQuality = quality; + sharedBinding.dirty = true; + updateSharedShadowCoverage(); + sharedBinding.controller.invalidate(); + }, + updateSoftSunShadows(enabled) { + if (softSunShadowsEnabled === enabled) return; + invalidateShadowPresentation(); + softSunShadowsEnabled = enabled; + sharedBinding.controller.setSoftSun(enabled); + sharedBinding.controller.invalidate(); + sharedBinding.dirty = true; + map.triggerRepaint(); + }, + updateTimeAnimating(animating) { + if (timeAnimating === animating) return; + timeAnimating = animating; + if (!animating) { + flushMapLibreLightSample(); + sharedBinding.dirty = true; + } + map.triggerRepaint(); + }, + refreshProjectionDebug() { + lastDebugPublishMs = 0; + sharedBinding.dirty = true; + map.triggerRepaint(); + }, + updateShadowIntensity(intensity) { + const nextIntensity = clamp(intensity, 0, 1); + if (latestShadowIntensity === nextIntensity) return; + invalidateShadowPresentation(); + latestShadowIntensity = nextIntensity; + sharedBinding.shadowIntensity = latestShadowIntensity; + for (const light of sharedBinding.controller.lights) { + light.shadow.intensity = latestShadowIntensity; + } + map.triggerRepaint(); + }, + updateMapStyleContentVisibility(visible) { + if (mapStyleContentVisible === visible) return; + mapStyleContentVisible = visible; + syncMapStyleContentVisibility(); + map.triggerRepaint(); + }, + updateMapStyleLabelOverlayVisibility(visible) { + sceneLease.setPointLabelOverlayVisible(visible); + map.triggerRepaint(); + }, + updateSunDebugVectorVisibility(visible) { + if (sharedBinding.sunVectorVisible === visible) return; + invalidateShadowPresentation(); + sharedBinding.sunVectorVisible = visible; + sharedBinding.sunVector.root.visible = visible && !!latestSolarPosition; + map.triggerRepaint(); + }, + updateAtmosphericLutUsage(options) { + if ( + atmosphericSunlightOptions.useTransmittanceLut === + options.useTransmittanceLut && + atmosphericSunlightOptions.useIrradianceLut === options.useIrradianceLut + ) { + return; + } + invalidateShadowPresentation(); + atmosphericSunlightOptions = options; + latestAtmosphericSunlight = null; + if (latestSolarPosition) { + evaluateAtmosphericSunlightForMap(latestSolarPosition); + invalidateShadowMap(); + } + map.triggerRepaint(); + }, + dispose() { + if (disposed) return; + disposed = true; + if (contentChangeTimer) window.clearTimeout(contentChangeTimer); + if (mapLibreStyleUpdateTimer !== null) { + globalThis.clearTimeout(mapLibreStyleUpdateTimer); + mapLibreStyleUpdateTimer = null; + } + clearShadowProjectionDebugSnapshot(map); + map.off(MAPLIBRE_EVENT.STYLE_LOAD, restoreLighting); + map.off(MAPLIBRE_EVENT.MOVE_START, handleMoveStart); + map.off(MAPLIBRE_EVENT.MOVE, handleMove); + map.off(MAPLIBRE_EVENT.MOVE_END, handleMoveEnd); + map.off(MAPLIBRE_EVENT.RESIZE, handleResize); + unsubscribeGenericLayers(); + unsubscribeSharedSceneContent(); + latestShadowView = null; + applyRuntimeShadowView(null); + for (const runtime of getSharedThreeSceneRuntimes(map)) { + runtime.setShadowSimulationStyle?.(null); + } + for (const bridge of genericBridges.values()) { + if (sceneLease.layer.hasRuntime(bridge.runtime.id)) { + sceneLease.layer.removeRuntime(bridge.runtime.id); + } + } + genericBridges.clear(); + try { + restoreMapLibreStyleLayers?.(); + } catch { + // The style may already be gone during map teardown. + } + restoreMapLibreStyleLayers = null; + sceneLease.layer.setMapStyleProjectionVisible?.(true); + releaseMapLibreTerrain(); + if (sceneLease.layer.hasRuntime(shadowControllerRuntime.id)) { + sceneLease.layer.removeRuntime(shadowControllerRuntime.id); + } + if (terrainRuntime && sceneLease.layer.hasRuntime(terrainRuntime.id)) { + sceneLease.layer.removeRuntime(terrainRuntime.id); + } + atmosphericSunlight.dispose(); + disposeShadowLightBinding(sharedBinding); + sceneLease.layer.setAccumulationController?.(null); + sceneLease.release(); + try { + if (map.isStyleLoaded()) map.setLight(previousLight); + } catch { + // Nothing remains to restore after map teardown. + } + }, + }; +}; diff --git a/libraries/mapping/shadow-simulation/src/lib/ui/ShadowProjectionDebugView.spec.tsx b/libraries/mapping/shadow-simulation/src/lib/ui/ShadowProjectionDebugView.spec.tsx new file mode 100644 index 0000000000..95cc4c2c3e --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/ui/ShadowProjectionDebugView.spec.tsx @@ -0,0 +1,28 @@ +// @vitest-environment jsdom + +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { ShadowProjectionDebugPortal } from "./ShadowProjectionDebugView"; + +describe("ShadowProjectionDebugPortal", () => { + it("owns a dedicated portal host and removes it on unmount", () => { + const view = render( + +
debug content
+
+ ); + + const host = document.querySelector( + "[data-carma-shadow-projection-debug-host]" + ); + expect(host).not.toBeNull(); + expect(host?.textContent).toBe("debug content"); + + view.unmount(); + + expect( + document.querySelector("[data-carma-shadow-projection-debug-host]") + ).toBeNull(); + }); +}); diff --git a/libraries/mapping/shadow-simulation/src/lib/ui/ShadowProjectionDebugView.tsx b/libraries/mapping/shadow-simulation/src/lib/ui/ShadowProjectionDebugView.tsx new file mode 100644 index 0000000000..3cb309a4f0 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/ui/ShadowProjectionDebugView.tsx @@ -0,0 +1,866 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, + type ReactNode, +} from "react"; +import { createPortal } from "react-dom"; + +import { + Checkbox, + ColorPicker, + Segmented, + Slider, + Space, + Tag, + Typography, +} from "antd"; +import type { Map as MaplibreMap } from "maplibre-gl"; +import * as THREE from "three"; + +import { + CarmaResponsiveInfoBox, + useHostElementSizeRef, +} from "@carma-commons/ui/components"; +import { ViewStateVisualizer } from "@carma-mapping/components"; +import { + acquireSharedThreeScene, + createSharedThreeSceneCameraPreview, + getSharedThreeSceneRuntimes, + MAPLIBRE_EVENT, + subscribeSharedThreeSceneContent, +} from "@carma-mapping/engines/maplibre"; + +import type { SolarPosition } from "../core/solar-position"; +import type { + MeshErrorTargetPixels, + ShadowQualityMultiplier, +} from "../core/shadow-types"; +import { + buildShadowProjectionDebugModel, + type ShadowProjectionDebugModel, +} from "../runtime/shadow-projection-debug-model"; +import { + readShadowProjectionDebugSnapshot, + subscribeShadowProjectionDebugSnapshot, +} from "../runtime/shadow-projection-debug-store"; +import { SHADOW_QUALITY_LEVELS } from "./shadow-control-utils"; + +const SHADOW_PROJECTION_DEBUG_CUE_OPTIONS = { + bearing: { label: "Schattenrichtung", color: "#d97706" }, + pitch: { label: "Höhe", color: "#f59e0b" }, + north: { label: "N", color: "#2563eb" }, +} as const; + +const SHADOW_PROJECTION_DEBUG_OVERVIEW_OPTIONS = { + orthographic: true, + fitOrthographicWidth: true, +} as const; + +const SHADOW_DEBUG_VIEWPOINT = { + OVERVIEW: "overview", + SUN: "sun", +} as const; + +type ShadowDebugViewpoint = + (typeof SHADOW_DEBUG_VIEWPOINT)[keyof typeof SHADOW_DEBUG_VIEWPOINT]; + +type VisualizerContentGroup = + | "worldAxes" + | "angleCues" + | "imagePlanes" + | "cameraAxes" + | "frustums" + | "projectionPlanes" + | "markers" + | "altitude" + | "labels" + | "tileVolumes"; + +const VISUALIZER_CONTENT_GROUPS: ReadonlyArray<{ + key: VisualizerContentGroup; + label: string; +}> = [ + { key: "worldAxes", label: "Weltachsen" }, + { key: "angleCues", label: "Winkel" }, + { key: "imagePlanes", label: "Bildflächen" }, + { key: "cameraAxes", label: "Kameraachsen" }, + { key: "frustums", label: "Frusta" }, + { key: "projectionPlanes", label: "Projektionsebenen" }, + { key: "markers", label: "Marker" }, + { key: "altitude", label: "Höhenbezug" }, + { key: "labels", label: "Beschriftung" }, + { key: "tileVolumes", label: "Tile-Volumes" }, +]; + +const DEFAULT_VISUALIZER_CONTENT_VISIBILITY: Record< + VisualizerContentGroup, + boolean +> = { + worldAxes: true, + angleCues: true, + imagePlanes: true, + cameraAxes: true, + frustums: true, + projectionPlanes: true, + markers: true, + altitude: false, + labels: true, + tileVolumes: true, +}; + +const MESH_ERROR_TARGETS: ReadonlyArray<{ + label: string; + value: MeshErrorTargetPixels; +}> = [ + { label: "0,25 px", value: 0.25 }, + { label: "1 px", value: 1 }, + { label: "4 px", value: 4 }, +]; + +const useShadowProjectionDebugPortalHost = () => { + const [host, setHost] = useState(null); + + useEffect(() => { + const element = document.createElement("div"); + element.dataset.carmaShadowProjectionDebugHost = ""; + document.body.appendChild(element); + setHost(element); + + return () => { + element.remove(); + }; + }, []); + + return host; +}; + +export const ShadowProjectionDebugPortal = ({ + children, +}: { + children: ReactNode; +}) => { + const host = useShadowProjectionDebugPortalHost(); + return host ? createPortal(children, host) : null; +}; + +export type ShadowProjectionDebugSettings = Readonly<{ + shadowQuality: ShadowQualityMultiplier; + meshErrorTarget: MeshErrorTargetPixels; + terrainColor: string; + buildingsFullOpacity: boolean; + buildingColorMix: number; + meshTextureSaturation: number; + buildingColor: string; + showSunDebugVector: boolean; + showTileBounds: boolean; + useTransmittanceLut: boolean; + useSkyIrradianceLut: boolean; +}>; + +const formatMeters = (value: number, fractionDigits = 0) => + `${value.toFixed(fractionDigits)} m`; + +const ShadowBufferStatistics = ({ + model, +}: { + model: ShadowProjectionDebugModel; +}) => { + const resolution = `${model.shadowBuffer.shadowMapWidth} × ${model.shadowBuffer.shadowMapHeight}`; + const values: ReadonlyArray> = [ + { label: "Samples", value: model.shadowSampleCount }, + { label: "Tiles", value: model.tileVolumes.length }, + { label: "Buffer", value: resolution }, + { + label: "Kernabdeckung", + value: `${formatMeters( + model.receiverCoverageWidthMeters + )} × ${formatMeters(model.receiverCoverageHeightMeters)}`, + }, + { + label: "Texel", + value: formatMeters( + Math.max(model.shadowTexelWidthMeters, model.shadowTexelHeightMeters), + 3 + ), + }, + { + label: "Viewport", + value: `${formatMeters(model.viewportWidthMeters)} × ${formatMeters( + model.viewportHeightMeters + )}`, + }, + { label: "Höhenspanne", value: formatMeters(model.elevationSpanMeters) }, + { label: "Caster", value: formatMeters(model.casterReachMeters) }, + { + label: "Horizontal / Höhe", + value: `${model.horizontalProjectionPerHeight.toFixed(2)} ×`, + }, + ]; + + return ( +
+ {values.map(({ label, value }) => ( +
+ + {label} + + + {value} + +
+ ))} +
+ ); +}; + +const SUN_CAMERA_PREVIEW_INTERVAL_MS = 120; + +const ShadowSunCameraView = ({ + map, + containerWidth, + containerHeight, + shadowMapWidth, + shadowMapHeight, +}: { + map: MaplibreMap; + containerWidth: number; + containerHeight: number; + shadowMapWidth: number; + shadowMapHeight: number; +}) => { + const canvasRef = useRef(null); + const [hasFrame, setHasFrame] = useState(false); + const aspectRatio = Math.max( + 0.1, + shadowMapWidth / Math.max(1, shadowMapHeight) + ); + const fittedWidth = Math.max( + 1, + Math.min(containerWidth, containerHeight * aspectRatio) + ); + const fittedHeight = Math.max(1, fittedWidth / aspectRatio); + const pixelRatio = Math.min(window.devicePixelRatio || 1, 1.5); + const frameWidth = Math.max(1, Math.round(fittedWidth * pixelRatio)); + const frameHeight = Math.max(1, Math.round(fittedHeight * pixelRatio)); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const lease = acquireSharedThreeScene(map); + const preview = createSharedThreeSceneCameraPreview(lease.layer); + let lastFrameAt = Number.NEGATIVE_INFINITY; + let framePresented = false; + + const renderFrame = () => { + const now = performance.now(); + if (now - lastFrameAt < SUN_CAMERA_PREVIEW_INTERVAL_MS) return; + const light = lease.layer + .getScene() + .getObjectByName("shadow-simulation-sun") as + | THREE.DirectionalLight + | undefined; + if (!light?.isDirectionalLight) return; + + const rendered = preview.render( + light.shadow.camera, + frameWidth, + frameHeight, + (framePixels, width, height) => { + if (canvas.width !== width) canvas.width = width; + if (canvas.height !== height) canvas.height = height; + const context = canvas.getContext("2d"); + if (!context) return; + const image = context.createImageData(width, height); + image.data.set(framePixels); + context.putImageData(image, 0, 0); + if (!framePresented) { + framePresented = true; + setHasFrame(true); + } + } + ); + if (rendered) lastFrameAt = now; + }; + + map.on(MAPLIBRE_EVENT.RENDER, renderFrame); + map.triggerRepaint(); + return () => { + map.off(MAPLIBRE_EVENT.RENDER, renderFrame); + preview.dispose(); + lease.release(); + }; + }, [frameHeight, frameWidth, map]); + + return ( +
+ + {!hasFrame && ( +
+ Sonnenkamera wird vorbereitet … +
+ )} +
+ Live-Szene · orthografische Schattenkamera +
+
+ ); +}; + +const ShadowDebugVisualizer = ({ + map, + model, + visibility, + viewpoint, + onViewpointChange, +}: { + map: MaplibreMap; + model: ShadowProjectionDebugModel; + visibility: Record; + viewpoint: ShadowDebugViewpoint; + onViewpointChange: (viewpoint: ShadowDebugViewpoint) => void; +}) => { + const host = useHostElementSizeRef(); + const width = Math.max(1, host.size?.width ?? 1); + const shadowMapAspectRatio = + model.shadowBuffer.shadowMapWidth / + Math.max(1, model.shadowBuffer.shadowMapHeight); + const height = + viewpoint === SHADOW_DEBUG_VIEWPOINT.SUN + ? Math.max(190, Math.round(width / Math.max(0.1, shadowMapAspectRatio))) + : Math.max(190, Math.min(245, Math.round(width * 0.36))); + const visualizedOptions = useMemo( + () => ({ + useCameraPosition: true, + worldScaleMeters: model.visualizationWorldScaleMeters, + imagePlaneDistance: 0.08, + }), + [model.visualizationWorldScaleMeters] + ); + const displayOptions = useMemo( + () => ({ + surface: { show: false }, + worldAxes: { + show: visibility.worldAxes, + showUp: false, + lineWidthPx: 1.5, + }, + angleCues: { show: visibility.angleCues, lineWidthPx: 1.5 }, + cameraView: { + imagePlane: { show: visibility.imagePlanes, showOffset: true }, + axes: { show: visibility.cameraAxes, showInactive: true }, + frustum: { + show: visibility.frustums, + showInactive: true, + lineWidthPx: 1, + }, + projectionPlane: { show: visibility.projectionPlanes }, + marker: { show: visibility.markers }, + }, + altitude: { show: visibility.altitude }, + labels: { + showAxes: visibility.labels, + showAngles: visibility.labels, + showImagePlane: false, + fontSizePx: 11, + }, + }), + [visibility] + ); + const volumeBoxes = useMemo( + () => ({ + boxes: model.tileVolumes, + visible: visibility.tileVolumes, + color: "#0f766e", + opacity: 0.58, + }), + [model.tileVolumes, visibility.tileVolumes] + ); + + return ( +
+
+ onViewpointChange(value as ShadowDebugViewpoint)} + /> +
+ {viewpoint === SHADOW_DEBUG_VIEWPOINT.OVERVIEW && + visibility.tileVolumes && ( +
+ + + Viewport + + + + Schattenpfad + +
+ )} +
+ {host.isReady && viewpoint === SHADOW_DEBUG_VIEWPOINT.OVERVIEW && ( +
+ +
+ )} + {host.isReady && viewpoint === SHADOW_DEBUG_VIEWPOINT.SUN && ( + + )} +
+
+ ); +}; + +const VisualizerContentToggles = ({ + visibility, + onToggle, +}: { + visibility: Record; + onToggle: (group: VisualizerContentGroup) => void; +}) => ( +
+ + Visualisierung + + + {VISUALIZER_CONTENT_GROUPS.map(({ key, label }) => ( + onToggle(key)} + className="!m-0 !text-xs" + > + {label} + + ))} + +
+); + +const ShadowDebugControls = ({ + settings, + transmittanceReady, + irradianceReady, + meshLoaded, + onChange, +}: { + settings: ShadowProjectionDebugSettings; + transmittanceReady: boolean; + irradianceReady: boolean; + meshLoaded: boolean; + onChange: (patch: Partial) => void; +}) => { + return ( +
+
+
+ + Qualität + + + onChange({ shadowQuality: value as ShadowQualityMultiplier }) + } + /> +
+ {meshLoaded && ( +
+ + Mesh-LOD + + + onChange({ meshErrorTarget: value as MeshErrorTargetPixels }) + } + /> +
+ )} +
+
+
+ + Terrain + + color.toHexString().toUpperCase()} + onChangeComplete={(color) => + onChange({ terrainColor: color.toHexString() }) + } + /> +
+ + onChange({ buildingsFullOpacity: event.target.checked }) + } + className="!text-xs" + > + Gebäude volle Deckkraft + +
+ + Mesh + + + Textur + + onChange({ buildingColorMix: value })} + tooltip={{ formatter: null }} + className="!m-0 w-24" + aria-label="Mischung aus Meshtextur und Farbe" + /> + + Farbe + + + {Math.round(settings.buildingColorMix * 100)}% + +
+
+ + Sättigung + + onChange({ meshTextureSaturation: value })} + tooltip={{ formatter: null }} + className="!m-0 w-24" + aria-label="Sättigung der Meshtextur" + /> + + {Math.round(settings.meshTextureSaturation * 100)}% + + color.toHexString().toUpperCase()} + onChangeComplete={(color) => + onChange({ buildingColor: color.toHexString() }) + } + /> +
+
+
+ + onChange({ showSunDebugVector: event.target.checked }) + } + className="!text-xs" + > + Sonnenvektor + + + onChange({ showTileBounds: event.target.checked }) + } + className="!text-xs" + > + Tile-Kanten + IDs + + + onChange({ useTransmittanceLut: event.target.checked }) + } + className="!text-xs" + > + Transmittanz-LUT + + {settings.useTransmittanceLut + ? transmittanceReady + ? "bereit" + : "lädt" + : "aus"} + + + + onChange({ useSkyIrradianceLut: event.target.checked }) + } + className="!text-xs" + > + Sky-Irradianz-LUT + + {settings.useSkyIrradianceLut + ? irradianceReady + ? "bereit" + : "lädt" + : "aus"} + + +
+
+ ); +}; + +export const ShadowProjectionDebugView = ({ + map, + solarPosition, + settings, + onSettingsChange, +}: { + map: MaplibreMap; + solarPosition: SolarPosition; + settings: ShadowProjectionDebugSettings; + onSettingsChange: (patch: Partial) => void; +}) => { + const subscribe = useCallback( + (listener: () => void) => + subscribeShadowProjectionDebugSnapshot(map, listener), + [map] + ); + const getSnapshot = useCallback( + () => readShadowProjectionDebugSnapshot(map), + [map] + ); + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + const subscribeMeshPresence = useCallback( + (listener: () => void) => subscribeSharedThreeSceneContent(map, listener), + [map] + ); + const getMeshPresence = useCallback( + () => + getSharedThreeSceneRuntimes(map).some( + (runtime) => runtime.providesTerrain === true + ), + [map] + ); + const meshLoaded = useSyncExternalStore( + subscribeMeshPresence, + getMeshPresence, + getMeshPresence + ); + const [visualizerContentVisibility, setVisualizerContentVisibility] = + useState(DEFAULT_VISUALIZER_CONTENT_VISIBILITY); + const [visualizerViewpoint, setVisualizerViewpoint] = + useState(SHADOW_DEBUG_VIEWPOINT.OVERVIEW); + const model = useMemo( + () => + snapshot + ? buildShadowProjectionDebugModel(map, solarPosition, snapshot) + : null, + [map, snapshot, solarPosition] + ); + if (!snapshot || !model) return null; + const displayedAzimuth = + snapshot.atmosphericSunlight?.azimuthDegrees ?? + solarPosition.azimuthDegrees; + const displayedElevation = + snapshot.atmosphericSunlight?.elevationDegrees ?? + solarPosition.elevationDegrees; + + const content = ( +
+ +
+ {visualizerViewpoint === SHADOW_DEBUG_VIEWPOINT.OVERVIEW && ( + + setVisualizerContentVisibility((current) => ({ + ...current, + [group]: !current[group], + })) + } + /> + )} +
+ + Sonne + + + Azimut{" "} + + {displayedAzimuth.toFixed(1)}° + + + + Höhe{" "} + + {displayedElevation.toFixed(1)}° + + + {snapshot.atmosphericSunlight && ( + <> + + Radiance{" "} + + {( + snapshot.atmosphericSunlight.relativeIntensity * 100 + ).toFixed(1)} + % + + + + Licht + + + {snapshot.atmosphericSunlight.color.toUpperCase()} + + + + )} + + + + Kamera + + + + Sonne + + + + Buffer-Grenzen + + +
+
+
+ + +
+
+ ); + + return ( + + Projektions-Debug + } + headingColor="rgba(51, 65, 85, 0.94)" + width={700} + content={content} + style={{ + position: "fixed", + bottom: 24, + right: 24, + zIndex: 5000, + maxWidth: "calc(100vw - 24px)", + pointerEvents: "auto", + }} + /> + + ); +}; diff --git a/libraries/mapping/shadow-simulation/src/lib/ui/ShadowSimulationCurveSettings.tsx b/libraries/mapping/shadow-simulation/src/lib/ui/ShadowSimulationCurveSettings.tsx new file mode 100644 index 0000000000..a4b094615b --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/ui/ShadowSimulationCurveSettings.tsx @@ -0,0 +1,35 @@ +import { useMemo } from "react"; + +import type { ShadowDateState } from "../contracts/shadow-simulation"; +import { + getSolarPosition, + type SolarLocation, +} from "../core/solar-position"; +import { SolarDayTimeControl } from "./SolarDayTimeControl"; + +export const ShadowSimulationCurveSettings = ({ + location, + dateState, + setDateState, +}: { + location: SolarLocation; + dateState: ShadowDateState; + setDateState: (state: ShadowDateState) => void; +}) => { + const solarPosition = useMemo( + () => getSolarPosition(dateState, location), + [dateState, location] + ); + + return ( +
+ +
+ ); +}; diff --git a/libraries/mapping/shadow-simulation/src/lib/ui/ShadowSimulationHeaderControlsView.tsx b/libraries/mapping/shadow-simulation/src/lib/ui/ShadowSimulationHeaderControlsView.tsx new file mode 100644 index 0000000000..861139c0d8 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/ui/ShadowSimulationHeaderControlsView.tsx @@ -0,0 +1,223 @@ +import { useMemo, useState } from "react"; +import type { Map as MaplibreMap } from "maplibre-gl"; + +import { + faCalendarDays, + faChevronLeft, + faChevronRight, + faClock, + faPause, + faPlay, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { DatePicker } from "antd"; +import deDE from "antd/locale/de_DE"; +import dayjs from "dayjs"; + +import { getDayOfYear, offsetYearDay } from "@carma-commons/utils"; + +import type { + ShadowDateState, + ShadowDateStateSetter, + ShadowSimulationConfig, + ShadowSimulationState, + ShadowSimulationStateSetter, +} from "../contracts/shadow-simulation"; +import { + DEFAULT_SHADOW_SIMULATION_LOCATION, + getDaylightWindow, + type SolarSelection, +} from "../core/solar-position"; +import { updateShadowDateState } from "../core/shadow-date-state"; +import { useMapCenterSolarLocation } from "../runtime/hooks/use-map-center-solar-location"; +import { + formatClockMinutes, + formatSolarSelectionDate, +} from "./format-shadow-selection"; +import { getRangeProgressStyle } from "./shadow-control-utils"; + +import "dayjs/locale/de"; + +export const ShadowSimulationHeaderControlsView = ({ + config, + libreMap, + state, + setState, + dateState, + setDateState, +}: { + config?: ShadowSimulationConfig; + libreMap: MaplibreMap | null; + state: ShadowSimulationState | undefined; + setState: ShadowSimulationStateSetter; + dateState: ShadowDateState | undefined; + setDateState: ShadowDateStateSetter; +}) => { + const { + latitude = DEFAULT_SHADOW_SIMULATION_LOCATION.latitude, + longitude = DEFAULT_SHADOW_SIMULATION_LOCATION.longitude, + } = config ?? {}; + const location = useMapCenterSolarLocation(libreMap, latitude, longitude); + const [datePickerOpen, setDatePickerOpen] = useState(false); + const selection = dateState; + const daylight = useMemo( + () => + selection + ? getDaylightWindow(selection, location) + : null, + [location, selection] + ); + const selectedDate = useMemo( + () => + selection + ? dayjs( + new Date(Date.UTC(selection.year, 0, selection.dayOfYear)) + ).locale("de") + : null, + [selection] + ); + if (!state || !selection || !daylight || !selectedDate) return null; + const minimumMinutes = Math.ceil(daylight.sunriseMinutes); + const maximumMinutes = Math.floor(daylight.sunsetMinutes); + + const publishSelection = (candidate: SolarSelection) => { + setDateState(updateShadowDateState(selection, candidate, location)); + }; + + return ( +
+
+ +
+ + trigger.parentElement ?? trigger} + onOpenChange={setDatePickerOpen} + onChange={(date) => { + if (!date) return; + publishSelection({ + ...selection, + year: date.year(), + dayOfYear: getDayOfYear(date.year(), date.month(), date.date()), + }); + setDatePickerOpen(false); + }} + className="pointer-events-none absolute left-0 top-full h-0 w-0 overflow-hidden p-0 opacity-0" + aria-label="Datum auswählen" + /> +
+ +
+ + + + publishSelection({ + ...selection, + minutes: Number(event.currentTarget.value), + }) + } + className="shadow-simulation-range min-w-[80px] flex-1 cursor-pointer" + style={getRangeProgressStyle( + selection.minutes, + minimumMinutes, + maximumMinutes + )} + aria-label="Uhrzeit" + data-test-id="shadow-simulation-ribbon-time" + /> + +
+ ); +}; diff --git a/libraries/mapping/shadow-simulation/src/lib/ui/ShadowSimulationQuickSettings.tsx b/libraries/mapping/shadow-simulation/src/lib/ui/ShadowSimulationQuickSettings.tsx new file mode 100644 index 0000000000..ff4be5ca6a --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/ui/ShadowSimulationQuickSettings.tsx @@ -0,0 +1,281 @@ +import { getDayOfYear } from "@carma-commons/utils"; + +import { + SHADOW_ANIMATION_MODE, + type ShadowAnimationMode, + type ShadowDateState, + type ShadowSimulationState, +} from "../contracts/shadow-simulation"; +import type { SolarLocation } from "../core/solar-position"; +import { + updateShadowCalendarDate, + updateShadowDateState, + updateShadowToCurrentDate, +} from "../core/shadow-date-state"; +import { resolveShadowQuality } from "../core/shadow-types"; +import { + formatHour, + getRangeProgressStyle, + QUICK_BUTTON_CLASS_NAME, + SEGMENT_BUTTON_CLASS_NAME, + SHADOW_QUALITY_LEVELS, +} from "./shadow-control-utils"; + +export const ShadowSimulationQuickSettings = ({ + location, + state, + setState, + dateState, + setDateState, +}: { + location: SolarLocation; + state: ShadowSimulationState; + setState: (state: ShadowSimulationState) => void; + dateState: ShadowDateState; + setDateState: (state: ShadowDateState) => void; +}) => { + const animationMode = state.animationMode ?? SHADOW_ANIMATION_MODE.DAY; + const animationSpeed = state.animationSpeed ?? 4; + const intensity = state.shadowIntensity ?? 1; + + const setCalendarDate = (month: number, day: number) => + setDateState( + updateShadowCalendarDate( + dateState, + dateState.year, + getDayOfYear(dateState.year, month, day), + location + ) + ); + + return ( +
+
+

+ Datum +

+
+ + + + +
+
+ +
+

+ Uhrzeit +

+
+ {[9, 12, 15, 18].map((hour) => ( + + ))} +
+
+ +
+

+ Animation +

+
+
+ {[ + [SHADOW_ANIMATION_MODE.DAY, "Tagesverlauf"], + [SHADOW_ANIMATION_MODE.YEAR, "Jahresverlauf"], + ].map(([mode, label]) => ( + + ))} +
+
+ {([1, 4, 12] as const).map((speed) => ( + + ))} +
+
+
+ +
+

+ Darstellung +

+
+ + + + +
+ Qualität +
+ {SHADOW_QUALITY_LEVELS.map(({ label, value }) => ( + + ))} +
+
+
+
+
+ ); +}; diff --git a/libraries/mapping/shadow-simulation/src/lib/ui/ShadowSimulationSecondaryPanel.tsx b/libraries/mapping/shadow-simulation/src/lib/ui/ShadowSimulationSecondaryPanel.tsx new file mode 100644 index 0000000000..9dcb6586ff --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/ui/ShadowSimulationSecondaryPanel.tsx @@ -0,0 +1,126 @@ +import { useMemo } from "react"; + +import { + faArrowRotateLeft, + faBug, + faSliders, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + SHADOW_CONTROL_STYLE, + type ShadowDateState, + type ShadowSimulationState, +} from "../contracts/shadow-simulation"; +import { + getSolarPosition, + type SolarLocation, +} from "../core/solar-position"; +import { resetShadowDateState } from "../core/shadow-date-state"; +import { resetShadowSimulationState } from "../core/shadow-state"; +import { ShadowSimulationCurveSettings } from "./ShadowSimulationCurveSettings"; +import { ShadowSimulationQuickSettings } from "./ShadowSimulationQuickSettings"; + +export const ShadowSimulationSecondaryPanel = ({ + location, + state, + setState, + dateState, + setDateState, +}: { + location: SolarLocation; + state: ShadowSimulationState; + setState: (state: ShadowSimulationState) => void; + dateState: ShadowDateState; + setDateState: (state: ShadowDateState) => void; +}) => { + const controlStyle = state.controlStyle ?? SHADOW_CONTROL_STYLE.QUICK; + const position = useMemo( + () => getSolarPosition(dateState, location), + [dateState, location] + ); + + return ( +
+
+ + Höhe {position.elevationDegrees.toFixed(0)}° · Azimut{" "} + {position.azimuthDegrees.toFixed(0)}° + +
+ {/* TODO(pre-merge): the Kurvenansicht and Debug controls ship + stealthed (visible on hover only) for development - remove + them or decide on their productized form before merging. */} + + + +
+
+ {controlStyle === SHADOW_CONTROL_STYLE.QUICK ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/libraries/mapping/shadow-simulation/src/lib/ui/ShadowSimulationView.tsx b/libraries/mapping/shadow-simulation/src/lib/ui/ShadowSimulationView.tsx new file mode 100644 index 0000000000..d0c4a5f8d9 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/ui/ShadowSimulationView.tsx @@ -0,0 +1,194 @@ +import { useEffect, useMemo } from "react"; +import type { Map as MaplibreMap } from "maplibre-gl"; + +import "./shadow-simulation.css"; + +import { faSun } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Tooltip } from "antd"; + +import { clamp } from "@carma-commons/math"; +import { + Control, + ControlButtonStyler, +} from "@carma-mapping/map-controls-layout"; +import { + type ShadowDateState, + type ShadowDateStateSetter, + type ShadowSimulationConfig, + type ShadowSimulationState, + type ShadowSimulationStateSetter, +} from "../contracts/shadow-simulation"; +import { + createInitialShadowDateState, + createInitialShadowSimulationState, +} from "../core/create-shadow-simulation-state"; +import { + DEFAULT_SHADOW_SIMULATION_LOCATION, + DEFAULT_SHADOW_SIMULATION_TIME_ZONE, + getSolarPosition, +} from "../core/solar-position"; +import { + DEFAULT_MESH_ERROR_TARGET_PIXELS, + DEFAULT_SHADOW_BUILDING_COLOR, + DEFAULT_SHADOW_BUILDING_COLOR_MIX, + DEFAULT_SHADOW_BUILDING_TEXTURE_SATURATION, + DEFAULT_SHADOW_SURFACE_COLOR, + resolveShadowQuality, +} from "../core/shadow-types"; +import { ShadowSimulationRuntime } from "../runtime/ShadowSimulationRuntime"; +import { useMapCenterSolarLocation } from "../runtime/hooks/use-map-center-solar-location"; +import { useShadowAnimation } from "../runtime/hooks/use-shadow-animation"; +import { ShadowProjectionDebugView } from "./ShadowProjectionDebugView"; +import { ShadowSimulationSecondaryPanel } from "./ShadowSimulationSecondaryPanel"; + +const ACTIVE_CONTROL_COLOR = "#1677ff"; +export const ShadowSimulationView = ({ + config, + libreMap, + targeted, + sharedState, + setSharedState, + sharedDateState, + setSharedDateState, +}: { + config?: ShadowSimulationConfig; + libreMap: MaplibreMap | null; + targeted: boolean; + sharedState: ShadowSimulationState | undefined; + setSharedState: ShadowSimulationStateSetter; + sharedDateState: ShadowDateState | undefined; + setSharedDateState: ShadowDateStateSetter; +}) => { + const { + year, + initialDayOfYear, + initialMinutes, + latitude = DEFAULT_SHADOW_SIMULATION_LOCATION.latitude, + longitude = DEFAULT_SHADOW_SIMULATION_LOCATION.longitude, + timeZone = DEFAULT_SHADOW_SIMULATION_TIME_ZONE, + shadowAreaMeters, + terrain, + controlPosition = "topleft", + controlOrder = 70, + } = config ?? {}; + const location = useMapCenterSolarLocation(libreMap, latitude, longitude); + const initialState = useMemo( + () => createInitialShadowSimulationState({ terrain }), + [terrain] + ); + const initialDateState = useMemo( + () => + createInitialShadowDateState( + { year, initialDayOfYear, initialMinutes, timeZone }, + location + ), + [initialDayOfYear, initialMinutes, location, timeZone, year] + ); + const state = sharedState ?? initialState; + const dateState = sharedDateState ?? initialDateState; + useEffect(() => { + if (!sharedState) setSharedState(initialState); + }, [initialState, setSharedState, sharedState]); + useEffect(() => { + if (!sharedDateState) setSharedDateState(initialDateState); + }, [initialDateState, setSharedDateState, sharedDateState]); + + useShadowAnimation({ + initialDateState, + setDateState: setSharedDateState, + location, + shadowState: state, + }); + + if (targeted) { + return ( + + ); + } + + return ( + <> + {libreMap && ( + + + + setSharedState({ + ...state, + enabled: !state.enabled, + }) + } + dataTestId="shadow-simulation-control-button" + aria-label={ + state.enabled + ? "Schattensimulation ausschalten" + : "Schattensimulation einschalten" + } + aria-pressed={state.enabled} + > + + + + + )} + + {state.showProjectionDebugView && libreMap && ( + setSharedState({ ...state, ...patch })} + /> + )} + + ); +}; diff --git a/libraries/mapping/shadow-simulation/src/lib/ui/SolarDayTimeControl.tsx b/libraries/mapping/shadow-simulation/src/lib/ui/SolarDayTimeControl.tsx new file mode 100644 index 0000000000..e714fefe58 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/ui/SolarDayTimeControl.tsx @@ -0,0 +1,316 @@ +import { useId, useMemo, useRef } from "react"; +import type { KeyboardEvent, PointerEvent } from "react"; + +import { getDayOfYear, getDaysInYear } from "@carma-commons/utils"; + +import { + clampSelectionToDaylight, + getDaylightWindow, + type SolarLocation, + type SolarPosition, + type SolarSelection, +} from "../core/solar-position"; +import { formatClockMinutes } from "./format-shadow-selection"; + +const SVG_WIDTH = 336; +const SVG_HEIGHT = 112; +const PLOT_LEFT = 36; +const PLOT_RIGHT = 10; +const PLOT_TOP = 4; +const PLOT_BOTTOM = 18; +const PLOT_WIDTH = SVG_WIDTH - PLOT_LEFT - PLOT_RIGHT; +const PLOT_HEIGHT = SVG_HEIGHT - PLOT_TOP - PLOT_BOTTOM; +const MINUTES_PER_DAY = 24 * 60; + +export type SolarDayTimeControlProps = { + expanded?: boolean; + location: SolarLocation; + selection: SolarSelection; + position: SolarPosition; + onChange: (selection: SolarSelection) => void; +}; + +const formatDay = (year: number, dayOfYear: number) => + new Intl.DateTimeFormat("de-DE", { + day: "2-digit", + month: "short", + timeZone: "UTC", + }).format(new Date(Date.UTC(year, 0, dayOfYear))); + +const getMonthTicks = (year: number) => + Array.from({ length: 12 }, (_, month) => { + const date = new Date(Date.UTC(year, month, 1)); + return { + dayOfYear: getDayOfYear(year, month, 1), + label: new Intl.DateTimeFormat("de-DE", { + month: "short", + timeZone: "UTC", + }).format(date), + }; + }); + +export const SolarDayTimeControl = ({ + expanded = false, + location, + selection, + position, + onChange, +}: SolarDayTimeControlProps) => { + const dragging = useRef(false); + const clipId = useId().replaceAll(":", ""); + const dayCount = getDaysInYear(selection.year); + const monthTicks = useMemo( + () => getMonthTicks(selection.year), + [selection.year] + ); + const daylight = useMemo( + () => + Array.from({ length: dayCount }, (_, index) => + getDaylightWindow( + { ...selection, dayOfYear: index + 1 }, + location + ) + ), + [dayCount, location, selection] + ); + + const toX = (dayOfYear: number) => + PLOT_LEFT + ((dayOfYear - 1) / Math.max(1, dayCount - 1)) * PLOT_WIDTH; + const toY = (minutes: number) => + PLOT_TOP + (1 - minutes / MINUTES_PER_DAY) * PLOT_HEIGHT; + + const sunrisePath = daylight + .map( + (window, index) => + `${index === 0 ? "M" : "L"}${toX(index + 1).toFixed(2)},${toY( + window.sunriseMinutes + ).toFixed(2)}` + ) + .join(" "); + const sunsetPath = daylight + .map( + (window, index) => + `${index === 0 ? "M" : "L"}${toX(index + 1).toFixed(2)},${toY( + window.sunsetMinutes + ).toFixed(2)}` + ) + .join(" "); + const daylightAreaPath = [ + ...daylight.map( + (window, index) => + `${index === 0 ? "M" : "L"}${toX(index + 1).toFixed(2)},${toY( + window.sunriseMinutes + ).toFixed(2)}` + ), + ...daylight + .map((window, index) => ({ window, day: index + 1 })) + .reverse() + .map( + ({ window, day }) => + `L${toX(day).toFixed(2)},${toY(window.sunsetMinutes).toFixed(2)}` + ), + "Z", + ].join(" "); + + const publishCandidate = (dayOfYear: number, minutes: number) => { + const next = clampSelectionToDaylight( + { ...selection, dayOfYear, minutes }, + location + ); + if (next) onChange(next); + }; + + const updateFromPointer = (event: PointerEvent) => { + const bounds = event.currentTarget.getBoundingClientRect(); + const scaleX = SVG_WIDTH / bounds.width; + const scaleY = SVG_HEIGHT / bounds.height; + const x = (event.clientX - bounds.left) * scaleX; + const y = (event.clientY - bounds.top) * scaleY; + const dayOfYear = Math.round( + 1 + ((x - PLOT_LEFT) / PLOT_WIDTH) * (dayCount - 1) + ); + const minutes = (1 - (y - PLOT_TOP) / PLOT_HEIGHT) * MINUTES_PER_DAY; + publishCandidate(dayOfYear, minutes); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + const dayStep = event.shiftKey ? 7 : 1; + const minuteStep = event.shiftKey ? 60 : 10; + let nextDay = selection.dayOfYear; + let nextMinutes = selection.minutes; + switch (event.key) { + case "ArrowLeft": + nextDay -= dayStep; + break; + case "ArrowRight": + nextDay += dayStep; + break; + case "ArrowDown": + nextMinutes -= minuteStep; + break; + case "ArrowUp": + nextMinutes += minuteStep; + break; + case "Home": + nextDay = 1; + break; + case "End": + nextDay = dayCount; + break; + default: + return; + } + event.preventDefault(); + publishCandidate(nextDay, nextMinutes); + }; + + const activeX = toX(selection.dayOfYear); + const activeY = toY(selection.minutes); + const activeDaylight = daylight[selection.dayOfYear - 1]; + + return ( +
+
+ { + event.stopPropagation(); + dragging.current = true; + event.currentTarget.setPointerCapture(event.pointerId); + updateFromPointer(event); + }} + onPointerMove={(event) => { + if (!dragging.current) return; + event.stopPropagation(); + updateFromPointer(event); + }} + onPointerUp={(event) => { + event.stopPropagation(); + dragging.current = false; + event.currentTarget.releasePointerCapture(event.pointerId); + }} + onPointerCancel={() => { + dragging.current = false; + }} + > + + + + + + + + + {[0, 6, 12, 18, 24].map((hour) => { + const y = toY(hour * 60); + return ( + + + + {String(hour).padStart(2, "0")} + + + ); + })} + + {monthTicks.map(({ dayOfYear, label }) => { + const x = toX(dayOfYear); + return ( + + + + {label} + + + ); + })} + + + + + + + + + +
+
+ ); +}; diff --git a/libraries/mapping/shadow-simulation/src/lib/ui/format-shadow-selection.ts b/libraries/mapping/shadow-simulation/src/lib/ui/format-shadow-selection.ts new file mode 100644 index 0000000000..845af01ee1 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/ui/format-shadow-selection.ts @@ -0,0 +1,30 @@ +import type { SolarSelection } from "../core/solar-position"; + +export const formatClockMinutes = (minutes: number): string => { + const roundedMinutes = Math.round(minutes); + const hours = String(Math.floor(roundedMinutes / 60)).padStart(2, "0"); + const minutePart = String(roundedMinutes % 60).padStart(2, "0"); + return `${hours}:${minutePart}`; +}; + +export const formatSolarSelectionDate = ( + selection: Pick, + includeYear = true +): string => + new Intl.DateTimeFormat("de-DE", { + day: "numeric", + month: "long", + ...(includeYear ? { year: "numeric" } : {}), + timeZone: "UTC", + }).format(new Date(Date.UTC(selection.year, 0, selection.dayOfYear))); + +export const formatShadowSelection = ( + selection: Pick +): string => { + const date = new Intl.DateTimeFormat("de-DE", { + day: "numeric", + month: "short", + timeZone: "UTC", + }).format(new Date(Date.UTC(selection.year, 0, selection.dayOfYear))); + return `${date} · ${formatClockMinutes(selection.minutes)}`; +}; diff --git a/libraries/mapping/shadow-simulation/src/lib/ui/shadow-control-utils.ts b/libraries/mapping/shadow-simulation/src/lib/ui/shadow-control-utils.ts new file mode 100644 index 0000000000..d69f2fd423 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/ui/shadow-control-utils.ts @@ -0,0 +1,37 @@ +import type { CSSProperties } from "react"; + +import type { ShadowQualityMultiplier } from "../core/shadow-types"; + +export const QUICK_BUTTON_CLASS_NAME = + "flex h-9 min-w-0 items-center justify-center whitespace-nowrap rounded-md border border-neutral-300 bg-white px-1 text-center text-sm text-neutral-800 transition-colors hover:border-amber-500 hover:text-amber-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/40"; + +export const SEGMENT_BUTTON_CLASS_NAME = + "h-8 whitespace-nowrap border-r border-neutral-300 px-3 text-sm text-neutral-700 transition-colors last:border-r-0 hover:text-amber-700"; + +export const SHADOW_QUALITY_LEVELS: ReadonlyArray<{ + label: string; + value: ShadowQualityMultiplier; +}> = [ + { label: "Mittel", value: 4 }, + { label: "Hoch", value: 16 }, + { label: "Max", value: 64 }, +]; + +export const formatHour = (hour: number): string => + `${String(hour).padStart(2, "0")}:00`; + +export const getRangeProgressStyle = ( + value: number, + minimum: number, + maximum: number +): CSSProperties => + ({ + "--shadow-range-progress": `${ + maximum > minimum + ? Math.max( + 0, + Math.min(100, ((value - minimum) / (maximum - minimum)) * 100) + ) + : 0 + }%`, + } as CSSProperties); diff --git a/libraries/mapping/shadow-simulation/src/lib/ui/shadow-simulation.css b/libraries/mapping/shadow-simulation/src/lib/ui/shadow-simulation.css new file mode 100644 index 0000000000..1dc9897998 --- /dev/null +++ b/libraries/mapping/shadow-simulation/src/lib/ui/shadow-simulation.css @@ -0,0 +1,72 @@ +.shadow-simulation-range { + --shadow-range-progress: 0%; + + height: 18px; + appearance: none; + background: transparent; +} + +.shadow-simulation-range::-webkit-slider-runnable-track { + height: 4px; + border-radius: 9999px; + background: linear-gradient( + to right, + #d97706 0 var(--shadow-range-progress), + #e5e7eb var(--shadow-range-progress) 100% + ); +} + +.shadow-simulation-range::-webkit-slider-thumb { + width: 18px; + height: 18px; + margin-top: -7px; + appearance: none; + border: 2px solid #d97706; + border-radius: 9999px; + background: #ffffff; + box-shadow: 0 1px 3px rgb(0 0 0 / 20%); +} + +.shadow-simulation-range::-moz-range-track { + height: 4px; + border-radius: 9999px; + background: linear-gradient( + to right, + #d97706 0 var(--shadow-range-progress), + #e5e7eb var(--shadow-range-progress) 100% + ); +} + +.shadow-simulation-range::-moz-range-thumb { + width: 14px; + height: 14px; + border: 2px solid #d97706; + border-radius: 9999px; + background: #ffffff; + box-shadow: 0 1px 3px rgb(0 0 0 / 20%); +} + +.shadow-simulation-range:focus-visible { + outline: 2px solid #f59e0b; + outline-offset: 3px; +} + +/* The native time input inherits the ribbon's type instead of the browser's + control styling; its picker indicator gives way to the Font Awesome clock + in front of it. */ +.shadow-simulation-time-input { + font: inherit; + font-variant-numeric: tabular-nums; + -webkit-appearance: none; + appearance: none; +} +.shadow-simulation-time-input::-webkit-date-and-time-value { + margin: 0; + text-align: left; +} +.shadow-simulation-time-input::-webkit-calendar-picker-indicator { + display: none; +} +.shadow-simulation-time-input::-webkit-datetime-edit { + padding: 0; +} diff --git a/libraries/mapping/shadow-simulation/tsconfig.json b/libraries/mapping/shadow-simulation/tsconfig.json new file mode 100644 index 0000000000..cf9a157060 --- /dev/null +++ b/libraries/mapping/shadow-simulation/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { "path": "./tsconfig.lib.json" }, + { "path": "./tsconfig.spec.json" } + ] +} diff --git a/libraries/mapping/shadow-simulation/tsconfig.lib.json b/libraries/mapping/shadow-simulation/tsconfig.lib.json new file mode 100644 index 0000000000..5548db05b7 --- /dev/null +++ b/libraries/mapping/shadow-simulation/tsconfig.lib.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node", "vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"], + "exclude": [ + "vite.config.ts", + "src/**/*.spec.ts", + "src/**/*.spec.tsx", + "src/**/*.test.ts", + "src/**/*.test.tsx" + ] +} diff --git a/libraries/mapping/shadow-simulation/tsconfig.spec.json b/libraries/mapping/shadow-simulation/tsconfig.spec.json new file mode 100644 index 0000000000..cd60ce3efa --- /dev/null +++ b/libraries/mapping/shadow-simulation/tsconfig.spec.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": ["vitest/globals", "vitest/importMeta", "vite/client", "node"] + }, + "include": [ + "vite.config.ts", + "vitest.setup.ts", + "src/**/*.spec.ts", + "src/**/*.spec.tsx" + ] +} diff --git a/libraries/mapping/shadow-simulation/vite.config.ts b/libraries/mapping/shadow-simulation/vite.config.ts new file mode 100644 index 0000000000..872aaccdad --- /dev/null +++ b/libraries/mapping/shadow-simulation/vite.config.ts @@ -0,0 +1,23 @@ +/// + +import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + root: __dirname, + cacheDir: "../../../node_modules/.vite/libraries/mapping/shadow-simulation", + plugins: [react(), nxViteTsPaths()], + test: { + watch: false, + globals: true, + environment: "jsdom", + setupFiles: ["./vitest.setup.ts"], + include: ["src/**/*.spec.{ts,tsx}"], + reporters: ["default"], + coverage: { + reportsDirectory: "../../../coverage/libraries/mapping/shadow-simulation", + provider: "v8", + }, + }, +}); diff --git a/libraries/mapping/shadow-simulation/vitest.setup.ts b/libraries/mapping/shadow-simulation/vitest.setup.ts new file mode 100644 index 0000000000..b798e35dc6 --- /dev/null +++ b/libraries/mapping/shadow-simulation/vitest.setup.ts @@ -0,0 +1,4 @@ +if (typeof window !== "undefined") { + window.URL.createObjectURL ??= () => ""; + window.URL.revokeObjectURL ??= () => undefined; +} diff --git a/package-lock.json b/package-lock.json index 3e58fdf670..c9b5e9598f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,22 +27,27 @@ "@fortawesome/free-solid-svg-icons": "^6.6.0", "@fortawesome/react-fontawesome": "^0.2.2", "@geomatico/maplibre-cog-protocol": "^0.8.0", + "@js-temporal/polyfill": "^0.5.1", "@maplibre/maplibre-gl-style-spec": "^24.4.1", "@motis-project/motis-client": "^2.7.6", "@react-hook/window-size": "^3.1.1", "@react-pdf/renderer": "^3.4.4", + "@react-three/fiber": "8.17.10", "@reduxjs/toolkit": "^2.2.7", "@rehooks/component-size": "^1.0.3", "@rehooks/online-status": "^1.1.2", "@sindresorhus/slugify": "^2.2.1", + "@takram/three-atmosphere": "0.10.3", + "@takram/three-geospatial": "0.1.0", "@tanstack/query-async-storage-persister": "^5.101.2", "@tanstack/react-query": "^5.51.15", "@tanstack/react-query-persist-client": "^5.101.2", "@turf/turf": "^7.0.0", "@uidotdev/usehooks": "^2.4.1", "@uiw/react-codemirror": "^4.23.0", - "3d-tiles-renderer": "^0.4.28", + "3d-tiles-renderer": "^0.5.2", "antd": "^5.19.4", + "astronomy-engine": "2.1.19", "autoprefixer": "^10.4.19", "axios": "^1.6.0", "cesium": "^1.134.1", @@ -77,6 +82,7 @@ "match-sorter": "^6.3.4", "md5": "^2.3.0", "postcss": "8.4.40", + "postprocessing": "6.39.4", "prismjs": "^1.29.0", "proj4": "^2.19.4", "rbush": "^4.0.1", @@ -6408,21 +6414,6 @@ "node": ">=8" } }, - "node_modules/@cismet-dev/react-cismap-envirometrics-maps/node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, "node_modules/@cismet-dev/react-cismap-envirometrics-maps/node_modules/v8-to-istanbul": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", @@ -9655,6 +9646,18 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-temporal/polyfill": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz", + "integrity": "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==", + "license": "ISC", + "dependencies": { + "jsbi": "^4.3.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@jsonjoy.com/base64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", @@ -13283,7 +13286,6 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -13323,7 +13325,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13344,7 +13345,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13365,7 +13365,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13386,7 +13385,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13407,7 +13405,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13428,7 +13425,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13449,7 +13445,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13470,7 +13465,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13491,7 +13485,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13512,7 +13505,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13533,7 +13525,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13554,7 +13545,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13575,7 +13565,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13593,7 +13582,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, "license": "MIT", "optional": true }, @@ -14684,6 +14672,89 @@ "integrity": "sha512-XsVRkt0hQ60I4e3leAVt+aZR3KJCaJd179BfJHAv4F4x6Vq3yqkry8lcbUWKGKDw1j3/8sW4FsgGR41SFvsG9A==", "license": "MIT" }, + "node_modules/@react-three/fiber": { + "version": "8.17.10", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.17.10.tgz", + "integrity": "sha512-S6bqa4DqUooEkInYv/W+Jklv2zjSYCXAhm6qKpAQyOXhTEt5gBXnA7W6aoJ0bjmp9pAeaSj/AZUoz1HCSof/uA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/debounce": "^1.2.1", + "@types/react-reconciler": "^0.26.7", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "debounce": "^1.2.1", + "its-fine": "^1.0.6", + "react-reconciler": "^0.27.0", + "scheduler": "^0.21.0", + "suspend-react": "^0.1.3", + "zustand": "^3.7.1" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=18.0", + "react-dom": ">=18.0", + "react-native": ">=0.64", + "three": ">=0.133" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/@react-three/fiber/node_modules/scheduler": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/@reactflow/background": { "version": "11.3.14", "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz", @@ -14699,18 +14770,6 @@ "react-dom": ">=17" } }, - "node_modules/@reactflow/background/node_modules/immer": { - "version": "11.1.18", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", - "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/@reactflow/background/node_modules/use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", @@ -14763,18 +14822,6 @@ "react-dom": ">=17" } }, - "node_modules/@reactflow/controls/node_modules/immer": { - "version": "11.1.18", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", - "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/@reactflow/controls/node_modules/use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", @@ -14890,18 +14937,6 @@ "node": ">=12" } }, - "node_modules/@reactflow/core/node_modules/immer": { - "version": "11.1.18", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", - "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/@reactflow/core/node_modules/use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", @@ -15015,18 +15050,6 @@ "node": ">=12" } }, - "node_modules/@reactflow/minimap/node_modules/immer": { - "version": "11.1.18", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", - "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/@reactflow/minimap/node_modules/use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", @@ -15103,18 +15126,6 @@ "node": ">=12" } }, - "node_modules/@reactflow/node-resizer/node_modules/immer": { - "version": "11.1.18", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", - "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/@reactflow/node-resizer/node_modules/use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", @@ -15167,18 +15178,6 @@ "react-dom": ">=17" } }, - "node_modules/@reactflow/node-toolbar/node_modules/immer": { - "version": "11.1.18", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", - "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/@reactflow/node-toolbar/node_modules/use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", @@ -15783,7 +15782,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15797,7 +15795,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15811,7 +15808,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15825,7 +15821,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15839,7 +15834,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15853,7 +15847,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15867,7 +15860,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15881,7 +15873,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15895,7 +15886,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -17725,7 +17715,7 @@ "version": "1.10.12", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.10.12.tgz", "integrity": "sha512-+iUL0PYpPm6N9AdV1wvafakvCqFegQus1aoEDxgFsv3/uNVNIyRaupf/v/Zkp5hbep2EzhtoJR0aiJIzDbXWHg==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -17767,7 +17757,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -17784,7 +17773,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -17801,7 +17789,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -17818,7 +17805,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -17835,7 +17821,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -17852,7 +17837,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -17869,7 +17853,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -17886,7 +17869,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -17903,7 +17885,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -17920,7 +17901,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -17934,14 +17914,14 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.8.0" @@ -17969,7 +17949,7 @@ "version": "0.1.17", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.17.tgz", "integrity": "sha512-V5gRru+aD8YVyCOMAjMpWR1Ui577DD5KSJsHP8RAxopAH22jFz6GZd/qxqjO6MJHQhcsjvjOFXyDhyLQUnMveQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3" @@ -17988,6 +17968,81 @@ "node": ">=14.16" } }, + "node_modules/@takram/three-atmosphere": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@takram/three-atmosphere/-/three-atmosphere-0.10.3.tgz", + "integrity": "sha512-j1MuQvhdYsDef56yhNybAEopsb8K1Vn2e+NIafBlflEbBlFyCN5V1K8fNYVm48CPcERT5JGKbVW5RHxI0FAPhQ==", + "license": "MIT", + "dependencies": { + "@takram/three-geospatial": "0.1.0", + "astronomy-engine": "^2.1.19", + "react-merge-refs": "^2.1.1", + "three-stdlib": "2.35.14", + "tiny-invariant": "^1.3.3", + "url-join": "^5.0.0" + }, + "peerDependencies": { + "@react-three/drei": ">=9.117.3", + "@react-three/fiber": ">=8.17.10", + "@react-three/postprocessing": ">=2.16.3", + "postprocessing": ">=6.36.4", + "react": ">=18.0", + "three": ">=0.170.0" + }, + "peerDependenciesMeta": { + "@react-three/drei": { + "optional": true + }, + "@react-three/fiber": { + "optional": true + }, + "@react-three/postprocessing": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@takram/three-atmosphere/node_modules/url-join": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", + "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/@takram/three-geospatial": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@takram/three-geospatial/-/three-geospatial-0.1.0.tgz", + "integrity": "sha512-u8eS2DddXX7b+scSe0tXwvI20AEF7lTXeVcq7csfHHhzhbInAEdbKhdBP1p9B6VlsGGMc2F1mZ0rc8ILau5FRg==", + "license": "MIT", + "dependencies": { + "@petamoriken/float16": "^3.9.1", + "react-merge-refs": "^2.1.1", + "three-stdlib": "2.35.14", + "tiny-invariant": "^1.3.3", + "type-fest": "^4.33.0" + }, + "peerDependencies": { + "@react-three/fiber": ">=8.17.10", + "react": ">=18.0", + "three": ">=0.170.0" + } + }, + "node_modules/@takram/three-geospatial/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@tanstack/query-async-storage-persister": { "version": "5.101.2", "resolved": "https://registry.npmjs.org/@tanstack/query-async-storage-persister/-/query-async-storage-persister-5.101.2.tgz", @@ -27099,6 +27154,18 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/debounce": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/debounce/-/debounce-1.2.4.tgz", + "integrity": "sha512-jBqiORIzKDOToaF63Fm//haOCHuwQuLa2202RK4MozpA6lh93eCBc+/8+wZn5OzjJt3ySdc+74SXWXB55Ewtyw==", + "license": "MIT" + }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "8.56.10", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.10.tgz", @@ -27361,6 +27428,12 @@ "@types/node": "*" } }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -27425,6 +27498,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-reconciler": { + "version": "0.26.7", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.26.7.tgz", + "integrity": "sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-transition-group": { "version": "4.4.10", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", @@ -27639,7 +27721,6 @@ "version": "0.5.24", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", - "dev": true, "license": "MIT" }, "node_modules/@types/whatwg-url": { @@ -28864,9 +28945,9 @@ "license": "Python-2.0" }, "node_modules/3d-tiles-renderer": { - "version": "0.4.28", - "resolved": "https://registry.npmjs.org/3d-tiles-renderer/-/3d-tiles-renderer-0.4.28.tgz", - "integrity": "sha512-eDFg3fTgnFeoa6kU9oyAQIci/N/CJq6JUi/ZUrmDyqLzEDH9mWd9FBs+NqS+0eO8b7y4HjIWfUVnf8o03AFtSA==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/3d-tiles-renderer/-/3d-tiles-renderer-0.5.2.tgz", + "integrity": "sha512-mpl6Gqvg6ZjkxZW/TQZ9SW91gjWRRDuscuU3D2WWzw1ETFct8ijVdnXJs400sDkkIXpcmrEoHlpN+r9NdROd+w==", "license": "Apache-2.0", "dependencies": { "@mapbox/vector-tile": "^2.0.3", @@ -29660,6 +29741,12 @@ "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", "license": "MIT" }, + "node_modules/astronomy-engine": { + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/astronomy-engine/-/astronomy-engine-2.1.19.tgz", + "integrity": "sha512-8yWKNf7UeNbH458h3sAJ6ZgAjE5jTXp/mNNRFoC20j2SHwZIjAQeEsBB2Q3uCFRaTCCJRv33K2XhkhZQMXoX6w==", + "license": "MIT" + }, "node_modules/async": { "version": "3.2.5", "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", @@ -34877,6 +34964,12 @@ "dev": true, "license": "MIT" }, + "node_modules/debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -35284,7 +35377,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", - "dev": true, "license": "Apache-2.0", "optional": true, "bin": { @@ -41923,6 +42015,27 @@ "node": ">= 0.4" } }, + "node_modules/its-fine": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", + "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.0" + }, + "peerDependencies": { + "react": ">=18.0" + } + }, + "node_modules/its-fine/node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -44561,6 +44674,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.2.tgz", + "integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==", + "license": "Apache-2.0" + }, "node_modules/jsdoc-type-pratt-parser": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz", @@ -50406,6 +50525,15 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/postprocessing": { + "version": "6.39.4", + "resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.4.tgz", + "integrity": "sha512-oAS/PjAbc/xT3OzjUrGsorJ4J064XwhVD2t0OwKLP/E8QwDMUJ0oOv6ZI1SYMtLchv4g0ySEx+JcLy92+48vlA==", + "license": "Zlib", + "peerDependencies": { + "three": ">= 0.168.0 < 0.186.0" + } + }, "node_modules/potpack": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", @@ -52860,6 +52988,16 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-merge-refs": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/react-merge-refs/-/react-merge-refs-2.1.1.tgz", + "integrity": "sha512-jLQXJ/URln51zskhgppGJ2ub7b2WFKGq3cl3NYKtlHoTG+dN2q7EzWrn3hN3EgPsTMvpR9tpq5ijdp7YwFZkag==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, "node_modules/react-modal": { "version": "3.16.1", "resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.16.1.tgz", @@ -52949,6 +53087,31 @@ "integrity": "sha512-kzmNjIgU32mO4mmH5+iUyrqlpFQhF8K2k7eZ4fdLSOPFrD1XgEuSBv9LDEgxRXTMBqMd8ppT0x6TIzqE5pdGdw==", "license": "MIT" }, + "node_modules/react-reconciler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.27.0.tgz", + "integrity": "sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.21.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, + "node_modules/react-reconciler/node_modules/scheduler": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/react-redux": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.1.2.tgz", @@ -56622,6 +56785,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, "node_modules/svg-arc-to-cubic-bezier": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz", @@ -57191,6 +57363,35 @@ "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", "license": "MIT" }, + "node_modules/three-stdlib": { + "version": "2.35.14", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.35.14.tgz", + "integrity": "sha512-kpCaEg59M9usFTgHC+YZNKvx7nMoLI2zQxZBV8pjoNW6vNZmGyXpaLBL09A2oLCsS3KepgMFkOuk6lRoebTNvA==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.11.tgz", + "integrity": "sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==", + "license": "MIT" + }, + "node_modules/three-stdlib/node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, "node_modules/throat": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/throat/-/throat-6.0.2.tgz", diff --git a/package.json b/package.json index a4a693d8b3..eaab919b9a 100644 --- a/package.json +++ b/package.json @@ -28,22 +28,27 @@ "@fortawesome/free-solid-svg-icons": "^6.6.0", "@fortawesome/react-fontawesome": "^0.2.2", "@geomatico/maplibre-cog-protocol": "^0.8.0", + "@js-temporal/polyfill": "^0.5.1", "@maplibre/maplibre-gl-style-spec": "^24.4.1", "@motis-project/motis-client": "^2.7.6", "@react-hook/window-size": "^3.1.1", "@react-pdf/renderer": "^3.4.4", + "@react-three/fiber": "8.17.10", "@reduxjs/toolkit": "^2.2.7", "@rehooks/component-size": "^1.0.3", "@rehooks/online-status": "^1.1.2", "@sindresorhus/slugify": "^2.2.1", + "@takram/three-atmosphere": "0.10.3", + "@takram/three-geospatial": "0.1.0", "@tanstack/query-async-storage-persister": "^5.101.2", "@tanstack/react-query": "^5.51.15", "@tanstack/react-query-persist-client": "^5.101.2", "@turf/turf": "^7.0.0", "@uidotdev/usehooks": "^2.4.1", "@uiw/react-codemirror": "^4.23.0", - "3d-tiles-renderer": "^0.4.28", + "3d-tiles-renderer": "^0.5.2", "antd": "^5.19.4", + "astronomy-engine": "2.1.19", "autoprefixer": "^10.4.19", "axios": "^1.6.0", "cesium": "^1.134.1", @@ -78,6 +83,7 @@ "match-sorter": "^6.3.4", "md5": "^2.3.0", "postcss": "8.4.40", + "postprocessing": "6.39.4", "prismjs": "^1.29.0", "proj4": "^2.19.4", "rbush": "^4.0.1", diff --git a/playgrounds/ng-topicmap-playground/src/app/pointcloud/PointCloudPlayground.tsx b/playgrounds/ng-topicmap-playground/src/app/pointcloud/PointCloudPlayground.tsx index 61790d1a27..12765a2beb 100644 --- a/playgrounds/ng-topicmap-playground/src/app/pointcloud/PointCloudPlayground.tsx +++ b/playgrounds/ng-topicmap-playground/src/app/pointcloud/PointCloudPlayground.tsx @@ -13,17 +13,35 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import Icon from "react-cismap/commons/Icon"; -import { Button, Checkbox, InputNumber, Radio, Select, Slider, Switch, Tabs } from "antd"; +import { + Button, + Checkbox, + InputNumber, + Radio, + Select, + Slider, + Switch, + Tabs, +} from "antd"; import { MercatorCoordinate } from "maplibre-gl"; import type { Map as MaplibreMap } from "maplibre-gl"; import { CarmaMap } from "@carma-mapping/core"; import { useHashState } from "@carma-providers/hash-state"; import { + buildSharedThreeSceneLayer, + buildThreeTilesRuntime, slugifyUrl, + TILES_ERROR_TARGET_DEFAULT_PIXELS, + TILES_ERROR_TARGET_MAX_PIXELS, + TILES_ERROR_TARGET_MIN_PIXELS, useLibreContext, WUPPERTAL_CONFIG, } from "@carma-mapping/engines/maplibre"; +import type { + ImageProjector, + ThreeTilesRuntime, +} from "@carma-mapping/engines/maplibre"; import { WUPP_LOD2_TILESET, @@ -46,13 +64,6 @@ import { utmToScene, } from "./orientedImagery"; import type { OrientedImageryLayer } from "./orientedImagery"; -import { - buildTiles3dLayer, - TILES_ERROR_TARGET_DEFAULT_PIXELS, - TILES_ERROR_TARGET_MAX_PIXELS, - TILES_ERROR_TARGET_MIN_PIXELS, -} from "./tiles3dLayer"; -import type { ImageProjector, Tiles3dLayer } from "./tiles3dLayer"; import * as THREE from "three"; import { buildCloudFieldInfos, openCopcPointSource } from "./copcLoader"; import type { @@ -108,7 +119,6 @@ import { parsePointCloudMicroCorrections, } from "./pointCloudMicroCorrections"; import type { PointCloudMicroCorrection } from "./pointCloudMicroCorrections"; -import { buildPointcloudSceneLayer } from "./pointcloudSceneLayer"; import { mergePersistedSettings, readPointcloudViewState, @@ -291,7 +301,8 @@ for (const feature of POINT_CLOUD_PRESET_FEATURE_COLLECTION.features) { continue; } const base = CLOUD_ASSETS.find( - (asset) => asset.artifactFileName && pointcloud.fields === asset.fieldDimensions + (asset) => + asset.artifactFileName && pointcloud.fields === asset.fieldDimensions ); CLOUD_ASSETS.push({ ...(base ?? CLOUD_ASSETS[0]), @@ -393,7 +404,7 @@ interface MeshSettings { enabled: boolean; zOffset: number; white: boolean; - clayColor: string; + clayColor: string; errorTarget: number; opacity: number; wireframe: boolean; @@ -468,7 +479,7 @@ const defaultMeshSettings = (definition?: MeshAssetDef): MeshSettings => ({ enabled: false, zOffset: 0, white: definition?.defaultClay ?? false, - clayColor: "#d6d2ca", + clayColor: "#d6d2ca", errorTarget: TILES_ERROR_TARGET_DEFAULT_PIXELS, opacity: 1, wireframe: false, @@ -805,10 +816,10 @@ const SceneManager = memo(function SceneManager({ }) { const { map } = useLibreContext(); const cloudSlotsRef = useRef>(new Map()); - const meshLayersRef = useRef>(new Map()); + const meshLayersRef = useRef>(new Map()); const terrainActiveRef = useRef(false); const sharedSceneLayer = useMemo( - () => (map ? buildPointcloudSceneLayer("pointcloud-three-scene") : null), + () => (map ? buildSharedThreeSceneLayer("pointcloud-three-scene") : null), [map] ); const onApiRef = useRef(onApi); @@ -823,9 +834,9 @@ const SceneManager = memo(function SceneManager({ new Map() ); - const activeCloudIds = cloudAssets.filter( - (def) => cloudSettings[def.id]?.enabled - ).map((def) => def.id); + const activeCloudIds = cloudAssets + .filter((def) => cloudSettings[def.id]?.enabled) + .map((def) => def.id); const activeMeshIds = MESH_ASSETS.filter( (def) => meshSettings[def.id]?.enabled ).map((def) => def.id); @@ -1758,11 +1769,11 @@ const SceneManager = memo(function SceneManager({ Math.floor(pointCapacity / Math.max(1, active.size)) ) ); - layer.setMinimumSpacingPixels( - cameraMovingRef.current - ? CAMERA_MOVE_MINIMUM_SPACING_PIXELS - : NORMAL_MINIMUM_SPACING_PIXELS - ); + layer.setMinimumSpacingPixels( + cameraMovingRef.current + ? CAMERA_MOVE_MINIMUM_SPACING_PIXELS + : NORMAL_MINIMUM_SPACING_PIXELS + ); layer.setFrustumNodeSource(source.nodes, reconcileFrustumNodes); applyCloudSettings(slot); scheduleSceneRequestAllocation(); @@ -1847,7 +1858,7 @@ const SceneManager = memo(function SceneManager({ for (const def of MESH_ASSETS) { if (!active.has(def.id) || layers.has(def.id)) continue; const center = map.getCenter(); - const tilesLayer = buildTiles3dLayer( + const tilesLayer = buildThreeTilesRuntime( `tiles3d-${def.id}`, def.url, [center.lng, center.lat], @@ -2232,10 +2243,7 @@ const buildAnchorMarker = (): THREE.Group => { return group; }; -const updateAnchorMarker = ( - slot: CloudSlot, - settings: CloudSettings -): void => { +const updateAnchorMarker = (slot: CloudSlot, settings: CloudSettings): void => { if (!slot.layer || !slot.meta) { disposeAnchorMarker(slot); return; @@ -2250,7 +2258,10 @@ const updateAnchorMarker = ( const originMerc = MercatorCoordinate.fromLngLat([lng, lat], 0); const meterScale = originMerc.meterInMercatorCoordinateUnits(); const merc = MercatorCoordinate.fromLngLat( - getFromUTM32ToWGS84([resolvedAnchor.easting, resolvedAnchor.northing]) as [number, number], + getFromUTM32ToWGS84([resolvedAnchor.easting, resolvedAnchor.northing]) as [ + number, + number + ], resolvedAnchor.height - slot.meta.zBase ); const localAnchor = [ @@ -2380,12 +2391,16 @@ export function PointCloudPlayground({ const { map } = useLibreContext(); const { getHashStateValues, updateHashState } = useHashState(); const hashView = getHashStateValues(); - const { addFeature, features: adhocFeatures, removeFeature } = - useAdhocFeatureDisplay(); - const [importedCloudAssets, setImportedCloudAssets] = useState( - [] - ); - const [adhocPointCloudsHydrated, setAdhocPointCloudsHydrated] = useState(false); + const { + addFeature, + features: adhocFeatures, + removeFeature, + } = useAdhocFeatureDisplay(); + const [importedCloudAssets, setImportedCloudAssets] = useState< + CloudAssetDef[] + >([]); + const [adhocPointCloudsHydrated, setAdhocPointCloudsHydrated] = + useState(false); useEffect(() => { try { const raw = localStorage.getItem(ADHOC_POINTCLOUD_STORAGE_KEY); @@ -2403,14 +2418,20 @@ export function PointCloudPlayground({ const asset: CloudAssetDef = { format: config.format, id: feature.id, - label: feature.metadata?.title ?? config.url.split("/").pop() ?? feature.id, + label: + feature.metadata?.title ?? + config.url.split("/").pop() ?? + feature.id, artifactFileName: config.url, sourceTag: "Import", acquiredOn: null, fieldDimensions: config.fields ?? [], hasRgb: config.hasRgb ?? false, runtimeEnabled: true, - defaultDatum: config.source?.verticalDatum === "ellipsoidal" ? "ellipsoidal" : "dhhn", + defaultDatum: + config.source?.verticalDatum === "ellipsoidal" + ? "ellipsoidal" + : "dhhn", source: config.source, transform: config.transform, url: config.url, @@ -2421,7 +2442,10 @@ export function PointCloudPlayground({ setCloudSettings((current) => ({ ...current, ...Object.fromEntries( - restoredAssets.map((asset) => [asset.id, defaultCloudSettings(asset, true)]) + restoredAssets.map((asset) => [ + asset.id, + defaultCloudSettings(asset, true), + ]) ), })); parsed.features.forEach((feature) => @@ -2434,7 +2458,10 @@ export function PointCloudPlayground({ ); } } catch (error) { - console.warn("Gespeicherte Pointcloud-Imports konnten nicht geladen werden.", error); + console.warn( + "Gespeicherte Pointcloud-Imports konnten nicht geladen werden.", + error + ); } finally { setAdhocPointCloudsHydrated(true); } @@ -2455,14 +2482,20 @@ export function PointCloudPlayground({ ); } } catch (error) { - console.warn("Pointcloud-Imports konnten nicht gespeichert werden.", error); + console.warn( + "Pointcloud-Imports konnten nicht gespeichert werden.", + error + ); } }, [adhocFeatures, adhocPointCloudsHydrated]); const handlePointCloudDrop = useCallback( async (event: React.DragEvent) => { event.preventDefault(); const file = event.dataTransfer.files[0]; - if (!file || (!file.name.endsWith(".json") && !file.name.endsWith(".geojson"))) { + if ( + !file || + (!file.name.endsWith(".json") && !file.name.endsWith(".geojson")) + ) { console.warn("Bitte eine Pointcloud-GeoJSON-Datei ablegen."); return; } @@ -2473,7 +2506,9 @@ export function PointCloudPlayground({ ...parsed.features.map((feature) => { const config = pointCloudFeatureToConfig(feature); const label = - feature.metadata?.title ?? config.url.split("/").pop() ?? feature.id; + feature.metadata?.title ?? + config.url.split("/").pop() ?? + feature.id; const nextAsset: CloudAssetDef = { format: config.format, id: feature.id, @@ -2660,7 +2695,9 @@ export function PointCloudPlayground({ const [sceneApi, setSceneApi] = useState(null); const [cloudOptionsIds, setCloudOptionsIds] = useState([]); const [meshOptionsIds, setMeshOptionsIds] = useState([]); - const [cloudDetailsOpen, setCloudDetailsOpen] = useState>({}); + const [cloudDetailsOpen, setCloudDetailsOpen] = useState< + Record + >({}); /** Cloud whose colorizer floats as a draggable expert panel */ const [colorizerCloudId, setColorizerCloudId] = useState(null); useEffect(() => { @@ -2988,19 +3025,19 @@ export function PointCloudPlayground({
{state.error}
)} {!isTilesetDelivery && ( - - - {(() => { - const source = settings.colorization.layers[0].source; - return source - ? formatColorizerSourceLabel(source) - : "nicht konfiguriert"; - })()} - - - + + + {(() => { + const source = settings.colorization.layers[0].source; + return source + ? formatColorizerSourceLabel(source) + : "nicht konfiguriert"; + })()} + + + )} {!isTilesetDelivery && ( @@ -3089,22 +3126,22 @@ export function PointCloudPlayground({ )} {!isTilesetDelivery && ( - - - patchCloud(def.id, { shape: event.target.value }) - } - options={[ - { value: POINT_SHAPES.SQUARE, label: "Quadrat" }, - { value: POINT_SHAPES.CIRCLE, label: "Kreis" }, - { value: POINT_SHAPES.DOME, label: "Kugel" }, - { value: POINT_SHAPES.SOFT_SPLAT, label: "Gradient" }, - ]} - /> - + + + patchCloud(def.id, { shape: event.target.value }) + } + options={[ + { value: POINT_SHAPES.SQUARE, label: "Quadrat" }, + { value: POINT_SHAPES.CIRCLE, label: "Kreis" }, + { value: POINT_SHAPES.DOME, label: "Kugel" }, + { value: POINT_SHAPES.SOFT_SPLAT, label: "Gradient" }, + ]} + /> + )}
{settings.nodeBoundsVisible && state && (
- Knoten: {state.visibleNodes} im Sichtfeld · {state.renderedNodes} gerendert · {state.loadedNodes} geladen + Knoten: {state.visibleNodes} im Sichtfeld ·{" "} + {state.renderedNodes} gerendert · {state.loadedNodes}{" "} + geladen
)} - - - patchMesh(def.id, { clayColor: event.target.value }) - } - className="h-7 w-12 cursor-pointer rounded border border-gray-300 bg-transparent p-0.5" - /> - + + + patchMesh(def.id, { clayColor: event.target.value }) + } + className="h-7 w-12 cursor-pointer rounded border border-gray-300 bg-transparent p-0.5" + /> + cloudSettings[def.id]?.enabled - ).length - }/${cloudAssets.length})`, - children: ( -
- {cloudItems.map((item) => ( -
{item.label}
- ))} -
- ), - }, - { - key: "meshes", - label: `3D-Meshes (${ - MESH_ASSETS.filter( - (def) => meshSettings[def.id]?.enabled - ).length - }/${MESH_ASSETS.length})`, - children: ( -
- {meshItems.map((item) => ( -
{item.label}
- ))} -
- - - - ALKIS-Extrusion via Gelände - - -
-
- ), - }, - { - key: "scene", - label: "Szene", - children: ( -
- + { + key: "pointclouds", + label: `Punktwolken (${ + cloudAssets.filter( + (def) => cloudSettings[def.id]?.enabled + ).length + }/${cloudAssets.length})`, + children: ( +
+ {cloudItems.map((item) => ( +
{item.label}
+ ))} +
+ ), + }, + { + key: "meshes", + label: `3D-Meshes (${ + MESH_ASSETS.filter( + (def) => meshSettings[def.id]?.enabled + ).length + }/${MESH_ASSETS.length})`, + children: ( +
+ {meshItems.map((item) => ( +
{item.label}
+ ))} +
+ - {pitchLimiterEnabled ? "60°" : "frei"} + ALKIS-Extrusion via Gelände - -
- `${value ?? 0} FPS`, +
+
+ ), + }, + { + key: "scene", + label: "Szene", + children: ( +
+ + + + {pitchLimiterEnabled ? "60°" : "frei"} + + + +
+ `${value ?? 0} FPS`, + }} + /> +
+ + {targetFrameRate} FPS + +
+ +
+ {( + [ + ["white", "Weiß"], + ["black", "Schwarz"], + ["gray50", "50 % Grau"], + ] as const + ).map(([preset, label]) => ( +
- - {targetFrameRate} FPS - -
- -
- {( - [ - ["white", "Weiß"], - ["black", "Schwarz"], - ["gray50", "50 % Grau"], - ] as const - ).map(([preset, label]) => ( -
-
- - - - - { + setSceneBackground("gray50"); + setSceneBackgroundColor(event.target.value); + }} + className="size-7 cursor-pointer rounded border border-gray-300 bg-transparent p-0.5" /> - {!terrainActive && ( - - Gelände über den Berg-Button links - - )} - -
- ), - }, - ]} - /> -
- )} +
+
+ + + + +