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
11 changes: 6 additions & 5 deletions crates/engine/src/cmd/raster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ pub fn commands() -> Vec<C> {
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)
Expand Down Expand Up @@ -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,
Expand All @@ -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}'")),
Expand Down
33 changes: 33 additions & 0 deletions crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions crates/map/src/qgis/raster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
11 changes: 10 additions & 1 deletion crates/map/src/qgis/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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#"<rasterrenderer type="contour" band="1"/>"#, "");
Expand Down
68 changes: 49 additions & 19 deletions crates/map/src/raster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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<Ramp>,
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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENTS.md (saved files): fill is written into projects without a format bump. project::FORMAT is still 1 (crates/engine/src/project.rs:58), and its doc says to advance it "when an older reader would lose meaningful saved information on resaving". An older RustGIS reads a project with "fill": 0.3, ignores the unknown key, and saves the project again without it. That is a silent loss, which the "Never silent" rule and docs/maintaining.md#saved-files-and-settings both rule out.

The PR does not say why no bump is needed. shadow and sky appear to have shipped the same way under FORMAT 1, so this may be existing practice the docs have not caught up with. Either way it is a decision for the maintainers, since the project lead owns how a project stores its data. Please either bump the format (accepting that older versions refuse the file) or state why the omitted-when-zero field is acceptable, and mention the choice in the PR description.

Confidence: medium. I did not check the git history for when shadow and sky were added.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept as is, following shadow (#1796) and sky (#1871), which were saved the same way under FORMAT 1: fill is written only when it isn't 0, so no existing project's file changes, and an older RustGIS draws the relief without the fill (and drops it on a resave). Bumping FORMAT would make older versions refuse every project, fill or not. The PR description now says this under Limits; moving the relief settings to a new format together is left to the maintainers.

},
}

Expand Down Expand Up @@ -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<u8>) -> 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<u8>, ambient: Option<u8>) -> 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<u8>, ambient: Option<u8>, fill: Option<u8>) -> 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];
Expand All @@ -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<u8>, ambient: Option<u8>) -> Option<[u8; 4]> {
fn paint(&self, vals: &[f32], hillshade: Option<u8>, ambient: Option<u8>, fill: Option<u8>) -> Option<[u8; 4]> {
let v0 = *vals.first()?;
let c = match &self.style.renderer {
RasterRenderer::Rgb { .. } => {
Expand Down Expand Up @@ -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]
}
};
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -2906,8 +2920,16 @@ mod tests {
let data: Vec<f32> = (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)));
Expand Down Expand Up @@ -2935,8 +2957,16 @@ mod tests {
let data: Vec<f32> = (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();
Expand Down
2 changes: 2 additions & 0 deletions crates/render/src/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions crates/render/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading