diff --git a/Cargo.toml b/Cargo.toml index 1663bc3..7707c0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,17 @@ [package] name = "generative" version = "0.1.0" +autotests = false edition = "2024" rust-version = "1.89" [lib] path = "generative/lib.rs" +[[test]] +name = "tests" +path = "tests/mod.rs" + [[bin]] name = "dla" path = "tools/dla.rs" @@ -105,8 +110,10 @@ fs_extra = { version = "1.3", optional = true } glob = { version = "0.3", optional = true } [dev-dependencies] +assert_cmd = { version = "2.1.1", features = ["color-auto"] } ctor = "0.6" float-cmp = "0.10" +pretty_assertions = "1.4.1" [features] # Tests can dump WKT for ease of visualization diff --git a/generative/flatten/points.rs b/generative/flatten/points.rs index 665c6e1..5fdfae8 100644 --- a/generative/flatten/points.rs +++ b/generative/flatten/points.rs @@ -74,8 +74,7 @@ pub fn flatten_geometries_into_points( .map(|coord| coord.into()) } -/// A variant of [`flatten_geometries_into_points`](flatten_geometries_into_points) that doesn't -/// consume the geometries +/// A variant of [`flatten_geometries_into_points`] that doesn't consume the geometries /// /// NOTE: Closed rings are implicitly opened. pub fn flatten_geometries_into_points_ref<'geom>( diff --git a/generative/snap.rs b/generative/snap.rs index 2e0cf9d..63f2c7d 100644 --- a/generative/snap.rs +++ b/generative/snap.rs @@ -41,6 +41,7 @@ pub fn snap_geoms( } let points = flatten_geometries_into_points_ref(geoms.iter()); + // Build the k-d tree, filtering out duplicate points as we go let mut index = GeomKdTree::new(2); for point in points { let coord: Coord = point.into(); @@ -79,32 +80,26 @@ fn snap_geom_impl(mut geom: Geometry, index: &mut GeomKdTree, tolerance: f64) -> filter_duplicate_vertices(geom) } -fn snap_coord(coord: Coord, index: &mut GeomKdTree, tolerance: f64) -> Coord { - // Find the closest two points in the index, because the first closest should always be ourself. - let coords = [coord.x, coord.y]; - let neighbors = index - .within(&coords, tolerance, &squared_euclidean) - .unwrap(); - // We should always find ourselves, or, if move_snapped_point is true, at least find where - // ourselves have already been snapped to (because one point in the kd-tree could be multiple - // vertices from multiple geometries). - debug_assert!(!neighbors.is_empty()); +fn snap_coord(to_snap: Coord, index: &mut GeomKdTree, tolerance: f64) -> Coord { + let query = [to_snap.x, to_snap.y]; + let neighbors = index.within(&query, tolerance, &squared_euclidean).unwrap(); if !neighbors.is_empty() { let (mut _distance, mut found_coords) = neighbors[0]; - // We found ourselves. Now look for a neighbor in range - if found_coords == &coord && neighbors.len() > 1 { + // If we found ourselves, snap to the next closest point + if found_coords == &to_snap && neighbors.len() > 1 { // The next closest point (_distance, found_coords) = neighbors[1]; } + // Remove the point that we snapped to, so that future snaps don't find it again let snapped_coord = *found_coords; - index.remove(&coords, &coord).unwrap(); + index.remove(&query, &to_snap).unwrap(); - return snapped_coord; + snapped_coord + } else { + to_snap } - - coord } fn snap_coord_grid(coord: Coord, tolerance: f64) -> Coord { @@ -210,6 +205,15 @@ where for node_idx in graph.node_indices() { let node = graph[node_idx]; let coords = [node.0.x, node.0.y]; + + // Don't add duplicate vertices to the index + let closest = index.nearest(&coords, 1, &squared_euclidean).unwrap(); + if let Some(closest) = closest.first() { + let (distance, _) = closest; + if *distance == 0.0 { + continue; + } + } index.add(coords, node_idx).unwrap(); } @@ -231,8 +235,8 @@ where { let mut nodes_to_remove = Vec::new(); for node in graph.node_indices() { - if let Some(snapped) = snap_graph_node(&mut graph, node, index, tolerance) { - nodes_to_remove.push(snapped); + if let Some(_snapped_to) = snap_graph_node(&mut graph, node, index, tolerance) { + nodes_to_remove.push(node); } } @@ -246,6 +250,9 @@ where graph } +/// Snap the given node to the closest other node within the given tolerance +/// +/// Returns the NodeIndex that was snapped to, if the given node was snapped. fn snap_graph_node( graph: &mut GeometryGraph, node_idx: NodeIndex, @@ -259,29 +266,40 @@ where let nearest_coords = index .within(&coords, tolerance, &squared_euclidean) .unwrap(); - debug_assert!( - !nearest_coords.is_empty(), - "We'll always look up at least ourselves" - ); // There's no node close enough to snap to - if nearest_coords.len() <= 1 { + if nearest_coords.is_empty() { return None; } - let (mut _distance, mut found_idx) = nearest_coords[0]; - let found_coord = graph[*found_idx].0; - if found_coord == graph[node_idx].0 && nearest_coords.len() > 1 { - (_distance, found_idx) = nearest_coords[1]; + // Find the closest node that isn't the query node itself + let mut snap_to = None; + for (_distance, found_idx) in nearest_coords { + if *found_idx != node_idx { + snap_to = Some(*found_idx); + break; + } } - let found_idx = *found_idx; - index.remove(&coords, &node_idx).unwrap(); + // We found a node to snap to + if let Some(found_idx) = snap_to { + // Remove the snapped from node from the index, but we have to be careful to only remove it + // if we know the coordinates are actually in the index (duplicate coordinates are filtered + // out ahead of time because they would otherwise cause infinite loops here) + if graph[found_idx] != graph[node_idx] { + // Remove the node we're snapping from + index.remove(&coords, &node_idx).unwrap(); + } - snap_graph_nodes(graph, node_idx, found_idx); - Some(node_idx) + // Snap the two nodes together, updating the adjacencies + snap_graph_nodes(graph, node_idx, found_idx); + Some(found_idx) + } else { + None + } } +/// Snap `snap_from` to `snap_to`, and update all of `snap_from`s adjacencies fn snap_graph_nodes( graph: &mut GeometryGraph, snap_from: NodeIndex, @@ -289,9 +307,7 @@ fn snap_graph_nodes( ) where D: EdgeType, { - if snap_from == snap_to || graph[snap_from] == graph[snap_to] { - return; - } + debug_assert_ne!(snap_from, snap_to); let neighbors: Vec<_> = graph.neighbors(snap_from).collect(); let mut neighbors_to_snap = Vec::new(); @@ -358,6 +374,7 @@ mod tests { use float_cmp::assert_approx_eq; use geo::{LineString, Point}; use petgraph::Undirected; + use pretty_assertions::assert_eq; use super::*; use crate::io::{read_tgf_graph, write_tgf_graph}; @@ -471,32 +488,102 @@ mod tests { } #[test] - fn test_snap_graph_closest_simple() { - let tgf = b"0 POINT(0 0)\n1 POINT(1 0)\n2 POINT(1.1 0)\n3 POINT(2 0)\n#\n0 1\n1 2\n2 3"; + fn test_snap_graph_closest_duplicate() { + let tgf = b"\ + 0 POINT(0 0)\n\ + 1 POINT(0 0)\n\ + #\n\ + 0 1\n\ + "; let graph = read_tgf_graph::(&tgf[..]); - assert_eq!(graph.node_count(), 4); - assert_eq!(graph.edge_count(), 3); + assert_eq!(graph.node_count(), 2); + assert_eq!(graph.edge_count(), 1); - let tgf = b"0\tPOINT(0 0)\n1\tPOINT(2 0)\n2\tPOINT(1.1 0)\n#\n2\t1\n2\t0\n"; - let expected_tgf = String::from_utf8_lossy(tgf); + let expected_tgf = "\ + 0\tPOINT(0 0)\n\ + #\n\ + "; let actual = snap_graph(graph, SnappingStrategy::ClosestPoint(0.2)); let actual_tgf = get_tgf(&actual); - assert_eq!(actual_tgf, expected_tgf); + assert_eq!(expected_tgf, actual_tgf); + } + + #[test] + fn test_snap_graph_closest_simple() { + let tgf = b"\ + 0 POINT(0 0)\n\ + 1 POINT(1 0)\n\ + 2 POINT(1.1 0)\n\ + 3 POINT(2 0)\n\ + 4 POINT(1.1 0)\n\ + #\n\ + 0 1\n\ + 1 2\n\ + 2 3\n\ + 2 4\n\ + "; + let graph = read_tgf_graph::(&tgf[..]); + assert_eq!(graph.node_count(), 5); + assert_eq!(graph.edge_count(), 4); + + let expected_tgf = "\ + 0\tPOINT(0 0)\n\ + 1\tPOINT(2 0)\n\ + 2\tPOINT(1.1 0)\n\ + #\n\ + 2\t1\n\ + 2\t0\n\ + "; + + let actual = snap_graph(graph, SnappingStrategy::ClosestPoint(0.2)); + + let actual_tgf = get_tgf(&actual); + assert_eq!(expected_tgf, actual_tgf); } #[test] fn test_snap_graph_closest_complex() { - let tgf = b"0\tPOINT(-0.1 0)\n1\tPOINT(0 0)\n2\tPOINT(0 0.1)\n3\tPOINT(0 -0.1)\n4\tPOINT(2 0)\n#\n0\t1\n2\t1\n3\t1\n1\t4\n"; + let tgf = b"\ + 0\tPOINT(-0.1 0)\n\ + 1\tPOINT(0 0)\n\ + 2\tPOINT(0 0.1)\n\ + 3\tPOINT(0 -0.1)\n\ + 4\tPOINT(2 0)\n\ + #\n\ + 0\t1\n\ + 2\t1\n\ + 3\t1\n\ + 1\t4\n\ + "; let graph = read_tgf_graph::(&tgf[..]); - let tgf = b"0\tPOINT(2 0)\n1\tPOINT(0 -0.1)\n#\n1\t0\n"; - let expected_tgf = String::from_utf8_lossy(tgf); + let expected_tgf = "\ + 0\tPOINT(2 0)\n\ + 1\tPOINT(0 -0.1)\n\ + #\n\ + 1\t0\n\ + "; let actual = snap_graph(graph, SnappingStrategy::ClosestPoint(0.11)); let actual_tgf = get_tgf(&actual); - assert_eq!(actual_tgf, expected_tgf); + assert_eq!(expected_tgf, actual_tgf); + } + + #[test] + fn test_snap_duplicate_vertices_crash() { + let points = [ + Geometry::Point(Point::new(-0.4999999999999998, 0.8660254037844387)), + Geometry::Point(Point::new(-0.4999999999999998, 0.8660254037844387)), + ]; + + let snapped: Vec<_> = + snap_geoms(points.into_iter(), SnappingStrategy::ClosestPoint(0.0)).collect(); + + // Snapping can't remove duplicate vertices; it can only move the coordinates of a vertex + // to the coordinates of another vertex. + assert_eq!(snapped.len(), 2); } } diff --git a/tests/mod.rs b/tests/mod.rs new file mode 100644 index 0000000..4942f51 --- /dev/null +++ b/tests/mod.rs @@ -0,0 +1,56 @@ +mod test_attractor; +mod test_bundle; +#[cfg(feature = "cxx-bindings")] +mod test_geom2graph; +mod test_grid; +mod test_pack; +mod test_smooth; +mod test_snap; +mod test_transform; +mod test_triangulate; +mod test_wkt2svg; + +use std::collections::HashMap; +use std::path::PathBuf; +use std::process::Output; +use std::sync::{LazyLock, Mutex}; + +use assert_cmd::Command; + +pub trait CommandExt { + /// Same as [Command::output] except with hooks to print stdout/stderr in failed tests + fn captured_output(&mut self) -> Output; +} + +impl CommandExt for Command { + fn captured_output(&mut self) -> Output { + let output = self.output().expect("Failed to execute command"); + + // libtest has hooks in the print! and eprint! macros to do output capturing in tests. + print!("{}", String::from_utf8_lossy(&output.stdout)); + eprint!("{}", String::from_utf8_lossy(&output.stderr)); + + output + } +} + +/// Get a command to run the given tool with Cargo +pub fn tool(name: &'static str) -> Command { + // XXX: Using nextest somewhat defeats this cache, because it runs each test in a separate + // process, so the cache has to be rebuilt each time. But having it at least makes me feel + // like I tried :/ + static TOOL_PATH_CACHE: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + + let mut cache = TOOL_PATH_CACHE.lock().unwrap(); + // assert_cmd::cargo::cargo_bin is deprecated but cargo_bin! requires string literal, not &'static str + #[allow(deprecated)] + let path = cache + .entry(name) + // TODO: Support the various Python tools as well + .or_insert_with(|| assert_cmd::cargo::cargo_bin(name)); + + let mut cmd = Command::new(path); + cmd.arg("--log-level=TRACE"); + cmd +} diff --git a/tests/test_attractor.rs b/tests/test_attractor.rs new file mode 100644 index 0000000..8a97204 --- /dev/null +++ b/tests/test_attractor.rs @@ -0,0 +1,23 @@ +use pretty_assertions::assert_eq; + +use crate::{CommandExt, tool}; + +#[test] +fn test_attractor_simple() { + let expected = "\ + POINT(1 2)\n\ + POINT(2 4)\n\ + POINT(3 6)\n\ + "; + + let output = tool("attractor") + .arg("--initial-x=0") + .arg("--initial-y=0") + .arg("--math=let x_new = x + 1.0;") + .arg("--math=let y_new = y + 2.0;") + .arg("--iterations=3") + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} diff --git a/tests/test_bundle.rs b/tests/test_bundle.rs new file mode 100644 index 0000000..b8dfa3c --- /dev/null +++ b/tests/test_bundle.rs @@ -0,0 +1,19 @@ +use pretty_assertions::assert_eq; + +use crate::{CommandExt, tool}; + +#[test] +fn test_bundle_simple_points() { + let input = b"\ + POINT (0 0)\n\ + POINT (1 1)\n\ + POINT (2 2)\n\ + "; + + let expected = "GEOMETRYCOLLECTION(POINT(0 0),POINT(1 1),POINT(2 2))\n"; + + let output = tool("bundle").write_stdin(input).captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} diff --git a/tests/test_geom2graph.rs b/tests/test_geom2graph.rs new file mode 100644 index 0000000..3f37748 --- /dev/null +++ b/tests/test_geom2graph.rs @@ -0,0 +1,51 @@ +use pretty_assertions::assert_eq; + +use crate::{CommandExt, tool}; + +#[test] +fn test_geom2graph_round_trip() { + // Two squares, one inside the other, sharing the (1, 1) corner. Results in seven vertices and + // eight edges. + let input = b"\ + POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))\n\ + POLYGON ((0.5 0.5, 0.5 1, 1 1, 1 0.5, 0.5 0.5))\n\ + "; + + let expected = "\ + 0\tPOINT(0 0)\n\ + 1\tPOINT(0 1)\n\ + 2\tPOINT(0.5 1)\n\ + 3\tPOINT(1 1)\n\ + 4\tPOINT(1 0.5)\n\ + 5\tPOINT(1 0)\n\ + 6\tPOINT(0.5 0.5)\n\ + #\n\ + 0\t5\n\ + 0\t1\n\ + 1\t2\n\ + 2\t6\n\ + 2\t3\n\ + 3\t4\n\ + 4\t6\n\ + 4\t5\n\ + "; + + let output = tool("geom2graph").write_stdin(input).captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); + + // Feed the output back in. + let output = tool("geom2graph") + .arg("--graph2geom") + .write_stdin(expected) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + + let expected = "\ + POLYGON((0 0,0 1,0.5 1,0.5 0.5,1 0.5,1 0,0 0))\n\ + POLYGON((0.5 1,1 1,1 0.5,0.5 0.5,0.5 1))\n\ + "; + assert_eq!(expected, stdout); +} diff --git a/tests/test_grid.rs b/tests/test_grid.rs new file mode 100644 index 0000000..e9ae62f --- /dev/null +++ b/tests/test_grid.rs @@ -0,0 +1,270 @@ +use pretty_assertions::assert_eq; + +use crate::{CommandExt, tool}; + +#[test] +fn test_graph_output_formats() { + let expected = "\ + 0\tPOINT(0 0)\n\ + 1\tPOINT(1 0)\n\ + 2\tPOINT(2 0)\n\ + 3\tPOINT(0 1)\n\ + 4\tPOINT(1 1)\n\ + 5\tPOINT(2 1)\n\ + 6\tPOINT(0 2)\n\ + 7\tPOINT(1 2)\n\ + 8\tPOINT(2 2)\n\ + #\n\ + 0\t3\n\ + 0\t1\n\ + 1\t4\n\ + 1\t2\n\ + 2\t5\n\ + 3\t6\n\ + 3\t4\n\ + 4\t7\n\ + 4\t5\n\ + 5\t8\n\ + 6\t7\n\ + 7\t8\n\ + "; + + let output = tool("grid") + .arg("--grid-type=quad") + .arg("--output-format=graph") + .arg("--width=2") + .arg("--height=2") + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} + +#[test] +fn test_lines_output_formats() { + let expected = "\ + LINESTRING(0 0,0 1)\n\ + LINESTRING(0 0,1 0)\n\ + LINESTRING(1 0,1 1)\n\ + LINESTRING(1 0,2 0)\n\ + LINESTRING(2 0,2 1)\n\ + LINESTRING(0 1,0 2)\n\ + LINESTRING(0 1,1 1)\n\ + LINESTRING(1 1,1 2)\n\ + LINESTRING(1 1,2 1)\n\ + LINESTRING(2 1,2 2)\n\ + LINESTRING(0 2,1 2)\n\ + LINESTRING(1 2,2 2)\n\ + "; + + let output = tool("grid") + .arg("--grid-type=quad") + .arg("--output-format=lines") + .arg("--width=2") + .arg("--height=2") + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} + +#[test] +fn test_points_output_formats() { + let expected = "\ + POINT(0 0)\n\ + POINT(1 0)\n\ + POINT(2 0)\n\ + POINT(0 1)\n\ + POINT(1 1)\n\ + POINT(2 1)\n\ + POINT(0 2)\n\ + POINT(1 2)\n\ + POINT(2 2)\n\ + "; + + let output = tool("grid") + .arg("--grid-type=quad") + .arg("--output-format=points") + .arg("--width=2") + .arg("--height=2") + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} + +#[test] +fn test_cells_output_formats() { + let expected = "\ + POLYGON((1 0,0 0,0 1,1 1,1 0))\n\ + POLYGON((2 0,1 0,1 1,2 1,2 0))\n\ + POLYGON((1 1,0 1,0 2,1 2,1 1))\n\ + POLYGON((2 1,1 1,1 2,2 2,2 1))\n\ + "; + + let output = tool("grid") + .arg("--grid-type=quad") + .arg("--output-format=cells") + .arg("--width=2") + .arg("--height=2") + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} + +#[test] +fn test_triangle() { + let expected = "\ + POINT(0 0)\n\ + POINT(1 0)\n\ + POINT(2 0)\n\ + POINT(0 1)\n\ + POINT(1 1)\n\ + POINT(2 1)\n\ + POINT(0 2)\n\ + POINT(1 2)\n\ + POINT(2 2)\n\ + "; + + let output = tool("grid") + .arg("--grid-type=quad") + .arg("--output-format=points") + .arg("--width=2") + .arg("--height=2") + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} + +#[test] +fn test_quad() { + let expected = "\ + POINT(0 0)\n\ + POINT(1 0)\n\ + POINT(2 0)\n\ + POINT(0 1)\n\ + POINT(1 1)\n\ + POINT(2 1)\n\ + POINT(0 2)\n\ + POINT(1 2)\n\ + POINT(2 2)\n\ + "; + + let output = tool("grid") + .arg("--grid-type=quad") + .arg("--output-format=points") + .arg("--width=2") + .arg("--height=2") + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} + +#[test] +fn test_ragged() { + let expected = "\ + POINT(0 0)\n\ + POINT(1 0)\n\ + POINT(2 0)\n\ + POINT(3 0)\n\ + POINT(0 1)\n\ + POINT(1 1)\n\ + POINT(2 1)\n\ + POINT(3 1)\n\ + POINT(0 2)\n\ + POINT(1 2)\n\ + POINT(2 2)\n\ + POINT(3 2)\n\ + "; + + let output = tool("grid") + .arg("--grid-type=ragged") + .arg("--output-format=points") + .arg("--width=2") + .arg("--height=2") + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} + +#[test] +fn test_hexagon() { + let expected = "\ + POINT(0 0)\n\ + POINT(1 0)\n\ + POINT(1.5 4.330127018922193)\n\ + POINT(-0.5 0.8660254037844386)\n\ + POINT(1.5 0.8660254037844386)\n\ + POINT(2.5 0.8660254037844386)\n\ + POINT(0 1.7320508075688772)\n\ + POINT(1 1.7320508075688772)\n\ + POINT(3 1.7320508075688772)\n\ + POINT(-0.5 2.598076211353316)\n\ + POINT(1.5 2.598076211353316)\n\ + POINT(2.5 2.598076211353316)\n\ + POINT(0 3.4641016151377544)\n\ + POINT(1 3.4641016151377544)\n\ + POINT(3 3.4641016151377544)\n\ + POINT(2.5 4.330127018922193)\n\ + "; + + let output = tool("grid") + .arg("--grid-type=hexagon") + .arg("--output-format=points") + .arg("--width=2") + .arg("--height=2") + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} + +#[test] +fn test_radial() { + let expected = "\ + POINT(1 0)\n\ + POINT(0.766044443118978 0.6427876096865393)\n\ + POINT(0.17364817766693041 0.984807753012208)\n\ + POINT(-0.4999999999999998 0.8660254037844387)\n\ + POINT(-0.9396926207859083 0.3420201433256689)\n\ + POINT(-0.9396926207859085 -0.3420201433256682)\n\ + POINT(-0.5000000000000004 -0.8660254037844384)\n\ + POINT(0.17364817766692997 -0.9848077530122081)\n\ + POINT(0.7660444431189778 -0.6427876096865396)\n\ + POINT(1 0)\n\ + POINT(2 0)\n\ + POINT(1.532088886237956 1.2855752193730785)\n\ + POINT(0.34729635533386083 1.969615506024416)\n\ + POINT(-0.9999999999999996 1.7320508075688774)\n\ + POINT(-1.8793852415718166 0.6840402866513378)\n\ + POINT(-1.879385241571817 -0.6840402866513364)\n\ + POINT(-1.0000000000000009 -1.7320508075688767)\n\ + POINT(0.34729635533385994 -1.9696155060244163)\n\ + POINT(1.5320888862379556 -1.2855752193730792)\n\ + POINT(2 0)\n\ + POINT(0 0)\n\ + POINT(1 0)\n\ + POINT(2 0)\n\ + POINT(0 0)\n\ + POINT(-0.4999999999999998 0.8660254037844387)\n\ + POINT(-0.9999999999999996 1.7320508075688774)\n\ + POINT(0 0)\n\ + POINT(-0.5000000000000004 -0.8660254037844384)\n\ + POINT(-1.0000000000000009 -1.7320508075688767)\n\ + "; + + let output = tool("grid") + .arg("--grid-type=radial") + .arg("--output-format=points") + .arg("--ring-fill-points=2") + .arg("--width=3") // angular division + .arg("--height=2") // radius + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} diff --git a/tests/test_pack.rs b/tests/test_pack.rs new file mode 100644 index 0000000..8464ac1 --- /dev/null +++ b/tests/test_pack.rs @@ -0,0 +1,28 @@ +use pretty_assertions::assert_eq; + +use crate::{CommandExt, tool}; + +#[test] +fn test_pack_four_squares() { + let input = b"\ + POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))\n\ + POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))\n\ + POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))\n\ + POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))\n\ + "; + + let expected = "\ + POLYGON((-0.5 -0.5,-0.5 0.5,0.5 0.5,0.5 -0.5,-0.5 -0.5))\n\ + POLYGON((1.5 -0.5,1.5 0.5,2.5 0.5,2.5 -0.5,1.5 -0.5))\n\ + POLYGON((-0.5 1.5,-0.5 2.5,0.5 2.5,0.5 1.5,-0.5 1.5))\n\ + POLYGON((1.5 1.5,1.5 2.5,2.5 2.5,2.5 1.5,1.5 1.5))\n\ + "; + + let output = tool("pack") + .arg("--width=4") + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} diff --git a/tests/test_smooth.rs b/tests/test_smooth.rs new file mode 100644 index 0000000..b017ee5 --- /dev/null +++ b/tests/test_smooth.rs @@ -0,0 +1,20 @@ +use pretty_assertions::assert_eq; + +use crate::{CommandExt, tool}; + +#[test] +fn test_smooth_unit_square() { + let input = b"POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))\n"; + + let expected = "\ + POLYGON((0 0.25,0 0.75,0.25 1,0.75 1,1 0.75,1 0.25,0.75 0,0.25 0,0 0.25))\n\ + "; + + let output = tool("smooth") + .arg("--iterations=1") + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} diff --git a/tests/test_snap.rs b/tests/test_snap.rs new file mode 100644 index 0000000..419de62 --- /dev/null +++ b/tests/test_snap.rs @@ -0,0 +1,132 @@ +use pretty_assertions::assert_eq; + +use crate::{CommandExt, tool}; + +#[test] +fn test_snap_graph() { + // A few connected nodes close together, and a few close together nodes that aren't connected. + let input = b"\ + 0\tPOINT(0 0)\n\ + 2\tPOINT(0 0.05)\n\ + 3\tPOINT(1 1)\n\ + 4\tPOINT(1.05 1)\n\ + #\n\ + 0\t2\n\ + 0\t3\n\ + "; + + let expected = "\ + 0\tPOINT(1.05 1)\n\ + 1\tPOINT(0 0.05)\n\ + #\n\ + 0\t1\n\ + "; + + let output = tool("snap") + .arg("--input-format=tgf") + .arg("--tolerance=0.1") + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} + +#[test] +fn test_snap_graph_duplicate_nodes() { + let input = b"\ + 1\tPOINT(0 0)\n\ + 2\tPOINT(0 0)\n\ + #\n\ + "; + + let expected = "\ + 0\tPOINT(0 0)\n\ + #\n\ + "; + + let output = tool("snap") + .arg("--input-format=tgf") + .arg("--tolerance=0.1") + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} + +#[test] +fn test_snap_graph_to_grid() { + let input = b"\ + 0\tPOINT(0 0)\n\ + 1\tPOINT(0 0)\n\ + 2\tPOINT(0 0.05)\n\ + 3\tPOINT(1 1)\n\ + 4\tPOINT(1.05 1)\n\ + #\n\ + 0\t2\n\ + 0\t3\n\ + "; + + let expected = "\ + 0\tPOINT(0 0)\n\ + 1\tPOINT(1 1)\n\ + #\n\ + 0\t1\n\ + "; + + let output = tool("snap") + .arg("--input-format=tgf") + .arg("--tolerance=0.5") + .arg("--strategy=regular-grid") // regular grid with --tolerance spacing + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} + +#[test] +fn test_snap_geoms() { + let input = b"\ + POLYGON((0 0, 0 0.99, 0.99 0.99, 0.99 0, 0 0))\n\ + POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))\n\ + "; + + let expected = "\ + POLYGON((0 0,0 1,1 1,1 0,0 0))\n\ + POLYGON((0 0,0 1,1 1,1 0,0 0))\n\ + "; + + let output = tool("snap") + .arg("--input-format=wkt") + .arg("--tolerance=0.1") + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} + +#[test] +fn test_snap_geoms_to_grid() { + // Just a single point (no other points to snap to) + let input = b"\ + POINT(0.1 0.1)\n\ + "; + + // Still gets snapped to the 0.5 grid + let expected = "\ + POINT(0 0)\n\ + "; + + let output = tool("snap") + .arg("--input-format=wkt") + .arg("--tolerance=0.5") + .arg("--strategy=regular-grid") // regular grid with --tolerance spacing + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} diff --git a/tests/test_transform.rs b/tests/test_transform.rs new file mode 100644 index 0000000..1e32366 --- /dev/null +++ b/tests/test_transform.rs @@ -0,0 +1,153 @@ +use pretty_assertions::assert_eq; + +use crate::{CommandExt, tool}; + +#[test] +fn test_rotate_about_origin() { + let input = b"POINT(1 0)\n"; + let expected = "POINT(-1 0.00000000000000012246467991473532)\n"; + + let output = tool("transform") + .arg("--center=origin") + .arg("--rotation=180") + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} + +#[test] +fn test_rotate_about_geom_bbox_center() { + let input = b"\ + LINESTRING(-1 0, 1 0)\n\ + LINESTRING(-1 1, 1 1)\n\ + "; + let expected = "\ + LINESTRING(-0.00000000000000006123233995736766 -1,0.00000000000000006123233995736766 1)\n\ + LINESTRING(0 0,0.00000000000000011102230246251565 2)\n\ + "; + let output = tool("transform") + .arg("--center=each-geometry") + .arg("--rotation=90") + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} + +#[test] +fn test_rotate_about_collection_center() { + let input = b"\ + LINESTRING(-1 0, 1 0)\n\ + LINESTRING(-1 1, 1 1)\n\ + "; + let expected = "\ + LINESTRING(0.49999999999999994 -0.5,0.5000000000000001 1.5)\n\ + LINESTRING(-0.5 -0.49999999999999994,-0.4999999999999999 1.5)\n\ + "; + let output = tool("transform") + .arg("--center=whole-collection") + .arg("--rotation=90") + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} + +#[test] +fn test_scale() { + let input = b"\ + POINT(0 0)\n\ + POINT(1 0)\n\ + POINT(0 2)\n\ + "; + let expected = "\ + POINT(0 0)\n\ + POINT(2 0)\n\ + POINT(0 4)\n\ + "; + let output = tool("transform") + .arg("--scale=2") + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} + +#[test] +fn test_offset() { + let input = b"\ + POINT(0 0)\n\ + "; + let expected = "\ + POINT(1 -1)\n\ + "; + let output = tool("transform") + .arg("--offset-x=1") + .arg("--offset-y=-1") + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} + +#[test] +fn test_skew_x() { + let input = b"\ + LINESTRING(0 0, 0 1)\n\ + "; + let expected = "\ + LINESTRING(0 0,0.9999999999999999 1)\n\ + "; + let output = tool("transform") + .arg("--skew-x=45") + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} + +#[test] +fn test_polar() { + let input = b"\ + LINESTRING(0 0, 0 1)\n\ + "; + let expected = "\ + LINESTRING(0 0,1 1.5707963267948966)\n\ + "; + let output = tool("transform") + .arg("--to-polar") + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} + +#[test] +fn fit_to_range() { + let input = b"\ + POINT(0 0)\n\ + POINT(2 0)\n\ + POINT(4 0)\n\ + "; + let expected = "\ + POINT(-4 0)\n\ + POINT(-3 0)\n\ + POINT(-2 0)\n\ + "; + let output = tool("transform") + .arg("--offset-x=-1") // -1, 1, -3 + .arg("--range1=-4,-2") // -4, -3, -2 + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} diff --git a/tests/test_triangulate.rs b/tests/test_triangulate.rs new file mode 100644 index 0000000..922c4a7 --- /dev/null +++ b/tests/test_triangulate.rs @@ -0,0 +1,94 @@ +use pretty_assertions::assert_eq; + +use crate::{CommandExt, tool}; + +#[test] +fn test_triangulate_each_geometry() { + let input = b"\ + POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))\n\ + POLYGON ((2 0, 2 1, 3 1, 3 0, 2 0))\n\ + "; + + let expected = "\ + LINESTRING(1 1,1 0)\n\ + LINESTRING(1 0,0 0)\n\ + LINESTRING(0 0,0 1)\n\ + LINESTRING(0 1,1 1)\n\ + LINESTRING(0 0,1 1)\n\ + LINESTRING(3 1,3 0)\n\ + LINESTRING(3 0,2 0)\n\ + LINESTRING(2 0,2 1)\n\ + LINESTRING(2 1,3 1)\n\ + LINESTRING(2 0,3 1)\n\ + "; + + let output = tool("triangulate") + .arg("--strategy=each-geometry") + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} + +#[test] +fn test_triangulate_whole_collection() { + let input = b"\ + POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))\n\ + POLYGON ((2 0, 2 1, 3 1, 3 0, 2 0))\n\ + "; + + // Includes the gap between the two polygons + let expected = "\ + LINESTRING(3 1,3 0)\n\ + LINESTRING(3 0,2 0)\n\ + LINESTRING(2 0,1 0)\n\ + LINESTRING(1 0,0 0)\n\ + LINESTRING(0 0,0 1)\n\ + LINESTRING(0 1,1 1)\n\ + LINESTRING(1 1,2 1)\n\ + LINESTRING(2 1,3 1)\n\ + LINESTRING(0 0,1 1)\n\ + LINESTRING(1 0,1 1)\n\ + LINESTRING(2 0,1 1)\n\ + LINESTRING(2 0,2 1)\n\ + LINESTRING(2 0,3 1)\n\ + "; + + let output = tool("triangulate") + .arg("--strategy=whole-collection") + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} + +#[test] +fn test_triangulate_graph() { + let input = b"\ + POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))\n\ + "; + + let expected = "\ + 0\tPOINT(0 0)\n\ + 1\tPOINT(0 1)\n\ + 2\tPOINT(1 1)\n\ + 3\tPOINT(1 0)\n\ + #\n\ + 2\t3\n\ + 3\t0\n\ + 0\t1\n\ + 1\t2\n\ + 0\t2\n\ + "; + + let output = tool("triangulate") + .arg("--strategy=whole-collection") + .arg("--output-format=tgf") + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} diff --git a/tests/test_wkt2svg.rs b/tests/test_wkt2svg.rs new file mode 100644 index 0000000..630b9a2 --- /dev/null +++ b/tests/test_wkt2svg.rs @@ -0,0 +1,95 @@ +use pretty_assertions::assert_eq; + +use crate::{CommandExt, tool}; + +#[test] +fn test_simple_geometries() { + let input = b"\ + POINT (0 0)\n\ + LINESTRING (1 1, 2 2)\n\ + POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))\n\ + "; + + let expected = "\ + \n\ + \n\ + \n\ + \n\ + \n\ + \ + "; + + let output = tool("wkt2svg") + .arg("--scale=10") + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} + +#[test] +fn test_cli_styles() { + let input = b"\ + POINT (0 0)\n\ + LINESTRING (1 1, 2 2)\n\ + POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))\n\ + "; + + let expected = "\ + \n\ + \n\ + \n\ + \n\ + \n\ + \ + "; + + let output = tool("wkt2svg") + .arg("--scale=10") + .arg("--point-radius=0.5") + .arg("--stroke=red") + .arg("--stroke-dasharray=5") + .arg("--fill=blue") + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} + +#[test] +fn test_wkt_styles() { + let input = b"\ + POINT(0 0)\n\ + POINT(100 100)\n\ + STROKEWIDTH(4)\n\ + STROKEDASHARRAY(6 1)\n\ + POINTRADIUS(20)\n\ + FILL(red)\n\ + POINT(50 50)\n\ + "; + + let expected = "\ + \n\ + \n\ + \n\ + \n\ + \n\ + \ + "; + + let output = tool("wkt2svg") + .arg("--scale=10") + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(expected, stdout); +} diff --git a/tools/transform.rs b/tools/transform.rs index 3615055..b82251e 100644 --- a/tools/transform.rs +++ b/tools/transform.rs @@ -97,13 +97,13 @@ struct CmdlineOptions { /// Scale coordinate 1 (x, or r) to fit in the given range /// /// If specified, will be applied regardless of whether polar conversion is performed - #[clap(long, num_args = 2)] + #[clap(long, value_name = "MIN>,, /// Scale coordinate 2 (y, or theta) to fit in the given range /// /// If specified, will be applied regardless of whether polar conversion is performed - #[clap(long, num_args = 2)] + #[clap(long, value_name = "MIN>,, } @@ -277,6 +277,12 @@ fn geoms_coordwise( fn main() -> eyre::Result<()> { color_eyre::install()?; let args = CmdlineOptions::parse(); + if !args.range1.is_empty() && args.range1.len() != 2 { + eyre::bail!("--range1 must have 2 comma-separated values"); + } + if !args.range2.is_empty() && args.range2.len() != 2 { + eyre::bail!("--range2 must have 2 comma-separated values"); + } let filter = tracing_subscriber::EnvFilter::builder() .with_default_directive(args.log_level.into())