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
53 changes: 36 additions & 17 deletions crates/engine/src/cmd/qgis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ use rustgis_geom::{Coord, Extent};
use rustgis_map::qgis::{self, NoteKind, Report, Source};
use serde_json::{Value, json};

use super::{need_feature_layer, ps};
use super::{need_layer, ps};
use crate::{CmdResult, CommandSpec as C, Session};

pub fn commands() -> Vec<C> {
vec![
C::new("project.importQgis", "Import QGIS Project", "Project › Import", import_project).params(
r#"{"path"?: "*.qgs|*.qgz", "text"?: "a .qgs file's text", "data"?: "a .qgs or .qgz file, base64", "dir"?: "the folder its relative paths start from (default: the file's)"} → the layers added and notes on what wasn't carried over"#,
),
C::new("style.importQgis", "Import QGIS Style", "Style › Styles", import_style).when(need_feature_layer).params(
C::new("style.importQgis", "Import QGIS Style", "Style › Styles", import_style).when(need_layer).params(
r#"{"layer"?, "path"?: "*.qml", "text"?: "a .qml file's text", "data"?: "a .qml file, base64"} → notes on what wasn't carried over"#,
),
]
Expand Down Expand Up @@ -198,20 +198,13 @@ fn add_layer(s: &mut Session, layer: &qgis::ProjectLayer, has_dir: bool, report:
let Some(l) = s.map_mut().layer_mut(id) else { return Err("the layer wasn't added".into()) };
let added_name = l.name.clone();
l.visible = layer.visible;
if l.is_raster() {
// A raster takes its opacity, visibility and scale range; its colouring is noted.
let st = &layer.style;
let t = st.transparency;
let (min, max) = (st.min_scale, st.max_scale);
if let Some(t) = t {
l.transparency = t;
}
if let Some(m) = min {
l.min_scale = m;
}
if let Some(m) = max {
l.max_scale = m;
if l.raster.is_some() {
// A raster takes its opacity, blending, scale range and colouring (when its bands are
// there).
if let Some(why) = l.raster.as_ref().and_then(|r| layer.style.raster_misfit(r)) {
report.skipped(name, why);
}

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.

Inconsistent with import_style: here, when the raster colouring doesn't fit, layer.style.apply(l) still applies opacity, blend and scale range (only the raster style is skipped), whereas import_style leaves the raster entirely as it was ("its opacity and scale range too"). The note says only the colouring is dropped, so for a project import this is accurate, but the two paths behave differently for the same style. Also, in the project import path, a raster whose data is still loading (data == None) gets no "unchecked" note, unlike import_style. Confidence: medium-low (consistency/UX, not a crash).

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.

Leaving this on purpose. The two paths do different things. A project import adds the layer from the project, so opacity, blending and scale range are that layer's own settings and come in whatever happens to its colouring. Only the colouring is noted as skipped. style.importQgis restyles a layer that already looks the way the user set it, and a style that doesn't fit leaves it as it was, as the docs say. As for the loading case in project import: a project's rasters are added from their paths there, and a remote one that is still loading is clamped by repair when it lands. Noting every remote raster of a project as "unchecked" would add a note per layer for what is normally fine.

layer.style.apply(l);
return Ok(Some(added_name));
}
layer.style.apply(l);
Expand Down Expand Up @@ -336,7 +329,33 @@ fn import_style(s: &mut Session, p: &Value) -> CmdResult {
let mut report = Report::default();
let style = qgis::read_style(&text, &name, &mut report)?;
let l = s.map_mut().layer_mut(id).ok_or("no such layer")?;
style.apply(l);
check_fields(l, &name, &mut report);
if l.raster.is_some() {
// A style that doesn't fit leaves the raster as it was, its opacity and scale range too.
if let Some(why) = l.raster.as_ref().and_then(|r| style.raster_misfit(r)) {
report.skipped(&name, why);
} else {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Pixels still on their way (a remote raster) can't be checked: the style is applied,
// and a band it reads that the file hasn't is drawn from the file's last band.
if style.raster.is_some() && l.raster.as_ref().is_some_and(|r| r.data.is_none()) {
report.approximated(
&name,
"its raster colouring, applied before the raster's bands could be checked (a band the file hasn't is drawn from its last)",
);
}
if style.raster.is_none() && style.renderer.is_some() {
report.skipped(
&name,
"a feature layer's symbols and labels, which a raster hasn't (only its opacity and scale range are taken)",
);
}
style.apply(l);
}
} else {
if style.raster.is_some() && style.renderer.is_none() {
report.skipped(&name, "a raster's colouring, which a feature layer hasn't (only its opacity and scale range are taken)");
}
style.apply(l);
check_fields(l, &name, &mut report);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Ok(json!({"notes": report.notes.iter().map(note_json).collect::<Vec<_>>()}))
}
33 changes: 33 additions & 0 deletions crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,10 @@ fn a_qgis_project_opens_as_a_map_and_a_qgis_style_styles_a_layer() {
s.run("style.importQgis", &json!({"layer": "Rivers", "text": qml})).unwrap();
let rivers = s.map().layer_by_name("Rivers").unwrap();
assert_eq!((rivers.line_strokes.len(), rivers.transparency), (2, 50.0));
// A raster's style on a feature layer says its colouring is left out.
let qml = r#"<qgis version="4.2.3" layerType="Raster"><pipe><rasterrenderer type="singlebandgray" grayBand="1"/></pipe></qgis>"#;
let out = s.run("style.importQgis", &json!({"layer": "Rivers", "text": qml})).unwrap();
assert!(serde_json::to_string(&out["notes"]).unwrap().contains("a raster's colouring"), "{out}");
assert!(s.run("style.importQgis", &json!({"layer": "Rivers", "text": "<nonsense"})).is_err());
// A style saved from another layer: classes and labels by fields Rivers hasn't are noted, and
// the label class that has nothing to label from is switched off.
Expand Down Expand Up @@ -949,6 +953,35 @@ fn with_raster() -> Session {
s
}

/// A QGIS raster style colours a raster layer; one reading a band the raster hasn't is noted and
/// leaves it as it was.
#[test]
fn a_qgis_raster_style_colours_a_raster() {
let mut s = with_raster();
let qml = r#"<qgis version="4.2.3" layerType="Raster"><pipe><rasterrenderer type="multibandcolor" redBand="3" greenBand="2" blueBand="1" opacity="0.5"/>
<brightnesscontrast brightness="0" contrast="25" gamma="1"/></pipe></qgis>"#;
let out = s.run("style.importQgis", &json!({"layer": "scene", "text": qml})).unwrap();
assert_eq!(out["notes"], json!([]));
let l = s.map().layer_by_name("scene").unwrap();
let st = &l.raster.as_ref().unwrap().style;
assert_eq!(
(&st.renderer, st.contrast, l.transparency),
(&rustgis_map::raster::RasterRenderer::Rgb { red: 2, green: 1, blue: 0 }, 25.0, 50.0)
);
let before = st.clone();
// A style that reads a band the raster hasn't leaves it as it was, its opacity too.
let qml = r#"<qgis version="4.2.3" layerType="Raster"><pipe><rasterrenderer type="singlebandgray" grayBand="5"/></pipe><layerOpacity>0.2</layerOpacity></qgis>"#;
let out = s.run("style.importQgis", &json!({"layer": "scene", "text": qml})).unwrap();
assert!(serde_json::to_string(&out["notes"]).unwrap().contains("band 5 of a raster with 3"), "{out}");
let l = s.map().layer_by_name("scene").unwrap();
assert_eq!((&l.raster.as_ref().unwrap().style, l.transparency), (&before, 50.0));
// Pixels not there yet (a remote raster still loading): applied, and noted as unchecked.
let id = s.map().layer_by_name("scene").unwrap().id;
s.map_mut().layer_mut(id).unwrap().raster.as_mut().unwrap().data = None;
let out = s.run("style.importQgis", &json!({"layer": "scene", "text": qml})).unwrap();
assert!(serde_json::to_string(&out["notes"]).unwrap().contains("before the raster's bands could be checked"), "{out}");
}

/// The parts of a large GeoTIFF the web app reads (#140), opened and kept in a project: a
/// cloud-optimised file read a part at a time, as `parts.rs` reads a picked file.
#[test]
Expand Down
48 changes: 34 additions & 14 deletions crates/map/src/qgis/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

mod expr;
mod labels;
mod raster;
mod renderer;
mod source;
mod symbol;
Expand Down Expand Up @@ -108,6 +109,8 @@ pub struct Style {
/// 0 opaque to 100 invisible.
pub transparency: Option<f32>,
pub blend: Option<Blend>,
/// A raster's colouring, stretch, resampling, brightness, contrast and mask.
pub raster: Option<crate::raster::RasterStyle>,
/// Drawn between these scales (RustGIS's, see [`QGIS_SCALE`]): zoomed in beyond 1:`min_scale`
/// and out beyond 1:`max_scale`. `Some(None)` clears a limit.
pub min_scale: Option<Option<f64>>,
Expand All @@ -117,6 +120,26 @@ pub struct Style {
impl Style {
/// Gives `layer` this style's parts (those it sets).
pub fn apply(&self, layer: &mut crate::Layer) {
if let Some(t) = self.transparency {
layer.transparency = t;
}
if let Some(b) = self.blend {
layer.blend = b;
}
if let Some(s) = self.min_scale {
layer.min_scale = s;
}
if let Some(s) = self.max_scale {
layer.max_scale = s;
}
// A raster takes its raster style (when its bands are there), and none of a feature
// layer's parts.
if let Some(r) = layer.raster.as_mut() {
if let Some(st) = self.raster.as_ref().filter(|_| self.raster_misfit(r).is_none()) {
r.style = st.clone();
}
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if let Some(r) = &self.renderer {
layer.renderer = r.clone();
}
Expand All @@ -132,18 +155,15 @@ impl Style {
if let Some(l) = &self.labels {
layer.labels = l.clone();
}
if let Some(t) = self.transparency {
layer.transparency = t;
}
if let Some(b) = self.blend {
layer.blend = b;
}
if let Some(s) = self.min_scale {
layer.min_scale = s;
}
if let Some(s) = self.max_scale {
layer.max_scale = s;
}
}

/// Why this style's raster colouring doesn't fit the raster `r` (it reads a band the file
/// hasn't), or `None` when it fits or there is none.
pub fn raster_misfit(&self, r: &crate::raster::RasterLayer) -> Option<String> {
let st = self.raster.as_ref()?;
let count = r.data.as_ref()?.band_count();

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.

Low-confidence: r.data.as_ref()? makes the misfit check a silent pass when the raster's pixel data isn't loaded (for example a lazily or partly read GeoTIFF on the web). A style that reads a nonexistent band is then applied, and the renderer references a band that isn't there. If RasterLayer keeps a band count apart from data (metadata or a source header), use that. Otherwise this is acceptable, but a comment saying "not loaded: assume it fits" would help.

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.

Leaving it. RasterLayer has no band count apart from data: data is None only while a remote read is in flight or when the source couldn't be read. In both cases there are no pixels to check against, and nothing is drawn. When the pixels do land, reload and poll_reload run RasterStyle::repair, which clamps every band the renderer names to the file's last band (crates/map/src/raster.rs:167 and :186). So a style naming a band that isn't there never draws from a missing band and can't panic. It just isn't noted at import, since the band count isn't known yet.

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.

Follow-up: CodeRabbit raised the same case, and 090813b now notes it at style import (applied, bands not yet checked) instead of passing silently. The band count still isn't known before the pixels land, so the style is still applied, and repair clamps the bands.

let band = st.renderer.bands().into_iter().find(|b| *b >= count)?;
Some(format!("its raster colouring, which reads band {} of a raster with {count}", band + 1))
}
}

Expand Down Expand Up @@ -305,12 +325,12 @@ fn read_style_node(node: Node, layer: &str, report: &mut Report) -> Style {
if let Some(r) = child(node, "renderer-v2") {
renderer::read(r, layer, &mut style, report);
}
// A raster's renderer: its opacity carried over, its colouring not yet.
// A raster's renderer: its opacity, and its colouring as RustGIS's raster style.
if let Some(r) = child(node, "pipe").and_then(|p| child(p, "rasterrenderer")) {
if let Some(o) = r.attribute("opacity").and_then(|o| o.trim().parse::<f32>().ok()).filter(|o| o.is_finite() && *o < 1.0) {
style.transparency = Some(((1.0 - o.clamp(0.0, 1.0)) * 100.0).round());
}
report.skipped(layer, format!("its raster colouring ({} renderer)", r.attribute("type").unwrap_or("unknown")));
style.raster = raster::read(node, layer, report);
}
if let Some(l) = child(node, "labeling") {
// Set up but switched off: carried over, switched off.
Expand Down
Loading
Loading