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 }