--- title: "Triangulation" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Triangulation} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.width = 7, fig.height = 5 ) has_decido <- requireNamespace("decido", quietly = TRUE) has_rtriangle <- requireNamespace("RTriangle", quietly = TRUE) has_silicate <- requireNamespace("silicate", quietly = TRUE) ``` A polygon in wkpool is a set of rings, and a ring is a cycle of segments: one-dimensional topology. Triangles are the two-dimensional version, and they can live on the same vertex pool. A `wkpool_tri` is a vector of triangles whose corners are vertex ids into a pool, just as a wkpool is a vector of segments. `wkpool_tri` and `pool_triangulate()` are **experimental** (new in 0.4.0) and may change. The older route, `as_pslg()` and `pool_holes()` feeding RTriangle, is unchanged and is covered below alongside it. ```{r setup} library(wkpool) ``` ## Ear cutting `pool_triangulate()` ear cuts each polygon with decido. Ear cutting adds no vertices, so the triangles reference the pool's vertices as they are. ```{r earcut, eval = has_decido} x <- wk::as_wkb(c( "POLYGON ((0 0, 4 0, 4 4, 0 4, 0 0), (1 1, 1 3, 2 2, 3 3, 3 1, 1 1))", "POLYGON ((4 0, 6 0, 6 4, 4 4, 4 0))" )) pool <- establish_topology(x) tri <- pool_triangulate(pool) tri pool_triangles(tri) ``` ```{r earcut-plot, eval = has_decido, fig.height = 4} plot(tri, asp = 1) ``` Each triangle keeps the feature and part it came from, and triangles are counter-clockwise. `wk_handle()` gives one POLYGON per triangle, so any wk consumer can take them: ```{r earcut-wk, eval = has_decido} wk::as_wkt(tri)[1:3] ``` Triangulating a raw pool and merging later gives the same result as merging first. After merging, the two features share the corners on their common edge. `tri_segments()` gives the triangle edges as an ordinary wkpool, so the segment tools apply; here the one edge the features share, once from each side: ```{r earcut-merge, eval = has_decido} tri <- merge_coincident(tri) edges <- tri_segments(tri) find_shared_edges(edges) ``` ## A real coverage `inlandwaters` from silicate is six regions of Australia (and New Caledonia) with many holes and long shared boundaries. ```{r inlandwaters, eval = has_decido && has_silicate, message = FALSE} op <- par(mar = rep(0, 4L)) data("inlandwaters", package = "silicate") # the geometry column as WKB iw_geom <- wk::wk_handle(inlandwaters$geom, wk::wkb_writer()) iw <- merge_coincident(establish_topology(iw_geom)) iw_tri <- pool_triangulate(iw) length(iw_tri) nrow(pool_vertices(iw_tri)) == nrow(pool_vertices(iw)) plot(iw_tri, border = NA, asp = 1, axes = FALSE, xlab = "", ylab = "") par(op) ``` `plot()` draws every triangle in one `polygon()` call indexed into the pool, so tens of thousands of triangles draw in well under a second. The view is much bigger than the land. Three small, distant islands set the extent: Macquarie Island far to the south, which is one part of the Tasmania feature; Lord Howe Island to the east, one part of the New South Wales feature; and the feature labelled New Caledonia to the north east. Each triangle records its feature and part, so the islands can be found from the triangles themselves and dropped with a subset. The plot extent follows the triangles that are drawn: ```{r inlandwaters-trim, eval = has_decido && has_silicate} op <- par(mar = rep(0, 4L)) tt <- pool_triangles(iw_tri) ev <- pool_vertices(iw_tri) tx <- ev$x[match(tt$.vx0, ev$.vx)] ty <- ev$y[match(tt$.vx0, ev$.vx)] prov <- inlandwaters$Province nsw <- tt$.feature == which(prov == "New South Wales") islands <- c( "Macquarie Island" = tt$.part[which.min(ty)], "Lord Howe Island" = tt$.part[nsw][which.max(tx[nsw])], "New Caledonia" = unique(tt$.part[tt$.feature == which(prov == "New Caledonia")]) ) islands plot(iw_tri[!tt$.part %in% islands], border = NA, asp = 1, axes = FALSE, xlab = "", ylab = "") par(op) ``` ## Constrained Delaunay: the PSLG route Ear cutting gives valid triangles but they are often long and thin, and it cannot add vertices. For good-quality triangles (minimum angle, maximum area) you need a constrained Delaunay triangulation, and its input is a planar straight line graph (PSLG): vertices, and segments that must appear as triangle edges. A merged pool is already a PSLG; `as_pslg()` just gives it as matrices, `P` for the vertices and `S` for the segments, in the form RTriangle uses. ```{r pslg} p <- as_pslg(establish_topology(wk::as_wkb( "POLYGON ((0 0, 4 0, 4 4, 0 4, 0 0), (1 1, 1 3, 2 2, 3 3, 3 1, 1 1))" ))) str(p) ``` A PSLG does not say which side of a segment is inside. RTriangle removes the triangles outside the outer boundary, and needs a point in each hole to remove those too. `pool_holes()` gives one point inside each hole ring (a hole that another polygon fills exactly, like an enclave, is not a hole of the coverage and is left out): ```{r rtriangle, eval = has_rtriangle, fig.height = 4} h <- pool_holes(merge_coincident(establish_topology(wk::as_wkb( "POLYGON ((0 0, 4 0, 4 4, 0 4, 0 0), (1 1, 1 3, 2 2, 3 3, 3 1, 1 1))" )))) h rt <- RTriangle::triangulate(RTriangle::pslg(P = p$P, S = p$S, H = h), q = 30, a = 0.1) plot(rt$P, asp = 1, pch = ".", xlab = "", ylab = "") polygon(rt$P[t(cbind(rt$T, NA)), ], col = "grey90") points(h, col = "firebrick", pch = 19) ``` RTriangle added vertices to meet the quality and area targets; those are the Steiner points, appended after the input vertices. ## Back to a triangle pool The result is a vertex table and an index table, which is all a `wkpool_tri` is. The input vertices come first in the same order as the pool, so their ids are unchanged and Steiner points get new ids after them: ```{r back, eval = has_rtriangle} v <- data.frame(.vx = seq_len(nrow(rt$P)), x = rt$P[, 1], y = rt$P[, 2]) rt_tri <- new_wkpool_tri(v, rt$T[, 1], rt$T[, 2], rt$T[, 3]) length(rt_tri) rt_tri[1:3] ``` On real data the difference in triangle quality is plain. The first feature of `inlandwaters` is the Australian Capital Territory: ear cut on the left, long thin triangles fanning from a few vertices; through RTriangle with a minimum angle of 25 degrees on the right, back on a triangle pool: ```{r act-rt, eval = has_rtriangle && has_decido && has_silicate} act <- iw_tri[pool_feature(iw_tri) == 1] act_p <- as_pslg(establish_topology(iw_geom[1])) act_rt <- RTriangle::triangulate(RTriangle::pslg(P = act_p$P, S = act_p$S), q = 25) act_v <- data.frame(.vx = seq_len(nrow(act_rt$P)), x = act_rt$P[, 1], y = act_rt$P[, 2]) act_rt_tri <- new_wkpool_tri(act_v, act_rt$T[, 1], act_rt$T[, 2], act_rt$T[, 3]) c(earcut = length(act), RTriangle = length(act_rt_tri)) op <- par(mfrow = c(1, 2), mar = rep(0.5, 4)) plot(act, col = "grey90", border = "grey40", asp = 1, axes = FALSE, xlab = "", ylab = "") plot(act_rt_tri, col = "grey90", border = "grey40", asp = 1, axes = FALSE, xlab = "", ylab = "") par(op) ``` The PSLG route triangulates the whole pool at once and does not record which feature a triangle belongs to; `pool_triangulate()` does, because it works one polygon at a time. On `inlandwaters` RTriangle covers the same area as ear cutting, up to one thing a PSLG cannot express with hole points alone: a region enclosed by neighbouring polygons that none of them covers is not a hole of any ring, so `pool_holes()` gives no point for it and RTriangle keeps it. ```{r compare, eval = has_rtriangle && has_decido && has_silicate} tri_area <- function(P, T) { sum(abs((P[T[, 2], 1] - P[T[, 1], 1]) * (P[T[, 3], 2] - P[T[, 1], 2]) - (P[T[, 3], 1] - P[T[, 1], 1]) * (P[T[, 2], 2] - P[T[, 1], 2]))) / 2 } iw_p <- as_pslg(iw) iw_rt <- RTriangle::triangulate( RTriangle::pslg(P = iw_p$P, S = iw_p$S, H = pool_holes(iw)) ) ev <- pool_vertices(iw_tri) c( earcut = tri_area(cbind(ev$x, ev$y), as.matrix(pool_triangles(iw_tri)[1:3])), RTriangle = tri_area(iw_rt$P, iw_rt$T) ) / 1e6 ``` ## The islands The three islands, each drawn from its own subset of the triangle pool: ```{r islands, eval = has_decido && has_silicate, fig.height = 3.5} op <- par(mfrow = c(1, 3), mar = c(0.5, 0.5, 2, 0.5)) for (nm in names(islands)) { plot(iw_tri[tt$.part == islands[nm]], border = "grey30", asp = 1, axes = FALSE, xlab = "", ylab = "", main = nm) } par(op) ``` A few things to notice: * A subset is a set of triangles, not a copy of the coordinates: each island still references the full vertex pool, and its vertex ids are the ids in the whole coverage. ```{r islands-pool, eval = has_decido && has_silicate} mq <- iw_tri[tt$.part == islands["Macquarie Island"]] c(triangles = length(mq), pool_vertices = nrow(pool_vertices(mq))) ``` * Ear cutting adds no vertices, so the count is fixed by the input: a polygon with `n` vertices and `h` holes gives `n + 2h - 2` triangles. These islands have no holes, so each has two fewer triangles than vertices: ```{r islands-count, eval = has_decido && has_silicate} sapply(islands, function(k) { i <- tt$.part == k c(vertices = length(unique(unlist(tt[i, 1:3]))), triangles = sum(i)) }) ``` * The islands are a few kilometres to a few tens of kilometres across, the mainland states around a thousand. A maximum triangle area (`a` in RTriangle) is in map units, square metres here, so one value refines a small island and a large state very differently; a minimum angle (`q`) does not depend on scale. ## Which engine | Engine | Adds vertices | Keeps feature | Holes from | |--------|---------------|---------------|------------| | `pool_triangulate()` (decido) | no | yes | ring roles | | RTriangle via `as_pslg()` | yes (quality, area) | no | `pool_holes()` points | [meshcore](https://github.com/hypertidy/meshcore) offers further constrained Delaunay engines behind `tri_mesh()`, with vertex origin (input, crossing, Steiner) and depth recorded in its mesh tables. ## Further reading - [trianglewins](https://mdsumner.github.io/trianglewins/): a guide to constrained triangulation in R, with a survey of the packages, earcut and GEOS compared on the same inputs, and a harness that runs RTriangle and newer engines on shared test cases.