378 lines
12 KiB
Go
378 lines
12 KiB
Go
package overlay
|
|
|
|
import (
|
|
"math"
|
|
"sort"
|
|
)
|
|
|
|
// Turning painted strokes into things an engine can place.
|
|
//
|
|
// A raster is enough for anything that is a mask - where the forest is, where the ground is a town - and the
|
|
// per-tile output is exactly that. It is not enough for anything that is a *position* or a *line*: "put a
|
|
// village here" wants a point and a radius, and "run a road along this" wants an ordered polyline, because
|
|
// the thing being built on the other side is a spline. So the marks are also reduced to features in world
|
|
// metres, once, over the whole cylinder.
|
|
//
|
|
// Both reductions work on connected components with X wrapped, because the world does. A component that
|
|
// straddles the seam is one thing, and reporting it as two would put half a forest at each end of the map.
|
|
|
|
// Feature is one connected piece of one mark, reduced to something placeable.
|
|
type Feature struct {
|
|
Mark string `json:"mark"`
|
|
Index int `json:"index"`
|
|
Kind string `json:"kind"`
|
|
ID int `json:"id"`
|
|
|
|
// CentreM is the centroid in world metres. X is a circular mean, so a component across the seam reports
|
|
// a centre on the component rather than on the far side of the world.
|
|
CentreM [2]float64 `json:"centre_m"`
|
|
|
|
// AreaM2 is the painted area, and RadiusM the radius of the disc with that area - the number to hand a
|
|
// placement rule that wants "how big is this village".
|
|
AreaM2 float64 `json:"area_m2"`
|
|
RadiusM float64 `json:"radius_m"`
|
|
|
|
// ExtentM is the bounding box, as width and height in metres. For a component across the seam the width
|
|
// is measured the short way round, which is the way it was painted.
|
|
ExtentM [2]float64 `json:"extent_m"`
|
|
|
|
Cells int `json:"cells_px"`
|
|
|
|
// PointsM is the centreline, in world metres, for a path. Empty for an area.
|
|
PointsM [][2]float64 `json:"points_m,omitempty"`
|
|
LengthM float64 `json:"length_m,omitempty"`
|
|
WidthM float64 `json:"width_m,omitempty"`
|
|
}
|
|
|
|
// Scale converts overlay pixels to world metres. The overlay is painted at the template's resolution, which
|
|
// is not the geology grid's, so nothing here may assume a pixel is a cell.
|
|
type Scale struct {
|
|
MetresPerPxX float64
|
|
MetresPerPxY float64
|
|
// CircumferenceM is how far X runs before it comes back to itself, for the circular mean.
|
|
CircumferenceM float64
|
|
}
|
|
|
|
// Features reduces every mark on the raster to placeable pieces, in mark order and then in a stable order
|
|
// within a mark.
|
|
//
|
|
// Stable means "does not depend on which goroutine ran", which is cross-cutting rule 12 and is why this is
|
|
// serial: it is one pass over a raster of a few tens of millions of pixels and it runs once per plan.
|
|
func (l *Legend) Features(r *Raster, s Scale) []Feature {
|
|
var out []Feature
|
|
// A visited flag and nothing more. It is a bool rather than a component id because nothing downstream
|
|
// asks which component a pixel belonged to, and at planet scale that is 29 MB against 116.
|
|
seen := make([]bool, len(r.Mark))
|
|
var stack []int32
|
|
|
|
for mi := range l.Marks {
|
|
m := &l.Marks[mi]
|
|
idx := uint8(mi + 1)
|
|
minArea := l.MinArea(m)
|
|
var found []Feature
|
|
for start := 0; start < len(r.Mark); start++ {
|
|
if r.Mark[start] != idx || seen[start] {
|
|
continue
|
|
}
|
|
cells := flood(r, idx, int32(start), seen, &stack)
|
|
if len(cells) < minArea {
|
|
continue
|
|
}
|
|
f := describe(r, m, mi+1, len(found), cells, s)
|
|
if !m.Area() {
|
|
pts := trace(r, cells)
|
|
f.PointsM, f.LengthM = project(pts, r, s)
|
|
f.WidthM = m.WidthM
|
|
}
|
|
found = append(found, f)
|
|
}
|
|
// Biggest first: a placement rule that takes the first few wants the ones that matter.
|
|
sort.SliceStable(found, func(a, b int) bool { return found[a].Cells > found[b].Cells })
|
|
for i := range found {
|
|
found[i].ID = i
|
|
}
|
|
out = append(out, found...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// flood collects one 8-connected component with X wrapped. The scratch stack is reused across components so
|
|
// a map with thousands of specks does not allocate thousands of slices.
|
|
func flood(r *Raster, idx uint8, start int32, seen []bool, stack *[]int32) []int32 {
|
|
cells := []int32{start}
|
|
seen[start] = true
|
|
*stack = (*stack)[:0]
|
|
*stack = append(*stack, start)
|
|
for len(*stack) > 0 {
|
|
i := (*stack)[len(*stack)-1]
|
|
*stack = (*stack)[:len(*stack)-1]
|
|
x, y := int(i)%r.W, int(i)/r.W
|
|
for dy := -1; dy <= 1; dy++ {
|
|
ny := y + dy
|
|
if ny < 0 || ny >= r.H {
|
|
continue
|
|
}
|
|
for dx := -1; dx <= 1; dx++ {
|
|
if dx == 0 && dy == 0 {
|
|
continue
|
|
}
|
|
nx := x + dx
|
|
if nx < 0 {
|
|
nx += r.W
|
|
} else if nx >= r.W {
|
|
nx -= r.W
|
|
}
|
|
n := int32(ny*r.W + nx)
|
|
if seen[n] || r.Mark[n] != idx {
|
|
continue
|
|
}
|
|
seen[n] = true
|
|
cells = append(cells, n)
|
|
*stack = append(*stack, n)
|
|
}
|
|
}
|
|
}
|
|
return cells
|
|
}
|
|
|
|
// describe measures a component: centroid, area, extent.
|
|
//
|
|
// X is a circular mean - the average of the unit vectors at each cell's longitude, turned back into an angle.
|
|
// A plain mean would put the centre of a component straddling the seam on the opposite side of the planet,
|
|
// which is the one failure mode a cylindrical map has and the one nobody notices until a village appears in
|
|
// the ocean.
|
|
func describe(r *Raster, m *Mark, idx, id int, cells []int32, s Scale) Feature {
|
|
var sx, sy, cx float64
|
|
for _, i := range cells {
|
|
x, y := float64(int(i)%r.W), float64(int(i)/r.W)
|
|
th := 2 * math.Pi * x / float64(r.W)
|
|
sx += math.Sin(th)
|
|
cx += math.Cos(th)
|
|
sy += y
|
|
}
|
|
n := float64(len(cells))
|
|
th := math.Atan2(sx/n, cx/n)
|
|
if th < 0 {
|
|
th += 2 * math.Pi
|
|
}
|
|
meanX := th / (2 * math.Pi) * float64(r.W)
|
|
meanY := sy / n
|
|
|
|
// The extent, measured relative to the circular centre so the seam is not a boundary.
|
|
var lo, hi, y0, y1 float64
|
|
lo, hi = math.Inf(1), math.Inf(-1)
|
|
y0, y1 = math.Inf(1), math.Inf(-1)
|
|
for _, i := range cells {
|
|
x, y := float64(int(i)%r.W), float64(int(i)/r.W)
|
|
d := x - meanX
|
|
if d > float64(r.W)/2 {
|
|
d -= float64(r.W)
|
|
} else if d < -float64(r.W)/2 {
|
|
d += float64(r.W)
|
|
}
|
|
lo = math.Min(lo, d)
|
|
hi = math.Max(hi, d)
|
|
y0 = math.Min(y0, y)
|
|
y1 = math.Max(y1, y)
|
|
}
|
|
|
|
areaM2 := n * s.MetresPerPxX * s.MetresPerPxY
|
|
return Feature{
|
|
Mark: m.Name, Index: idx, Kind: m.Kind, ID: id,
|
|
CentreM: [2]float64{meanX * s.MetresPerPxX, meanY * s.MetresPerPxY},
|
|
AreaM2: areaM2,
|
|
RadiusM: math.Sqrt(areaM2 / math.Pi),
|
|
ExtentM: [2]float64{(hi - lo + 1) * s.MetresPerPxX, (y1 - y0 + 1) * s.MetresPerPxY},
|
|
Cells: len(cells),
|
|
}
|
|
}
|
|
|
|
// trace reduces a painted stroke to its centreline, as an ordered run of pixel indices.
|
|
//
|
|
// The stroke's width is not the road; a brush eight pixels wide standing for a cart track is an author saying
|
|
// "along here", not "this is eighty metres of carriageway". What comes out is the longest line through the
|
|
// component, which for a stroke is the stroke.
|
|
//
|
|
// It is the geodesic diameter, found by two breadth-first searches: from any cell to the furthest cell A,
|
|
// then from A to the furthest cell B, keeping parents. The walk from B back to A is the path. That is the
|
|
// standard trick and it is exact on a tree; on a stroke with a loop in it, it takes the long way round, which
|
|
// is the right answer for a road that loops and the wrong one for a road that forks - a fork reports its two
|
|
// longest arms as one path and drops the third. The remedy is an author's, not the tool's: paint each run as
|
|
// its own stroke. `terrain plan` says how many components each path mark has, which is where that shows.
|
|
//
|
|
// The walk is then smoothed once and simplified, because a breadth-first search leaves a D8 staircase and a
|
|
// spline built straight from it would wobble at the pixel scale.
|
|
func trace(r *Raster, cells []int32) []int32 {
|
|
if len(cells) < 2 {
|
|
return cells
|
|
}
|
|
// A local index for the component, so the searches do not allocate over the whole map.
|
|
local := make(map[int32]int32, len(cells)*2)
|
|
for i, c := range cells {
|
|
local[c] = int32(i)
|
|
}
|
|
|
|
far := func(from int32) (int32, []int32) {
|
|
dist := make([]int32, len(cells))
|
|
parent := make([]int32, len(cells))
|
|
for i := range dist {
|
|
dist[i] = -1
|
|
parent[i] = -1
|
|
}
|
|
start := local[from]
|
|
dist[start] = 0
|
|
queue := []int32{start}
|
|
best, bestD := start, int32(0)
|
|
for head := 0; head < len(queue); head++ {
|
|
cur := queue[head]
|
|
ci := cells[cur]
|
|
x, y := int(ci)%r.W, int(ci)/r.W
|
|
for dy := -1; dy <= 1; dy++ {
|
|
ny := y + dy
|
|
if ny < 0 || ny >= r.H {
|
|
continue
|
|
}
|
|
for dx := -1; dx <= 1; dx++ {
|
|
if dx == 0 && dy == 0 {
|
|
continue
|
|
}
|
|
nx := x + dx
|
|
if nx < 0 {
|
|
nx += r.W
|
|
} else if nx >= r.W {
|
|
nx -= r.W
|
|
}
|
|
n, ok := local[int32(ny*r.W+nx)]
|
|
if !ok || dist[n] >= 0 {
|
|
continue
|
|
}
|
|
dist[n] = dist[cur] + 1
|
|
parent[n] = cur
|
|
if dist[n] > bestD {
|
|
bestD, best = dist[n], n
|
|
}
|
|
queue = append(queue, n)
|
|
}
|
|
}
|
|
}
|
|
return best, parent
|
|
}
|
|
|
|
a, _ := far(cells[0])
|
|
b, parent := far(cells[a])
|
|
|
|
var path []int32
|
|
for n := b; n >= 0; n = parent[n] {
|
|
path = append(path, cells[n])
|
|
if parent[n] < 0 {
|
|
break
|
|
}
|
|
}
|
|
// Reversed so the line runs from A to B, which is the order the search found them in and therefore the
|
|
// same order on every run.
|
|
for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 {
|
|
path[i], path[j] = path[j], path[i]
|
|
}
|
|
return path
|
|
}
|
|
|
|
// project turns a run of pixels into a simplified polyline in world metres, and measures its length.
|
|
//
|
|
// Simplification is Douglas-Peucker at half a pixel of the overlay, which is well below anything an author
|
|
// drew and well above the single-pixel staircase the walk leaves behind. The seam is handled by unrolling X:
|
|
// each point is taken to the branch nearest the last, so a road crossing the meridian comes out as one
|
|
// continuous run of coordinates rather than jumping the width of the world. A consumer that wraps it back
|
|
// does so knowing the circumference; a consumer that does not gets a spline that still looks right.
|
|
func project(path []int32, r *Raster, s Scale) ([][2]float64, float64) {
|
|
if len(path) == 0 {
|
|
return nil, 0
|
|
}
|
|
pts := make([][2]float64, len(path))
|
|
prevX := float64(int(path[0]) % r.W)
|
|
for i, p := range path {
|
|
x, y := float64(int(p)%r.W), float64(int(p)/r.W)
|
|
for x-prevX > float64(r.W)/2 {
|
|
x -= float64(r.W)
|
|
}
|
|
for prevX-x > float64(r.W)/2 {
|
|
x += float64(r.W)
|
|
}
|
|
prevX = x
|
|
pts[i] = [2]float64{x, y}
|
|
}
|
|
pts = smooth(pts)
|
|
pts = simplify(pts, 0.5)
|
|
|
|
out := make([][2]float64, len(pts))
|
|
length := 0.0
|
|
for i, p := range pts {
|
|
out[i] = [2]float64{p[0] * s.MetresPerPxX, p[1] * s.MetresPerPxY}
|
|
if i > 0 {
|
|
length += math.Hypot(out[i][0]-out[i-1][0], out[i][1]-out[i-1][1])
|
|
}
|
|
}
|
|
return out, length
|
|
}
|
|
|
|
// smooth is a three-point moving average with the ends pinned. One pass: enough to take the staircase off a
|
|
// D8 walk, not enough to pull a real corner off the line it was drawn on.
|
|
func smooth(p [][2]float64) [][2]float64 {
|
|
if len(p) < 3 {
|
|
return p
|
|
}
|
|
out := make([][2]float64, len(p))
|
|
out[0], out[len(p)-1] = p[0], p[len(p)-1]
|
|
for i := 1; i < len(p)-1; i++ {
|
|
out[i] = [2]float64{
|
|
(p[i-1][0] + p[i][0] + p[i+1][0]) / 3,
|
|
(p[i-1][1] + p[i][1] + p[i+1][1]) / 3,
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// simplify is Douglas-Peucker, iterative so a ten-thousand-point stroke cannot blow the stack.
|
|
func simplify(p [][2]float64, tol float64) [][2]float64 {
|
|
if len(p) < 3 {
|
|
return p
|
|
}
|
|
keep := make([]bool, len(p))
|
|
keep[0], keep[len(p)-1] = true, true
|
|
type span struct{ a, b int }
|
|
stack := []span{{0, len(p) - 1}}
|
|
for len(stack) > 0 {
|
|
sp := stack[len(stack)-1]
|
|
stack = stack[:len(stack)-1]
|
|
if sp.b <= sp.a+1 {
|
|
continue
|
|
}
|
|
worst, worstD := -1, tol
|
|
for i := sp.a + 1; i < sp.b; i++ {
|
|
if d := perpendicular(p[i], p[sp.a], p[sp.b]); d > worstD {
|
|
worstD, worst = d, i
|
|
}
|
|
}
|
|
if worst < 0 {
|
|
continue
|
|
}
|
|
keep[worst] = true
|
|
stack = append(stack, span{sp.a, worst}, span{worst, sp.b})
|
|
}
|
|
out := make([][2]float64, 0, len(p))
|
|
for i, k := range keep {
|
|
if k {
|
|
out = append(out, p[i])
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func perpendicular(p, a, b [2]float64) float64 {
|
|
dx, dy := b[0]-a[0], b[1]-a[1]
|
|
l := math.Hypot(dx, dy)
|
|
if l == 0 {
|
|
return math.Hypot(p[0]-a[0], p[1]-a[1])
|
|
}
|
|
return math.Abs(dy*(p[0]-a[0])-dx*(p[1]-a[1])) / l
|
|
}
|