diff --git a/crates/engine/src/cmd/raster.rs b/crates/engine/src/cmd/raster.rs index 25b286223..2958ffee2 100644 --- a/crates/engine/src/cmd/raster.rs +++ b/crates/engine/src/cmd/raster.rs @@ -36,7 +36,7 @@ pub fn commands() -> Vec { C::new("layer.addDataBytes", "Add Data (from file contents)", "Home › Add", add_bytes).roles(&[]).effects(crate::policy::Effects::NONE) .params(r#"{"fileName": "name.tif|.tiff|.asc|.geojson|.csv|.zip|.kml|.kmz|.gpx|.parquet|.fgb|.gpkg|.mbtiles|.jp2|.j2k (JPEG 2000: desktop, through GDAL)", "data": base64, "zoom"?: bool, "layer"?: "one layer of a zip, KML or GPX by name", "name"?: text, "x"?, "y"?, "wkt"?, "crs"?, "delimiter"?, "paperSpace"?, "stream"?, "as"? (as layer.addData)}"#), C::new("raster.symbology", "Symbology", "Style › Rendering", symbology).when(need_raster).roles(&[]).effects(crate::policy::Effects::NONE).params( - r#"{"layer"?, "type": "stretch"|"rgb"|"classify"|"unique"|"colormap"|"shadedRelief", "band"?: 1.., "ramp"?, "invert"?: bool, "classes"?: 1..32, "method"?, "azimuth"?, "altitude"?, "zFactor"?, "shadow"?: 0..1 (shadedRelief: how dark the shadows the terrain casts are; left out, kept; 0 casts none), "sky"?: 0..1 (shadedRelief: how much the share of the sky each cell sees dims its ambient light; left out, kept; 0 leaves it whole), "grey"?: bool (shadedRelief without its colour ramp)}"#, + r#"{"layer"?, "type": "stretch"|"rgb"|"classify"|"unique"|"colormap"|"shadedRelief", "band"?: 1.., "ramp"?, "invert"?: bool, "classes"?: 1..32, "method"?, "azimuth"?, "altitude"?, "zFactor"?, "shadow"?: 0..1 (shadedRelief: how dark the shadows the terrain casts are; left out, kept; 0 casts none), "sky"?: 0..1 (shadedRelief: how much the share of the sky each cell sees dims its ambient light; left out, kept; 0 leaves it whole), "fill"?: 0..1 (shadedRelief: a fill light from the side opposite the sun that lightens the slopes turned toward it; left out, kept; 0 has none), "grey"?: bool (shadedRelief without its colour ramp)}"#, ), C::new("raster.stretch", "Stretch Type", "Style › Rendering", stretch).inputs(stretch_inputs).roles(&[]).effects(crate::policy::Effects::NONE) .when(need_raster) @@ -778,10 +778,10 @@ fn symbology(s: &mut Session, p: &Value) -> CmdResult { RasterRenderer::Colormap } "shadedrelief" | "hillshade" => { - // Left out, a Shaded Relief keeps its own shadows and sky; a new one has neither. - let (cur_shadow, cur_sky) = match rl.style.renderer { - RasterRenderer::ShadedRelief { shadow, sky, .. } => (shadow, sky), - _ => (0.0, 0.0), + // Left out, a Shaded Relief keeps its own shadows, sky and fill light; a new one has none. + let (cur_shadow, cur_sky, cur_fill) = match rl.style.renderer { + RasterRenderer::ShadedRelief { shadow, sky, fill, .. } => (shadow, sky, fill), + _ => (0.0, 0.0, 0.0), }; RasterRenderer::ShadedRelief { band: bi, @@ -791,6 +791,7 @@ fn symbology(s: &mut Session, p: &Value) -> CmdResult { z_factor: f(p, "zFactor").unwrap_or(1.0).clamp(0.001, 1000.0), shadow: f(p, "shadow").unwrap_or(cur_shadow).clamp(0.0, 1.0), sky: f(p, "sky").unwrap_or(cur_sky).clamp(0.0, 1.0), + fill: f(p, "fill").unwrap_or(cur_fill).clamp(0.0, 1.0), } } other => return Err(format!("unknown raster symbology '{other}'")), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8b63ef6a5..880549db4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4000,6 +4000,39 @@ fn shaded_relief_keeps_its_sky_visibility() { assert!(s.notices.iter().all(|n| !n.text.contains("sky")), "{:?}", s.notices); } +/// Shaded Relief's fill light is set, clamped, kept when left out, saved with the project only +/// when there is some, and back when the project is opened again. +#[test] +fn shaded_relief_keeps_its_fill_light() { + let mut s = with_raster(); + let fill = |s: &Session| match s.map().layers.iter().find_map(|l| l.raster.as_ref()).map(|r| &r.style.renderer) { + Some(rustgis_map::RasterRenderer::ShadedRelief { fill, .. }) => *fill, + other => panic!("{other:?}"), + }; + let fills_saved = |s: &mut Session| s.project.saved_text(None).unwrap().0.matches("\"fill\"").count(); + let before = fills_saved(&mut s); + s.run("raster.symbology", &json!({"type": "shadedRelief"})).unwrap(); + assert_eq!((fill(&s), fills_saved(&mut s)), (0.0, before), "none, and not saved"); + s.run("raster.symbology", &json!({"type": "shadedRelief", "fill": 0.3})).unwrap(); + s.run("raster.symbology", &json!({"type": "shadedRelief", "azimuth": 200})).unwrap(); + assert_eq!(fill(&s), 0.3, "left out, kept"); + assert_eq!(fills_saved(&mut s), before + 1); + s.run("raster.symbology", &json!({"type": "shadedRelief", "fill": -2})).unwrap(); + assert_eq!(fill(&s), 0.0); + s.run("raster.symbology", &json!({"type": "shadedRelief", "fill": 7})).unwrap(); + assert_eq!(fill(&s), 1.0); + s.run("raster.symbology", &json!({"type": "shadedRelief", "fill": 0.45})).unwrap(); + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos()); + let dir = std::env::temp_dir().join(format!("rustgis-fill-{}-{nanos}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("fill.rgis"); + s.run("project.saveAs", &json!({"path": path.display().to_string()})).unwrap(); + let mut t = Session::new(Project::default()); + t.run("project.open", &json!({"path": path.display().to_string()})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + assert_eq!(fill(&t), 0.45, "back when opened"); +} + /// An export draws the sky a pit hides: its floor darker with `sky` 1 than with 0, the ground /// around it (which sees the whole sky) the same. The first export with the sky, from a cold /// cache, waits for the grid: it is the same image as one drawn once the grid is kept. diff --git a/crates/map/src/qgis/raster.rs b/crates/map/src/qgis/raster.rs index 8cf235013..53b735e41 100644 --- a/crates/map/src/qgis/raster.rs +++ b/crates/map/src/qgis/raster.rs @@ -96,6 +96,7 @@ fn renderer_style(node: Node, pipe: Node, r: Node, kind: &str, layer: &str, repo z_factor: num("zfactor", 1.0).clamp(0.0001, 10_000.0), shadow: 0.0, sky: 0.0, + fill: 0.0, }; (renderer, Stretch::None) } diff --git a/crates/map/src/qgis/tests.rs b/crates/map/src/qgis/tests.rs index 319f4eb74..3d1f1f5c1 100644 --- a/crates/map/src/qgis/tests.rs +++ b/crates/map/src/qgis/tests.rs @@ -867,7 +867,16 @@ fn raster_renderers_read_as_rustgis_raster_styles() { ); assert_eq!( s.raster.unwrap().renderer, - RasterRenderer::ShadedRelief { band: 0, ramp: None, azimuth: 300.0, altitude: 35.0, z_factor: 2.0, shadow: 0.0, sky: 0.0 } + RasterRenderer::ShadedRelief { + band: 0, + ramp: None, + azimuth: 300.0, + altitude: 35.0, + z_factor: 2.0, + shadow: 0.0, + sky: 0.0, + fill: 0.0 + } ); assert!(report.notes.iter().any(|n| n.text.contains("saturation"))); let (s, report) = read(r#""#, ""); diff --git a/crates/map/src/raster.rs b/crates/map/src/raster.rs index b2d093912..fa43afb2b 100644 --- a/crates/map/src/raster.rs +++ b/crates/map/src/raster.rs @@ -1150,7 +1150,7 @@ impl Default for ZarrTime { } } -fn is_no_shadow(v: &f64) -> bool { +fn is_no_light(v: &f64) -> bool { *v == 0.0 } @@ -1356,17 +1356,21 @@ pub enum RasterRenderer { /// Hillshade, optionally tinted by elevation. `shadow` (0 to 1) is how dark the shadows the /// terrain casts are drawn (see [`crate::shadow`]); 0, the default, casts none. `sky` (0 to 1) /// is how much the share of the sky each cell sees scales its ambient light - /// ([`crate::shadow::sky`]); 0, the default, leaves it whole. + /// ([`crate::shadow::sky`]); 0, the default, leaves it whole. `fill` (0 to 1) is a fill light + /// from the side opposite the sun, 55° up, that lightens only the slopes turned toward it; 0, + /// the default, has none. ShadedRelief { band: usize, ramp: Option, azimuth: f64, altitude: f64, z_factor: f64, - #[serde(default, skip_serializing_if = "is_no_shadow")] + #[serde(default, skip_serializing_if = "is_no_light")] shadow: f64, - #[serde(default, skip_serializing_if = "is_no_shadow")] + #[serde(default, skip_serializing_if = "is_no_light")] sky: f64, + #[serde(default, skip_serializing_if = "is_no_light")] + fill: f64, }, } @@ -1925,16 +1929,17 @@ impl<'a> Colorizer<'a> { /// The colour of a pixel from its renderer band values (`NaN` = no data), or `None` for /// transparent. `hillshade` (0–255) is used by Shaded Relief. pub fn color(&self, vals: &[f32], hillshade: Option) -> Option<[u8; 4]> { - self.color_lit(vals, hillshade, None) + self.color_lit(vals, hillshade, None, None) } /// [`Colorizer::color`], with Shaded Relief's ambient light scaled by `ambient` (0–255, the - /// share of it the sky a cell sees leaves, see [`RasterRenderer::ShadedRelief`]'s `sky`). - pub fn color_lit(&self, vals: &[f32], hillshade: Option, ambient: Option) -> Option<[u8; 4]> { + /// share of it the sky a cell sees leaves, see [`RasterRenderer::ShadedRelief`]'s `sky`), and + /// `fill` (0–255, a share of the colour) added for its fill light (its `fill`). + pub fn color_lit(&self, vals: &[f32], hillshade: Option, ambient: Option, fill: Option) -> Option<[u8; 4]> { // The renderer masks values as it samples them (they arrive as NaN); this is for other callers. let mask = &self.style.mask; if mask.is_empty() || !vals.iter().any(|v| mask.hides(*v)) { - return self.paint(vals, hillshade, ambient); + return self.paint(vals, hillshade, ambient, fill); } // Masked values are no data. The renderer bands are at most three. let mut kept = [f32::NAN; 3]; @@ -1943,10 +1948,10 @@ impl<'a> Colorizer<'a> { *k = *v; } } - self.paint(kept.get(..vals.len().min(3)).unwrap_or(&kept), hillshade, ambient) + self.paint(kept.get(..vals.len().min(3)).unwrap_or(&kept), hillshade, ambient, fill) } - fn paint(&self, vals: &[f32], hillshade: Option, ambient: Option) -> Option<[u8; 4]> { + fn paint(&self, vals: &[f32], hillshade: Option, ambient: Option, fill: Option) -> Option<[u8; 4]> { let v0 = *vals.first()?; let c = match &self.style.renderer { RasterRenderer::Rgb { .. } => { @@ -1977,9 +1982,10 @@ impl<'a> Colorizer<'a> { let h = hillshade.unwrap_or(180) as f64 / 255.0; let base = if ramp.is_some() { *self.ramp_lut.get(self.byte(0, v0) as usize)? } else { [255, 255, 255, 255] }; // Multiply the tint by the hillshade, keeping some ambient light (less of it where - // the terrain hides part of the sky). + // the terrain hides part of the sky), and adding the fill light on its own. let a = 0.25 * ambient.map_or(1.0, |a| f64::from(a) / 255.0); - let m = |c: u8| (c as f64 * (a + 0.75 * h)).round().clamp(0.0, 255.0) as u8; + let f = fill.map_or(0.0, |f| f64::from(f) / 255.0); + let m = |c: u8| (c as f64 * (a + 0.75 * h + f)).round().clamp(0.0, 255.0) as u8; [m(base[0]), m(base[1]), m(base[2]), 255] } }; @@ -2788,7 +2794,7 @@ mod tests { let r = Raster::new("tower", 20, 20, vec![data], gt, Crs::WebMercator, None, ColorHint::Gray, "Float32").unwrap(); let mut rl = RasterLayer::from_path(r, "tower.tif"); rl.style.renderer = - RasterRenderer::ShadedRelief { band: 0, ramp: None, azimuth: 90.0, altitude: 30.0, z_factor: 1.0, shadow, sky: 0.0 }; + RasterRenderer::ShadedRelief { band: 0, ramp: None, azimuth: 90.0, altitude: 30.0, z_factor: 1.0, shadow, sky: 0.0, fill: 0.0 }; rl } @@ -2846,8 +2852,16 @@ mod tests { let r = Raster::new("hills", 1200, 1200, vec![data], gt, Crs::WebMercator, None, ColorHint::Gray, "Float32").unwrap(); let mut rl = RasterLayer::from_path(r, "hills.tif"); let lit = |rl: &mut RasterLayer, az: f64| { - rl.style.renderer = - RasterRenderer::ShadedRelief { band: 0, ramp: None, azimuth: az, altitude: 10.0, z_factor: 1.0, shadow: 1.0, sky: 0.0 }; + rl.style.renderer = RasterRenderer::ShadedRelief { + band: 0, + ramp: None, + azimuth: az, + altitude: 10.0, + z_factor: 1.0, + shadow: 1.0, + sky: 0.0, + fill: 0.0, + }; }; let other = |az: f64, rl: &RasterLayer| { let mut c = rl.clone(); @@ -2906,8 +2920,16 @@ mod tests { let data: Vec = (0..2000 * 2000).map(|i| ((i % 2000) as f32 * 0.01) + ((i / 2000) as f32 * 0.01)).collect(); let r = Raster::new("plain", 2000, 2000, vec![data], gt, Crs::WebMercator, None, ColorHint::Gray, "Float32").unwrap(); let mut rl = RasterLayer::from_path(r, "plain.tif"); - rl.style.renderer = - RasterRenderer::ShadedRelief { band: 0, ramp: None, azimuth: 135.0, altitude: 2.0, z_factor: 1.0, shadow: 1.0, sky: 0.0 }; + rl.style.renderer = RasterRenderer::ShadedRelief { + band: 0, + ramp: None, + azimuth: 135.0, + altitude: 2.0, + z_factor: 1.0, + shadow: 1.0, + sky: 0.0, + fill: 0.0, + }; let (sent, landings) = std::sync::mpsc::channel::<()>(); { let _cancelled = rustgis_data::progress::install(Some(std::sync::Arc::new(Cancelled))); @@ -2935,8 +2957,16 @@ mod tests { let data: Vec = (0..800 * 800).map(|i| ((i % 800) as f32 * 0.01) + ((i / 800) as f32 * 0.01)).collect(); let r = Raster::new("plain", 800, 800, vec![data], gt, Crs::WebMercator, None, ColorHint::Gray, "Float32").unwrap(); let mut rl = RasterLayer::from_path(r, "plain.tif"); - rl.style.renderer = - RasterRenderer::ShadedRelief { band: 0, ramp: None, azimuth: 135.0, altitude: 3.0, z_factor: 1.0, shadow: 1.0, sky: 0.0 }; + rl.style.renderer = RasterRenderer::ShadedRelief { + band: 0, + ramp: None, + azimuth: 135.0, + altitude: 3.0, + z_factor: 1.0, + shadow: 1.0, + sky: 0.0, + fill: 0.0, + }; let (sent, landings) = std::sync::mpsc::channel::<&'static str>(); let told = |who: &'static str| { let tx = sent.clone(); diff --git a/crates/render/src/layout.rs b/crates/render/src/layout.rs index 0a3d06d90..04f264aa9 100644 --- a/crates/render/src/layout.rs +++ b/crates/render/src/layout.rs @@ -3681,6 +3681,7 @@ mod tests { z_factor: 1.0, shadow, sky: 0.0, + fill: 0.0, }; for shadow in [0.0, 1.0] { let map = ramp_map(relief(shadow), rustgis_map::raster::Stretch::MinMax); @@ -3730,6 +3731,7 @@ mod tests { z_factor: 1.0, shadow: 1.0, sky: 0.0, + fill: 0.0, }; let mut m = Map::new(name.clone()); assert_eq!(m.add_raster("dem", copy), 1); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 209901b30..19069df57 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -8775,6 +8775,7 @@ mod tests { z_factor: 1.0, shadow, sky: 0.0, + fill: 0.0, }; }; let camera = Camera { center: lonlat_to_merc(Coord::new(0.0, 0.0)), res: 1200.0, bearing: 0.0, pitch: 0.0 }; @@ -8808,6 +8809,7 @@ mod tests { z_factor: 1.0, shadow: 1.0, sky: 0.0, + fill: 0.0, }; // Drawn only zoomed out past a scale between the centre's and the far band's. l.max_scale = Some((centre + farthest) / 2.0); @@ -8851,6 +8853,7 @@ mod tests { z_factor: 1.0, shadow: 1.0, sky: 0.0, + fill: 0.0, }; // Drawn only zoomed in past 1:1: or in a group drawn only there. if grouped { diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 5954d7e35..0db44290d 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -491,6 +491,11 @@ fn draw_with( RasterRenderer::ShadedRelief { shadow, sky, .. } => (lit_by(light.shadow, shadow, cells), lit_by(light.sky, sky, cells)), _ => (None, None), }; + // The fill light's strength (0 to 1); 0 has none. + let fill = match style.renderer { + RasterRenderer::ShadedRelief { fill, .. } if fill > 0.0 => fill.min(1.0), + _ => 0.0, + }; // Blending neighbours would invent values that aren't classes or palette entries. let resampling = match style.renderer { RasterRenderer::UniqueValues { .. } | RasterRenderer::Colormap => Resampling::Nearest, @@ -521,6 +526,7 @@ fn draw_with( relief, shadow, sky, + fill, resampling, alpha_band, alpha_max: rustgis_data::raster::alpha_max(&r.data_type), @@ -532,6 +538,7 @@ fn draw_with( let mut shade = if relief.is_some() { vec![0u8; bw * bh] } else { Vec::new() }; let mut alpha = if alpha_band.is_some() { vec![0u8; bw * bh] } else { Vec::new() }; let mut ambient = if sky.is_some() { vec![255u8; bw * bh] } else { Vec::new() }; + let mut filled = if relief.is_some() && fill > 0.0 { vec![0u8; bw * bh] } else { Vec::new() }; let threads = threads.clamp(1, bh); let rows = bh.div_ceil(threads.max(1)); each_chunk( @@ -540,8 +547,9 @@ fn draw_with( .zip(chunks_or_empty(&mut shade, rows * bw)) .zip(chunks_or_empty(&mut alpha, rows * bw)) .zip(chunks_or_empty(&mut ambient, rows * bw)) + .zip(chunks_or_empty(&mut filled, rows * bw)) .enumerate(), - |(i, (((v, sh), al), am))| pass1.rows(y0 + i * rows, v, sh, al, am), + |(i, ((((v, sh), al), am), fl))| pass1.rows(y0 + i * rows, v, sh, al, am, fl), ); // Dynamic range adjustment: statistics of the values in view. @@ -551,19 +559,19 @@ fn draw_with( // Pass 2: colours, premultiplied, into a pixmap the size of the raster's screen box. let Some(mut lay) = Pixmap::new(bw as u32, bh as u32) else { return }; - let (vals, shade, alpha, ambient) = (&vals, &shade, &alpha, &ambient); + let (vals, shade, alpha, ambient, filled) = (&vals, &shade, &alpha, &ambient, &filled); each_chunk(threads, lay.data_mut().chunks_mut(rows * bw * 4).enumerate(), |(ci, out)| { let first = ci * rows * bw; // Neighbouring pixels often share their values (zoomed in, or classes): colour once. - let mut last: Option<(&[f32], [Option; 3], Option<[u8; 4]>)> = None; + let mut last: Option<(&[f32], [Option; 4], Option<[u8; 4]>)> = None; for (j, px) in out.as_chunks_mut::<4>().0.iter_mut().enumerate() { let i = first + j; let Some(v) = vals.get(i * nb..(i + 1) * nb) else { continue }; - let (sh, al, am) = (shade.get(i).copied(), alpha.get(i).copied(), ambient.get(i).copied()); + let (sh, al, am, fl) = (shade.get(i).copied(), alpha.get(i).copied(), ambient.get(i).copied(), filled.get(i).copied()); let c = match last { - Some((lv, l, c)) if l == [sh, al, am] && same(lv, v) => c, + Some((lv, l, c)) if l == [sh, al, am, fl] && same(lv, v) => c, _ => { - let c = colorizer.color_lit(v, sh, am).map(|mut c| { + let c = colorizer.color_lit(v, sh, am, fl).map(|mut c| { if let Some(a) = al { c[3] = ((c[3] as u16 * a as u16 + 127) / 255) as u8; } @@ -571,7 +579,7 @@ fn draw_with( let pm = |x: u8| ((x as u16 * a + 127) / 255) as u8; [pm(c[0]), pm(c[1]), pm(c[2]), c[3]] }); - last = Some((v, [sh, al, am], c)); + last = Some((v, [sh, al, am, fl], c)); c } }; @@ -657,6 +665,8 @@ struct Pass1<'a> { shadow: Option<(&'a [u8], f64)>, /// The share of the sky each cell sees, and how much it scales the ambient light (0 to 1). sky: Option<(&'a [u8], f64)>, + /// The fill light's strength (0 to 1; 0, none), see [`FILL_ALTITUDE`]. + fill: f64, resampling: Resampling, alpha_band: Option, /// The alpha band's opaque value (its pixel type's: 65535 for 16 bits). @@ -670,9 +680,9 @@ struct Pass1<'a> { } impl Pass1<'_> { - /// Fills the band values (and hillshade, alpha and ambient light, when kept) of the screen rows - /// from `py0` on, as many as `vals` holds. - fn rows(&self, py0: usize, vals: &mut [f32], shade: &mut [u8], alpha: &mut [u8], ambient: &mut [u8]) { + /// Fills the band values (and hillshade, alpha, ambient and fill light, when kept) of the screen + /// rows from `py0` on, as many as `vals` holds. + fn rows(&self, py0: usize, vals: &mut [f32], shade: &mut [u8], alpha: &mut [u8], ambient: &mut [u8], filled: &mut [u8]) { let (r, bw, nb) = (self.r, self.bw, self.bands.len().max(1)); let lerp = |p: f64, q: f64, t: f64| p + (q - p) * t; // Each grid column's source position at this row, interpolated down the grid. @@ -810,6 +820,9 @@ impl Pass1<'_> { { *a = light.ambient.round().clamp(0.0, 255.0) as u8; } + if let Some(f) = filled.get_mut(i) { + *f = light.fill.round().clamp(0.0, 255.0) as u8; + } } } } @@ -817,9 +830,10 @@ impl Pass1<'_> { /// The lights on the relief's cell `(ci, ri)`, from 0 to 255 and not rounded: the direct /// light, the hillshade of its 3 × 3 window times what the cast shadows leave on it; and the - /// ambient light, scaled by the sky the cell sees. `centre` is the cell's height, and stands in - /// for a neighbour that is no data, masked or outside the grid. Every light a cell gets is - /// worked out here, so that [`blend`] smooths each of them. + /// ambient light, scaled by the sky the cell sees; and the fill light (see [`FILL_ALTITUDE`]), + /// which nothing shadows. `centre` is the cell's height, and stands in for a neighbour that is + /// no data, masked or outside the grid. Every light a cell gets is worked out here, so that + /// [`blend`] smooths each of them. fn light(&self, band: usize, (az, alt, zf): (f64, f64, f64), (ci, ri): (i64, i64), centre: f64) -> Lit { let r = self.r; let mut z = [centre; 9]; @@ -836,7 +850,15 @@ impl Pass1<'_> { if let Some(left) = left(self.shadow) { direct *= left; } - Lit { direct, ambient: left(self.sky).map_or(255.0, |left| left * 255.0) } + // The fill light lights only what turns to it more than flat ground does, so it opens the + // slopes away from the sun without lifting the whole map. + let fill = if self.fill > 0.0 { + let toward = f64::from(hillshade(&z, self.cell.0, self.cell.1, (az + 180.0) % 360.0, FILL_ALTITUDE, zf)); + self.fill * (toward - FILL_FLAT).max(0.0) + } else { + 0.0 + }; + Lit { direct, ambient: left(self.sky).map_or(255.0, |left| left * 255.0), fill } } /// The lights of the cell `(i, j)`, or none when it is no data, masked or outside the grid; a @@ -857,11 +879,20 @@ impl Pass1<'_> { } } +/// How high a Shaded Relief's fill light is, in degrees: it comes from the side opposite the sun, +/// high enough not to draw long gradients of its own. +const FILL_ALTITUDE: f64 = 55.0; + +/// The fill light's hillshade (0–255) on flat ground, rounded as [`hillshade`] rounds it: the +/// share of it nothing gains. +const FILL_FLAT: f64 = 209.0; + /// A relief cell's lights, from 0 to 255 and not rounded (see [`Pass1::light`]). #[derive(Clone, Copy, Debug, PartialEq)] struct Lit { direct: f64, ambient: f64, + fill: f64, } /// The four cells' lights of [`Pass1::quad_lights`] blended bilinearly at `(fx, fy)` (0 to 1) @@ -871,15 +902,16 @@ struct Lit { /// with a weight has lights. fn blend(lights: [Option; 4], fx: f64, fy: f64) -> Option { let weights = [(1.0 - fx) * (1.0 - fy), fx * (1.0 - fy), (1.0 - fx) * fy, fx * fy]; - let (mut direct, mut ambient, mut total) = (0.0, 0.0, 0.0); + let (mut direct, mut ambient, mut fill, mut total) = (0.0, 0.0, 0.0, 0.0); for (light, w) in lights.into_iter().zip(weights) { if let Some(light) = light { direct += w * light.direct; ambient += w * light.ambient; + fill += w * light.fill; total += w; } } - (total > 0.0).then(|| Lit { direct: direct / total, ambient: ambient / total }) + (total > 0.0).then(|| Lit { direct: direct / total, ambient: ambient / total, fill: fill / total }) } /// Whether two pixels' band values are the same (NaN, no data, equal to itself). @@ -1023,8 +1055,16 @@ mod tests { .collect(); let r = Raster::new("relief", 10, 10, vec![data], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); let mut style = RasterStyle::default_for(&r); - style.renderer = - RasterRenderer::ShadedRelief { band: 0, ramp: None, azimuth, altitude: 45.0, z_factor: 1.0, shadow: 0.0, sky: 0.0 }; + style.renderer = RasterRenderer::ShadedRelief { + band: 0, + ramp: None, + azimuth, + altitude: 45.0, + z_factor: 1.0, + shadow: 0.0, + sky: 0.0, + fill: 0.0, + }; style.resampling = Resampling::Nearest; let mut pm = Pixmap::new(1, 1).unwrap(); draw(&mut pm, &r, &style, None, &view, 1.0, false, Place::Flat(&Flat::Mercator, "mercator"), Lights::default()); @@ -1057,8 +1097,16 @@ mod tests { let data = [0.0, 1_000_000.0, 2_000_000.0, 3_000_000.0].repeat(18); let r = Raster::new("regional relief", 4, 18, vec![data], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); let mut style = RasterStyle::default_for(&r); - style.renderer = - RasterRenderer::ShadedRelief { band: 0, ramp: None, azimuth: 270.0, altitude: 45.0, z_factor: 1.0, shadow: 0.0, sky: 0.0 }; + style.renderer = RasterRenderer::ShadedRelief { + band: 0, + ramp: None, + azimuth: 270.0, + altitude: 45.0, + z_factor: 1.0, + shadow: 0.0, + sky: 0.0, + fill: 0.0, + }; style.resampling = Resampling::Nearest; let view = View { width: 1.0, @@ -1097,8 +1145,16 @@ mod tests { let dark = vec![0u8; 4 * 18]; let drawn = |shadow: f64, light: Option<&[u8]>| { let mut style = RasterStyle::default_for(&r); - style.renderer = - RasterRenderer::ShadedRelief { band: 0, ramp: None, azimuth: 270.0, altitude: 45.0, z_factor: 1.0, shadow, sky: 0.0 }; + style.renderer = RasterRenderer::ShadedRelief { + band: 0, + ramp: None, + azimuth: 270.0, + altitude: 45.0, + z_factor: 1.0, + shadow, + sky: 0.0, + fill: 0.0, + }; style.resampling = Resampling::Nearest; let mut pm = Pixmap::new(1, 1).unwrap(); draw( @@ -1151,7 +1207,7 @@ mod tests { let drawn = |shadow: f64, sky: f64, light: Lights| { let mut style = RasterStyle::default_for(&r); style.renderer = - RasterRenderer::ShadedRelief { band: 0, ramp: None, azimuth: 270.0, altitude: 45.0, z_factor: 1.0, shadow, sky }; + RasterRenderer::ShadedRelief { band: 0, ramp: None, azimuth: 270.0, altitude: 45.0, z_factor: 1.0, shadow, sky, fill: 0.0 }; style.resampling = Resampling::Nearest; let mut pm = Pixmap::new(1, 1).unwrap(); draw(&mut pm, &r, &style, None, &view, 1.0, false, Place::Flat(&Flat::Mercator, "mercator"), light); @@ -1164,9 +1220,17 @@ mod tests { assert_eq!(drawn(0.0, 1.0, Lights::default()), today, "no grid"); // No sky at all: the direct light alone. let mut style = RasterStyle::default_for(&r); - style.renderer = - RasterRenderer::ShadedRelief { band: 0, ramp: None, azimuth: 270.0, altitude: 45.0, z_factor: 1.0, shadow: 0.0, sky: 1.0 }; - let want = Colorizer::new(&style, &r, None).color_lit(&[0.0], Some(lit), Some(0)).unwrap(); + style.renderer = RasterRenderer::ShadedRelief { + band: 0, + ramp: None, + azimuth: 270.0, + altitude: 45.0, + z_factor: 1.0, + shadow: 0.0, + sky: 1.0, + fill: 0.0, + }; + let want = Colorizer::new(&style, &r, None).color_lit(&[0.0], Some(lit), Some(0), None).unwrap(); assert_eq!(drawn(0.0, 1.0, hidden), want); assert!(want[0] < today[0]); // In full shadow only the ambient light is left: a quarter of the colour, scaled by the @@ -1178,6 +1242,50 @@ mod tests { } } + /// The fill light (#1912), from the side opposite a western sun, 55° up: a fill of 0 draws + /// today's pixels; flat ground gains nothing; a slope turned east, toward the fill, gains + /// `fill × (L − sin 55°)` of its colour, L its Lambert term toward the fill light. + #[test] + fn the_fill_light_opens_only_the_slopes_turned_toward_it() { + assert_eq!(f64::from(hillshade(&[0.0; 9], 100.0, 100.0, 90.0, FILL_ALTITUDE, 1.0)), FILL_FLAT); + // Three columns of 100 m cells, their heights `east` from west to east: the middle cell's red. + let drawn = |east: [f64; 3], fill: f64| { + let gt = GeoTransform { origin_x: 0.0, origin_y: 300.0, pixel_width: 100.0, pixel_height: -100.0 }; + let data = east.map(|h| h as f32).repeat(3); + let r = Raster::new("relief", 3, 3, vec![data], gt, Crs::WebMercator, None, ColorHint::Gray, "Float32").unwrap(); + let mut style = RasterStyle::default_for(&r); + let (azimuth, altitude) = (270.0, 45.0); + style.renderer = + RasterRenderer::ShadedRelief { band: 0, ramp: None, azimuth, altitude, z_factor: 1.0, shadow: 0.0, sky: 0.0, fill }; + style.resampling = Resampling::Nearest; + let view = View { + width: 1.0, + height: 1.0, + pixels_per_point: 1.0, + camera: Camera { center: Coord::new(150.0, 150.0), res: 1.0, bearing: 0.0, pitch: 0.0 }, + sky: false, + }; + let mut pm = Pixmap::new(1, 1).unwrap(); + draw(&mut pm, &r, &style, None, &view, 1.0, false, Place::Flat(&Flat::Mercator, "mercator"), Lights::default()); + pm.pixel(0, 0).unwrap().red() + }; + // Today's colour (a quarter ambient, three quarters the sun's hillshade), plus the fill. + let want = |east: [f64; 3], fill: f64| { + let z: [f64; 9] = std::array::from_fn(|k| east[k % 3]); + let shade = f64::from(hillshade(&z, 100.0, 100.0, 270.0, 45.0, 1.0)); + let toward = f64::from(hillshade(&z, 100.0, 100.0, 90.0, FILL_ALTITUDE, 1.0)); + (255.0 * 0.25 + 0.75 * shade + (fill * (toward - FILL_FLAT).max(0.0)).round()).round() as u8 + }; + let (flat, to_sun, to_fill) = ([0.0; 3], [100.0, 200.0, 300.0], [300.0, 200.0, 100.0]); + for terrain in [flat, to_sun, to_fill] { + assert_eq!(drawn(terrain, 0.0), want(terrain, 0.0), "a fill of 0 draws today's pixels, {terrain:?}"); + assert_eq!(drawn(terrain, 0.3), want(terrain, 0.3), "{terrain:?}"); + } + assert_eq!(drawn(flat, 1.0), drawn(flat, 0.0), "flat ground gains nothing"); + assert_eq!(drawn(to_sun, 1.0), drawn(to_sun, 0.0), "nor a slope turned to the sun"); + assert!(drawn(to_fill, 0.3) > drawn(to_fill, 0.0) && drawn(to_fill, 1.0) > drawn(to_fill, 0.3), "a slope turned to the fill"); + } + /// A 0–360° grid seen across its seam: the part in view is two strips, one at each end of /// the grid, not the whole width. #[test] @@ -1218,8 +1326,16 @@ mod tests { let data: Vec = (0..36 * 18).map(|i| elevations[(i % 36 + shift) % 36]).collect(); let r = Raster::new("relief", 36, 18, vec![data], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); let mut style = RasterStyle::default_for(&r); - style.renderer = - RasterRenderer::ShadedRelief { band: 0, ramp: None, azimuth: 270.0, altitude: 45.0, z_factor: 1.0, shadow: 0.0, sky: 0.0 }; + style.renderer = RasterRenderer::ShadedRelief { + band: 0, + ramp: None, + azimuth: 270.0, + altitude: 45.0, + z_factor: 1.0, + shadow: 0.0, + sky: 0.0, + fill: 0.0, + }; style.resampling = Resampling::Nearest; let mut pm = Pixmap::new(80, 40).unwrap(); draw(&mut pm, &r, &style, None, &view, 1.0, false, Place::Flat(&Flat::Mercator, "mercator"), Lights::default()); @@ -1313,6 +1429,7 @@ mod tests { z_factor: 1.0, shadow: 0.0, sky: 0.0, + fill: 0.0, }, }; s.dra = true; @@ -1339,6 +1456,7 @@ mod tests { z_factor: 1.0, shadow: 0.0, sky: 0.0, + fill: 0.0, }, _ => s.renderer, }; @@ -1474,7 +1592,8 @@ mod tests { let mut style = RasterStyle::default_for(&r); let full = |grid: Option<&[u8]>| if grid.is_some() { 1.0 } else { 0.0 }; let (shadow, sky) = (full(light.shadow), full(light.sky)); - style.renderer = RasterRenderer::ShadedRelief { band: 0, ramp: None, azimuth: 270.0, altitude: 45.0, z_factor: 1.0, shadow, sky }; + style.renderer = + RasterRenderer::ShadedRelief { band: 0, ramp: None, azimuth: 270.0, altitude: 45.0, z_factor: 1.0, shadow, sky, fill: 0.0 }; style.resampling = resampling; style.mask = RasterMask { values: mask.to_vec(), ..RasterMask::default() }; let view = View { @@ -1570,8 +1689,16 @@ mod tests { let data = (0..120).map(|k| (k * 37 % 11) as f32 * 10.0).collect(); let r = Raster::new("relief", 40, 3, vec![data], gt, Crs::WebMercator, None, ColorHint::Gray, "Float32").unwrap(); let mut style = RasterStyle::default_for(&r); - style.renderer = - RasterRenderer::ShadedRelief { band: 0, ramp: None, azimuth: 270.0, altitude: 45.0, z_factor: 1.0, shadow: 0.0, sky: 0.0 }; + style.renderer = RasterRenderer::ShadedRelief { + band: 0, + ramp: None, + azimuth: 270.0, + altitude: 45.0, + z_factor: 1.0, + shadow: 0.0, + sky: 0.0, + fill: 0.0, + }; style.resampling = resampling; let view = View { width: 24.0, @@ -1611,8 +1738,16 @@ mod tests { }; let row = |resampling: Resampling| { let mut style = RasterStyle::default_for(&r); - style.renderer = - RasterRenderer::ShadedRelief { band: 0, ramp: None, azimuth: 315.0, altitude: 45.0, z_factor: 1.0, shadow: 1.0, sky: 0.0 }; + style.renderer = RasterRenderer::ShadedRelief { + band: 0, + ramp: None, + azimuth: 315.0, + altitude: 45.0, + z_factor: 1.0, + shadow: 1.0, + sky: 0.0, + fill: 0.0, + }; style.resampling = resampling; let mut pm = Pixmap::new(80, 1).unwrap(); draw( diff --git a/crates/render/src/raster_export.rs b/crates/render/src/raster_export.rs index d538fad54..5d270b5e3 100644 --- a/crates/render/src/raster_export.rs +++ b/crates/render/src/raster_export.rs @@ -198,6 +198,7 @@ mod tests { z_factor: 1.0, shadow: 1.0, sky: 0.0, + fill: 0.0, }; let e = export(&layer, 2, 2, 1.0).err().unwrap_or_else(|| panic!("an archive without its shadows")); assert!(e.contains("cast shadows") && e.contains("read in place"), "{e}"); @@ -229,6 +230,7 @@ mod tests { z_factor: 1.0, shadow: 1.0, sky: 0.0, + fill: 0.0, }; let _cancelled = rustgis_data::progress::install(Some(std::sync::Arc::new(Cancelled))); let e = export(&layer, 0, 0, 1.0).err().unwrap_or_else(|| panic!("an archive without its shadows")); diff --git a/crates/ui-egui/src/mapview.rs b/crates/ui-egui/src/mapview.rs index 5681c2db5..831d9e650 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -5087,8 +5087,16 @@ mod tests { let r = Raster::new("dem", 4, 4, vec![vec![0.0; 16]], gt, Crs::WebMercator, None, ColorHint::Gray, "Float32").unwrap(); let mut map = Map::new("Relief"); let id = map.add_raster("dem", RasterLayer::from_path(r, "dem.tif")); - let relief = - |shadow| RasterRenderer::ShadedRelief { band: 0, ramp: None, azimuth: 315.0, altitude: 30.0, z_factor: 1.0, shadow, sky: 0.0 }; + let relief = |shadow| RasterRenderer::ShadedRelief { + band: 0, + ramp: None, + azimuth: 315.0, + altitude: 30.0, + z_factor: 1.0, + shadow, + sky: 0.0, + fill: 0.0, + }; let set = |map: &mut Map, shadow: f64, visible: bool| { let l = map.layer_mut(id).unwrap(); l.visible = visible; diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index fd2c4f322..269d9c499 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -6732,10 +6732,13 @@ fn raster_symbology(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { RasterRenderer::Colormap => { ui.label(egui::RichText::new(tl!("Colors from the raster's own color table.")).size(11.5).color(t.text_dim)); } - RasterRenderer::ShadedRelief { band, ramp, azimuth, altitude, z_factor, shadow, sky } => { - let (az, alt, zf, sh, sk, rp) = (*azimuth, *altitude, *z_factor, *shadow, *sky, ramp.as_ref().map(|r| r.name().to_string())); + RasterRenderer::ShadedRelief { band, ramp, azimuth, altitude, z_factor, shadow, sky, fill } => { + let (az, alt, zf, sh, sk, fl) = (*azimuth, *altitude, *z_factor, *shadow, *sky, *fill); + let rp = ramp.as_ref().map(|r| r.name().to_string()); let relief = move |band: usize, az: f64, alt: f64, zf: f64, sh: f64| { - let mut p = json!({"type": "shadedRelief", "band": band + 1, "azimuth": az, "altitude": alt, "zFactor": zf, "shadow": sh, "sky": sk}); + let mut p = json!({ + "type": "shadedRelief", "band": band + 1, "azimuth": az, "altitude": alt, "zFactor": zf, "shadow": sh, "sky": sk, "fill": fl + }); match &rp { Some(r) => p["ramp"] = json!(r), None => p["grey"] = json!(true), @@ -6750,6 +6753,7 @@ fn raster_symbology(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { ("Z factor", zf, 0.001..=1000.0, 0.01, 2), ("Cast shadows", sh, 0.0..=1.0, 0.01, 3), ("Sky visibility", sk, 0.0..=1.0, 0.01, 4), + ("Fill light", fl, 0.0..=1.0, 0.01, 5), ] { let input_label = ui.label(tl!(label)); let mut x = v; @@ -6763,6 +6767,9 @@ fn raster_symbology(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { 4 => r.on_hover_text(tl!( "How much the sky each cell sees, over the terrain around it, scales its ambient light, from 0 (not at all) to 1" )), + 5 => r.on_hover_text(tl!( + "A light from the side opposite the sun that lightens the slopes turned toward it, from 0 (none) to 1" + )), _ => r, }; if drag.settled(ui, &r, &mut x) && x != v { @@ -6774,8 +6781,10 @@ fn raster_symbology(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { _ => (az, alt, zf, sh), }; let mut p = relief(*band, a, b, c, d); - if which == 4 { - p["sky"] = json!(x); + match which { + 4 => p["sky"] = json!(x), + 5 => p["fill"] = json!(x), + _ => {} } let _ = app.run("raster.symbology", p); } diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index 57a645b3e..0475a058a 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -1323,10 +1323,20 @@ exports, and said when it can't be drawn (in the browser over 500,000 cells, or GeoTIFF read in place) as cast shadows are. Terrain outside the raster, or farther than 100 cells, hides none of its sky. -With **Resampling** set to bilinear, a Shaded Relief layer's light (its hillshade, cast shadows and -sky visibility) is blended between the cells' centres, as its values are, so a cell drawn over many -pixels shades smoothly instead of in squares; nearest keeps one light per cell, as before. A cell -with no data, or masked, is left out of the blend, so it doesn't darken the relief next to it. +**Fill light**, on a Shaded Relief layer (`raster.symbology` `"fill"`, 0 to 1), opens the slopes +turned away from the sun without lifting the rest of the map: a second, weaker light from the +opposite side (the sun's azimuth plus 180°), 55° up, adds `fill × max(0, L − sin 55°)` of the +colour to each cell, where `L` is how squarely the cell faces that light (the same slopes as the +hillshade). Flat ground gets exactly `sin 55°` of it and so gains nothing, nor does a slope turned +to the sun; the more a slope turns toward the fill, the more it gains, up to 0.18 of its colour +with a fill of 1. Nothing shadows it, and it is drawn at once (nothing to work out). 0, the +default, draws the relief as before, and it is saved with the project only when it isn't 0. + +With **Resampling** set to bilinear, a Shaded Relief layer's light (its hillshade, cast shadows, +sky visibility and fill light) is blended between the cells' centres, as its values are, so a cell +drawn over many pixels shades smoothly instead of in squares; nearest keeps one light per cell, as +before. A cell with no data, or masked, is left out of the blend, so it doesn't darken the relief +next to it. **Masking**, at the foot of the Symbology pane (`raster.mask`), hides pixel values from view without changing the data: values to draw as no data (a background of 0 or −9999 the file doesn't flag, QA