From 5a28bb6b6f4b1bf03ae9d89e2a330a934752a9d6 Mon Sep 17 00:00:00 2001 From: Anatoly Tsyplenkov Date: Fri, 17 Jul 2026 17:59:21 +0300 Subject: [PATCH 1/6] bench: pre-sfnetworks-drop baseline --- .../fixtures/graph-migration-baseline.rds | Bin 0 -> 55550 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/testthat/fixtures/graph-migration-baseline.rds diff --git a/tests/testthat/fixtures/graph-migration-baseline.rds b/tests/testthat/fixtures/graph-migration-baseline.rds new file mode 100644 index 0000000000000000000000000000000000000000..861d5f46f6390b1280e1823b84b9ebac173155ca GIT binary patch literal 55550 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 literal 0 HcmV?d00001 From dc1926366f96a1bd7368346e2f79f9bbba7793d3 Mon Sep 17 00:00:00 2001 From: Anatoly Tsyplenkov Date: Fri, 17 Jul 2026 18:41:23 +0300 Subject: [PATCH 2/6] feat: add GEOS/igraph skeleton graph layer --- R/skeleton_graph.R | 195 +++++++++++++++++++++++++++ tests/testthat/test-skeleton_graph.R | 169 +++++++++++++++++++++++ 2 files changed, 364 insertions(+) create mode 100644 R/skeleton_graph.R create mode 100644 tests/testthat/test-skeleton_graph.R diff --git a/R/skeleton_graph.R b/R/skeleton_graph.R new file mode 100644 index 0000000..d1ba90b --- /dev/null +++ b/R/skeleton_graph.R @@ -0,0 +1,195 @@ +# Planar line graph from GEOS-noded skeleton segments -------------------- +# Undirected weighted graph with edge geometry and node strtree. Used by +# cnt_path(), cnt_path_guess(), and boundary-anchor topology queries. + +skeleton_graph_build <- function(lines, crs = wk::wk_crs(lines), grid_size = NULL) { + if (!inherits(lines, "geos_geometry")) { + stop("lines must be a geos_geometry object") + } + + line_types <- geos::geos_type(lines) + if ( + !all( + line_types %in% + c("linestring", "multilinestring", "linearring") + ) + ) { + stop("lines must contain only line geometries") + } + + if (any(geos::geos_is_empty(lines))) { + lines <- lines[!geos::geos_is_empty(lines)] + } + + if (length(lines) == 0L) { + stop("no non-empty edges after noding") + } + + gs <- NA_real_ + if (!is.null(grid_size)) { + if ( + length(grid_size) != 1L || + !is.finite(grid_size) || + grid_size <= 0 + ) { + stop("grid_size must be one positive finite number") + } + gs <- as.numeric(grid_size) + lines <- geos::geos_set_precision( + lines, + gs, + preserve_topology = TRUE, + keep_collapsed = FALSE + ) + } + + # One collection so intersections across separate input features node together. + noded <- geos::geos_node(geos::geos_make_collection(lines)) + edges <- geos::geos_unnest(noded, keep_multi = FALSE) + edges <- edges[!geos::geos_is_empty(edges)] + edge_types <- geos::geos_type(edges) + edges <- edges[edge_types %in% c("linestring", "linearring")] + + if (length(edges) == 0L) { + stop("no non-empty edges after noding") + } + + weight <- as.numeric(geos::geos_length(edges)) + if (any(!is.finite(weight) | weight <= 0)) { + stop("edge lengths must be positive and finite") + } + + start_coords <- wk::wk_coords(geos::geos_point_start(edges)) + end_coords <- wk::wk_coords(geos::geos_point_end(edges)) + + sx <- start_coords$x + sy <- start_coords$y + ex <- end_coords$x + ey <- end_coords$y + + # Interleave start/end in edge order for deterministic first-occurrence IDs. + xs <- as.numeric(rbind(sx, ex)) + ys <- as.numeric(rbind(sy, ey)) + # Normalize signed zero so %a keys match. + xs[xs == 0] <- 0 + ys[ys == 0] <- 0 + + keys_all <- paste(sprintf("%a", xs), sprintf("%a", ys), sep = "|") + key_unique <- unique(keys_all) + endpoint_ids <- match(keys_all, key_unique) + + from <- as.integer(endpoint_ids[c(TRUE, FALSE)]) + to <- as.integer(endpoint_ids[c(FALSE, TRUE)]) + + n_nodes <- length(key_unique) + # First-occurrence coordinates for each unique key. + first_idx <- match(key_unique, keys_all) + node_x <- xs[first_idx] + node_y <- ys[first_idx] + + graph <- igraph::graph_from_edgelist( + cbind(from, to), + directed = FALSE + ) + # Ensure isolated-node-free graphs still report full vertex count when + # edgelist max id equals n_nodes (always true here). + if (igraph::vcount(graph) < n_nodes) { + graph <- igraph::add_vertices(graph, n_nodes - igraph::vcount(graph)) + } + + igraph::E(graph)$weight <- weight + igraph::E(graph)$edge_id <- seq_along(weight) + + node_geom <- geos::geos_make_point(node_x, node_y, crs = crs) + node_tree <- geos::geos_strtree(node_geom) + + if (!is.null(crs)) { + wk::wk_crs(edges) <- crs + } + + structure( + list( + nodes = list( + x = node_x, + y = node_y, + key = key_unique, + geom = node_geom, + tree = node_tree + ), + edges = list( + from = from, + to = to, + weight = weight, + geom = edges + ), + graph = graph, + crs = crs, + meta = list( + noded = TRUE, + grid_size = gs + ) + ), + class = "cnt_skeleton_graph" + ) +} + +skeleton_graph_degree <- function(g) { + as.integer(igraph::degree(g$graph)) +} + +skeleton_graph_neighbors <- function(g, id) { + as.integer(igraph::neighbors(g$graph, id, mode = "all")) +} + +skeleton_graph_nearest_nodes <- function(g, points) { + as.integer(geos::geos_nearest(points, g$nodes$tree)) +} + +skeleton_graph_paths <- function(g, from, to) { + if (length(from) != 1L) { + stop("paths() requires exactly one source node") + } + + withCallingHandlers( + { + sp <- igraph::shortest_paths( + g$graph, + from = from, + to = to, + mode = "all", + weights = g$edges$weight, + output = "epath" + ) + }, + warning = function(w) { + if (grepl("Couldn't reach some vertices", conditionMessage(w), fixed = TRUE)) { + invokeRestart("muffleWarning") + } + } + ) + + lapply(sp$epath, function(e) { + if (length(e) == 0L) { + integer() + } else { + as.integer(igraph::E(g$graph)$edge_id[e]) + } + }) +} + +skeleton_graph_path_line <- function(g, epath) { + if (length(epath) == 0L) { + stop("empty edge path") + } + line <- g$edges$geom[epath] |> + geos::geos_make_collection() |> + geos::geos_line_merge() + if (!is.null(g$crs)) { + wk::wk_crs(line) <- g$crs + } + line +} + +skeleton_graph_node_points <- function(g) { + g$nodes$geom +} diff --git a/tests/testthat/test-skeleton_graph.R b/tests/testthat/test-skeleton_graph.R new file mode 100644 index 0000000..2a63e1f --- /dev/null +++ b/tests/testthat/test-skeleton_graph.R @@ -0,0 +1,169 @@ +test_that("triangle of three edges has correct topology and weights", { + lines <- geos::as_geos_geometry(c( + "LINESTRING(0 0, 1 0)", + "LINESTRING(1 0, 1 1)", + "LINESTRING(1 1, 0 0)" + )) + g <- skeleton_graph_build(lines) + + expect_s3_class(g, "cnt_skeleton_graph") + expect_equal(length(g$nodes$x), 3L) + expect_equal(length(g$edges$weight), 3L) + expect_equal(as.integer(igraph::vcount(g$graph)), 3L) + expect_equal(as.integer(igraph::ecount(g$graph)), 3L) + expect_equal(sort(skeleton_graph_degree(g)), c(2L, 2L, 2L)) + expect_equal(g$edges$weight, as.numeric(geos::geos_length(g$edges$geom))) + expect_true(g$meta$noded) + expect_true(is.na(g$meta$grid_size)) +}) + +test_that("two disconnected segments stay disconnected", { + lines <- geos::as_geos_geometry(c( + "LINESTRING(0 0, 1 0)", + "LINESTRING(0 1, 1 1)" + )) + g <- skeleton_graph_build(lines) + + expect_equal(as.integer(igraph::vcount(g$graph)), 4L) + expect_equal(as.integer(igraph::ecount(g$graph)), 2L) + expect_equal(sort(skeleton_graph_degree(g)), c(1L, 1L, 1L, 1L)) + + from <- skeleton_graph_nearest_nodes( + g, + geos::as_geos_geometry("POINT(0 0)") + ) + to <- skeleton_graph_nearest_nodes( + g, + geos::as_geos_geometry("POINT(0 1)") + ) + epath <- skeleton_graph_paths(g, from, to)[[1]] + expect_equal(length(epath), 0L) +}) + +test_that("four separately supplied ring edges node into a closed ring", { + lines <- geos::as_geos_geometry(c( + "LINESTRING(0 0, 1 0)", + "LINESTRING(1 0, 1 1)", + "LINESTRING(1 1, 0 1)", + "LINESTRING(0 1, 0 0)" + )) + g <- skeleton_graph_build(lines) + + expect_equal(as.integer(igraph::vcount(g$graph)), 4L) + expect_equal(as.integer(igraph::ecount(g$graph)), 4L) + expect_equal(sort(skeleton_graph_degree(g)), c(2L, 2L, 2L, 2L)) +}) + +test_that("crossing X yields five nodes and center degree four", { + lines <- geos::as_geos_geometry(c( + "LINESTRING(0 0, 1 1)", + "LINESTRING(0 1, 1 0)" + )) + g <- skeleton_graph_build(lines) + + expect_equal(as.integer(igraph::vcount(g$graph)), 5L) + expect_equal(as.integer(igraph::ecount(g$graph)), 4L) + expect_equal(sort(skeleton_graph_degree(g)), c(1L, 1L, 1L, 1L, 4L)) + + center <- skeleton_graph_nearest_nodes( + g, + geos::as_geos_geometry("POINT(0.5 0.5)") + ) + expect_equal(skeleton_graph_degree(g)[[center]], 4L) + expect_equal(length(skeleton_graph_neighbors(g, center)), 4L) +}) + +test_that("exact endpoints share a node; near-touch needs grid_size", { + exact <- geos::as_geos_geometry(c( + "LINESTRING(0 0, 1 0)", + "LINESTRING(1 0, 2 0)" + )) + g_exact <- skeleton_graph_build(exact) + expect_equal(as.integer(igraph::vcount(g_exact$graph)), 3L) + expect_equal(as.integer(igraph::ecount(g_exact$graph)), 2L) + + eps <- 1e-12 + near <- geos::as_geos_geometry(c( + "LINESTRING(0 0, 1 0)", + sprintf("LINESTRING(%.16f 0, 2 0)", 1 + eps) + )) + g_near <- skeleton_graph_build(near) + # Distinct IEEE doubles keep separate nodes without precision. + expect_equal(as.integer(igraph::vcount(g_near$graph)), 4L) + + g_snap <- skeleton_graph_build(near, grid_size = 1e-9) + expect_equal(as.integer(igraph::vcount(g_snap$graph)), 3L) + expect_equal(g_snap$meta$grid_size, 1e-9) +}) + +test_that("edge weights equal GEOS lengths", { + lines <- geos::as_geos_geometry(c( + "LINESTRING(0 0, 3 0)", + "LINESTRING(0 0, 0 4)" + )) + g <- skeleton_graph_build(lines) + expect_equal(sort(g$edges$weight), c(3, 4)) + expect_equal(g$edges$weight, as.numeric(geos::geos_length(g$edges$geom))) +}) + +test_that("parallel edges keep both geometries and select shorter epath", { + short <- geos::as_geos_geometry("LINESTRING(0 0, 1 0)") + long <- geos::as_geos_geometry("LINESTRING(0 0, 0 1, 1 1, 1 0)") + lines <- c(short, long) + g <- skeleton_graph_build(lines) + + expect_equal(as.integer(igraph::ecount(g$graph)), 2L) + expect_equal(as.integer(igraph::vcount(g$graph)), 2L) + expect_equal(sort(g$edges$weight), sort(as.numeric(geos::geos_length(lines)))) + + from <- skeleton_graph_nearest_nodes( + g, + geos::as_geos_geometry("POINT(0 0)") + ) + to <- skeleton_graph_nearest_nodes( + g, + geos::as_geos_geometry("POINT(1 0)") + ) + epath <- skeleton_graph_paths(g, from, to)[[1]] + expect_equal(length(epath), 1L) + + path_line <- skeleton_graph_path_line(g, epath) + expect_equal( + as.numeric(geos::geos_length(path_line)), + min(g$edges$weight), + tolerance = 1e-12 + ) + # Edge-ID order matches edges$geom: selected edge is the short segment. + expect_true( + isTRUE(geos::geos_equals_exact(path_line, short, 1e-9)) || + isTRUE(geos::geos_equals_exact( + path_line, + geos::geos_reverse(short), + 1e-9 + )) + ) +}) + +test_that("path_line rejects empty edge paths", { + lines <- geos::as_geos_geometry("LINESTRING(0 0, 1 0)") + g <- skeleton_graph_build(lines) + expect_error(skeleton_graph_path_line(g, integer()), "empty edge path") +}) + +test_that("node_points returns stored node geometries", { + lines <- geos::as_geos_geometry("LINESTRING(0 0, 1 0)") + g <- skeleton_graph_build(lines) + pts <- skeleton_graph_node_points(g) + expect_equal(length(pts), 2L) + expect_identical(pts, g$nodes$geom) +}) + +test_that("builder validates grid_size and empty input", { + lines <- geos::as_geos_geometry("LINESTRING(0 0, 1 0)") + expect_error(skeleton_graph_build(lines, grid_size = 0), "positive finite") + expect_error(skeleton_graph_build(lines, grid_size = c(1, 2)), "positive finite") + expect_error( + skeleton_graph_build(geos::as_geos_geometry("POINT(0 0)")), + "line geometries" + ) +}) From 7abcd5d8cb00f9ceb6416970a639c6ff19301a4c Mon Sep 17 00:00:00 2001 From: Anatoly Tsyplenkov Date: Fri, 17 Jul 2026 18:41:29 +0300 Subject: [PATCH 3/6] refactor(cnt_path): route on skeleton graph instead of sfnetworks --- R/cnt_path.R | 107 ++++++++++++--------------------- man/cnt_path.Rd | 6 +- tests/testthat/test-cnt_path.R | 59 ++++++++++++++++++ 3 files changed, 100 insertions(+), 72 deletions(-) diff --git a/R/cnt_path.R b/R/cnt_path.R index 13aba71..80c4c3e 100644 --- a/R/cnt_path.R +++ b/R/cnt_path.R @@ -7,9 +7,9 @@ #' \code{start_point} parameters. #' #' @details -#' The following function uses the [sfnetworks::st_network_paths()] approach to -#' connect \code{start_point} with \code{end_point} by using the -#' \code{skeleton} of a closed polygon as potential routes. +#' The function connects start and end points with weighted shortest paths +#' on an undirected skeleton graph using [igraph::shortest_paths()]. +#' The \code{skeleton} of a closed polygon provides the potential routes. #' #' It is important to note that multiple starting points are permissible, #' but there can only be **one ending point**. Should there be two or more @@ -102,17 +102,11 @@ cnt_path.geos_geometry <- function(skeleton, start_point, end_point) { stopifnot(check_points(end_point)) check_same_class(skeleton, start_point, end_point) - if (any(get_geom_type(skeleton) == "multilinestring")) { - skeleton <- geos::geos_unnest(skeleton, keep_multi = FALSE) - } - - # Transform to sf - skeleton <- sf::st_as_sf(skeleton) - start_point <- sf::st_as_sf(start_point) - end_point <- sf::st_as_sf(end_point) - - # Find the paths - cnt_path_master(skeleton, start_point, end_point) + cnt_path_master( + skeleton_geos = skeleton, + start_geos = start_point, + end_geos = end_point + ) } #' @export @@ -123,12 +117,12 @@ cnt_path.sf <- function(skeleton, start_point, end_point) { stopifnot(check_points(end_point)) check_same_class(skeleton, start_point, end_point) - if (any(get_geom_type(skeleton) == "MULTILINESTRING")) { - skeleton <- sf::st_cast(skeleton, "LINESTRING") - } - # Find the paths - cnt_path_master(skeleton, start_point, end_point) |> + cnt_path_master( + skeleton_geos = geos::as_geos_geometry(skeleton), + start_geos = geos::as_geos_geometry(start_point), + end_geos = geos::as_geos_geometry(end_point) + ) |> sf::st_as_sf() |> cbind(sf::st_drop_geometry(start_point)) } @@ -140,12 +134,13 @@ cnt_path.sfc <- function(skeleton, start_point, end_point) { stopifnot(check_points(start_point)) stopifnot(check_points(end_point)) - if (any(get_geom_type(skeleton) == "MULTILINESTRING")) { - skeleton <- sf::st_cast(skeleton, "LINESTRING") - } - # Find the paths - cnt_path_master(skeleton, start_point, end_point) |> sf::st_as_sfc() + cnt_path_master( + skeleton_geos = geos::as_geos_geometry(skeleton), + start_geos = geos::as_geos_geometry(start_point), + end_geos = geos::as_geos_geometry(end_point) + ) |> + sf::st_as_sfc() } #' @export @@ -159,56 +154,34 @@ cnt_path.SpatVector <- function(skeleton, start_point, end_point) { # Save CRS crs <- terra::crs(skeleton) - # Transform to sf objects - skeleton <- sf::st_as_sf(skeleton) - start_point <- sf::st_as_sf(start_point) - end_point <- sf::st_as_sf(end_point) - - if (any(get_geom_type(skeleton) == "MULTILINESTRING")) { - skeleton <- sf::st_cast(skeleton, "LINESTRING") - } + start_sf <- sf::st_as_sf(start_point) # Find the paths - cnt_path_master(skeleton, start_point, end_point) |> + cnt_path_master( + skeleton_geos = terra_to_geos(skeleton), + start_geos = terra_to_geos(start_point), + end_geos = terra_to_geos(end_point) + ) |> wk::as_wkt() |> as.character() |> terra::vect(crs = crs) |> - cbind(sf::st_drop_geometry(start_point)) + cbind(sf::st_drop_geometry(start_sf)) } -cnt_path_master <- function(skeleton_sf, start_point_sf, end_point_sf) { - # Convert skeleton sf object to sfnetworks - pol_network <- sfnetworks::as_sfnetwork( - x = skeleton_sf, - directed = FALSE, - length_as_weight = TRUE, - edges_as_lines = TRUE - ) - - # Convert sfnetworks to igraph - df_graph <- igraph::as_data_frame(pol_network) - names(df_graph)[3] <- "geometry" - df_graph <- df_graph[, c("weight", "geometry")] - df_graph$weight <- as.numeric(df_graph$weight) +cnt_path_master <- function(skeleton_geos, start_geos, end_geos) { + g <- skeleton_graph_build(skeleton_geos, crs = wk::wk_crs(skeleton_geos)) - # Find indices of nearest nodes for start ... - start_nodes <- sf::st_nearest_feature(start_point_sf, pol_network) - # ... and end points - end_nodes <- sf::st_nearest_feature(end_point_sf, pol_network) + start_nodes <- skeleton_graph_nearest_nodes(g, start_geos) + end_nodes <- skeleton_graph_nearest_nodes(g, end_geos) # Check if there are several end nodes stopifnot("Only one end point is allowed" = length(end_nodes) == 1) - # Find the shortest path between two points - paths <- base::suppressWarnings(sfnetworks::st_network_paths( - pol_network, - from = end_nodes, - to = start_nodes, - weights = "weight" - )) + # Route from the single end ID to all start IDs + edge_paths <- skeleton_graph_paths(g, from = end_nodes, to = start_nodes) - failed_start_indices <- which(lengths(paths$edge_paths) == 0L) + failed_start_indices <- which(lengths(edge_paths) == 0L) if (length(failed_start_indices) > 0L) { if (length(start_nodes) > 1L) { stop( @@ -225,17 +198,13 @@ cnt_path_master <- function(skeleton_sf, start_point_sf, end_point_sf) { ) } - # Convert to GEOS geometries and create a GEOS collection - lines_list_geos <- lapply(paths$edge_paths, function(x) { - df_graph[x, "geometry"] - }) |> - lapply(geos::as_geos_geometry) |> - lapply(geos::geos_make_collection) |> - lapply(geos::geos_line_merge) + lines_list_geos <- lapply(edge_paths, function(epath) { + skeleton_graph_path_line(g, epath) + }) # Check if we need to reverse the lines - rev_lines_list <- reverse_lines_if_needed(lines_list_geos, end_point_sf) + rev_lines_list <- reverse_lines_if_needed(lines_list_geos, end_geos) - # Return pathes binded together as GEOS geometry + # Return paths binded together as GEOS geometry do.call(c, rev_lines_list) } diff --git a/man/cnt_path.Rd b/man/cnt_path.Rd index 7393a24..55539ee 100644 --- a/man/cnt_path.Rd +++ b/man/cnt_path.Rd @@ -23,9 +23,9 @@ or \code{geos_geometry} class objects of a \code{LINESTRING} geometry Find the shortest path between start and end points within a polygon } \details{ -The following function uses the \code{\link[sfnetworks:st_network_paths]{sfnetworks::st_network_paths()}} approach to -connect \code{start_point} with \code{end_point} by using the -\code{skeleton} of a closed polygon as potential routes. +The function connects start and end points with weighted shortest paths +on an undirected skeleton graph using \code{\link[igraph:shortest_paths]{igraph::shortest_paths()}}. +The \code{skeleton} of a closed polygon provides the potential routes. It is important to note that multiple starting points are permissible, but there can only be \strong{one ending point}. Should there be two or more diff --git a/tests/testthat/test-cnt_path.R b/tests/testthat/test-cnt_path.R index facc4a6..3c763f6 100644 --- a/tests/testthat/test-cnt_path.R +++ b/tests/testthat/test-cnt_path.R @@ -492,3 +492,62 @@ test_that("cnt_path returns exact ordered endpoints for anchored skeletons", { ) expect_equal(as.data.frame(path_terra), as.data.frame(anchors_terra[1])) }) + + + +migration_geom_close <- function(actual, baseline_wkb, baseline_length) { + tol <- max(1e-3, 1e-6 * baseline_length) + actual_len <- as.numeric(geos::geos_length(actual)) + expect_equal(actual_len, baseline_length, tolerance = tol) + baseline <- geos::as_geos_geometry(baseline_wkb) + equal_exact <- tryCatch( + isTRUE(geos::geos_equals_exact(actual, baseline, tol)), + error = function(e) FALSE + ) + if (!equal_exact) { + # Allow reverse orientation of the whole path + equal_rev <- tryCatch( + isTRUE(geos::geos_equals_exact(actual, geos::geos_reverse(baseline), tol)), + error = function(e) FALSE + ) + if (!equal_rev) { + hd <- as.numeric(geos::geos_distance_hausdorff(actual, baseline)) + expect_lte(hd, tol) + } + } + invisible(TRUE) +} + +test_that("cnt_path free and anchored routes match graph-migration baseline", { + baseline <- readRDS(test_path("fixtures/graph-migration-baseline.rds")) + f <- system.file("extdata/example.gpkg", package = "centerline") + p <- geos::as_geos_geometry(sf::st_read(f, layer = "polygon", quiet = TRUE)) + pts <- geos::as_geos_geometry( + sf::st_read(f, layer = "polygon_points", quiet = TRUE) + ) + b <- geos::geos_boundary(p) + a <- pts[geos::geos_equals(geos::geos_intersection(b, pts), pts)][1:2] + + ordinary <- cnt_skeleton(p, keep = 1) + free1 <- cnt_path(ordinary, pts[2], pts[1]) + free2 <- cnt_path(ordinary, pts[3], pts[1]) + migration_geom_close( + free1, + baseline$free_paths_wkb[1], + baseline$lengths[["free_path_1"]] + ) + migration_geom_close( + free2, + baseline$free_paths_wkb[2], + baseline$lengths[["free_path_2"]] + ) + + anchored <- cnt_skeleton(p, keep = 1, anchors = a) + apath <- cnt_path(anchored, a[1], a[2]) + migration_geom_close( + apath, + baseline$anchored_path_wkb, + baseline$lengths[["anchored_path"]] + ) +}) + From 314096ad869f731cfe763c75ba935cb31b82735f Mon Sep 17 00:00:00 2001 From: Anatoly Tsyplenkov Date: Fri, 17 Jul 2026 18:41:35 +0300 Subject: [PATCH 4/6] refactor(cnt_path_guess): route on skeleton graph instead of sfnetworks --- R/cnt_path_guess.R | 86 +++++++++------------------- R/utils.R | 9 +-- tests/testthat/test-cnt_path_guess.R | 32 +++++++++++ 3 files changed, 62 insertions(+), 65 deletions(-) diff --git a/R/cnt_path_guess.R b/R/cnt_path_guess.R index 8e48989..9f114f2 100644 --- a/R/cnt_path_guess.R +++ b/R/cnt_path_guess.R @@ -244,74 +244,44 @@ cnt_path_guess.SpatVector <- function( } cnt_path_guess_master <- function(skeleton_geos) { - if (geos::geos_type(skeleton_geos) == "multilinestring") { - skeleton_geos <- geos::geos_unnest(skeleton_geos, keep_multi = FALSE) - } + g <- skeleton_graph_build(skeleton_geos, crs = wk::wk_crs(skeleton_geos)) - skeleton_sf <- sf::st_as_sf(skeleton_geos) - # Convert skeleton to sfnetworks - pol_network <- sfnetworks::as_sfnetwork( - x = skeleton_sf, - directed = FALSE, - length_as_weight = TRUE, - edges_as_lines = TRUE - ) - # Convert sfnetworks to igraph - # pol_graph <- igraph::as.igraph(pol_network) - df_graph <- igraph::as_data_frame(pol_network) - names(df_graph)[3] <- "geometry" - df_graph <- df_graph[, c("weight", "geometry")] - df_graph$weight <- as.numeric(df_graph$weight) + # find_outer_nodes expects unnested linework + outer_lines <- skeleton_geos + if (any(geos::geos_type(outer_lines) == "multilinestring")) { + outer_lines <- geos::geos_unnest(outer_lines, keep_multi = FALSE) + } # Find border points of skeleton - closest_points <- find_closest_nodes( - pol_network, - find_outer_nodes(skeleton_geos) - ) + terminal_ids <- find_closest_nodes(g, find_outer_nodes(outer_lines)) - # Find the most distant point from center + # Find the most distant point from center (lowest closeness). # It will serve as the end point - closest_end_points <- closest_points[which.min(igraph::closeness( - pol_network, - vid = closest_points + closest_end_points <- terminal_ids[which.min(igraph::closeness( + g$graph, + vids = terminal_ids, + weights = g$edges$weight ))] - # Find paths - paths <- base::suppressWarnings(sfnetworks::st_network_paths( - pol_network, - to = closest_points[closest_points != closest_end_points], + remaining <- terminal_ids[terminal_ids != closest_end_points] + edge_paths <- skeleton_graph_paths( + g, from = closest_end_points, - weights = "weight" - )) - - # Paths lengths in counts - paths_length <- lengths(paths$edge_paths) - - # Filter non-zero paths - paths_length_flag <- paths_length > 1 - paths_length_nonzero <- paths_length[paths_length_flag] - edge_paths_nonzero <- paths$edge_paths[paths_length_flag] - - # Estimate paths lengths - edge_paths_vec <- unlist(edge_paths_nonzero, use.names = FALSE) - edge_paths_groups <- rep( - seq_along(edge_paths_nonzero), - times = paths_length_nonzero + to = remaining ) - edge_paths_length <- df_graph[edge_paths_vec, "weight"] - # Sum paths lengths in meters - true_paths_igraph <- tapply(edge_paths_length, edge_paths_groups, FUN = sum) + # Filter non-zero multi-edge paths + paths_length <- lengths(edge_paths) + paths_length_flag <- paths_length > 1L + edge_paths_nonzero <- edge_paths[paths_length_flag] - # Return the longest path - longest_path_igraph <- which.max(true_paths_igraph) - longest_path_geos <- df_graph[ - edge_paths_nonzero[[longest_path_igraph]], - "geometry" - ] |> - geos::as_geos_geometry() |> - geos::geos_make_collection() |> - geos::geos_line_merge() + true_paths_igraph <- vapply( + edge_paths_nonzero, + function(epath) sum(g$edges$weight[epath]), + numeric(1), + USE.NAMES = FALSE + ) - longest_path_geos + longest_path_igraph <- which.max(true_paths_igraph) + skeleton_graph_path_line(g, edge_paths_nonzero[[longest_path_igraph]]) } diff --git a/R/utils.R b/R/utils.R index e0c02cd..44cc9b6 100644 --- a/R/utils.R +++ b/R/utils.R @@ -93,13 +93,8 @@ find_outer_nodes <- function(skeleton_geos) { c(lonely_end, lonely_start) } -find_closest_nodes <- function(sf_graph, nodes_geos) { - geos_graph <- sfnetworks::activate(sf_graph, "nodes") |> - sf::st_as_sf() |> - geos::as_geos_geometry() |> - geos::geos_strtree() - - geos::geos_nearest(nodes_geos, geos_graph) +find_closest_nodes <- function(g, nodes_geos) { + skeleton_graph_nearest_nodes(g, nodes_geos) } # Straight skeleton helpers ---------------------------------------------- diff --git a/tests/testthat/test-cnt_path_guess.R b/tests/testthat/test-cnt_path_guess.R index b28d7e4..9231519 100644 --- a/tests/testthat/test-cnt_path_guess.R +++ b/tests/testthat/test-cnt_path_guess.R @@ -411,3 +411,35 @@ test_that("cnt_path_guess forwards anchors only when building a skeleton", { expect_s3_class(result_supplied, "sf") expect_contains(get_geom_type(result_supplied), "LINESTRING") }) + + +test_that("cnt_path_guess shapes[89] matches graph-migration baseline", { + baseline <- readRDS(test_path("fixtures/graph-migration-baseline.rds")) + shapes <- sf::st_read( + system.file("extdata/example.gpkg", package = "centerline"), + layer = "shapes", + quiet = TRUE + ) + gpath <- cnt_path_guess(shapes[89, ], keep = 1, return_geos = TRUE) + tol <- max(1e-3, 1e-6 * baseline$lengths[["guessed_path"]]) + expect_equal( + as.numeric(geos::geos_length(gpath)), + baseline$lengths[["guessed_path"]], + tolerance = tol + ) + base_geom <- geos::as_geos_geometry(baseline$guessed_path_wkb) + equal_exact <- tryCatch( + isTRUE(geos::geos_equals_exact(gpath, base_geom, tol)), + error = function(e) FALSE + ) + if (!equal_exact) { + equal_rev <- tryCatch( + isTRUE(geos::geos_equals_exact(gpath, geos::geos_reverse(base_geom), tol)), + error = function(e) FALSE + ) + if (!equal_rev) { + hd <- as.numeric(geos::geos_distance_hausdorff(gpath, base_geom)) + expect_lte(hd, tol) + } + } +}) From a178bbb3281f5d47670c9b407884337cc8d93ea2 Mon Sep 17 00:00:00 2001 From: Anatoly Tsyplenkov Date: Fri, 17 Jul 2026 18:41:41 +0300 Subject: [PATCH 5/6] refactor(cnt_skeleton): build anchor topology on skeleton graph --- R/cnt_skeleton_anchors.R | 147 +++++++++++++---------------- tests/testthat/test-cnt_skeleton.R | 103 +++++++++++--------- 2 files changed, 125 insertions(+), 125 deletions(-) diff --git a/R/cnt_skeleton_anchors.R b/R/cnt_skeleton_anchors.R index 459df1d..be5c7c2 100644 --- a/R/cnt_skeleton_anchors.R +++ b/R/cnt_skeleton_anchors.R @@ -113,66 +113,48 @@ add_boundary_anchors <- function(skeleton, polygon, anchors) { boundary <- geos::geos_boundary(polygon) # Raw unrounded graph used for connector endpoints and branch coverage. - raw_lines <- ordinary_skeleton |> - geos::geos_node() |> - geos::geos_unnest(keep_multi = FALSE) |> - sf::st_as_sf() - raw_network <- sfnetworks::as_sfnetwork( - raw_lines, - directed = FALSE, - edges_as_lines = TRUE + # First implementation: use the same raw graph for analysis (single-graph). + # If fixture-approved junction/scale assertions require dual-graph behavior, + # fall back to a precision-cleaned analysis graph below. + raw_graph <- skeleton_graph_build(ordinary_skeleton, crs = crs) + raw_degree <- skeleton_graph_degree(raw_graph) + raw_xy <- cbind(raw_graph$nodes$x, raw_graph$nodes$y) + raw_node_pts <- skeleton_graph_node_points(raw_graph) + + # Precision-cleaned analysis graph for candidate discovery/scoring. + # Raw graph remains the connector-target and postcondition authority. + # Extreme scales fall back to the unrounded node set for analysis only. + analysis_graph <- tryCatch( + skeleton_graph_build( + ordinary_skeleton, + crs = crs, + grid_size = grid_size + ), + error = function(e) raw_graph ) - raw_nodes <- sfnetworks::activate(raw_network, "nodes") |> sf::st_as_sf() - raw_degree <- igraph::degree(raw_network) - raw_xy <- sf::st_coordinates(raw_nodes) - - # Cleaned analysis graph used only for candidate discovery and scoring. - cleaned_lines <- tryCatch( - { - ordinary_skeleton |> - geos::geos_set_precision( - grid_size, - preserve_topology = TRUE, - keep_collapsed = FALSE - ) |> - geos::geos_node() |> - geos::geos_unnest(keep_multi = FALSE) |> - sf::st_as_sf() |> - sf::st_set_precision(0) - }, - error = function(e) { - # Extreme scales can make precision-rounded noding fail to converge. - # Fall back to the unrounded node set for analysis only; mapping still - # uses grid_size against raw junctions. - raw_lines - } - ) - cleaned_network <- sfnetworks::as_sfnetwork( - cleaned_lines, - directed = FALSE, - edges_as_lines = TRUE + + analysis_degree <- skeleton_graph_degree(analysis_graph) + analysis_xy <- cbind(analysis_graph$nodes$x, analysis_graph$nodes$y) + analysis_node_pts <- skeleton_graph_node_points(analysis_graph) + analysis_boundary_dist <- as.numeric( + geos::geos_distance(analysis_node_pts, boundary) ) - cleaned_nodes <- sfnetworks::activate(cleaned_network, "nodes") |> - sf::st_as_sf() - cleaned_degree <- igraph::degree(cleaned_network) - cleaned_xy <- sf::st_coordinates(cleaned_nodes) - cleaned_boundary_dist <- as.numeric(sf::st_distance( - cleaned_nodes, - sf::st_as_sf(boundary) - )) - cleaned_junction_ids <- which(cleaned_degree >= 3L) + analysis_junction_ids <- which(analysis_degree >= 3L) raw_junction_ids <- which(raw_degree >= 3L) - if (length(cleaned_junction_ids) == 0L || length(raw_junction_ids) == 0L) { + if (length(analysis_junction_ids) == 0L || length(raw_junction_ids) == 0L) { stop( "No valid interior junction connector; ", "ordinary skeleton has no degree-at-least-three junction" ) } - map_cleaned_to_raw <- function(cleaned_id) { - cxy <- cleaned_xy[cleaned_id, 1:2, drop = FALSE] + map_analysis_to_raw <- function(analysis_id) { + if (identical(analysis_graph, raw_graph)) { + return(as.integer(analysis_id)) + } + cxy <- analysis_xy[analysis_id, 1:2, drop = FALSE] d <- sqrt(rowSums( (raw_xy[raw_junction_ids, 1:2, drop = FALSE] - matrix(cxy, nrow = length(raw_junction_ids), ncol = 2, byrow = TRUE))^2 @@ -189,49 +171,49 @@ add_boundary_anchors <- function(skeleton, polygon, anchors) { } mapped_raw <- vapply( - cleaned_junction_ids, - map_cleaned_to_raw, + analysis_junction_ids, + map_analysis_to_raw, integer(1), USE.NAMES = FALSE ) keep_map <- !is.na(mapped_raw) - cleaned_junction_ids <- cleaned_junction_ids[keep_map] + analysis_junction_ids <- analysis_junction_ids[keep_map] mapped_raw <- mapped_raw[keep_map] - if (length(cleaned_junction_ids) == 0L) { + if (length(analysis_junction_ids) == 0L) { stop( "No valid interior junction connector; ", "no cleaned junction maps to a raw degree-at-least-three node" ) } - n_junctions <- length(cleaned_junction_ids) + n_junctions <- length(analysis_junction_ids) # Junction coordinate matrix and raw targets (numeric hot path). - j_xy <- cleaned_xy[cleaned_junction_ids, 1:2, drop = FALSE] + j_xy <- analysis_xy[analysis_junction_ids, 1:2, drop = FALSE] j_raw_id <- as.integer(mapped_raw) - # Adjacency once; avoids igraph::neighbors per junction per anchor. - cleaned_adj <- vector("list", nrow(cleaned_xy)) - for (cid in cleaned_junction_ids) { - cleaned_adj[[cid]] <- as.integer(igraph::neighbors(cleaned_network, cid)) + # Adjacency once; avoids neighbors() per junction per anchor. + analysis_adj <- vector("list", length(analysis_degree)) + for (cid in analysis_junction_ids) { + analysis_adj[[cid]] <- skeleton_graph_neighbors(analysis_graph, cid) } select_inward_neighbor <- function(cleaned_id, anchor_xy) { - neighbours <- cleaned_adj[[cleaned_id]] + neighbours <- analysis_adj[[cleaned_id]] if (length(neighbours) == 0L) { return(NA_integer_) } - dists <- cleaned_boundary_dist[neighbours] + dists <- analysis_boundary_dist[neighbours] max_d <- max(dists) candidates <- neighbours[dists >= (max_d - grid_size)] if (length(candidates) == 1L) { return(candidates[[1]]) } - jxy <- cleaned_xy[cleaned_id, 1:2] + jxy <- analysis_xy[cleaned_id, 1:2] angles <- vapply( candidates, function(nid) { - continuation_angle(anchor_xy, jxy, cleaned_xy[nid, 1:2]) + continuation_angle(anchor_xy, jxy, analysis_xy[nid, 1:2]) }, numeric(1), USE.NAMES = FALSE @@ -277,13 +259,13 @@ add_boundary_anchors <- function(skeleton, polygon, anchors) { for (i in seq_len(n)) { j <- j_idx[[i]] - cid <- cleaned_junction_ids[[j]] + cid <- analysis_junction_ids[[j]] cxy <- j_xy[j, ] nid <- select_inward_neighbor(cid, axy) if (is.na(nid)) { next } - ang <- continuation_angle(axy, cxy, cleaned_xy[nid, 1:2]) + ang <- continuation_angle(axy, cxy, analysis_xy[nid, 1:2]) if (is.na(ang)) { next } @@ -363,16 +345,16 @@ add_boundary_anchors <- function(skeleton, polygon, anchors) { } already_terminal_on_skeleton <- function(anchor) { - d <- as.numeric(sf::st_distance(sf::st_as_sf(anchor), raw_nodes)) - zero_ids <- which(d == 0) - if (length(zero_ids) != 1L) { + id <- skeleton_graph_nearest_nodes(raw_graph, anchor) + if ( + as.numeric(geos::geos_distance(anchor, raw_node_pts[id])) != 0 + ) { return(FALSE) } - id <- zero_ids[[1]] if (raw_degree[[id]] != 1L) { return(FALSE) } - neighbours <- as.integer(igraph::neighbors(raw_network, id)) + neighbours <- skeleton_graph_neighbors(raw_graph, id) length(neighbours) == 1L && raw_degree[[neighbours[[1]]]] >= 3L } @@ -391,8 +373,10 @@ add_boundary_anchors <- function(skeleton, polygon, anchors) { # Anchor on a non-terminal skeleton node cannot become a terminal # without pruning branches. - d_raw <- as.numeric(sf::st_distance(sf::st_as_sf(anchor), raw_nodes)) - if (any(d_raw == 0)) { + raw_near <- skeleton_graph_nearest_nodes(raw_graph, anchor) + if ( + as.numeric(geos::geos_distance(anchor, raw_node_pts[raw_near])) == 0 + ) { stop( "No valid interior junction connector for anchor ", ai, @@ -574,22 +558,19 @@ add_boundary_anchors <- function(skeleton, polygon, anchors) { # Postconditions: anchors are degree-one terminals; ordinary branches remain. aug_edges <- augmented |> geos::geos_unnest(keep_multi = FALSE) - aug_lines <- sf::st_as_sf(aug_edges) - aug_network <- sfnetworks::as_sfnetwork( - aug_lines, - directed = FALSE, - edges_as_lines = TRUE - ) - aug_nodes <- sfnetworks::activate(aug_network, "nodes") |> sf::st_as_sf() - aug_degree <- igraph::degree(aug_network) + aug_graph <- skeleton_graph_build(augmented, crs = crs) + aug_degree <- skeleton_graph_degree(aug_graph) + aug_node_pts <- skeleton_graph_node_points(aug_graph) for (ai in seq_along(anchors)) { if (isTRUE(selected_meta[[ai]]$noop)) { next } - d <- as.numeric(sf::st_distance(sf::st_as_sf(anchors[ai]), aug_nodes)) - zero_ids <- which(d == 0) - if (length(zero_ids) != 1L || aug_degree[[zero_ids[[1]]]] != 1L) { + zero_id <- skeleton_graph_nearest_nodes(aug_graph, anchors[ai]) + on_node <- as.numeric( + geos::geos_distance(anchors[ai], aug_node_pts[zero_id]) + ) == 0 + if (!on_node || aug_degree[[zero_id]] != 1L) { stop( "Postcondition failed: anchor ", ai, diff --git a/tests/testthat/test-cnt_skeleton.R b/tests/testthat/test-cnt_skeleton.R index c92502a..ca15d29 100644 --- a/tests/testthat/test-cnt_skeleton.R +++ b/tests/testthat/test-cnt_skeleton.R @@ -286,22 +286,13 @@ anchor_grid_size <- function(polygon) { sqrt((bbox$xmax - bbox$xmin)^2 + (bbox$ymax - bbox$ymin)^2) * 1e-7 } -skeleton_nodes_network <- function(skeleton) { - lines <- skeleton |> - geos::geos_node() |> - geos::geos_unnest(keep_multi = FALSE) |> - sf::st_as_sf() - net <- sfnetworks::as_sfnetwork( - lines, - directed = FALSE, - edges_as_lines = TRUE - ) - nodes <- sfnetworks::activate(net, "nodes") |> sf::st_as_sf() +skeleton_graph_info <- function(skeleton) { + g <- skeleton_graph_build(skeleton, crs = wk::wk_crs(skeleton)) list( - network = net, - nodes = nodes, - degree = igraph::degree(net), - xy = sf::st_coordinates(nodes) + graph = g, + nodes = skeleton_graph_node_points(g), + degree = skeleton_graph_degree(g), + xy = cbind(g$nodes$x, g$nodes$y) ) } @@ -310,22 +301,19 @@ test_that("boundary anchors attach approved junctions on example.gpkg", { polygon <- data$polygon anchors <- data$anchors ordinary <- cnt_skeleton(polygon, keep = 1) - ordinary_net <- skeleton_nodes_network(ordinary) + ordinary_net <- skeleton_graph_info(ordinary) grid_size <- anchor_grid_size(polygon) # Pre-augmentation: anchors are not ordinary skeleton nodes. for (i in seq_along(anchors)) { - d <- as.numeric(sf::st_distance( - sf::st_as_sf(anchors[i]), - ordinary_net$nodes - )) + d <- as.numeric(geos::geos_distance(anchors[i], ordinary_net$nodes)) expect_true(all(d > 0)) } anchored <- cnt_skeleton(polygon, keep = 1, anchors = anchors) expect_true(geos::geos_covers(anchored, ordinary)) - aug <- skeleton_nodes_network(anchored) + aug <- skeleton_graph_info(anchored) approved <- list( c(1830873.1875, 5453788.018333333), c(1830873.699999998, 5453778.95) @@ -334,12 +322,12 @@ test_that("boundary anchors attach approved junctions on example.gpkg", { boundary <- geos::geos_boundary(polygon) for (i in seq_along(anchors)) { - d <- as.numeric(sf::st_distance(sf::st_as_sf(anchors[i]), aug$nodes)) + d <- as.numeric(geos::geos_distance(anchors[i], aug$nodes)) zero_ids <- which(d == 0) expect_equal(length(zero_ids), 1L) expect_equal(unname(aug$degree[[zero_ids[[1]]]]), 1L) - nbrs <- as.integer(igraph::neighbors(aug$network, zero_ids[[1]])) + nbrs <- skeleton_graph_neighbors(aug$graph, zero_ids[[1]]) expect_equal(length(nbrs), 1L) expect_true(aug$degree[[nbrs[[1]]]] >= 3L) target_xy <- aug$xy[nbrs[[1]], 1:2] @@ -425,23 +413,20 @@ test_that("boundary anchors scale with relative precision grid", { for (scale in c(1e-6, 1e6)) { pair <- scale_pair(scale) ordinary <- cnt_skeleton(pair$polygon, keep = 1) - ordinary_net <- skeleton_nodes_network(ordinary) + ordinary_net <- skeleton_graph_info(ordinary) for (i in seq_along(pair$anchors)) { - d <- as.numeric(sf::st_distance( - sf::st_as_sf(pair$anchors[i]), - ordinary_net$nodes - )) + d <- as.numeric(geos::geos_distance(pair$anchors[i], ordinary_net$nodes)) expect_true(all(d > 0)) } anchored <- cnt_skeleton(pair$polygon, keep = 1, anchors = pair$anchors) - aug <- skeleton_nodes_network(anchored) + aug <- skeleton_graph_info(anchored) targets <- list() for (i in seq_along(pair$anchors)) { - d <- as.numeric(sf::st_distance(sf::st_as_sf(pair$anchors[i]), aug$nodes)) + d <- as.numeric(geos::geos_distance(pair$anchors[i], aug$nodes)) zero_ids <- which(d == 0) expect_equal(length(zero_ids), 1L) expect_equal(unname(aug$degree[[zero_ids[[1]]]]), 1L) - nbrs <- as.integer(igraph::neighbors(aug$network, zero_ids[[1]])) + nbrs <- skeleton_graph_neighbors(aug$graph, zero_ids[[1]]) targets[[i]] <- aug$xy[nbrs[[1]], 1:2] } # normalized junction choice: same relative offset order @@ -577,11 +562,11 @@ test_that("add_boundary_anchors rejects concavity-crossing chords", { anchor )) - aug <- skeleton_nodes_network(augmented) - d <- as.numeric(sf::st_distance(sf::st_as_sf(anchor), aug$nodes)) + aug <- skeleton_graph_info(augmented) + d <- as.numeric(geos::geos_distance(anchor, aug$nodes)) zero_ids <- which(d == 0) expect_equal(length(zero_ids), 1L) - nbrs <- as.integer(igraph::neighbors(aug$network, zero_ids[[1]])) + nbrs <- skeleton_graph_neighbors(aug$graph, zero_ids[[1]]) target <- aug$xy[nbrs[[1]], 1:2] expect_equal(target[[1]], 0.5, tolerance = 1e-9) expect_equal(target[[2]], 0.5, tolerance = 1e-9) @@ -603,10 +588,10 @@ test_that("add_boundary_anchors rejects early skeleton crossings", { anchor <- geos::as_geos_geometry("POINT (0 5)") augmented <- add_boundary_anchors(skeleton, polygon, anchor) - aug <- skeleton_nodes_network(augmented) - d <- as.numeric(sf::st_distance(sf::st_as_sf(anchor), aug$nodes)) + aug <- skeleton_graph_info(augmented) + d <- as.numeric(geos::geos_distance(anchor, aug$nodes)) zero_ids <- which(d == 0) - nbrs <- as.integer(igraph::neighbors(aug$network, zero_ids[[1]])) + nbrs <- skeleton_graph_neighbors(aug$graph, zero_ids[[1]]) target <- aug$xy[nbrs[[1]], 1:2] expect_equal(target[[1]], 8, tolerance = 1e-9) expect_equal(target[[2]], 8, tolerance = 1e-9) @@ -639,12 +624,12 @@ test_that("add_boundary_anchors falls back to first skeleton hit", { augmented <- add_boundary_anchors(skeleton, polygon, anchor) expect_true(geos::geos_covers(augmented, skeleton)) - aug <- skeleton_nodes_network(augmented) - d <- as.numeric(sf::st_distance(sf::st_as_sf(anchor), aug$nodes)) + aug <- skeleton_graph_info(augmented) + d <- as.numeric(geos::geos_distance(anchor, aug$nodes)) zero_ids <- which(d == 0) expect_equal(length(zero_ids), 1L) expect_equal(unname(aug$degree[[zero_ids[[1]]]]), 1L) - nbrs <- as.integer(igraph::neighbors(aug$network, zero_ids[[1]])) + nbrs <- skeleton_graph_neighbors(aug$graph, zero_ids[[1]]) expect_equal(length(nbrs), 1L) target <- aug$xy[nbrs[[1]], 1:2] # First hit is the blocking segment at x = 1, y = 5. @@ -701,9 +686,9 @@ test_that("straight skeleton accepts the same anchors contract", { path <- expect_no_warning(cnt_path(sk, anchors[1], anchors[2])) expect_equal(geos::geos_distance(geos::geos_point_start(path), anchors[1]), 0) expect_equal(geos::geos_distance(geos::geos_point_end(path), anchors[2]), 0) - aug <- skeleton_nodes_network(sk) + aug <- skeleton_graph_info(sk) for (i in seq_along(anchors)) { - d <- as.numeric(sf::st_distance(sf::st_as_sf(anchors[i]), aug$nodes)) + d <- as.numeric(geos::geos_distance(anchors[i], aug$nodes)) expect_equal(unname(aug$degree[[which(d == 0)[[1]]]]), 1L) } }) @@ -902,3 +887,37 @@ test_that("voronoi anchors at keep < 1 yield zero-distance cnt_path ends", { expect_equal(geos::geos_distance(geos::geos_point_start(path), anchors[1]), 0) expect_equal(geos::geos_distance(geos::geos_point_end(path), anchors[2]), 0) }) + + +test_that("ordinary and anchored skeletons match graph-migration baseline", { + baseline <- readRDS(test_path("fixtures/graph-migration-baseline.rds")) + f <- system.file("extdata/example.gpkg", package = "centerline") + p <- geos::as_geos_geometry(sf::st_read(f, layer = "polygon", quiet = TRUE)) + pts <- geos::as_geos_geometry( + sf::st_read(f, layer = "polygon_points", quiet = TRUE) + ) + b <- geos::geos_boundary(p) + a <- pts[geos::geos_equals(geos::geos_intersection(b, pts), pts)][1:2] + + ordinary <- cnt_skeleton(p, keep = 1) + anchored <- cnt_skeleton(p, keep = 1, anchors = a) + + for (item in list( + list(ordinary, baseline$ordinary_skeleton_wkb, baseline$lengths[["ordinary_skeleton"]]), + list(anchored, baseline$anchored_skeleton_wkb, baseline$lengths[["anchored_skeleton"]]) + )) { + actual <- item[[1]] + base_len <- item[[3]] + tol <- max(1e-3, 1e-6 * base_len) + expect_equal(as.numeric(geos::geos_length(actual)), base_len, tolerance = tol) + base_geom <- geos::as_geos_geometry(item[[2]]) + equal_exact <- tryCatch( + isTRUE(geos::geos_equals_exact(actual, base_geom, tol)), + error = function(e) FALSE + ) + if (!equal_exact) { + hd <- as.numeric(geos::geos_distance_hausdorff(actual, base_geom)) + expect_lte(hd, tol) + } + } +}) From aadd6c76aea9ec5e257b7a75aed1b2f137c892a8 Mon Sep 17 00:00:00 2001 From: Anatoly Tsyplenkov Date: Fri, 17 Jul 2026 18:41:49 +0300 Subject: [PATCH 6/6] chore: drop sfnetworks dependency --- DESCRIPTION | 1 - NEWS.md | 6 ++++++ codemeta.json | 14 +++++++------- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index ba1bfa4..5eadc4b 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -24,7 +24,6 @@ Imports: wk (>= 0.9), sf (>= 1.0), geos (>= 0.2.4), - sfnetworks (>= 0.6), checkmate, igraph (>= 2.0.0) Suggests: diff --git a/NEWS.md b/NEWS.md index 786c787..5ddd9bc 100644 --- a/NEWS.md +++ b/NEWS.md @@ -13,6 +13,12 @@ centerline 0.3.0 (unreleased) * `cnt_path()` now reports when start and end points cannot be connected through the skeleton graph instead of passing an empty path to GEOS. +### UPDATES + + * Skeleton routing and anchor graphs now use igraph directly; the + dependency on sfnetworks has been removed. Public geometry APIs are + unchanged. + centerline 0.2.5 (2026-03-12) ========================= diff --git a/codemeta.json b/codemeta.json index 523e514..5d8c7ca 100644 --- a/codemeta.json +++ b/codemeta.json @@ -189,28 +189,28 @@ }, "5": { "@type": "SoftwareApplication", - "identifier": "sfnetworks", - "name": "sfnetworks", - "version": ">= 0.6", + "identifier": "checkmate", + "name": "checkmate", "provider": { "@id": "https://cran.r-project.org", "@type": "Organization", "name": "Comprehensive R Archive Network (CRAN)", "url": "https://cran.r-project.org" }, - "sameAs": "https://CRAN.R-project.org/package=sfnetworks" + "sameAs": "https://CRAN.R-project.org/package=checkmate" }, "6": { "@type": "SoftwareApplication", - "identifier": "checkmate", - "name": "checkmate", + "identifier": "igraph", + "name": "igraph", + "version": ">= 2.0.0", "provider": { "@id": "https://cran.r-project.org", "@type": "Organization", "name": "Comprehensive R Archive Network (CRAN)", "url": "https://cran.r-project.org" }, - "sameAs": "https://CRAN.R-project.org/package=checkmate" + "sameAs": "https://CRAN.R-project.org/package=igraph" }, "SystemRequirements": null },