Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f9eb680
feat(map): add Vietnam VN-2000 coordinate system support and presets
quanguet0409 Oct 22, 2026
b50499b
Merge branch 'main' into feature/vietnam-crs
quanguet0409 Oct 22, 2026
638b5b8
fix(map): address review comments for VN-2000 presets and place-name …
quanguet0409 Oct 22, 2026
ad2ae95
fix(crs,map): case-insensitive VN-2000 prefix stripping and PROJ pars…
quanguet0409 Oct 22, 2026
a29c5b0
fix(map,crs): complete 63-province VN-2000 table and case-insensitive…
quanguet0409 Oct 22, 2026
5f36131
fix(crs): use get() over direct slice in PROJ prefix normalization
quanguet0409 Oct 22, 2026
4532fbf
fix(crs,map): support VN-2000 UTM zones by name and align UI province…
quanguet0409 Oct 22, 2026
58b5623
fix: prioritize numeric EPSG/ESRI before alias resolution, use i18n::…
quanguet0409 Oct 22, 2026
68d8788
fix(crs,map): strictly prefix-bound VN-2000 UTM matching, direct defi…
quanguet0409 Oct 22, 2026
7dd28e3
fix(crs,map): treat bare VN-2000 as ambiguous and require explicit TM…
quanguet0409 Oct 22, 2026
4daefff
Merge branch 'main' into feature/vietnam-crs
quanguet0409 Oct 22, 2026
ffb0ead
test(crs,map,ui): verify all 63 provinces, aliases, degree formatting…
quanguet0409 Oct 22, 2026
a1b67b4
fix(crs): resolve all remaining CRS, VN-2000, and localization review…
quanguet0409 Oct 22, 2026
db7608a
fix(crs,map,ui): save province codes as the TM-3 preset, reuse EPSG:5897
giswqs Oct 22, 2026
3fd8c3e
refactor(crs,map): one VN-2000 Helmert literal, presets checked again…
giswqs Oct 22, 2026
b04aec1
test(crs): check rejected PROJ strings by exact membership, not table…
giswqs Oct 22, 2026
9bc5231
fix(map): drop the 'vietnam' alias for VN-2000 / TM-3 105
giswqs Oct 22, 2026
8f77da6
Merge remote-tracking branch 'origin/main' into pr-684
giswqs Oct 22, 2026
b4db5c4
fix(crs,map,ui): one VN-2000 name matcher; no bare provinces in the d…
giswqs Oct 22, 2026
1c9ebac
Merge branch 'main' into feature/vietnam-crs
giswqs Oct 22, 2026
f09bcb0
fix(map): add 'vithanh' to Hậu Giang aliases and add VN-2000 project …
quanguet0409 Oct 22, 2026
0e87ffa
fix(crs): refuse every grid key and pipelines; cap typed PROJ strings
giswqs Oct 22, 2026
34fd21a
fix(crs): check the typed-PROJ cap under the insert's lock; share the…
giswqs Oct 22, 2026
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
282 changes: 280 additions & 2 deletions crates/data/src/raster/crs.rs

Large diffs are not rendered by default.

11 changes: 8 additions & 3 deletions crates/data/src/raster/gdal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,11 +227,14 @@ fn valid_crs(c: &str) -> bool {
.iter()
.any(|k| up.starts_with(k))
&& c.contains('[');
// PROJ parameters that name grid or definition files PROJ would look up are refused.
let file_keys = ["nadgrids", "geoidgrids", "grids", "init", "file"];
// PROJ parameters that name grid or definition files PROJ would look up, or build a
// pipeline, are refused (the same check as for typed definitions).
let proj = c.starts_with("+proj=")
&& c.chars().all(|ch| ch.is_ascii_alphanumeric() || " +=_.,-".contains(ch))
&& !c.split('+').filter_map(|kv| kv.split('=').next()).any(|k| file_keys.contains(&k.trim().to_ascii_lowercase().as_str()));
&& !c.split('+').any(|kv| {
let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
super::crs::proj_key_refused(k, v)
});
let urn = up.starts_with("URN:OGC:DEF:CRS:") && c.chars().all(|ch| ch.is_ascii_alphanumeric() || ":._-".contains(ch));
!c.chars().any(char::is_control) && (code(&up) || wkt || proj || urn)
}
Expand Down Expand Up @@ -451,6 +454,8 @@ mod tests {
"+proj=utm +zone=33 +geoidgrids=egm96_15.gtx",
"+proj=longlat +init=epsg",
"+proj=pipeline +step +proj=hgridshift +grids=ntv2.gsb",
"+proj=utm +zone=33 +vgrids=egm96_15.gtx",
"+proj=deformation +xy_grids=a.tif",
] {
assert!(!valid_crs(bad), "{bad}");
}
Expand Down
51 changes: 51 additions & 0 deletions crates/data/src/raster/geotiff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2314,4 +2314,55 @@ mod tests {
}
}
}

