diff --git a/src/execute/mod.rs b/src/execute/mod.rs index 0a9d3bee0..dfd2949e9 100644 --- a/src/execute/mod.rs +++ b/src/execute/mod.rs @@ -3407,6 +3407,64 @@ mod tests { ); } + /// Regression test: a point behind the orthographic horizon projects to + /// (inf, inf), which used to flow into the scale domain and send + /// graticule break generation into an unbounded Vec::collect (OOM). + /// Non-finite projected positions must be culled instead. + #[cfg(all(feature = "duckdb", feature = "spatial"))] + #[test] + fn test_orthographic_culls_points_behind_horizon() { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + reader + .connection() + .execute_batch("INSTALL spatial; LOAD spatial") + .unwrap(); + + // (0, 0) is on the visible hemisphere of the default origin; + // (38.7, 141.9) is behind the horizon. + let query = r#" + SELECT * FROM (VALUES (0.0, 0.0), (38.6954, 141.9302)) AS t(latitude, longitude) + VISUALISE + DRAW point MAPPING longitude AS lon, latitude AS lat + PROJECT TO orthographic + "#; + + let result = prepare_data_with_reader(query, &reader); + assert!( + result.is_ok(), + "mixed-visibility point set should execute: {:?}", + result.err() + ); + + let prepared = result.unwrap(); + let layer_key = prepared.specs[0].layers[0].data_key.as_ref().unwrap(); + let df = prepared.data.get(layer_key).unwrap(); + assert_eq!( + df.height(), + 1, + "only the visible point should survive projection" + ); + + // Both points behind the horizon: an empty (not OOMing) layer. + let query = r#" + SELECT * FROM (VALUES (36.0, 140.0), (38.6954, 141.9302)) AS t(latitude, longitude) + VISUALISE + DRAW point MAPPING longitude AS lon, latitude AS lat + PROJECT TO orthographic + "#; + + let result = prepare_data_with_reader(query, &reader); + assert!( + result.is_ok(), + "all-behind-horizon point set should execute: {:?}", + result.err() + ); + let prepared = result.unwrap(); + let layer_key = prepared.specs[0].layers[0].data_key.as_ref().unwrap(); + let df = prepared.data.get(layer_key).unwrap(); + assert_eq!(df.height(), 0, "no points should survive projection"); + } + #[cfg(feature = "duckdb")] #[test] fn test_case_insensitive_facet() { diff --git a/src/golden/duckdb.sql b/src/golden/duckdb.sql index d92914a3f..69454e35c 100644 --- a/src/golden/duckdb.sql +++ b/src/golden/duckdb.sql @@ -137,7 +137,7 @@ LOAD spatial; CREATE OR REPLACE TEMP TABLE "__ggsql_clip_boundary___" AS SELECT ST_Transform(ST_GeomFromText('POLYGON((-180.000000 85.000000, 180.000000 85.000000, 180.000000 -85.000000, -180.000000 -85.000000, -180.000000 85.000000))'), 'EPSG:4326', '+proj=longlat +datum=WGS84 +no_defs +type=crs', always_xy := true) AS geom; SELECT ST_AsText(ST_Transform(ST_GeomFromText('POLYGON((-180.000000 85.000000, 180.000000 85.000000, 180.000000 -85.000000, -180.000000 -85.000000, -180.000000 85.000000))'), 'EPSG:4326', '+proj=merc +lon_0=0', always_xy := true)) AS wkt; SELECT ST_XMin(ext) AS xmin, ST_YMin(ext) AS ymin, ST_XMax(ext) AS xmax, ST_YMax(ext) AS ymax FROM (SELECT ST_Extent_Agg(ST_Transform(geom, '+proj=longlat +datum=WGS84 +no_defs +type=crs', '+proj=merc +lon_0=0', always_xy := true)) AS ext FROM __ggsql_clip_boundary___) AS "__ggsql_ext__"; -CREATE OR REPLACE TEMP TABLE "__ggsql_layer_0___proj" AS SELECT ST_X("__ggsql_proj_pt__") AS "__ggsql_aes_pos1__", ST_Y("__ggsql_proj_pt__") AS "__ggsql_aes_pos2__" FROM (SELECT *, ST_Transform(ST_Point("__ggsql_aes_pos1__", "__ggsql_aes_pos2__"), '+proj=longlat +datum=WGS84 +no_defs +type=crs', '+proj=merc +lon_0=0', always_xy := true) AS "__ggsql_proj_pt__" FROM (SELECT *, "id" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_proj__") AS "__ggsql_pp__"; +CREATE OR REPLACE TEMP TABLE "__ggsql_layer_0___proj" AS SELECT ST_X("__ggsql_proj_pt__") AS "__ggsql_aes_pos1__", ST_Y("__ggsql_proj_pt__") AS "__ggsql_aes_pos2__" FROM (SELECT *, ST_Transform(ST_Point("__ggsql_aes_pos1__", "__ggsql_aes_pos2__"), '+proj=longlat +datum=WGS84 +no_defs +type=crs', '+proj=merc +lon_0=0', always_xy := true) AS "__ggsql_proj_pt__" FROM (SELECT *, "id" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_proj__") AS "__ggsql_pp__" WHERE isfinite(ST_X("__ggsql_proj_pt__")) AND isfinite(ST_Y("__ggsql_proj_pt__")); SELECT MIN("__ggsql_aes_pos1__"), MIN("__ggsql_aes_pos2__"), MAX("__ggsql_aes_pos1__"), MAX("__ggsql_aes_pos2__") FROM "__ggsql_layer_0___proj"; SELECT * FROM "__ggsql_layer_0___proj"; diff --git a/src/golden/postgres.sql b/src/golden/postgres.sql index 1ed7a3665..71aaad01b 100644 --- a/src/golden/postgres.sql +++ b/src/golden/postgres.sql @@ -142,7 +142,7 @@ CREATE TEMP TABLE "__ggsql_clip_boundary___" AS SELECT ST_Transform(ST_ SELECT ST_AsText(ST_Transform(ST_SetSRID(ST_GeomFromText('POLYGON((-180.000000 85.000000, 180.000000 85.000000, 180.000000 -85.000000, -180.000000 -85.000000, -180.000000 85.000000))'), 4326), '+proj=merc +lon_0=0')) AS wkt; SELECT ST_XMin(ext) AS xmin, ST_YMin(ext) AS ymin, ST_XMax(ext) AS xmax, ST_YMax(ext) AS ymax FROM (SELECT ST_Extent(ST_Transform(ST_SetSRID(geom, 4326), '+proj=merc +lon_0=0')) AS ext FROM __ggsql_clip_boundary___) AS "__ggsql_ext__"; DROP TABLE IF EXISTS "__ggsql_layer_0___proj"; -CREATE TEMP TABLE "__ggsql_layer_0___proj" AS SELECT ST_X("__ggsql_proj_pt__") AS "__ggsql_aes_pos1__", ST_Y("__ggsql_proj_pt__") AS "__ggsql_aes_pos2__" FROM (SELECT *, ST_Transform(ST_SetSRID(ST_Point("__ggsql_aes_pos1__", "__ggsql_aes_pos2__"), 4326), '+proj=merc +lon_0=0') AS "__ggsql_proj_pt__" FROM (SELECT *, "id" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_proj__") AS "__ggsql_pp__"; +CREATE TEMP TABLE "__ggsql_layer_0___proj" AS SELECT ST_X("__ggsql_proj_pt__") AS "__ggsql_aes_pos1__", ST_Y("__ggsql_proj_pt__") AS "__ggsql_aes_pos2__" FROM (SELECT *, ST_Transform(ST_SetSRID(ST_Point("__ggsql_aes_pos1__", "__ggsql_aes_pos2__"), 4326), '+proj=merc +lon_0=0') AS "__ggsql_proj_pt__" FROM (SELECT *, "id" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_proj__") AS "__ggsql_pp__" WHERE ABS(ST_X("__ggsql_proj_pt__")) < 1e308 AND ABS(ST_Y("__ggsql_proj_pt__")) < 1e308; SELECT MIN("__ggsql_aes_pos1__"), MIN("__ggsql_aes_pos2__"), MAX("__ggsql_aes_pos1__"), MAX("__ggsql_aes_pos2__") FROM "__ggsql_layer_0___proj"; SELECT * FROM "__ggsql_layer_0___proj"; diff --git a/src/golden/snowflake.sql b/src/golden/snowflake.sql index 7935eedcd..f6f267115 100644 --- a/src/golden/snowflake.sql +++ b/src/golden/snowflake.sql @@ -141,7 +141,7 @@ CREATE TEMP TABLE "__ggsql_clip_boundary___" AS SELECT ST_Transform(ST_ SELECT ST_AsText(ST_Transform(ST_SetSRID(ST_GeomFromText('POLYGON((-180.000000 85.000000, 180.000000 85.000000, 180.000000 -85.000000, -180.000000 -85.000000, -180.000000 85.000000))'), 4326), '+proj=merc +lon_0=0')) AS wkt; SELECT MIN(ST_XMIN(g)) AS xmin, MIN(ST_YMIN(g)) AS ymin, MAX(ST_XMAX(g)) AS xmax, MAX(ST_YMAX(g)) AS ymax FROM (SELECT ST_ENVELOPE(ST_Transform(ST_SetSRID(geom, 4326), '+proj=merc +lon_0=0')) AS g FROM __ggsql_clip_boundary___) AS "__ggsql_env__"; DROP TABLE IF EXISTS "__ggsql_layer_0___proj"; -CREATE TEMP TABLE "__ggsql_layer_0___proj" AS SELECT ST_X("__ggsql_proj_pt__") AS "__ggsql_aes_pos1__", ST_Y("__ggsql_proj_pt__") AS "__ggsql_aes_pos2__" FROM (SELECT *, ST_Transform(ST_SetSRID(ST_Point("__ggsql_aes_pos1__", "__ggsql_aes_pos2__"), 4326), '+proj=merc +lon_0=0') AS "__ggsql_proj_pt__" FROM (SELECT *, "id" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_proj__") AS "__ggsql_pp__"; +CREATE TEMP TABLE "__ggsql_layer_0___proj" AS SELECT ST_X("__ggsql_proj_pt__") AS "__ggsql_aes_pos1__", ST_Y("__ggsql_proj_pt__") AS "__ggsql_aes_pos2__" FROM (SELECT *, ST_Transform(ST_SetSRID(ST_Point("__ggsql_aes_pos1__", "__ggsql_aes_pos2__"), 4326), '+proj=merc +lon_0=0') AS "__ggsql_proj_pt__" FROM (SELECT *, "id" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_proj__") AS "__ggsql_pp__" WHERE ABS(ST_X("__ggsql_proj_pt__")) < 1e308 AND ABS(ST_Y("__ggsql_proj_pt__")) < 1e308; SELECT MIN("__ggsql_aes_pos1__"), MIN("__ggsql_aes_pos2__"), MAX("__ggsql_aes_pos1__"), MAX("__ggsql_aes_pos2__") FROM "__ggsql_layer_0___proj"; SELECT * FROM "__ggsql_layer_0___proj"; diff --git a/src/golden/sqlite.sql b/src/golden/sqlite.sql index 5b54c0703..d32a9fc8f 100644 --- a/src/golden/sqlite.sql +++ b/src/golden/sqlite.sql @@ -143,7 +143,7 @@ CREATE TEMP TABLE "__ggsql_clip_boundary___" AS SELECT ST_Transform(Set SELECT ST_AsText(ST_Transform(SetSRID(ST_GeomFromText('POLYGON((-180.000000 85.000000, 180.000000 85.000000, 180.000000 -85.000000, -180.000000 -85.000000, -180.000000 85.000000))'), 4326), 0, NULL, 'EPSG:4326', '+proj=merc +lon_0=0')) AS wkt; SELECT MIN(MbrMinX(ST_Transform(geom, 0, NULL, '+proj=longlat +datum=WGS84 +no_defs +type=crs', '+proj=merc +lon_0=0'))) AS xmin, MIN(MbrMinY(ST_Transform(geom, 0, NULL, '+proj=longlat +datum=WGS84 +no_defs +type=crs', '+proj=merc +lon_0=0'))) AS ymin, MAX(MbrMaxX(ST_Transform(geom, 0, NULL, '+proj=longlat +datum=WGS84 +no_defs +type=crs', '+proj=merc +lon_0=0'))) AS xmax, MAX(MbrMaxY(ST_Transform(geom, 0, NULL, '+proj=longlat +datum=WGS84 +no_defs +type=crs', '+proj=merc +lon_0=0'))) AS ymax FROM __ggsql_clip_boundary___; DROP TABLE IF EXISTS "__ggsql_layer_0___proj"; -CREATE TEMP TABLE "__ggsql_layer_0___proj" AS SELECT ST_X("__ggsql_proj_pt__") AS "__ggsql_aes_pos1__", ST_Y("__ggsql_proj_pt__") AS "__ggsql_aes_pos2__" FROM (SELECT *, ST_Transform(ST_Point("__ggsql_aes_pos1__", "__ggsql_aes_pos2__"), 0, NULL, '+proj=longlat +datum=WGS84 +no_defs +type=crs', '+proj=merc +lon_0=0') AS "__ggsql_proj_pt__" FROM (SELECT *, "id" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_proj__") AS "__ggsql_pp__"; +CREATE TEMP TABLE "__ggsql_layer_0___proj" AS SELECT ST_X("__ggsql_proj_pt__") AS "__ggsql_aes_pos1__", ST_Y("__ggsql_proj_pt__") AS "__ggsql_aes_pos2__" FROM (SELECT *, ST_Transform(ST_Point("__ggsql_aes_pos1__", "__ggsql_aes_pos2__"), 0, NULL, '+proj=longlat +datum=WGS84 +no_defs +type=crs', '+proj=merc +lon_0=0') AS "__ggsql_proj_pt__" FROM (SELECT *, "id" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_proj__") AS "__ggsql_pp__" WHERE ABS(ST_X("__ggsql_proj_pt__")) < 1e308 AND ABS(ST_Y("__ggsql_proj_pt__")) < 1e308; SELECT MIN("__ggsql_aes_pos1__"), MIN("__ggsql_aes_pos2__"), MAX("__ggsql_aes_pos1__"), MAX("__ggsql_aes_pos2__") FROM "__ggsql_layer_0___proj"; SELECT * FROM "__ggsql_layer_0___proj"; diff --git a/src/golden/trino.sql b/src/golden/trino.sql index a710e254f..a5d709ca3 100644 --- a/src/golden/trino.sql +++ b/src/golden/trino.sql @@ -141,7 +141,7 @@ CREATE TEMP TABLE "__ggsql_clip_boundary___" AS SELECT ST_Transform(ST_ SELECT ST_AsText(ST_Transform(ST_SetSRID(ST_GeomFromText('POLYGON((-180.000000 85.000000, 180.000000 85.000000, 180.000000 -85.000000, -180.000000 -85.000000, -180.000000 85.000000))'), 4326), '+proj=merc +lon_0=0')) AS wkt; SELECT MIN(ST_XMin(g)) AS xmin, MIN(ST_YMin(g)) AS ymin, MAX(ST_XMax(g)) AS xmax, MAX(ST_YMax(g)) AS ymax FROM (SELECT ST_Envelope(ST_Transform(ST_SetSRID(geom, 4326), '+proj=merc +lon_0=0')) AS g FROM __ggsql_clip_boundary___) AS "__ggsql_env__"; DROP TABLE IF EXISTS "__ggsql_layer_0___proj"; -CREATE TEMP TABLE "__ggsql_layer_0___proj" AS SELECT ST_X("__ggsql_proj_pt__") AS "__ggsql_aes_pos1__", ST_Y("__ggsql_proj_pt__") AS "__ggsql_aes_pos2__" FROM (SELECT *, ST_Transform(ST_SetSRID(ST_Point("__ggsql_aes_pos1__", "__ggsql_aes_pos2__"), 4326), '+proj=merc +lon_0=0') AS "__ggsql_proj_pt__" FROM (SELECT *, "id" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_proj__") AS "__ggsql_pp__"; +CREATE TEMP TABLE "__ggsql_layer_0___proj" AS SELECT ST_X("__ggsql_proj_pt__") AS "__ggsql_aes_pos1__", ST_Y("__ggsql_proj_pt__") AS "__ggsql_aes_pos2__" FROM (SELECT *, ST_Transform(ST_SetSRID(ST_Point("__ggsql_aes_pos1__", "__ggsql_aes_pos2__"), 4326), '+proj=merc +lon_0=0') AS "__ggsql_proj_pt__" FROM (SELECT *, "id" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_proj__") AS "__ggsql_pp__" WHERE is_finite(ST_X("__ggsql_proj_pt__")) AND is_finite(ST_Y("__ggsql_proj_pt__")); SELECT MIN("__ggsql_aes_pos1__"), MIN("__ggsql_aes_pos2__"), MAX("__ggsql_aes_pos1__"), MAX("__ggsql_aes_pos2__") FROM "__ggsql_layer_0___proj"; SELECT * FROM "__ggsql_layer_0___proj"; diff --git a/src/plot/layer/geom/mod.rs b/src/plot/layer/geom/mod.rs index 1609116d8..12af10f19 100644 --- a/src/plot/layer/geom/mod.rs +++ b/src/plot/layer/geom/mod.rs @@ -386,6 +386,17 @@ pub(crate) fn project_position_columns( let x_expr = format!("ST_X({proj_col})"); let y_expr = format!("ST_Y({proj_col})"); + // Cull rows whose projected coordinates are non-finite. Azimuthal + // projections (orthographic, stereographic, gnomonic) yield ±Infinity + // for positions outside the projectable region (e.g. behind the + // horizon); such rows cannot be drawn and would poison downstream + // extent computations (scale domains, graticule breaks) with infinities. + let finite = format!( + "{} AND {}", + dialect.sql_is_finite(&x_expr), + dialect.sql_is_finite(&y_expr) + ); + // Build a column list that replaces pos1 and pos2 with projected values // and drops the temporary projected-point column. if columns.is_empty() { @@ -395,6 +406,7 @@ pub(crate) fn project_position_columns( "__ggsql_pp__", ) .from_aliased(crate::sql::FromItem::Query(&inner), "__ggsql_pp__") + .and_where(finite) .build()); } let select_list: Vec = columns @@ -410,12 +422,11 @@ pub(crate) fn project_position_columns( } }) .collect(); - Ok(crate::sql::select_from( - dialect, - &select_list.join(", "), - crate::sql::FromItem::Query(&inner), - "__ggsql_pp__", - )) + Ok(crate::sql::Select::new(dialect) + .select_items(&select_list) + .from_aliased(crate::sql::FromItem::Query(&inner), "__ggsql_pp__") + .and_where(finite) + .build()) } /// Returns true when the projection requires position transformation (Map coord diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index 3c23797b6..439c1c7be 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -269,6 +269,10 @@ impl BBox { .downcast_ref::() .filter(|a| !a.is_null(0)) .map(|a| a.value(0)) + // Degenerate projections (e.g. points behind an orthographic + // horizon) yield infinite extents; reject them so the frame + // falls back to the world/clip bbox. + .filter(|v| v.is_finite()) }; match (get_f64(0), get_f64(1), get_f64(2), get_f64(3)) { (Some(xmin), Some(ymin), Some(xmax), Some(ymax)) => Some(Self { @@ -1105,6 +1109,43 @@ mod tests { BBox::from_array([xmin, ymin, xmax, ymax], "EPSG:4326") } + fn bbox_df(values: [f64; 4]) -> DataFrame { + use arrow::array::{ArrayRef, Float64Array}; + use std::sync::Arc; + let columns: Vec<(&str, ArrayRef)> = values + .iter() + .enumerate() + .map(|(i, v)| { + ( + ["xmin", "ymin", "xmax", "ymax"][i], + Arc::new(Float64Array::from(vec![*v])) as ArrayRef, + ) + }) + .collect(); + DataFrame::new(columns).unwrap() + } + + #[test] + fn test_bbox_from_df_rejects_infinite_extent() { + let df = bbox_df([0.0, 0.0, f64::INFINITY, 1.0]); + assert!(BBox::from_df(&df, "EPSG:4326").is_none()); + } + + #[test] + fn test_bbox_from_df_rejects_nan_extent() { + let df = bbox_df([f64::NAN, 0.0, 1.0, 1.0]); + assert!(BBox::from_df(&df, "EPSG:4326").is_none()); + } + + #[test] + fn test_bbox_from_df_accepts_finite_extent() { + let df = bbox_df([0.0, 1.0, 2.0, 3.0]); + assert_eq!( + BBox::from_df(&df, "EPSG:4326"), + Some(bbox(0.0, 1.0, 2.0, 3.0)) + ); + } + #[test] fn test_resolve_final_bbox_no_bounds_uses_computed() { let computed = Some(bbox(0.0, 0.0, 100.0, 200.0)); diff --git a/src/plot/scale/breaks.rs b/src/plot/scale/breaks.rs index 1811da940..0d72db090 100644 --- a/src/plot/scale/breaks.rs +++ b/src/plot/scale/breaks.rs @@ -515,6 +515,12 @@ fn thin_breaks(breaks: Vec, n: usize) -> Vec { /// values (-180/180 for longitude, -90/90 for latitude) when the range spans /// the full extent. pub fn graticule_breaks(min: f64, max: f64, n: usize) -> Vec { + // Graticules never span more than the globe. max/min also tame non-finite + // inputs (e.g. infinite coordinates from degenerate projections), which + // otherwise make the step loop below iterate ~i64::MAX times. + let min = min.max(-180.0); + let max = max.min(180.0); + let range = max - min; if range <= 0.0 || n == 0 { return vec![]; @@ -535,9 +541,10 @@ pub fn graticule_breaks(min: f64, max: f64, n: usize) -> Vec { .filter(|&v| v > min && v < max) .collect(); - if min <= -180.0 && !breaks.contains(&-180.0) { + // min/max are clamped to ±180 above, so these boundaries are equality checks + if min == -180.0 && !breaks.contains(&-180.0) { breaks.insert(0, -180.0); - } else if max >= 180.0 && !breaks.contains(&180.0) { + } else if max == 180.0 && !breaks.contains(&180.0) { breaks.push(180.0); } if min <= -90.0 && !breaks.contains(&-90.0) { @@ -2435,4 +2442,32 @@ mod tests { let breaks = graticule_breaks(50.0, 50.0, 7); assert!(breaks.is_empty()); } + + #[test] + fn test_graticule_breaks_infinite_range_falls_back_to_globe() { + let breaks = graticule_breaks(f64::NEG_INFINITY, f64::INFINITY, 7); + assert_eq!( + breaks, + vec![-180.0, -135.0, -90.0, -45.0, 0.0, 45.0, 90.0, 135.0] + ); + } + + #[test] + fn test_graticule_breaks_nan_range_falls_back_to_globe() { + let breaks = graticule_breaks(f64::NAN, f64::NAN, 7); + assert_eq!(breaks, graticule_breaks(-180.0, 180.0, 7)); + } + + #[test] + fn test_graticule_breaks_one_sided_infinity() { + let breaks = graticule_breaks(0.0, f64::INFINITY, 7); + assert!(breaks.iter().all(|&b| b > 0.0 && b <= 180.0)); + assert!(!breaks.is_empty()); + } + + #[test] + fn test_graticule_breaks_huge_finite_range_is_clamped() { + let breaks = graticule_breaks(-1e15, 1e15, 7); + assert!(breaks.iter().all(|&b| (-180.0..=180.0).contains(&b))); + } } diff --git a/src/plot/scale/scale_type/mod.rs b/src/plot/scale/scale_type/mod.rs index 766b28a8f..facbf9509 100644 --- a/src/plot/scale/scale_type/mod.rs +++ b/src/plot/scale/scale_type/mod.rs @@ -762,6 +762,12 @@ pub trait ScaleTypeTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { return None; } + // Break generators iterate the domain in step-sized increments; a + // non-finite bound would make that iteration (effectively) unbounded. + if !min.is_finite() || !max.is_finite() { + return None; + } + // Get break count from properties let count = match properties.get("breaks") { Some(ParameterValue::Number(n)) => *n as usize, @@ -3307,6 +3313,34 @@ mod tests { assert!(!breaks.is_empty()); } + #[test] + fn test_resolve_breaks_non_finite_domain() { + // Break generators iterate the domain in step-sized increments; a + // non-finite bound would make that iteration effectively unbounded + // (observed as an OOM when a map projection produced inf coordinates). + let mut props = Parameters::new(); + props.insert("breaks".to_string(), ParameterValue::Number(7.0)); + + let geographic = Transform::geographic(); + for (min, max) in [ + (0.0, f64::INFINITY), + (f64::NEG_INFINITY, 10.0), + (f64::NAN, 10.0), + (f64::NEG_INFINITY, f64::INFINITY), + ] { + let input_range = Some(vec![ArrayElement::Number(min), ArrayElement::Number(max)]); + let breaks = ScaleType::continuous().resolve_breaks( + input_range.as_deref(), + &props, + Some(&geographic), + ); + assert!( + breaks.is_none(), + "expected no breaks for non-finite domain ({min}, {max})" + ); + } + } + #[test] fn test_resolve_breaks_continuous_log10() { let input_range = Some(vec![ diff --git a/src/reader/dialects/bigquery.rs b/src/reader/dialects/bigquery.rs index 04c6069ab..8b1b45a3a 100644 --- a/src/reader/dialects/bigquery.rs +++ b/src/reader/dialects/bigquery.rs @@ -30,6 +30,11 @@ impl SqlDialect for BigQueryDialect { super::backtick_quote_ident(name) } + // BigQuery has IS_INF and IS_NAN but no single is-finite function + fn sql_is_finite(&self, expr: &str) -> String { + format!("(NOT IS_INF({expr}) AND NOT IS_NAN({expr}))") + } + fn sql_cast(&self, expr: &str, type_name: &str) -> String { format!("SAFE_CAST({} AS {})", expr, type_name) } diff --git a/src/reader/dialects/clickhouse.rs b/src/reader/dialects/clickhouse.rs index d078ae746..53f2ec50d 100644 --- a/src/reader/dialects/clickhouse.rs +++ b/src/reader/dialects/clickhouse.rs @@ -46,6 +46,10 @@ impl SqlDialect for ClickHouseDialect { super::backtick_quote_ident(name) } + fn sql_is_finite(&self, expr: &str) -> String { + format!("isFinite({expr})") + } + fn sql_greatest(&self, exprs: &[&str]) -> String { if exprs.len() == 1 { return exprs[0].to_string(); diff --git a/src/reader/dialects/duckdb.rs b/src/reader/dialects/duckdb.rs index 4446e3f2e..9c00be237 100644 --- a/src/reader/dialects/duckdb.rs +++ b/src/reader/dialects/duckdb.rs @@ -17,6 +17,10 @@ impl SqlDialect for DuckDbDialect { true } + fn sql_is_finite(&self, expr: &str) -> String { + format!("isfinite({expr})") + } + fn sql_st_transform(&self, column: &str, source_crs: &str, target_crs: &str) -> String { format!( "ST_Transform({}, '{}', '{}', always_xy := true)", diff --git a/src/reader/dialects/trino.rs b/src/reader/dialects/trino.rs index 464d602f0..c616e90a4 100644 --- a/src/reader/dialects/trino.rs +++ b/src/reader/dialects/trino.rs @@ -19,6 +19,10 @@ impl SqlDialect for TrinoDialect { true } + fn sql_is_finite(&self, expr: &str) -> String { + format!("is_finite({expr})") + } + fn sql_cast(&self, expr: &str, type_name: &str) -> String { format!("TRY_CAST({} AS {})", expr, type_name) } diff --git a/src/reader/mod.rs b/src/reader/mod.rs index 463c5103f..82d294b4b 100644 --- a/src/reader/mod.rs +++ b/src/reader/mod.rs @@ -656,6 +656,22 @@ pub trait SqlDialect { column.to_string() } + /// SQL predicate: whether a numeric expression is finite. + /// + /// Used to cull positions that project to non-finite coordinates (e.g. + /// points behind the horizon of an azimuthal map projection, which PROJ + /// returns as ±Infinity). + /// + /// Default relies on three-valued logic: the comparison is false for + /// ±Infinity, NULL for NULL input, and false for NaN on backends that + /// order NaN above all numbers (DuckDB, Postgres, Trino, Snowflake; + /// SQLite converts NaN to NULL) — every non-finite case is filtered by + /// the enclosing WHERE. Override for backends with a native finite + /// predicate (e.g. DuckDB's `isfinite`). + fn sql_is_finite(&self, expr: &str) -> String { + format!("ABS({expr}) < 1e308") + } + /// SQL expression constructing a point geometry from x/y expressions. /// /// Default is the PostGIS-style `ST_Point`. Override for backends with