Files
UnrealPrototyping/Tools/Terrain/internal/overlay/features.go
T
2026-09-25 17:02:24 +03:00

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
}