The hardware and bandwidth for this mirror is donated by dogado GmbH, the Webhosting and Full Service-Cloud Provider. Check out our Wordpress Tutorial.
If you wish to report a bug, or if you are interested in having us mirror your free-software or open-source project, please feel free to contact us at mirror[@]dogado.de.

Triangulation

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.

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.

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
#> <wkpool_tri[11 triangles, 16 vertices]>
#>  [1] <triangle: 4,1,6>    <triangle: 7,8,9>    <triangle: 10,6,1>  
#>  [4] <triangle: 4,6,7>    <triangle: 10,1,2>   <triangle: 3,4,7>   
#>  [7] <triangle: 9,10,2>   <triangle: 3,7,9>    <triangle: 9,2,3>   
#> [10] <triangle: 14,15,12> <triangle: 12,13,14>
pool_triangles(tri)
#>    .vx0 .vx1 .vx2 .feature .part
#> 1     4    1    6        1     1
#> 2     7    8    9        1     1
#> 3    10    6    1        1     1
#> 4     4    6    7        1     1
#> 5    10    1    2        1     1
#> 6     3    4    7        1     1
#> 7     9   10    2        1     1
#> 8     3    7    9        1     1
#> 9     9    2    3        1     1
#> 10   14   15   12        2     2
#> 11   12   13   14        2     2
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:

wk::as_wkt(tri)[1:3]
#> <wk_wkt[3]>
#> [1] POLYGON ((0 4, 0 0, 1 1, 0 4)) POLYGON ((1 3, 2 2, 3 3, 1 3))
#> [3] POLYGON ((3 1, 1 1, 0 0, 3 1))

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:

tri <- merge_coincident(tri)
edges <- tri_segments(tri)
find_shared_edges(edges)
#>    edge_key .vx0 .vx1 .feature segment_idx features
#> 26      2-3    2    3        1          26     1, 2
#> 29      2-3    2    3        2          29     1, 2

A real coverage

inlandwaters from silicate is six regions of Australia (and New Caledonia) with many holes and long shared boundaries.

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)
#> [1] 33205
nrow(pool_vertices(iw_tri)) == nrow(pool_vertices(iw))
#> [1] TRUE
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:

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
#> Macquarie Island Lord Howe Island    New Caledonia 
#>              126               16                4
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.

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)
#> List of 2
#>  $ P: num [1:9, 1:2] 0 4 4 0 1 1 2 3 3 0 ...
#>  $ S: int [1:9, 1:2] 1 2 3 4 5 6 7 8 9 2 ...

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):

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
#>      x   y
#> [1,] 2 1.5
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:

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)
#> [1] 208
rt_tri[1:3]
#> <wkpool_tri[3 triangles, 131 vertices]>
#> [1] <triangle: 72,51,49>    <triangle: 106,103,105> <triangle: 47,19,17>

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:

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))
#>    earcut RTriangle 
#>       277       983
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.

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
#>    earcut RTriangle 
#>   1923706   1923944

The islands

The three islands, each drawn from its own subset of the triangle pool:

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:

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 offers further constrained Delaunay engines behind tri_mesh(), with vertex origin (input, crossing, Steiner) and depth recorded in its mesh tables.

Further reading

These binaries (installable software) and packages are in development.
They may not be fully stable and should be used with caution. We make no claims about them.
Health stats visible at Monitor.