From 6d93f212b7369b2ac9399091641f0a2ab0699766 Mon Sep 17 00:00:00 2001 From: strebdom Date: Sun, 25 Oct 2026 13:19:35 +0100 Subject: [PATCH 1/6] feat(map): QGIS projects in degrees open in Equirectangular, as QGIS draws them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project whose coordinate system is in degrees (EPSG:4326, ETRS89, NAD83, OGC:CRS84) opened in Web Mercator with a note, because map.projection refuses degrees. QGIS draws such a project as plate carrée, which the Equirectangular preset draws the same way: the import now picks it, and reads the project's saved view in degrees. A datum PROJ shifts from WGS 84 is noted; a project on another prime meridian (Paris) goes to its data's extent. Closes #883 --- crates/engine/src/cmd/qgis.rs | 57 ++++++++++++++++++++++++++++++----- crates/engine/src/tests.rs | 29 ++++++++++++++++++ docs/user-guide/workspace.md | 4 ++- 3 files changed, 81 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/qgis.rs b/crates/engine/src/cmd/qgis.rs index 3eef1e4b8..e0da2fac6 100644 --- a/crates/engine/src/cmd/qgis.rs +++ b/crates/engine/src/cmd/qgis.rs @@ -84,13 +84,20 @@ fn import_project(s: &mut Session, p: &Value) -> CmdResult { let m = s.map_mut(); m.basemap = rustgis_map::Basemap::Blank; m.graticule = false; + let degrees = project.crs.as_deref().and_then(Geographic::of); if let Some(crs) = &project.crs { let code = match crs.as_str() { "EPSG:3857" | "EPSG:900913" => "mercator", + // QGIS draws a project in degrees as plate carrée: longitude and latitude as x and y. + _ if degrees.is_some() => "equirectangular", other => other, }; - if let Err(e) = s.run_part("map.projection", &json!({"type": code})) { - report.approximated("", format!("the project's coordinate system {crs} ({e}), drawn in Web Mercator")); + match s.run_part("map.projection", &json!({"type": code})) { + Err(e) => report.approximated("", format!("the project's coordinate system {crs} ({e}), drawn in Web Mercator")), + Ok(_) if degrees.is_some_and(|g| !g.wgs84) => { + report.approximated("", format!("the project's coordinate system {crs}, drawn in Equirectangular on WGS 84")); + } + Ok(_) => {} } } // Bottom first, so each is added on top of the ones below it. @@ -102,7 +109,7 @@ fn import_project(s: &mut Session, p: &Value) -> CmdResult { Err(e) => report.skipped(&layer.name, format!("the layer: {e}")), } } - go_to_view(s, &project); + go_to_view(s, &project, degrees); let notes: Vec = report.notes.iter().map(note_json).collect(); if !report.notes.is_empty() { let left = report.notes.iter().filter(|n| n.kind == NoteKind::Skipped).count(); @@ -309,12 +316,21 @@ pub(crate) fn web_settings(s: &mut Session, added: &Value, before: Option, } /// Goes to the project's saved view, or to its layers when that view shows none of them. -fn go_to_view(s: &mut Session, project: &qgis::Project) { +fn go_to_view(s: &mut Session, project: &qgis::Project, degrees: Option) { let data = s.map().full_extent(); - let saved = project.extent.and_then(|[x0, y0, x1, y1]| { - let m = s.map(); - let corners = [(x0, y0), (x1, y0), (x0, y1), (x1, y1)].map(|(x, y)| m.to_lonlat(Coord::new(x, y))); - corners.iter().flatten().fold(None, |e: Option, c| Some(e.unwrap_or(Extent::EMPTY).union(&Extent::new(c.x, c.y, c.x, c.y)))) + let saved = project.extent.and_then(|[x0, y0, x1, y1]| match degrees { + // In degrees already (from Greenwich: the view of a project on another prime meridian + // is left to its data). + Some(g) if g.greenwich => Some(Extent::new(x0.max(-180.0), y0.max(-90.0), x1.min(180.0), y1.min(90.0))).filter(|e| !e.is_empty()), + Some(_) => None, + None => { + let m = s.map(); + let corners = [(x0, y0), (x1, y0), (x0, y1), (x1, y1)].map(|(x, y)| m.to_lonlat(Coord::new(x, y))); + corners + .iter() + .flatten() + .fold(None, |e: Option, c| Some(e.unwrap_or(Extent::EMPTY).union(&Extent::new(c.x, c.y, c.x, c.y)))) + } }); let target = match saved { Some(e) if !e.is_empty() && (data.is_empty() || e.intersects(&data)) => e, @@ -325,6 +341,31 @@ fn go_to_view(s: &mut Session, project: &qgis::Project) { } } +/// A coordinate system in degrees (EPSG:4326, ETRS89, NAD83, …), which QGIS draws as plate carrée. +#[derive(Clone, Copy, Debug)] +struct Geographic { + /// On WGS 84, or a datum PROJ takes as the same (no shift to WGS 84). + wgs84: bool, + /// Longitudes from Greenwich (not from another prime meridian, such as Paris's). + greenwich: bool, +} + +impl Geographic { + /// `None` for a coordinate system that isn't in degrees, or isn't known. + fn of(crs: &str) -> Option { + if crs.eq_ignore_ascii_case("OGC:CRS84") { + return Some(Geographic { wgs84: true, greenwich: true }); + } + let code = crs.strip_prefix("EPSG:")?.trim().parse::().ok()?; + let def = rustgis_data::raster::crs::epsg_proj4(code)?; + let has = |p: &str| def.split_whitespace().any(|t| t == p); + has("+proj=longlat").then(|| Geographic { + wgs84: has("+datum=WGS84") || has("+datum=NAD83") || has("+towgs84=0,0,0,0,0,0,0") || has("+towgs84=0,0,0"), + greenwich: !def.contains("+pm=") || has("+pm=greenwich") || has("+pm=0"), + }) + } +} + fn note_json(n: &qgis::Note) -> Value { json!({"layer": n.layer, "kind": n.kind, "text": n.text, "count": n.count}) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 818af8266..05e124493 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -623,6 +623,35 @@ fn rivers_are_labelled_along_their_lines_in_italics() { assert!(!text.contains("italic"), "{text}"); } +#[test] +fn a_qgis_project_in_degrees_opens_in_equirectangular() { + // QGIS draws a project in degrees as plate carrée, longitude and latitude as x and y. + let qgs = |crs: &str| { + format!( + r#"World{crs} + 5451550"# + ) + }; + let mut s = world(); + for (crs, datum_note) in [("EPSG:4326", false), ("EPSG:4258", false), ("OGC:CRS84", false), ("EPSG:4314", true)] { + let out = s.run("project.importQgis", &json!({"text": qgs(crs)})).unwrap(); + assert_eq!(s.map().projection.code(), "equirectangular", "{crs}"); + let text = serde_json::to_string(&out["notes"]).unwrap(); + assert!(!text.contains("Web Mercator"), "{crs}: {text}"); + assert_eq!(text.contains("drawn in Equirectangular on WGS 84"), datum_note, "{crs}: {text}"); + // The saved view, read in degrees. + let c = s.map().to_lonlat(s.map().camera.center).unwrap(); + assert!((c.x - 10.0).abs() < 0.5 && (c.y - 47.5).abs() < 0.5, "{crs}: {c:?}"); + } + // Longitudes from Paris (in grads): drawn the same way, the view left to the data. + let out = s.run("project.importQgis", &json!({"text": qgs("EPSG:4807")})).unwrap(); + assert_eq!(s.map().projection.code(), "equirectangular"); + assert!(serde_json::to_string(&out["notes"]).unwrap().contains("on WGS 84")); + // A projected one keeps its own projection. + s.run("project.importQgis", &json!({"text": qgs("EPSG:2056").replace("5", "2600000")})).unwrap(); + assert_eq!(s.map().projection.code(), "EPSG:2056"); +} + #[test] fn a_qgis_project_opens_as_a_map_and_a_qgis_style_styles_a_layer() { let dir = std::env::temp_dir().join(format!("rustgis-qgis-{}", std::process::id())); diff --git a/docs/user-guide/workspace.md b/docs/user-guide/workspace.md index 077ebde55..d74eb0566 100644 --- a/docs/user-guide/workspace.md +++ b/docs/user-guide/workspace.md @@ -27,7 +27,9 @@ Its layers come in the layer tree's order, each from its file (GeoPackage, Shape GeoTIFF and the other formats RustGIS reads, relative paths from the project's folder), a CSV of points, XYZ tiles or a WMS layer, unchecked layers and groups hidden. The map takes the project's coordinate system and its saved view (or its layers' extent, when that view shows none of them), -with no basemap and no graticule, as QGIS draws it. The import is one undo step. +with no basemap and no graticule, as QGIS draws it. A project in degrees (EPSG:4326 and other +longitude/latitude systems) opens in the Equirectangular projection, the plate carrée QGIS draws +it in. The import is one undo step. Each layer keeps its look: From dff79140efc47b919e9d6487412980525fa8c96a Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Sun, 25 Oct 2026 14:44:18 +0100 Subject: [PATCH 2/6] fix(engine): a QGIS project in degrees from another prime meridian is noted, whatever its datum --- crates/engine/src/cmd/qgis.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/qgis.rs b/crates/engine/src/cmd/qgis.rs index e0da2fac6..04bd90512 100644 --- a/crates/engine/src/cmd/qgis.rs +++ b/crates/engine/src/cmd/qgis.rs @@ -94,7 +94,7 @@ fn import_project(s: &mut Session, p: &Value) -> CmdResult { }; match s.run_part("map.projection", &json!({"type": code})) { Err(e) => report.approximated("", format!("the project's coordinate system {crs} ({e}), drawn in Web Mercator")), - Ok(_) if degrees.is_some_and(|g| !g.wgs84) => { + Ok(_) if degrees.is_some_and(|g| !g.wgs84 || !g.greenwich) => { report.approximated("", format!("the project's coordinate system {crs}, drawn in Equirectangular on WGS 84")); } Ok(_) => {} From a625cf7d23243ea39d5c41624836b7966a3429b9 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Sun, 25 Oct 2026 15:52:24 +0100 Subject: [PATCH 3/6] fix(engine): a QGIS project saved with a PROJ string in degrees opens in Equirectangular; the note says longitudes off Greenwich are taken as from it --- crates/engine/src/cmd/qgis.rs | 16 +++++++++++----- crates/engine/src/tests.rs | 17 ++++++++++++++--- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/qgis.rs b/crates/engine/src/cmd/qgis.rs index 04bd90512..7a1899c44 100644 --- a/crates/engine/src/cmd/qgis.rs +++ b/crates/engine/src/cmd/qgis.rs @@ -95,7 +95,8 @@ fn import_project(s: &mut Session, p: &Value) -> CmdResult { match s.run_part("map.projection", &json!({"type": code})) { Err(e) => report.approximated("", format!("the project's coordinate system {crs} ({e}), drawn in Web Mercator")), Ok(_) if degrees.is_some_and(|g| !g.wgs84 || !g.greenwich) => { - report.approximated("", format!("the project's coordinate system {crs}, drawn in Equirectangular on WGS 84")); + let meridian = if degrees.is_some_and(|g| !g.greenwich) { ", its longitudes taken as from Greenwich" } else { "" }; + report.approximated("", format!("the project's coordinate system {crs}, drawn in Equirectangular on WGS 84{meridian}")); } Ok(_) => {} } @@ -351,15 +352,20 @@ struct Geographic { } impl Geographic { - /// `None` for a coordinate system that isn't in degrees, or isn't known. + /// `None` for a coordinate system that isn't in degrees, or isn't known: an `EPSG:` code, + /// `OGC:CRS84`, or a PROJ string (a project QGIS saved without an authority code). fn of(crs: &str) -> Option { if crs.eq_ignore_ascii_case("OGC:CRS84") { return Some(Geographic { wgs84: true, greenwich: true }); } - let code = crs.strip_prefix("EPSG:")?.trim().parse::().ok()?; - let def = rustgis_data::raster::crs::epsg_proj4(code)?; + let def = if crs.trim_start().starts_with('+') { + crs + } else { + let code = crs.strip_prefix("EPSG:")?.trim().parse::().ok()?; + rustgis_data::raster::crs::epsg_proj4(code)? + }; let has = |p: &str| def.split_whitespace().any(|t| t == p); - has("+proj=longlat").then(|| Geographic { + (has("+proj=longlat") || has("+proj=latlong")).then(|| Geographic { wgs84: has("+datum=WGS84") || has("+datum=NAD83") || has("+towgs84=0,0,0,0,0,0,0") || has("+towgs84=0,0,0"), greenwich: !def.contains("+pm=") || has("+pm=greenwich") || has("+pm=0"), }) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 05e124493..41929b168 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -626,14 +626,24 @@ fn rivers_are_labelled_along_their_lines_in_italics() { #[test] fn a_qgis_project_in_degrees_opens_in_equirectangular() { // QGIS draws a project in degrees as plate carrée, longitude and latitude as x and y. + // A PROJ string goes where QGIS keeps it for a system without an authority code. let qgs = |crs: &str| { + let tag = if crs.starts_with('+') { "proj4" } else { "authid" }; format!( - r#"World{crs} + r#"World<{tag}>{crs} 5451550"# ) }; let mut s = world(); - for (crs, datum_note) in [("EPSG:4326", false), ("EPSG:4258", false), ("OGC:CRS84", false), ("EPSG:4314", true)] { + for (crs, datum_note) in [ + ("EPSG:4326", false), + ("EPSG:4258", false), + ("OGC:CRS84", false), + ("EPSG:4314", true), + // Saved without an authority code: its PROJ string. + ("+proj=longlat +datum=WGS84 +no_defs", false), + ("+proj=longlat +ellps=bessel +towgs84=598.1,73.7,418.2,0.202,0.045,-2.455,6.7 +no_defs", true), + ] { let out = s.run("project.importQgis", &json!({"text": qgs(crs)})).unwrap(); assert_eq!(s.map().projection.code(), "equirectangular", "{crs}"); let text = serde_json::to_string(&out["notes"]).unwrap(); @@ -646,7 +656,8 @@ fn a_qgis_project_in_degrees_opens_in_equirectangular() { // Longitudes from Paris (in grads): drawn the same way, the view left to the data. let out = s.run("project.importQgis", &json!({"text": qgs("EPSG:4807")})).unwrap(); assert_eq!(s.map().projection.code(), "equirectangular"); - assert!(serde_json::to_string(&out["notes"]).unwrap().contains("on WGS 84")); + let text = serde_json::to_string(&out["notes"]).unwrap(); + assert!(text.contains("on WGS 84") && text.contains("from Greenwich"), "{text}"); // A projected one keeps its own projection. s.run("project.importQgis", &json!({"text": qgs("EPSG:2056").replace("5", "2600000")})).unwrap(); assert_eq!(s.map().projection.code(), "EPSG:2056"); From a513c074988ecbab1c3c5fb831451885819a9ae4 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Sun, 25 Oct 2026 16:14:46 +0100 Subject: [PATCH 4/6] fix(engine): a QGIS PROJ string's prime meridian given as a zero offset is Greenwich --- crates/engine/src/cmd/qgis.rs | 6 +++++- crates/engine/src/tests.rs | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/qgis.rs b/crates/engine/src/cmd/qgis.rs index 7a1899c44..43146d3a9 100644 --- a/crates/engine/src/cmd/qgis.rs +++ b/crates/engine/src/cmd/qgis.rs @@ -367,7 +367,11 @@ impl Geographic { let has = |p: &str| def.split_whitespace().any(|t| t == p); (has("+proj=longlat") || has("+proj=latlong")).then(|| Geographic { wgs84: has("+datum=WGS84") || has("+datum=NAD83") || has("+towgs84=0,0,0,0,0,0,0") || has("+towgs84=0,0,0"), - greenwich: !def.contains("+pm=") || has("+pm=greenwich") || has("+pm=0"), + // `+pm=` names a meridian or gives its offset in degrees (`0`, `0.0`: Greenwich). + greenwich: def + .split_whitespace() + .filter_map(|t| t.strip_prefix("+pm=")) + .all(|pm| pm.eq_ignore_ascii_case("greenwich") || pm.parse::().is_ok_and(|d| d == 0.0)), }) } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 41929b168..c5ff2f28d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -642,6 +642,7 @@ fn a_qgis_project_in_degrees_opens_in_equirectangular() { ("EPSG:4314", true), // Saved without an authority code: its PROJ string. ("+proj=longlat +datum=WGS84 +no_defs", false), + ("+proj=longlat +datum=WGS84 +pm=0.0 +no_defs", false), ("+proj=longlat +ellps=bessel +towgs84=598.1,73.7,418.2,0.202,0.045,-2.455,6.7 +no_defs", true), ] { let out = s.run("project.importQgis", &json!({"text": qgs(crs)})).unwrap(); From df6b13d0e69c51fc7018b61abbebf1147999fd26 Mon Sep 17 00:00:00 2001 From: giswqs Date: Sun, 25 Oct 2026 11:21:27 -0400 Subject: [PATCH 5/6] Clarify supported geographic CRS imports --- docs/user-guide/workspace.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-guide/workspace.md b/docs/user-guide/workspace.md index d29185ff0..054c0d295 100644 --- a/docs/user-guide/workspace.md +++ b/docs/user-guide/workspace.md @@ -27,7 +27,7 @@ Its layers come in the layer tree's order, each from its file (GeoPackage, Shape GeoTIFF and the other formats RustGIS reads, relative paths from the project's folder), a CSV of points, XYZ tiles or a WMS layer, unchecked layers and groups hidden. The map takes the project's coordinate system and its saved view (or its layers' extent, when that view shows none of them), -with no basemap and no graticule, as QGIS draws it. A project in degrees (EPSG:4326 and other +with no basemap and no graticule, as QGIS draws it. A project in degrees (EPSG:4326 and other supported longitude/latitude systems) opens in the Equirectangular projection, the plate carrée QGIS draws it in. The import is one undo step. From 8a8f33ffc50619a1bc427403a557168e915533f4 Mon Sep 17 00:00:00 2001 From: giswqs Date: Sun, 25 Oct 2026 11:32:23 -0400 Subject: [PATCH 6/6] Address CodeRabbit review feedback - Recognize all four geographic PROJ aliases when importing QGIS projects so their saved views stay in degrees. - Cover each alias through project.importQgis. --- crates/engine/src/cmd/qgis.rs | 2 +- crates/engine/src/tests.rs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/qgis.rs b/crates/engine/src/cmd/qgis.rs index 7973bfe44..4ab560396 100644 --- a/crates/engine/src/cmd/qgis.rs +++ b/crates/engine/src/cmd/qgis.rs @@ -358,7 +358,7 @@ impl Geographic { rustgis_data::raster::crs::epsg_proj4(code)? }; let has = |p: &str| def.split_whitespace().any(|t| t == p); - (has("+proj=longlat") || has("+proj=latlong")).then(|| Geographic { + ["+proj=longlat", "+proj=latlong", "+proj=lonlat", "+proj=latlon"].iter().any(|p| has(p)).then(|| Geographic { wgs84: has("+datum=WGS84") || has("+datum=NAD83") || has("+towgs84=0,0,0,0,0,0,0") || has("+towgs84=0,0,0"), // `+pm=` names a meridian or gives its offset in degrees (`0`, `0.0`: Greenwich). greenwich: def diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7daaa0555..609131a5b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -668,6 +668,9 @@ fn a_qgis_project_in_degrees_opens_in_equirectangular() { fn a_qgis_project_with_a_geographic_proj_string_keeps_its_saved_view() { for (crs, datum_note, saved_view) in [ ("+proj=longlat +datum=WGS84", false, true), + ("+proj=latlong +datum=WGS84", false, true), + ("+proj=lonlat +datum=WGS84", false, true), + ("+proj=latlon +datum=WGS84", false, true), (" +datum=WGS84 +proj=longlat +no_defs", false, true), ("+proj=longlat +datum=potsdam", true, true), ("+proj=longlat +datum=WGS84 +pm=paris", true, false),