Skip to content

Commit 61fe0d4

Browse files
authored
feat: Add 3D extrusion for vectortile layers + 3D Tiles support (#942)
* feat: Add 3D extrusion for vectortile layers + 3D Tiles support Adds two new 3D rendering capabilities to the Cesium globe renderer: 1. Vector tile extrusion: vectortile layers can now render extruded 3D buildings on the globe via a new "3D Extrusion" tab in the layer config. A CesiumMVTLayer class manages tile lifecycle (load, decode, evict) with batched Cesium.Primitive rendering and per-feature color support from the OpenMapTiles schema. No Cesium ion token required — works with any MVT source (OpenFreeMap, Versatiles, self-hosted). 2. 3D Tiles layer type: new "3dtiles" layer type for Cesium3DTileset URLs, with configurable LOD, memory limits, height offset, and style expressions. Also: - New auxiliary/resolve-tile-url CLI utility to resolve TileJSON endpoints to concrete tile URLs (keeps MMGIS provider-agnostic). - Scene lighting enabled with a fixed sun angle (summer solstice, 10am EDT) for consistent, readable building shading. - L.vectorGrid sublayer filtering in Map_.js to hide MVT sublayers not explicitly styled (prevents default blue rendering of roads, water, etc.). - Backend validation (API/Backend/Config/validate.js) accepts the new 3dtiles type. * feat: terrain-aware building placement + MVT simplification - CesiumMVTLayer now samples terrain height at each tile center so extruded buildings sit on the ground surface instead of at elevation 0. Tries globe.getHeight() first (fast, synchronous), falls back to fetching the Mapzen Terrarium tile directly and decoding heights from the PNG. Cached per terrain tile to avoid re-fetching. - Added SimplifiedVectorGrid: subclass of L.VectorGrid.Protobuf that applies Douglas-Peucker simplification to polygon rings after decode, before SVG rendering. Reduces vertex counts ~50-80% on dense sources like OSM buildings with no perceptible visual change. Opt-in via a simplifyTolerance option; inline algorithm, no new dependencies. - Map_.js wires SimplifiedVectorGrid into the vectortile flow for layers with extrusion enabled (default tolerance: 4 MVT units).
1 parent a45e9ff commit 61fe0d4

25 files changed

Lines changed: 2017 additions & 6 deletions

API/Backend/Config/validate.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ const validateLayers = (config) => {
6565
// Check zooms
6666
errs = errs.concat(isValidZooms(layer));
6767
break;
68+
case "3dtiles":
69+
// Check url
70+
errs = errs.concat(isValidUrl(layer));
71+
break;
6872
case "data":
6973
// Check url
7074
errs = errs.concat(isValidDemUrl(layer));
@@ -370,6 +374,8 @@ const fillInMissingFieldsWithDefaults = (layer) => {
370374
layer.style = layer.style || {};
371375
layer.style.className = layer.name.replace(/ /g, "").toLowerCase();
372376
break;
377+
case "3dtiles":
378+
break;
373379
case "data":
374380
layer.style = layer.style || {};
375381
layer.style.className = layer.name.replace(/ /g, "").toLowerCase();
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* resolve-tile-url
5+
*
6+
* Resolves vector tile source URLs from metadata endpoints.
7+
* Useful for providers that use versioned or rotating tile URLs
8+
* (e.g., OpenFreeMap's TileJSON endpoint).
9+
*
10+
* Supports:
11+
* - TileJSON (spec: https://github.com/mapbox/tilejson-spec)
12+
* - Direct tile URL passthrough (if already contains {z}/{x}/{y})
13+
*
14+
* Usage:
15+
* node resolve-tile-url.js <url>
16+
* node resolve-tile-url.js https://tiles.openfreemap.org/planet
17+
* node resolve-tile-url.js --info https://tiles.openfreemap.org/planet
18+
*
19+
* Options:
20+
* --info Show full metadata (minzoom, maxzoom, layers, etc.)
21+
* --help Show this help message
22+
*/
23+
24+
const args = process.argv.slice(2)
25+
26+
if (args.includes('--help') || args.length === 0) {
27+
console.log(`
28+
resolve-tile-url — Resolve vector tile source URLs from metadata endpoints
29+
30+
Usage:
31+
node resolve-tile-url.js <url>
32+
node resolve-tile-url.js --info <url>
33+
34+
Examples:
35+
node resolve-tile-url.js https://tiles.openfreemap.org/planet
36+
node resolve-tile-url.js --info https://tiles.openfreemap.org/planet
37+
38+
The resolved URL can be used directly in MMGIS layer configuration
39+
as the URL field for vectortile or vectortile3d layer types.
40+
41+
Options:
42+
--info Show full metadata (zoom range, available layers, attribution)
43+
--help Show this help message
44+
`)
45+
process.exit(0)
46+
}
47+
48+
const showInfo = args.includes('--info')
49+
const url = args.find((a) => !a.startsWith('--'))
50+
51+
if (!url) {
52+
console.error('Error: No URL provided')
53+
process.exit(1)
54+
}
55+
56+
// If URL already has tile placeholders, just pass it through
57+
if (url.includes('{z}') && url.includes('{x}') && url.includes('{y}')) {
58+
console.log(url)
59+
process.exit(0)
60+
}
61+
62+
async function resolve() {
63+
try {
64+
const response = await fetch(url)
65+
66+
if (!response.ok) {
67+
console.error(`Error: HTTP ${response.status} from ${url}`)
68+
process.exit(1)
69+
}
70+
71+
const contentType = response.headers.get('content-type') || ''
72+
73+
// Try to parse as JSON (TileJSON)
74+
let data
75+
try {
76+
data = await response.json()
77+
} catch (e) {
78+
console.error(
79+
`Error: Response from ${url} is not valid JSON (content-type: ${contentType})`
80+
)
81+
process.exit(1)
82+
}
83+
84+
// TileJSON format
85+
if (data.tiles && Array.isArray(data.tiles) && data.tiles.length > 0) {
86+
if (showInfo) {
87+
console.log('Format: TileJSON')
88+
console.log(`Tile URL: ${data.tiles[0]}`)
89+
if (data.minzoom != null)
90+
console.log(`Min Zoom: ${data.minzoom}`)
91+
if (data.maxzoom != null)
92+
console.log(`Max Zoom: ${data.maxzoom}`)
93+
if (data.name) console.log(`Name: ${data.name}`)
94+
if (data.description)
95+
console.log(`Description: ${data.description}`)
96+
if (data.attribution)
97+
console.log(`Attribution: ${data.attribution}`)
98+
if (data.bounds)
99+
console.log(`Bounds: ${data.bounds.join(', ')}`)
100+
if (data.center)
101+
console.log(`Center: ${data.center.join(', ')}`)
102+
103+
// Try to fetch a sample tile to list available layers
104+
if (data.center && data.center.length >= 3) {
105+
const sampleLayers = await listLayers(
106+
data.tiles[0],
107+
data.center[0],
108+
data.center[1],
109+
Math.min(data.center[2] || 14, data.maxzoom || 14)
110+
)
111+
if (sampleLayers) {
112+
console.log(`Layers: ${sampleLayers.join(', ')}`)
113+
}
114+
}
115+
} else {
116+
console.log(data.tiles[0])
117+
}
118+
process.exit(0)
119+
}
120+
121+
console.error(
122+
'Error: Response does not contain a recognized tile URL format'
123+
)
124+
console.error(
125+
'Expected TileJSON with a "tiles" array. Keys found:',
126+
Object.keys(data).join(', ')
127+
)
128+
process.exit(1)
129+
} catch (e) {
130+
console.error(`Error: ${e.message}`)
131+
process.exit(1)
132+
}
133+
}
134+
135+
/**
136+
* Fetch a sample tile to list available vector tile layers
137+
*/
138+
async function listLayers(tileUrlTemplate, lng, lat, zoom) {
139+
try {
140+
const { VectorTile } = await import('@mapbox/vector-tile')
141+
const PbfModule = await import('pbf')
142+
const Pbf = PbfModule.default || PbfModule
143+
144+
const z = Math.floor(zoom)
145+
const n = Math.pow(2, z)
146+
const x = Math.floor(((lng + 180) / 360) * n)
147+
const latRad = (lat * Math.PI) / 180
148+
const y = Math.floor(
149+
((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) /
150+
2) *
151+
n
152+
)
153+
154+
const tileUrl = tileUrlTemplate
155+
.replace('{z}', z)
156+
.replace('{x}', x)
157+
.replace('{y}', y)
158+
159+
const response = await fetch(tileUrl)
160+
if (!response.ok || response.headers.get('content-length') === '0')
161+
return null
162+
163+
const arrayBuffer = await response.arrayBuffer()
164+
if (arrayBuffer.byteLength === 0) return null
165+
166+
const tile = new VectorTile(new Pbf(arrayBuffer))
167+
return Object.keys(tile.layers)
168+
} catch (e) {
169+
return null
170+
}
171+
}
172+
173+
resolve()

blueprints/Missions/Reference-Mission/config.reference-mission.json

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,123 @@
103103
"name": "Vector Layers",
104104
"type": "header",
105105
"sublayers": [
106+
{
107+
"name": "Vector Tile Layers",
108+
"uuid": "f4087afe-8b01-4e0b-b623-04a67a18e7de",
109+
"type": "header",
110+
"expanded": false,
111+
"sublayers": [
112+
{
113+
"name": "OSM Buildings",
114+
"uuid": "0f8a6dca-a99c-436e-aa90-297ed178c73e",
115+
"type": "vectortile",
116+
"kind": "none",
117+
"visibility": false,
118+
"url": "https://tiles.openfreemap.org/planet/20260408_001001_pt/{z}/{x}/{y}.pbf",
119+
"minZoom": 13,
120+
"maxNativeZoom": 14,
121+
"maxZoom": 22,
122+
"layer3dType": "clamped",
123+
"initialOpacity": 1,
124+
"extrudeEnabled": true,
125+
"extrudeHeightProperty": "render_height",
126+
"extrudeDefaultHeight": 0,
127+
"extrudeBaseProperty": "render_min_height",
128+
"extrudeVtLayer": "building",
129+
"extrudeColor": "#ff0000",
130+
"extrudeOverrideFeatureColor": true,
131+
"extrudeOpacity": 1,
132+
"style": {
133+
"color": "#FFFFFF",
134+
"fillColor": "#FFFFFF",
135+
"vtId": "id",
136+
"vtLayer": {
137+
"building": {
138+
"fill": true,
139+
"fillColor": "#ff0000",
140+
"fillOpacity": 0.6,
141+
"weight": 0.5,
142+
"color": "#999999"
143+
}
144+
},
145+
"className": "osmbuildings"
146+
},
147+
"shape": "none",
148+
"time": {
149+
"enabled": false,
150+
"type": "requery"
151+
},
152+
"variables": {
153+
"legendOrientation": "vertical",
154+
"style": {
155+
"nointeraction": false
156+
},
157+
"layerAttachments": {
158+
"labels": {
159+
"enabled": false,
160+
"initialVisibility": false,
161+
"theme": "default",
162+
"size": "default"
163+
},
164+
"pairings": {
165+
"enabled": false,
166+
"initialVisibility": false,
167+
"style": {
168+
"color": "#FFFFFF"
169+
}
170+
}
171+
},
172+
"coordinateAttachments": {
173+
"marker": {
174+
"enabled": false,
175+
"initialVisibility": false,
176+
"color": "#FFFFFF",
177+
"fillColor": "#FFFFFF"
178+
}
179+
},
180+
"markerAttachments": {
181+
"bearing": {
182+
"enabled": false,
183+
"angleUnit": "deg",
184+
"color": "#FFFFFF",
185+
"useCustomShape": false
186+
},
187+
"image": {
188+
"enabled": false,
189+
"initialVisibility": false,
190+
"angleUnit": "deg",
191+
"show": "click"
192+
},
193+
"model": {
194+
"enabled": false,
195+
"yawUnit": "deg",
196+
"invertYaw": false,
197+
"pitchUnit": "deg",
198+
"invertPitch": false,
199+
"rollUnit": "deg",
200+
"invertRoll": false,
201+
"show": "click"
202+
},
203+
"uncertainty": {
204+
"enabled": false,
205+
"initialVisibility": false,
206+
"strokeColor": "#FFFFFF",
207+
"color": "#FFFFFF",
208+
"axisUnit": "meters",
209+
"angleUnit": "deg"
210+
}
211+
},
212+
"pathAttachments": {
213+
"gradient": {
214+
"enabled": false,
215+
"connectAllPoints": false
216+
}
217+
},
218+
"hideMainFeature": false
219+
}
220+
}
221+
]
222+
},
106223
{
107224
"name": "📄 GeoJSON Data Features",
108225
"uuid": "29dd6328-4333-40d4-be4c-94c018e512c8",

configure/src/components/Tabs/Layers/Modals/LayerModal/LayerModal.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import useMediaQuery from "@mui/material/useMediaQuery";
3737

3838
import Maker from "../../../../../core/Maker";
3939

40+
import threedtilesConfig from "../../../../../metaconfigs/layer-3dtiles-config.json";
4041
import dataConfig from "../../../../../metaconfigs/layer-data-config.json";
4142
import headerConfig from "../../../../../metaconfigs/layer-header-config.json";
4243
import modelConfig from "../../../../../metaconfigs/layer-model-config.json";
@@ -166,6 +167,10 @@ const LayerModal = (props) => {
166167

167168
let config = {};
168169
switch (layer.type) {
170+
case "3dtiles":
171+
config = threedtilesConfig;
172+
break;
173+
169174
case "data":
170175
config = dataConfig;
171176
break;

0 commit comments

Comments
 (0)