#[test]
fn custom_crs_geotiff_round_trip() {
let r = small();
let custom_proj =
Crs::parse("+proj=tmerc +lat_0=0 +lon_0=105 +k=0.9999 +x_0=500000 +y_0=0 +datum=WGS84 +units=m +no_defs").unwrap();
let raster_custom = Raster::new(
"cust",
r.width,
r.height,
r.bands.iter().map(|b| b.data.clone()).collect(),
r.transform,
custom_proj,
r.nodata,
r.color.clone(),
"Float32",
)
.unwrap();
let bytes = write(&raster_custom, 0).expect("custom CRS with standard projection parameters should serialize to GeoTIFF");
let back = read(std::io::Cursor::new(&bytes), "back").unwrap();
assert_eq!((back.width, back.height), (r.width, r.height));
assert_eq!(back.transform, r.transform);
assert!(!back.crs.is_geographic());
// Verify custom CRS definition is equivalent and usable
let def = back.crs.custom_def().expect("re-opened raster has custom CRS definition");
assert!(def.proj4.contains("+proj=tmerc"), "{}", def.proj4);
assert!(def.proj4.contains("+lon_0=105"), "{}", def.proj4);
let tr = back.crs.transformer().expect("transformer available");
let pt = rustgis_geom::Coord::new(105.5, 21.0);
let projected = tr.from_lonlat(pt).unwrap();
let back_pt = tr.to_lonlat(projected).unwrap();
assert!((back_pt.x - pt.x).abs() < 1e-6 && (back_pt.y - pt.y).abs() < 1e-6);

// VN-2000 / TM-3 is EPSG:5897, written as its code and read back as it.
let vn2000_tm3 = Crs::parse("VN-2000 / TM-3").unwrap();
let raster_vn2000 = Raster::new(
"vn",
r.width,
r.height,
r.bands.iter().map(|b| b.data.clone()).collect(),
r.transform,
vn2000_tm3,
r.nodata,
r.color,
"Float32",
)
.unwrap();
let bytes = write(&raster_vn2000, 0).unwrap();
let back = read(std::io::Cursor::new(&bytes), "vn").unwrap();
assert_eq!(back.crs, Crs::Epsg(5897));
}
}
44 changes: 44 additions & 0 deletions crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,50 @@ fn projection_presets_take_parameters() {
assert!(png.len() > 1000);
}

#[test]
fn vn2000_projection_command_and_project_persistence() {
let mut s = world();
// Explicit prefixed province syntax works and canonicalizes to the preset
s.run("map.projection", &json!({"type": "vn2000:danang"})).unwrap();
assert_eq!(s.map().projection.code(), "vn2000tm3 +lon_0=107.75");
assert_eq!(s.map().projection.label(), "VN-2000 / TM-3 (Vietnam) (lon_0=107.75)");

// Capital Hanoi on central meridian 105.0 drops redundant parameter
s.run("map.projection", &json!({"type": "vn2000:hanoi"})).unwrap();
assert_eq!(s.map().projection.code(), "vn2000tm3");
assert_eq!(s.map().projection.label(), "VN-2000 / TM-3 (Vietnam)");

// UTM zones work
s.run("map.projection", &json!({"type": "vn2000Utm48"})).unwrap();
assert_eq!(s.map().projection.code(), "vn2000Utm48");
assert_eq!(s.map().projection.label(), "VN-2000 / UTM zone 48N");

s.run("map.projection", &json!({"type": "vn2000Utm49"})).unwrap();
assert_eq!(s.map().projection.code(), "vn2000Utm49");
assert_eq!(s.map().projection.label(), "VN-2000 / UTM zone 49N");

// Bare province names and "vietnam" are rejected in strict command context
for bare in ["danang", "vinh", "hanoi", "hue", "hcm", "vietnam"] {
assert!(s.run("map.projection", &json!({"type": bare})).is_err(), "{bare}");
}
// Ambiguous bare VN-2000 is rejected
for bare in ["vn2000", "VN-2000", "VN2000"] {
let err = s.run("map.projection", &json!({"type": bare})).unwrap_err();
assert!(err.contains("ambiguous"), "{bare}: {err}");
}

// Set a provincial TM-3 projection and verify project save and reload
s.run("map.projection", &json!({"type": "VN-2000 / Đà Nẵng"})).unwrap();
assert_eq!(s.map().projection.code(), "vn2000tm3 +lon_0=107.75");
let text = serde_json::to_string(&s.project).unwrap();

// Reload project from serialized JSON text
let mut s2 = world();
s2.run("project.openText", &json!({"text": text})).unwrap();
assert_eq!(s2.map().projection.code(), "vn2000tm3 +lon_0=107.75");
assert_eq!(s2.map().projection.label(), "VN-2000 / TM-3 (Vietnam) (lon_0=107.75)");
}

#[test]
fn custom_projections_switch_keep_scale_query_and_render() {
let mut s = with_raster();
Expand Down
Loading
Loading