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
58 changes: 58 additions & 0 deletions src/execute/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion src/golden/duckdb.sql
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ LOAD spatial;
CREATE OR REPLACE TEMP TABLE "__ggsql_clip_boundary_<session>__" 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_<session>__) 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";

Expand Down
2 changes: 1 addition & 1 deletion src/golden/postgres.sql
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ CREATE TEMP TABLE "__ggsql_clip_boundary_<session>__" 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_<session>__) 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";

Expand Down
2 changes: 1 addition & 1 deletion src/golden/snowflake.sql
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ CREATE TEMP TABLE "__ggsql_clip_boundary_<session>__" 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_<session>__) 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";

Expand Down
2 changes: 1 addition & 1 deletion src/golden/sqlite.sql
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ CREATE TEMP TABLE "__ggsql_clip_boundary_<session>__" 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_<session>__;
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";

Expand Down
2 changes: 1 addition & 1 deletion src/golden/trino.sql
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ CREATE TEMP TABLE "__ggsql_clip_boundary_<session>__" 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_<session>__) 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";

Expand Down
23 changes: 17 additions & 6 deletions src/plot/layer/geom/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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<String> = columns
Expand All @@ -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
Expand Down
41 changes: 41 additions & 0 deletions src/plot/projection/coord/map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,10 @@ impl BBox {
.downcast_ref::<arrow::array::Float64Array>()
.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 {
Expand Down Expand Up @@ -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));
Expand Down
39 changes: 37 additions & 2 deletions src/plot/scale/breaks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,12 @@ fn thin_breaks(breaks: Vec<f64>, n: usize) -> Vec<f64> {
/// 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<f64> {
// 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![];
Expand All @@ -535,9 +541,10 @@ pub fn graticule_breaks(min: f64, max: f64, n: usize) -> Vec<f64> {
.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) {
Expand Down Expand Up @@ -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)));
}
}
34 changes: 34 additions & 0 deletions src/plot/scale/scale_type/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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![
Expand Down
Loading
Loading