Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 45 additions & 3 deletions crates/engine/src/cmd/tiles.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Home › Add › Basemap: web tile layers (XYZ/TMS/WMS raster tiles, vector tiles and
//! MapLibre styles) over the basemap.

use rustgis_map::effects::Blend;
use rustgis_map::tiles::{MAX_TILE_LAYERS, TileKind, TileLayer};
use serde_json::{Value, json};

Expand Down Expand Up @@ -39,12 +40,12 @@ pub const NETWORK: &[&str] = &[
pub fn commands() -> Vec<C> {
vec![
C::new("map.addTiles", "Add Tile Layer", "Home › Add", add).params(
r#"{"url": "https://…/{z}/{x}/{y}.png" | vector tiles ".pbf" or TileJSON | MapLibre style.json | a PMTiles archive (web address or file path) | an MBTiles file path, "kind"?: "raster"|"vector"|"style"|"pmtiles"|"mbtiles" (guessed from the url), "name"?: text, "attribution"?: text, "minZoom"?: number, "maxZoom"?: number, "tileSize"?: 256|512, "tms"?: bool, "subdomains"?: [text], "layers"?: [vector tile layer names, drawn in colours of their own], "opacity"?: 0–1, "overlay"?: bool (a transparent layer kept on top), "replace"?: bool (removes the other tile basemaps, not overlays), "id"?: catalog id}"#,
r#"{"url": "https://…/{z}/{x}/{y}.png" | vector tiles ".pbf" or TileJSON | MapLibre style.json | a PMTiles archive (web address or file path) | an MBTiles file path, "kind"?: "raster"|"vector"|"style"|"pmtiles"|"mbtiles" (guessed from the url), "name"?: text, "attribution"?: text, "minZoom"?: number, "maxZoom"?: number, "tileSize"?: 256|512, "tms"?: bool, "subdomains"?: [text], "layers"?: [vector tile layer names, drawn in colours of their own], "opacity"?: 0–1, "blend"?: "normal"|"multiply"|"screen"|"overlay"|"darken"|"lighten"|"difference" (how it combines with what is beneath), "overlay"?: bool (a transparent layer kept on top), "replace"?: bool (removes the other tile basemaps, not overlays), "id"?: catalog id}"#,
),
C::new("map.removeTiles", "Remove Tile Layer", "Home › Add", remove)
.params(r#"{"name"?: text, "index"?: number, "id"?: catalog id} (removes every tile layer when none is given)"#),
C::new("map.setTiles", "Tile Layer Properties", "Home › Add", set).params(
r#"{"name"?: text | "index"?: number (0 = top) | "id"?: catalog id, "visible"?: bool, "opacity"?: 0–1, "move"?: "up"|"down"|"top"|"bottom", "rename"?: text}"#,
r#"{"name"?: text | "index"?: number (0 = top) | "id"?: catalog id, "visible"?: bool, "opacity"?: 0–1, "blend"?: "normal"|"multiply"|"screen"|"overlay"|"darken"|"lighten"|"difference", "move"?: "up"|"down"|"top"|"bottom", "rename"?: text}"#,
),
C::new("map.tiles", "Tile Layers", "Home › Add", |s, _| Ok(list(s, false))).view(),
]
Expand All @@ -61,7 +62,7 @@ pub(crate) fn list(s: &Session, redact: bool) -> Value {
.map(|(i, t)| {
let url = if redact { crate::sources::redact(&t.url) } else { t.url.clone() };
json!({"index": i, "name": t.name, "id": t.id, "kind": t.kind.code(), "url": url, "visible": t.visible,
"opacity": t.opacity, "overlay": t.overlay, "attribution": t.attribution, "minZoom": t.min_zoom, "maxZoom": t.max_zoom})
"opacity": t.opacity, "blend": t.blend.code(), "overlay": t.overlay, "attribution": t.attribution, "minZoom": t.min_zoom, "maxZoom": t.max_zoom})
})
.collect(),
)
Expand Down Expand Up @@ -93,6 +94,9 @@ pub(crate) fn add(s: &mut Session, p: &Value) -> CmdResult {
if let Some(o) = f(p, "opacity") {
t.opacity = o as f32;
}
if let Some(m) = blend(p)? {
t.blend = m;
}
// Vector tiles' layer names (an OGC API collection's), each drawn in a colour of its own.
if let Some(names) = p.get("layers").and_then(Value::as_array) {
t.layers = names.iter().filter_map(Value::as_str).map(str::to_string).collect();
Expand Down Expand Up @@ -154,8 +158,14 @@ fn remove(s: &mut Session, p: &Value) -> CmdResult {
}
}

/// The `blend` mode a command gives, if any.
fn blend(p: &Value) -> Result<Option<Blend>, String> {
ps(p, "blend").map(|m| Blend::from_code(m).ok_or_else(|| format!("unknown blend mode '{m}'"))).transpose()
}

fn set(s: &mut Session, p: &Value) -> CmdResult {
let i = find(s, p)?.ok_or("name or index is required")?;
let mode = blend(p)?;
let m = s.map_mut();
let n = m.tiles.len();
let Some(t) = m.tiles.get_mut(i) else { return Err("no tile layer at that index".into()) };
Expand All @@ -165,6 +175,9 @@ fn set(s: &mut Session, p: &Value) -> CmdResult {
if let Some(o) = f(p, "opacity") {
t.opacity = if o.is_finite() { o.clamp(0.0, 1.0) as f32 } else { 1.0 };
}
if let Some(m) = mode {
t.blend = m;
}
if let Some(r) = ps(p, "rename").map(str::trim).filter(|r| !r.is_empty()) {
t.name = r.chars().take(120).collect();
}
Expand Down Expand Up @@ -219,4 +232,33 @@ mod tests {
assert_eq!(p.map().tiles.len(), 1);
assert_eq!(p.map().tiles[0].attribution, "© OpenStreetMap");
}

#[test]
fn stacks_basemaps_and_blends_them() {
use rustgis_map::effects::Blend;
let mut s = Session::new(Project::default());
let relief = json!({"url": "https://relief.example.com/{z}/{x}/{y}.png", "name": "Relief", "replace": true});
assert!(s.run("map.addTiles", &relief).is_ok());
// Without `replace`, a second basemap goes over the first, here multiplied with it.
let imagery = json!({"url": "https://imagery.example.com/{z}/{x}/{y}.jpg", "name": "Imagery", "blend": "multiply"});
assert!(s.run("map.addTiles", &imagery).is_ok());
let names: Vec<&str> = s.map().tiles.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, ["Imagery", "Relief"]);
assert_eq!(s.map().tiles[0].blend, Blend::Multiply);
assert_eq!(s.map().tiles[1].blend, Blend::Normal);
let listed = s.run("map.tiles", &json!({})).unwrap();
assert_eq!(listed[0]["blend"], "multiply");
assert!(s.run("map.setTiles", &json!({"name": "Relief", "blend": "Screen"})).is_ok());
assert_eq!(s.map().tiles[1].blend, Blend::Screen);
assert!(s.run("edit.undo", &json!({})).is_ok());
assert_eq!(s.map().tiles[1].blend, Blend::Normal);
assert!(s.run("map.setTiles", &json!({"index": 0, "blend": "nope"})).is_err());
assert!(s.run("map.addTiles", &json!({"url": "https://z.example.com/{z}/{x}/{y}.png", "blend": "nope"})).is_err());
assert_eq!(s.map().tiles.len(), 2);
// Saved with the project; a normal layer saves no blend.
let text = serde_json::to_string(&s.project).unwrap();
let p = Project::from_json(&text).unwrap();
assert_eq!(p.map().tiles[0].blend, Blend::Multiply);
assert_eq!(text.matches("\"blend\"").count(), 1, "{text}");
}
}
8 changes: 8 additions & 0 deletions crates/map/src/tiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ pub struct TileLayer {
/// A transparent layer meant to sit over a basemap (labels, roads, traffic).
#[serde(default, skip_serializing_if = "is_false")]
pub overlay: bool,
/// How it blends with the tile layers and basemap beneath (Multiply drapes imagery over a
/// hillshade).
#[serde(default, skip_serializing_if = "is_normal")]
pub blend: crate::effects::Blend,
/// The catalog entry it came from (`osm`, `esri-imagery`), when it came from one.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub id: String,
Expand All @@ -143,6 +147,9 @@ fn one() -> f32 {
fn is_false(b: &bool) -> bool {
!*b
}
fn is_normal(b: &crate::effects::Blend) -> bool {
*b == crate::effects::Blend::Normal
}

/// The deepest zoom level tiles are requested at.
pub const MAX_ZOOM: u8 = 24;
Expand All @@ -162,6 +169,7 @@ impl TileLayer {
visible: true,
opacity: 1.0,
overlay: false,
blend: crate::effects::Blend::Normal,
id: String::new(),
layers: Vec::new(),
}
Expand Down
2 changes: 1 addition & 1 deletion crates/render/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1422,7 +1422,7 @@ fn map_ground_res(view: &View, flat: &rustgis_map::projection::Flat) -> f64 {
(view.camera.res * flat.ground_per_unit(view.camera.center)).max(1e-9)
}

fn blend_mode(b: rustgis_map::effects::Blend) -> tiny_skia::BlendMode {
pub(crate) fn blend_mode(b: rustgis_map::effects::Blend) -> tiny_skia::BlendMode {
use rustgis_map::effects::Blend;
match b {
Blend::Normal => tiny_skia::BlendMode::SourceOver,
Expand Down
57 changes: 54 additions & 3 deletions crates/render/src/tiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
//! into place pixel by pixel.

use rustgis_geom::{Coord, Extent, MAX_LAT, lonlat_to_merc};
use rustgis_map::effects::Blend;
use rustgis_map::projection::Flat;
use rustgis_map::{Map, Rgba, TileKind, TileLayer};
use rustgis_tiles::{Frame, Kind, Spec};
use tiny_skia::Pixmap;
use tiny_skia::{Pixmap, PixmapPaint, Transform};

use crate::raster::Place;
use crate::{PlacedIcon, PlacedLabel, View};
Expand Down Expand Up @@ -106,8 +107,9 @@ pub(crate) fn draw(pm: &mut Pixmap, map: &Map, view: &View, place: Place, labels
m_per_px: res / view.pixels_per_point.max(0.01),
ppp,
};
let mut scratch = None;
for t in layers {
let d = rustgis_tiles::draw(pm, &spec(t), t.opacity, &frame, labels);
let d = draw_layer(pm, t, &frame, labels, &mut scratch);
collect(d, &t.name, &mut out);
}
return out;
Expand Down Expand Up @@ -149,14 +151,40 @@ pub(crate) fn draw(pm: &mut Pixmap, map: &Map, view: &View, place: Place, labels
// centre once the mosaic is warped there.
let mosaic_ppp = if detail > 0.0 { (detail * k / m_per_px) as f32 } else { ppp };
let frame = Frame { left: e.min_x, top: e.max_y, m_per_px, ppp: mosaic_ppp.clamp(0.05, 8.0) };
let mut scratch = None;
for t in layers {
let d = rustgis_tiles::draw(&mut mosaic, &spec(t), t.opacity, &frame, false);
let d = draw_layer(&mut mosaic, t, &frame, false, &mut scratch);
collect(d, &t.name, &mut out);
}
warp(pm, &mosaic, &frame, &to_lonlat, &to_merc);
out
}

/// Draws one tile layer into `pm`: straight in, or (blended) on a layer of its own composited
/// with its blend mode over what's beneath. `scratch` holds that layer between calls, so the
/// blended layers of one draw share one allocation.
fn draw_layer(pm: &mut Pixmap, t: &TileLayer, frame: &Frame, labels: bool, scratch: &mut Option<Pixmap>) -> rustgis_tiles::Drawn {
if t.blend == Blend::Normal {
return rustgis_tiles::draw(pm, &spec(t), t.opacity, frame, labels);
}
let fits = scratch.as_ref().is_some_and(|s| s.width() == pm.width() && s.height() == pm.height());
if fits {
if let Some(s) = scratch.as_mut() {
s.fill(tiny_skia::Color::TRANSPARENT);
}
} else {
*scratch = Pixmap::new(pm.width(), pm.height());
}
let Some(own) = scratch.as_mut() else {
log::debug!("no room to blend tile layer '{}' ({}x{}): drawing it unblended", t.name, pm.width(), pm.height());
return rustgis_tiles::draw(pm, &spec(t), t.opacity, frame, labels);
};
let d = rustgis_tiles::draw(own, &spec(t), t.opacity, frame, labels);
let paint = PixmapPaint { blend_mode: crate::blend_mode(t.blend), ..PixmapPaint::default() };
pm.draw_pixmap(0, 0, own.as_ref(), &paint, Transform::identity(), None);
d
}

/// Draws the mosaic over `pm`: each pixel takes the mosaic pixel under its longitude/latitude.
fn warp(pm: &mut Pixmap, mosaic: &Pixmap, frame: &Frame, to_lonlat: &dyn Fn(f64, f64) -> Option<Coord>, to_merc: &dyn Fn(Coord) -> Coord) {
let (w, h) = (pm.width() as usize, pm.height() as usize);
Expand Down Expand Up @@ -251,4 +279,27 @@ mod tests {
assert!(!r.tiles_pending);
assert_eq!(r.tile_errors.len(), 1, "{:?}", r.tile_errors);
}

#[test]
fn blends_tile_layers_with_those_beneath() {
serve();
let mut m = Map::new("Map");
m.graticule = false;
m.tiles.push(TileLayer::new("Red", TileKind::Raster, "https://tiles.test/red/{z}/{x}/{y}.png"));
let mut blue = TileLayer::new("Blue", TileKind::Style, "https://tiles.test/style.json");
blue.blend = rustgis_map::effects::Blend::Multiply;
m.tiles.insert(0, blue);
// Multiplied: red × blue, not the blue on top.
let p = pixel(&mut m);
assert!(p[0] < 40 && p[1] < 15 && p[2] < 30 && p[3] == 255, "{p:?}");
// Screened: lighter than either.
m.tiles[0].blend = rustgis_map::effects::Blend::Screen;
let p = pixel(&mut m);
assert!(p[0] > 220 && p[1] > 80 && p[2] > 0xE0, "{p:?}");
// And in the mosaic warped onto another projection (the globe shades it a little).
m.tiles[0].blend = rustgis_map::effects::Blend::Multiply;
m.projection = Projection::Globe;
let p = pixel(&mut m);
assert!(p[0] < 60 && p[1] < 40 && p[2] < 60, "{p:?}");
}
}
10 changes: 10 additions & 0 deletions crates/ui-egui/src/panes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1019,6 +1019,16 @@ fn tile_row(app: &mut RustGisApp, ui: &mut Ui, i: usize, count: usize, reorder:
action = Some(("map.setTiles", json!({"index": i, "opacity": o / 100.0})));
}
});
ui.menu_button(crate::i18n::fmt("Blend: {mode}", &[("mode", tl!(tile.blend.label()))]), |ui| {
for b in rustgis_engine::map::effects::Blend::ALL {
if ui.radio(tile.blend == b, tl!(b.label())).clicked() {
action = Some(("map.setTiles", json!({"index": i, "blend": b.code()})));
ui.close();
}
}
})
.response
.on_hover_text(tl!("How the layer's colours combine with the tile layers and basemap beneath."));
ui.separator();
if ui.add_enabled(reorder && i > 0, egui::Button::new(tl!("Move up"))).clicked() {
action = Some(("map.setTiles", json!({"index": i, "move": "up"})));
Expand Down
29 changes: 23 additions & 6 deletions crates/ui-egui/src/ribbon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -591,21 +591,38 @@ const WEB_BASEMAPS: [(&str, &str, &str, &str, &str, u8); 6] = [
/// The id of the registry plugin with many more web basemaps.
const BASEMAPS_PLUGIN: &str = "basemaps";

/// Web basemaps (OpenFreeMap, USGS) that replace the other tile basemaps, and where to get more.
/// Web basemaps (OpenFreeMap, USGS) that replace the other tile basemaps or stack on them, and
/// where to get more.
fn web_basemaps(ui: &mut Ui, app: &mut RustGisApp, t: &Tokens) {
ui.add_space(6.0);
ui.separator();
ui.label(egui::RichText::new(tl!("Web Basemaps")).font(semibold(12.5)));
ui.add_space(2.0);
// The web basemap on top (the one showing), if any.
let top = app.session.map().tiles.iter().find(|l| !l.overlay && l.visible).map(|l| l.id.clone());
// Stacked: a web basemap goes over the others (to blend with them in the Layers pane)
// rather than replacing them.
let stack_id = egui::Id::new("web-basemaps-stack");
let mut stack = ui.data_mut(|d| *d.get_persisted_mut_or_default::<bool>(stack_id));
let resp = ui.checkbox(&mut stack, tl!("Stack on the current basemaps"));
let tip =
tl!("Add a web basemap over the others instead of replacing them, to blend it with them. Click one that shows to take it off.");
if resp.on_hover_text(tip).changed() {
ui.data_mut(|d| d.insert_persisted(stack_id, stack));
}
// The web basemaps showing: the one on top, or every one when stacking.
let shown: Vec<String> = app.session.map().tiles.iter().filter(|l| !l.overlay && l.visible).map(|l| l.id.clone()).collect();
egui::Grid::new("web-basemaps").spacing(vec2(6.0, 4.0)).show(ui, |ui| {
for (i, (id, name, kind, url, attribution, max_zoom)) in WEB_BASEMAPS.into_iter().enumerate() {
let sel = top.as_deref() == Some(id);
let sel = if stack { shown.iter().any(|s| s == id) } else { shown.first().map(String::as_str) == Some(id) };
let resp = ui.add_sized(vec2(140.0, 22.0), egui::Button::selectable(sel, name)).on_hover_text(url);
if resp.clicked() {
let p = json!({"url": url, "kind": kind, "name": name, "attribution": attribution, "maxZoom": max_zoom, "id": id, "replace": true});
let _ = app.run("map.addTiles", p);
// Stacking, a shown basemap's button takes it off, keeping the others' blending;
// adding it again would replace it with a fresh layer on top.
let _ = if stack && sel {
app.run("map.removeTiles", json!({"id": id}))
} else {
let p = json!({"url": url, "kind": kind, "name": name, "attribution": attribution, "maxZoom": max_zoom, "id": id, "replace": !stack});
app.run("map.addTiles", p)
};
ui.close();
}
if i % 2 == 1 {
Expand Down
2 changes: 1 addition & 1 deletion docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ Inside a request, `host::call(op, params)` sends one of these to RustGIS:
| `host.view` | `{center: [lon, lat], scale, projection}` |
| `host.notify` `{text, error?}` | shows a message in the status bar |
| `host.addLayer` `{name, geojson}` | adds a layer (`edit`) |
| `map.addTiles` `{url, kind?, name?, attribution?, maxZoom?, overlay?, replace?}` | adds a web tile layer (`edit` and `network`) |
| `map.addTiles` `{url, kind?, name?, attribution?, maxZoom?, overlay?, replace?, blend?}` | adds a web tile layer (`edit` and `network`) |
| any command id (`layer.zoomTo`, `select.byAttributes`, `layer.symbology`…) | what the command returns, within the plugin's permissions |

In the web app a command can't wait for a download, so where `layer.addUrl` has to download a
Expand Down
9 changes: 7 additions & 2 deletions docs/user-guide/maps.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,14 +114,19 @@ Ocean, Atlas, Blank), which need no network, and web tile layers over them:
its text that the map's font can draw: a style's "Syria / سوريا" shows as "Syria".

The gallery's **Web Basemaps** need no API key: the OpenFreeMap vector maps (Liberty, Bright,
Positron, Dark, Fiord) and USGS Imagery. Choosing one replaces the other web basemaps. For many
Positron, Dark, Fiord) and USGS Imagery. Choosing one replaces the other web basemaps; tick
**Stack on the current basemaps** above them to add it over the others instead (clicking one
that already shows then takes it off). For many
more (OpenStreetMap, Esri, NASA and others), follow **Install the Basemaps plugin** under them,
which opens the Plugin Manager at the
[Basemaps plugin](https://plugins.rustgis.app/catalog/basemaps/); it adds a tab of public
basemaps. Add any other with **Add Tile Layer…** at the bottom of the Basemap gallery.
The gallery lists the map's tile layers: show or hide them, change their opacity and order, or
remove them. The Layers pane lists them too, under the map's layers and above the basemap: tick
one to show or hide it, and right-click it for its opacity, order and **Remove**. Overlays (labels, roads) stay on top of the basemaps; choosing a built-in basemap
one to show or hide it, and right-click it for its opacity, **Blend** mode, order and **Remove**.
A blend mode (Multiply, Screen, Overlay, Darken, Lighten, Difference) combines a tile layer with
the tile layers and basemap beneath it: stack imagery over a hillshade and set it to Multiply to
let the terrain show through. Overlays (labels, roads) stay on top of the basemaps; choosing a built-in basemap
removes the web basemaps but keeps the overlays. Tile layers are saved with the project, and
their credits are shown in the corner of the map and drawn on exports.

Expand Down
Loading