From ede75e7e80ab7bdfde32da42982bb1d9fedd1382 Mon Sep 17 00:00:00 2001 From: elpidioassinaturas Date: Sat, 14 Nov 2026 00:09:12 -0300 Subject: [PATCH 1/3] feat(render): a fill light on Shaded Relief that opens the slopes in shadow (#1912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `fill` setting (0 to 1, default 0) on Shaded Relief adds a second light from the side opposite the sun, 55° up. Each cell gains fill × max(0, L − sin 55°) of its colour, L its Lambert term toward that light from the same Horn slopes as the hillshade: flat ground and slopes turned to the sun gain nothing, slopes turned away from it gain the more they face the fill. Nothing shadows it. The fill is its own term in Colorizer::paint (ambient + direct + fill), is blended between cells with Bilinear like the other lights, and is saved only when it isn't 0. raster.symbology takes "fill" (kept when left out), and the Symbology pane has a Fill light control beside Sky visibility. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/raster.rs | 11 +- crates/engine/src/tests.rs | 33 +++++ crates/map/src/qgis/raster.rs | 1 + crates/map/src/qgis/tests.rs | 11 +- crates/map/src/raster.rs | 62 ++++++--- crates/render/src/layout.rs | 2 + crates/render/src/lib.rs | 3 + crates/render/src/raster.rs | 201 ++++++++++++++++++++++++----- crates/render/src/raster_export.rs | 2 + crates/ui-egui/src/mapview.rs | 12 +- crates/ui-egui/src/panes.rs | 19 ++- docs/user-guide/data.md | 18 ++- 12 files changed, 309 insertions(+), 66 deletions(-) diff --git a/crates/engine/src/cmd/raster.rs b/crates/engine/src/cmd/raster.rs index 25b286223b..2958ffee2c 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 8b63ef6a59..880549db4f 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 8cf2350131..53b735e41f 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 319f4eb740..3d1f1f5c14 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 b2d0939125..afc07f4bd4 100644 --- a/crates/map/src/raster.rs +++ b/crates/map/src/raster.rs @@ -1356,7 +1356,9 @@ 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, @@ -1367,6 +1369,8 @@ pub enum RasterRenderer { shadow: f64, #[serde(default, skip_serializing_if = "is_no_shadow")] sky: f64, + #[serde(default, skip_serializing_if = "is_no_shadow")] + 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 0a3d06d90c..04f264aa95 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 209901b301..19069df57a 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 5954d7e354..0db44290df 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 d538fad541..5d270b5e32 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 5681c2db5f..831d9e6504 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 fd2c4f3228..269d9c4991 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 57a645b3e8..fccd3fd9f9 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 × (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 about a fifth of its colour at 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 From e5a22eefa7c733d50ec4edd18af27c923d523c6f Mon Sep 17 00:00:00 2001 From: elpidioassinaturas Date: Sat, 14 Nov 2026 00:29:43 -0300 Subject: [PATCH 2/3] docs(raster): the fill formula's clamp at zero; a neutral name for the zero-light predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user guide gives the fill light as fill × max(0, L − sin 55°) and its real maximum (0.18 of the colour), and the serde predicate that leaves a relief light of 0 out of the saved file is is_no_light, as it now serves shadow, sky and fill. Co-Authored-By: Claude Opus 5.5 --- crates/map/src/raster.rs | 8 ++++---- docs/user-guide/data.md | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/map/src/raster.rs b/crates/map/src/raster.rs index afc07f4bd4..fa43afb2b2 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 } @@ -1365,11 +1365,11 @@ pub enum RasterRenderer { 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_shadow")] + #[serde(default, skip_serializing_if = "is_no_light")] fill: f64, }, } diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index fccd3fd9f9..9c163d1c9b 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -1325,11 +1325,11 @@ hides none of its sky. **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 × (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 about a fifth of its colour at 1. -Nothing shadows it, and it is drawn at once (nothing to work out). 0, the default, draws the relief +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, From 1eca23db6a6d7702ee9505cf101658ac7af7e76b Mon Sep 17 00:00:00 2001 From: elpidioassinaturas Date: Sat, 14 Nov 2026 00:56:58 -0300 Subject: [PATCH 3/3] docs(raster): wrap the fill light paragraph's long line Co-Authored-By: Claude Opus 5.5 --- docs/user-guide/data.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index 9c163d1c9b..0475a058ae 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -1329,8 +1329,8 @@ opposite side (the sun's azimuth plus 180°), 55° up, adds `fill × max(0, L 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 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