Skip to content

Commit 32eadbd

Browse files
committed
feat(3d-buildings): add detailed German LoD2
1 parent 27a1d72 commit 32eadbd

11 files changed

Lines changed: 442 additions & 11 deletions

File tree

apps/web/next.config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ const contentSecurityPolicy = [
2121
"style-src 'self' 'unsafe-inline'",
2222
"worker-src 'self' blob:",
2323
"connect-src 'self' https: http: ws: wss: data: blob:",
24-
"script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:",
24+
// The only third-party script is BKG's official basemap.de 3D renderer.
25+
// Its loader also enforces a reviewed SHA-384 Subresource Integrity hash.
26+
"script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: https://sgx.geodatenzentrum.de",
2527
].join("; ");
2628

2729
// Security headers applied to every route. CSP enforces object/base/frame/form

apps/web/src/lib/attributionWiringCoverage.test.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,6 @@ const BORROWED_CREDIT_WIRES = ATTRIBUTION_WIRES.filter(
131131
* used to fail nothing.
132132
*/
133133
const NO_CREDIT_OWED: Record<string, string> = {
134-
"overlay-3d-buildings":
135-
"extrudes the basemap's own `building` source-layer; the base style's credits already cover it",
136134
"overlay-cycling":
137135
"restyles the basemap's own `transportation`/`poi` source-layers; the base style's credits already cover it",
138136
"overlay-tool-measurement": "draws only the user's own measurement geometry — no external data",

apps/web/src/test/fakeMap.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface FakeMapState {
2121
styleLoaded: boolean;
2222
/** Backing value for `getZoom()` — mutate then emit("moveend") to simulate a zoom gesture. */
2323
zoom: number;
24+
center: { lng: number; lat: number };
2425
pitch: number;
2526
maxPitch: number;
2627
cameraTransitions: Array<{
@@ -47,6 +48,7 @@ export interface CreateFakeMapOptions {
4748
styleLoaded?: boolean;
4849
/** Initial `getZoom()` value (default 10). */
4950
zoom?: number;
51+
center?: { lng: number; lat: number };
5052
/** Initial camera pitch and pitch constraint. */
5153
pitch?: number;
5254
maxPitch?: number;
@@ -62,6 +64,7 @@ export function createFakeMap(options: CreateFakeMapOptions = {}): FakeMap {
6264
images: new Set(),
6365
styleLoaded: options.styleLoaded ?? true,
6466
zoom: options.zoom ?? 10,
67+
center: options.center ?? { lng: 0, lat: 0 },
6568
pitch: options.pitch ?? 0,
6669
maxPitch: options.maxPitch ?? 60,
6770
cameraTransitions: [],
@@ -156,7 +159,11 @@ export function createFakeMap(options: CreateFakeMapOptions = {}): FakeMap {
156159
setLight: (light: Record<string, unknown>) => {
157160
state.light = light;
158161
},
159-
getCenter: () => ({ lng: 0, lat: 0 }),
162+
getCenter: () => state.center,
163+
setCenter: (center: { lng: number; lat: number }) => {
164+
state.center = center;
165+
},
166+
getProjection: () => ({ type: "mercator" }),
160167
getBounds: () => ({
161168
getWest: () => -180,
162169
getSouth: () => -90,
@@ -177,6 +184,7 @@ export function createFakeMap(options: CreateFakeMapOptions = {}): FakeMap {
177184
if (typeof options.pitch === "number") state.pitch = options.pitch;
178185
},
179186
fitBounds: () => {},
187+
triggerRepaint: () => {},
180188
on,
181189
off,
182190
once: on,

docs/docs/features/map-layers.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,23 @@ a glance.
133133
| **Winter sports** | Ski areas, pistes, and lifts | OpenSnowMap |
134134
| **Nautical** | Sea marks, depths, tides, and water levels | OpenSeaMap and marine agencies |
135135
| **Satellite imagery**| A true-color satellite overlay (distinct from the satellite base) | NASA GIBS / MODIS |
136-
| **3D buildings** | Extruded building footprints | OpenMapTiles building data |
136+
| **3D buildings** | Detailed LoD2 roofs in Germany at z16+, with extruded-footprint fallback | GeoBasis-DE/BKG and OpenMapTiles |
137+
138+
The **3D buildings** overlay uses the official basemap.de LoD2 building service
139+
when the map is in Germany, in Web Mercator, and zoomed to level 16 or closer.
140+
Roofs and building parts then use their generalized official geometry instead
141+
of remaining flat-topped boxes. At lower zooms, outside Germany, on the globe,
142+
while the detailed tiles load, or if the external service is unavailable, the
143+
overlay keeps rendering the lightweight OpenMapTiles footprint extrusion.
144+
145+
The detailed source is a hosted BKG test service rather than downloadable open
146+
data. OpenMapX streams it directly at runtime, does not mirror or persist its
147+
tiles, and displays the required `© GeoBasis-DE/BKG` credit and modification
148+
notice while it is active. Its current terms cover testing, demos, and genuine
149+
beta use; operators planning regular commercial-production use must first
150+
obtain written permission from BKG. See the
151+
[basemap.de 3D product page](https://basemap.de/produkte-und-dienste/3d/) and
152+
[test-operation terms](https://sgx.geodatenzentrum.de/public/gdz/lizenz/deu/lizenz_basemapde_3d-testbetrieb.pdf).
137153

138154
### Map tools
139155

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// @vitest-environment jsdom
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
4+
afterEach(() => {
5+
document.getElementById("openmapx-basemap-de-3d-renderer")?.remove();
6+
delete window.Mapbox3DTiles;
7+
vi.resetModules();
8+
});
9+
10+
describe("basemap.de LoD2 renderer", () => {
11+
it("loads only the reviewed renderer bytes with CORS-enabled SRI", async () => {
12+
const { BASEMAP_LOD2_RENDERER_INTEGRITY, BASEMAP_LOD2_RENDERER_URL, loadBasemapLod2Renderer } =
13+
await import("../basemap-lod2");
14+
15+
const pending = loadBasemapLod2Renderer();
16+
const script = document.getElementById("openmapx-basemap-de-3d-renderer") as HTMLScriptElement;
17+
18+
expect(script.src).toBe(BASEMAP_LOD2_RENDERER_URL);
19+
expect(script.integrity).toBe(BASEMAP_LOD2_RENDERER_INTEGRITY);
20+
expect(script.crossOrigin).toBe("anonymous");
21+
22+
class FakeDetailedLayer {
23+
id = "fake";
24+
type = "custom" as const;
25+
render = vi.fn();
26+
}
27+
window.Mapbox3DTiles = {
28+
Mapbox3DTilesLayer: FakeDetailedLayer as never,
29+
};
30+
script.dispatchEvent(new Event("load"));
31+
32+
await expect(pending).resolves.toBe(window.Mapbox3DTiles);
33+
});
34+
35+
it("fails closed and removes a renderer that does not pass browser loading checks", async () => {
36+
const { loadBasemapLod2Renderer } = await import("../basemap-lod2");
37+
const pending = loadBasemapLod2Renderer();
38+
const script = document.getElementById("openmapx-basemap-de-3d-renderer") as HTMLScriptElement;
39+
40+
script.dispatchEvent(new Event("error"));
41+
42+
await expect(pending).rejects.toThrow("integrity-pinned");
43+
expect(document.getElementById("openmapx-basemap-de-3d-renderer")).toBeNull();
44+
});
45+
46+
it("removes an incompatible renderer so a later activation can retry", async () => {
47+
const { loadBasemapLod2Renderer } = await import("../basemap-lod2");
48+
const pending = loadBasemapLod2Renderer();
49+
const script = document.getElementById("openmapx-basemap-de-3d-renderer") as HTMLScriptElement;
50+
51+
script.dispatchEvent(new Event("load"));
52+
53+
await expect(pending).rejects.toThrow("without its layer constructor");
54+
expect(document.getElementById("openmapx-basemap-de-3d-renderer")).toBeNull();
55+
});
56+
});

integrations/overlay-3d-buildings/__tests__/map-layer.test.tsx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ import { useBuildingsStore } from "../store";
55

66
let fake: FakeMap;
77

8+
const createBasemapLod2LayerMock = vi.hoisted(() => vi.fn());
9+
10+
vi.mock("../basemap-lod2", async (importOriginal) => {
11+
const actual = await importOriginal<typeof import("../basemap-lod2")>();
12+
return {
13+
...actual,
14+
createBasemapLod2Layer: createBasemapLod2LayerMock,
15+
};
16+
});
17+
818
vi.mock("@/lib/MapContext", () => ({
919
useMap: () => ({
1020
mapRef: { current: fake.map },
@@ -16,6 +26,7 @@ vi.mock("@/lib/MapContext", () => ({
1626
import { BuildingExtrusionLayer } from "../map-layer";
1727

1828
const LAYER_ID = "openmapx-3d-buildings";
29+
const DETAILED_LAYER_ID = "openmapx-3d-buildings-lod2";
1930
const BUILDING_LAYER_ID = "base-buildings";
2031
const SYMBOL_LAYER_ID = "place-labels";
2132

@@ -46,6 +57,14 @@ function addBaseStyle(): void {
4657
beforeEach(() => {
4758
fake = createFakeMap({ zoom: 16, pitch: 20, maxPitch: 70 });
4859
addBaseStyle();
60+
createBasemapLod2LayerMock.mockReset();
61+
createBasemapLod2LayerMock.mockImplementation(async (id: string) => ({
62+
id,
63+
type: "custom",
64+
renderingMode: "3d",
65+
loadStatus: 1,
66+
render: vi.fn(),
67+
}));
4968
useBuildingsStore.setState({ panelOpen: false, layerVisible: false });
5069
vi.stubGlobal(
5170
"matchMedia",
@@ -71,6 +90,58 @@ describe("BuildingExtrusionLayer", () => {
7190
expect(layer?.source).not.toBe("unrelated");
7291
});
7392

93+
it("replaces the fallback with detailed LoD2 geometry at z16 in Germany", async () => {
94+
fake.state.center = { lng: 8.6821, lat: 50.1109 };
95+
useBuildingsStore.setState({ layerVisible: true });
96+
97+
render(<BuildingExtrusionLayer />);
98+
await vi.waitFor(() => expect(fake.state.layers.has(DETAILED_LAYER_ID)).toBe(true));
99+
expect(createBasemapLod2LayerMock).toHaveBeenCalled();
100+
expect(fake.state.layout.get(DETAILED_LAYER_ID)?.visibility).toBe("visible");
101+
expect(fake.state.layout.get(LAYER_ID)?.visibility).toBe("none");
102+
expect(fake.state.layout.get(BUILDING_LAYER_ID)?.visibility).toBe("none");
103+
104+
act(() => {
105+
fake.state.center = { lng: -73.9855, lat: 40.758 };
106+
fake.emit("moveend");
107+
});
108+
expect(fake.state.layers.has(DETAILED_LAYER_ID)).toBe(false);
109+
expect(fake.state.layout.get(LAYER_ID)?.visibility).toBe("visible");
110+
});
111+
112+
it("keeps the extrusion fallback below z16 and outside Germany", async () => {
113+
fake.state.center = { lng: 8.6821, lat: 50.1109 };
114+
fake.state.zoom = 15;
115+
useBuildingsStore.setState({ layerVisible: true });
116+
117+
render(<BuildingExtrusionLayer />);
118+
await act(async () => Promise.resolve());
119+
120+
expect(createBasemapLod2LayerMock).not.toHaveBeenCalled();
121+
expect(fake.state.layout.get(LAYER_ID)?.visibility).toBe("visible");
122+
123+
fake.state.zoom = 16;
124+
fake.state.center = { lng: -73.9855, lat: 40.758 };
125+
act(() => fake.emit("moveend"));
126+
await act(async () => Promise.resolve());
127+
128+
expect(createBasemapLod2LayerMock).not.toHaveBeenCalled();
129+
expect(fake.state.layout.get(LAYER_ID)?.visibility).toBe("visible");
130+
});
131+
132+
it("falls back without hiding buildings when the detailed renderer fails", async () => {
133+
fake.state.center = { lng: 8.6821, lat: 50.1109 };
134+
createBasemapLod2LayerMock.mockRejectedValueOnce(new Error("renderer unavailable"));
135+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
136+
useBuildingsStore.setState({ layerVisible: true });
137+
138+
render(<BuildingExtrusionLayer />);
139+
140+
await vi.waitFor(() => expect(warn).toHaveBeenCalledTimes(1));
141+
expect(fake.state.layers.has(DETAILED_LAYER_ID)).toBe(false);
142+
expect(fake.state.layout.get(LAYER_ID)?.visibility).toBe("visible");
143+
});
144+
74145
it("re-anchors after idle when the overlay is restored before its layer exists", () => {
75146
useBuildingsStore.setState({ layerVisible: true });
76147
render(<BuildingExtrusionLayer />);
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import type maplibregl from "maplibre-gl";
2+
3+
export const BASEMAP_LOD2_TILESET_URL =
4+
"https://sgx.geodatenzentrum.de/gdz_basemapde_3d_gebaeude/lod2_3857_null.json";
5+
export const BASEMAP_LOD2_RENDERER_URL =
6+
"https://sgx.geodatenzentrum.de/gdz_basemapde_3d_gebaeude/Maplibre3DTiles.js";
7+
8+
// BKG publishes the renderer at an unversioned URL. Pin the reviewed bytes so
9+
// activating the optional layer cannot silently execute changed third-party
10+
// JavaScript. If BKG updates it, the detailed layer fails closed to the normal
11+
// OpenMapTiles extrusion until this hash is reviewed and updated.
12+
export const BASEMAP_LOD2_RENDERER_INTEGRITY =
13+
"sha384-q3pnK85xX/QxPjhFutyFScKvWMrmzh3pmdUcx19wM/zWhsLLbguExO88f2LpvmFd";
14+
15+
export const BASEMAP_LOD2_MIN_ZOOM = 16;
16+
17+
const RENDERER_SCRIPT_ID = "openmapx-basemap-de-3d-renderer";
18+
const GERMANY_BOUNDS = {
19+
west: 5.5,
20+
south: 47,
21+
east: 15.5,
22+
north: 55.2,
23+
} as const;
24+
25+
export interface BasemapLod2Layer extends maplibregl.CustomLayerInterface {
26+
/** Set to 1 by BKG's renderer after the root tileset is ready. */
27+
loadStatus?: number;
28+
}
29+
30+
interface BasemapLod2Renderer {
31+
Mapbox3DTilesLayer: new (options: {
32+
id: string;
33+
url: string;
34+
colorWall: string;
35+
colorRoof: string;
36+
colorBridge: string;
37+
}) => BasemapLod2Layer;
38+
}
39+
40+
declare global {
41+
interface Window {
42+
Mapbox3DTiles?: BasemapLod2Renderer;
43+
}
44+
}
45+
46+
let rendererPromise: Promise<BasemapLod2Renderer> | null = null;
47+
48+
function rendererFromWindow(): BasemapLod2Renderer {
49+
const renderer = window.Mapbox3DTiles;
50+
if (!renderer?.Mapbox3DTilesLayer) {
51+
throw new Error("basemap.de 3D renderer loaded without its layer constructor");
52+
}
53+
return renderer;
54+
}
55+
56+
export function loadBasemapLod2Renderer(): Promise<BasemapLod2Renderer> {
57+
if (window.Mapbox3DTiles?.Mapbox3DTilesLayer) {
58+
return Promise.resolve(window.Mapbox3DTiles);
59+
}
60+
if (rendererPromise) return rendererPromise;
61+
62+
rendererPromise = new Promise<BasemapLod2Renderer>((resolve, reject) => {
63+
const existing = document.getElementById(RENDERER_SCRIPT_ID) as HTMLScriptElement | null;
64+
const script = existing ?? document.createElement("script");
65+
66+
const handleLoad = () => {
67+
try {
68+
resolve(rendererFromWindow());
69+
} catch (error) {
70+
rendererPromise = null;
71+
script.remove();
72+
reject(error);
73+
}
74+
};
75+
const handleError = () => {
76+
rendererPromise = null;
77+
script.remove();
78+
reject(new Error("Unable to load the integrity-pinned basemap.de 3D renderer"));
79+
};
80+
81+
script.addEventListener("load", handleLoad, { once: true });
82+
script.addEventListener("error", handleError, { once: true });
83+
84+
if (!existing) {
85+
script.id = RENDERER_SCRIPT_ID;
86+
script.src = BASEMAP_LOD2_RENDERER_URL;
87+
script.integrity = BASEMAP_LOD2_RENDERER_INTEGRITY;
88+
script.crossOrigin = "anonymous";
89+
script.async = true;
90+
document.head.append(script);
91+
}
92+
});
93+
94+
return rendererPromise;
95+
}
96+
97+
export async function createBasemapLod2Layer(id: string): Promise<BasemapLod2Layer> {
98+
const renderer = await loadBasemapLod2Renderer();
99+
return new renderer.Mapbox3DTilesLayer({
100+
id,
101+
url: BASEMAP_LOD2_TILESET_URL,
102+
colorWall: "#c8c4c0",
103+
colorRoof: "#b86f63",
104+
colorBridge: "#8f9094",
105+
});
106+
}
107+
108+
/**
109+
* BKG's MapLibre tileset is Web Mercator, Germany-only, and expensive enough
110+
* that its own example starts it at z16. Everywhere else the existing vector
111+
* extrusion remains the correct low-cost fallback.
112+
*/
113+
export function supportsBasemapLod2View(map: maplibregl.Map): boolean {
114+
if (map.getZoom() < BASEMAP_LOD2_MIN_ZOOM) return false;
115+
116+
const projection = map.getProjection?.();
117+
if (projection?.type && projection.type !== "mercator") return false;
118+
119+
const center = map.getCenter();
120+
return (
121+
center.lng >= GERMANY_BOUNDS.west &&
122+
center.lng <= GERMANY_BOUNDS.east &&
123+
center.lat >= GERMANY_BOUNDS.south &&
124+
center.lat <= GERMANY_BOUNDS.north
125+
);
126+
}

0 commit comments

Comments
 (0)