Tooling
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user