Tooling
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
)
|
||||
|
||||
// What the overlay hands to whatever builds the level.
|
||||
//
|
||||
// Two shapes, because two different things want it. A *raster* answers "what is under this square metre" and
|
||||
// is what a per-tile importer wants: one 8-bit image beside each height tile, an index per detail cell, zero
|
||||
// for nothing. A *feature list* answers "where do I put the village" and "what curve does the road follow",
|
||||
// and lives once at the planet root in world metres because a point is not a pixel and a spline crossing a
|
||||
// tile boundary is still one spline.
|
||||
//
|
||||
// Nothing in the generator reads either of them back. That is the point of the layer.
|
||||
|
||||
// CoastScale is the per-pixel multiplier on the waterline roughening, or nil when no mark asks for one.
|
||||
//
|
||||
// A pixel with no instruction comes back negative rather than 1, which is the contract template.Coast.Scale
|
||||
// documents: an unmarked cell takes its instruction from the far side of the waterline instead of overriding
|
||||
// what the marked side said.
|
||||
func (l *Legend) CoastScale(r *Raster) []float32 {
|
||||
if !l.TouchesCoast() {
|
||||
return nil
|
||||
}
|
||||
per := make([]float32, len(l.Marks)+1)
|
||||
per[Blank] = -1
|
||||
for i := range l.Marks {
|
||||
if j, set := l.Marks[i].Jitter(); set {
|
||||
per[i+1] = float32(j)
|
||||
} else {
|
||||
per[i+1] = -1
|
||||
}
|
||||
}
|
||||
out := make([]float32, len(r.Mark))
|
||||
field.Rows(r.H, func(y0, y1 int) {
|
||||
for i := y0 * r.W; i < y1*r.W; i++ {
|
||||
out[i] = per[r.Mark[i]]
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Document is overlay.json: everything an importer needs to place what the author painted.
|
||||
type Document struct {
|
||||
Image string `json:"image"`
|
||||
Legend string `json:"legend"`
|
||||
|
||||
// The frame the coordinates are in: metres east from the seam and metres south from the top painted row,
|
||||
// which is the same frame world.Planet uses for a painted cell once the polar pad is taken off.
|
||||
CircumferenceM float64 `json:"circumference_m"`
|
||||
HeightM float64 `json:"height_m"`
|
||||
PaintW int `json:"paint_w"`
|
||||
PaintH int `json:"paint_h"`
|
||||
MetresPerPxX float64 `json:"metres_per_px_x"`
|
||||
MetresPerPxY float64 `json:"metres_per_px_y"`
|
||||
|
||||
Marks []MarkShare `json:"marks"`
|
||||
Features []Feature `json:"features"`
|
||||
}
|
||||
|
||||
// MarkShare is one mark and how much of the world carries it.
|
||||
type MarkShare struct {
|
||||
Index int `json:"index"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
RGB [3]int `json:"rgb"`
|
||||
Cells int `json:"cells_px"`
|
||||
AreaKm2 float64 `json:"area_km2"`
|
||||
Pieces int `json:"pieces"`
|
||||
WidthM float64 `json:"width_m,omitempty"`
|
||||
|
||||
// Jitter and HasJitter are a pair and neither is omitempty, because the interesting value of the first
|
||||
// is **zero** - a pinned coastline - and omitting it would leave every consumer of this file unable to
|
||||
// tell "pin it" from "said nothing", which is the one distinction the key exists to make.
|
||||
Jitter float64 `json:"coast_jitter"`
|
||||
HasJitter bool `json:"has_coast_jitter"`
|
||||
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// Describe builds the document from a classified overlay.
|
||||
func (l *Legend) Describe(r *Raster, m Match, s Scale, image, legend string) *Document {
|
||||
feats := l.Features(r, s)
|
||||
pieces := make([]int, len(l.Marks)+1)
|
||||
for _, f := range feats {
|
||||
pieces[f.Index]++
|
||||
}
|
||||
doc := &Document{
|
||||
Image: image, Legend: legend,
|
||||
CircumferenceM: s.CircumferenceM,
|
||||
HeightM: float64(r.H) * s.MetresPerPxY,
|
||||
PaintW: r.W, PaintH: r.H,
|
||||
MetresPerPxX: s.MetresPerPxX, MetresPerPxY: s.MetresPerPxY,
|
||||
Features: feats,
|
||||
}
|
||||
for i := range l.Marks {
|
||||
mk := &l.Marks[i]
|
||||
cells := 0
|
||||
if i+1 < len(m.Counts) {
|
||||
cells = m.Counts[i+1]
|
||||
}
|
||||
share := MarkShare{
|
||||
Index: i + 1, Name: mk.Name, Kind: mk.Kind, RGB: mk.RGB,
|
||||
Cells: cells, AreaKm2: float64(cells) * s.MetresPerPxX * s.MetresPerPxY / 1e6,
|
||||
Pieces: pieces[i+1], WidthM: mk.WidthM, Note: mk.Note,
|
||||
}
|
||||
if j, set := mk.Jitter(); set {
|
||||
share.Jitter, share.HasJitter = j, true
|
||||
}
|
||||
doc.Marks = append(doc.Marks, share)
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
// WriteJSON writes overlay.json.
|
||||
func (d *Document) WriteJSON(dir string) error {
|
||||
data, err := json.MarshalIndent(d, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, "overlay.json"), append(data, '\n'), 0o644)
|
||||
}
|
||||
|
||||
// WriteMask writes an 8-bit indexed PNG: one mark index a pixel, zero for nothing. It is the per-tile
|
||||
// output, and it is indexed rather than one image a mark because marks cannot overlap - the overlay is one
|
||||
// painting and a pixel is one colour - so 254 masks fit in the file one would have taken.
|
||||
func WriteMask(path string, w, h int, marks []uint8) error {
|
||||
return field.WriteGray8(path, w, h, marks, png.BestCompression)
|
||||
}
|
||||
|
||||
// SampleWorld reads the overlay over a rectangle of some other grid, described in world metres.
|
||||
//
|
||||
// World metres rather than cell indices, because the caller is a *detail* tile: it is 2 m where the overlay
|
||||
// is 12.9 and the geology is 8, and it sits at an origin that is only expressible in metres. Going through
|
||||
// the common frame is the only way the three agree, and it is rule 1 of the tiling plan applied to a raster
|
||||
// instead of to a noise - a cell gets the same mark whichever tile reaches it.
|
||||
//
|
||||
// Nearest neighbour, for the same reason the class raster is: an index is a name, and the average of
|
||||
// "forest" and "road" is neither.
|
||||
func (r *Raster) SampleWorld(originXM, originYM, cellM float64, w, h int, s Scale) []uint8 {
|
||||
out := make([]uint8, w*h)
|
||||
field.Rows(h, func(b0, b1 int) {
|
||||
for y := b0; y < b1; y++ {
|
||||
py := int((originYM + (float64(y)+0.5)*cellM) / s.MetresPerPxY)
|
||||
if py < 0 {
|
||||
py = 0
|
||||
} else if py >= r.H {
|
||||
py = r.H - 1
|
||||
}
|
||||
row := py * r.W
|
||||
for x := 0; x < w; x++ {
|
||||
px := int((originXM + (float64(x)+0.5)*cellM) / s.MetresPerPxX)
|
||||
px = ((px % r.W) + r.W) % r.W
|
||||
out[y*w+x] = r.Mark[row+px]
|
||||
}
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,867 @@
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/noise"
|
||||
)
|
||||
|
||||
// Filling an overlay in from a baked world, so an author starts from something rather than from nothing.
|
||||
//
|
||||
// The annotation layer is hand-painted and it starts blank, which is the right default and a bad starting
|
||||
// point. Where a forest can grow, where a town would actually stand and what a road between two towns would
|
||||
// follow are all *consequences of the terrain* - of slope, of where the rivers run, of how far the sea is -
|
||||
// and the terrain is the one thing an author cannot see while painting, because the solve has not happened
|
||||
// yet when they are painting classes and the heightmap is 29 million pixels when it has. So the generator
|
||||
// reads a finished bake and proposes the marks the terrain implies. The author then moves them.
|
||||
//
|
||||
// Four rules, and they are the whole design:
|
||||
//
|
||||
// - **A painted pixel is never touched.** Generation fills blank pixels only. An author who has drawn the
|
||||
// capital exactly where they want it can regenerate everything else around it as often as they like, and
|
||||
// the two halves compose rather than competing. This is what makes the feature safe to re-run.
|
||||
// - **It is opt-in per mark.** A mark generates only if it carries a `generate` block. A legend written
|
||||
// before this existed produces exactly the blank sheet it always did, and a mark the author wants to own
|
||||
// completely simply says nothing.
|
||||
// - **It runs at the template's resolution**, which is the overlay's own. Generating on the 8 m geology
|
||||
// grid and downsampling would smear a road across two colours, and the classifier reads exact colours -
|
||||
// a blended pixel is dropped or becomes a different mark. Nothing here antialiases anything, for the
|
||||
// same reason the studio's brush does not.
|
||||
// - **It proposes, it does not decide.** These are starting points. The numbers below are chosen to put
|
||||
// something plausible on the sheet, not to be a settlement model.
|
||||
//
|
||||
// What it is emphatically not: a simulation. There is no economy, no history and no climate here - the Go
|
||||
// generator has no climate model at all - so "where would a city be" is answered with drainage, slope and
|
||||
// distance to the sea, which is the part of the question the terrain can actually answer.
|
||||
|
||||
// Generate kinds. A mark's `generate.kind` picks one.
|
||||
const (
|
||||
// GenForest fills ground that could carry trees: shallow enough, below the treeline, and broken up by a
|
||||
// noise field so it reads as woodland rather than as a contour band.
|
||||
GenForest = "forest"
|
||||
|
||||
// GenSettlement places discs at scored sites - rivers, flat ground, the coast - with a minimum spacing,
|
||||
// largest tier first. Several marks may use it; they share one spacing rule, so a village never lands
|
||||
// inside a city.
|
||||
GenSettlement = "settlement"
|
||||
|
||||
// GenRoad joins the settlements that were placed, along least-cost paths over the terrain. Water is
|
||||
// impassable, so roads never swim: an island group comes out as one road network per island.
|
||||
GenRoad = "road"
|
||||
|
||||
// GenCoast bands the waterline. It is the one kind whose mark usually carries `coast_jitter`, which is
|
||||
// the only overlay property any pass reads.
|
||||
GenCoast = "coast"
|
||||
)
|
||||
|
||||
// GenSpec is a mark's `generate` block: what to put where, and the few numbers worth varying. Every zero
|
||||
// field takes a default that is derived from the world being generated rather than from a constant, because
|
||||
// a treeline in metres means nothing until you know how high the land got.
|
||||
type GenSpec struct {
|
||||
Kind string `json:"kind"`
|
||||
|
||||
// MaxSlopeDeg is the steepest ground this mark will be put on. Forests stop at cliffs, towns stand on
|
||||
// flat ground, and roads climb but grudgingly.
|
||||
MaxSlopeDeg float64 `json:"max_slope_deg"`
|
||||
|
||||
// MinHeightM and MaxHeightM bound the elevation band. MaxHeightM zero means "derive a treeline from the
|
||||
// land's own height distribution", which is the only honest default on a world whose relief is unknown
|
||||
// until it is baked.
|
||||
MinHeightM float64 `json:"min_height_m"`
|
||||
MaxHeightM float64 `json:"max_height_m"`
|
||||
|
||||
// Cover is roughly the fraction of the eligible ground this mark should take, for area kinds. It is a
|
||||
// quantile of the noise field rather than a count, so it means the same thing on any size of world.
|
||||
Cover float64 `json:"cover"`
|
||||
|
||||
// WavelengthKm is how big the patches are, for area kinds.
|
||||
WavelengthKm float64 `json:"wavelength_km"`
|
||||
|
||||
// Count is how many of this mark to place, for settlements.
|
||||
Count int `json:"count"`
|
||||
|
||||
// MinSpacingKm is how far apart settlements must stand. Shared across every settlement mark, taken from
|
||||
// the largest that sets one.
|
||||
MinSpacingKm float64 `json:"min_spacing_km"`
|
||||
|
||||
// RadiusM is how big the painted blob is. Zero derives one from the mark's own min_area_px, so the blob
|
||||
// this writes is never one the feature reducer would then discard as a speck.
|
||||
RadiusM float64 `json:"radius_m"`
|
||||
|
||||
// WidthM is how wide a band or a road is painted. For a road the legend's own width_m is used when this
|
||||
// is zero, because that is the same number said once.
|
||||
WidthM float64 `json:"width_m"`
|
||||
|
||||
// CoastKm is how far inland a coast band reaches, and how close to the sea a settlement wants to be for
|
||||
// its coastal bonus.
|
||||
CoastKm float64 `json:"coast_km"`
|
||||
|
||||
// OnlyClasses and NotClasses restrict a mark to, or bar it from, ground painted with named classes from
|
||||
// the *class* legend.
|
||||
//
|
||||
// They exist because height and slope cannot tell an ice cap from a meadow. The first run of this
|
||||
// generator grew woodland across both polar caps: the caps are flat, they are below the treeline, and
|
||||
// nothing the terrain knows says otherwise - the only thing that does is the colour the author painted
|
||||
// there. A class name that is not in the legend is an error rather than an empty filter, because a
|
||||
// misspelt exclusion is a forest on an ice cap that nobody notices.
|
||||
OnlyClasses []string `json:"only_classes"`
|
||||
NotClasses []string `json:"not_classes"`
|
||||
|
||||
// Resolved forms of the two lists above, as class indices. Filled in by Generate.
|
||||
onlyIdx map[int]bool
|
||||
notIdx map[int]bool
|
||||
}
|
||||
|
||||
// resolveClasses turns the class names into indices against the class legend that was actually loaded.
|
||||
func (g *GenSpec) resolveClasses(markName string, names []string) error {
|
||||
find := func(list []string) (map[int]bool, error) {
|
||||
if len(list) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return nil, fmt.Errorf("mark %q names classes, but no class legend was handed to the generator",
|
||||
markName)
|
||||
}
|
||||
out := map[int]bool{}
|
||||
for _, want := range list {
|
||||
found := -1
|
||||
for i, n := range names {
|
||||
if n == want {
|
||||
found = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if found < 0 {
|
||||
return nil, fmt.Errorf("mark %q names the class %q, which is not in the class legend",
|
||||
markName, want)
|
||||
}
|
||||
out[found] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
var err error
|
||||
if g.onlyIdx, err = find(g.OnlyClasses); err != nil {
|
||||
return err
|
||||
}
|
||||
g.notIdx, err = find(g.NotClasses)
|
||||
return err
|
||||
}
|
||||
|
||||
func (g *GenSpec) validate(markName string) error {
|
||||
switch g.Kind {
|
||||
case GenForest, GenSettlement, GenRoad, GenCoast:
|
||||
default:
|
||||
return fmt.Errorf("mark %q: generate.kind %q is not one of %q, %q, %q, %q",
|
||||
markName, g.Kind, GenForest, GenSettlement, GenRoad, GenCoast)
|
||||
}
|
||||
if g.Cover < 0 || g.Cover > 1 {
|
||||
return fmt.Errorf("mark %q: generate.cover is %v, outside 0..1", markName, g.Cover)
|
||||
}
|
||||
if g.Count < 0 {
|
||||
return fmt.Errorf("mark %q: generate.count is %d", markName, g.Count)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenInputs is the baked world the marks are read off, at the overlay's own resolution.
|
||||
type GenInputs struct {
|
||||
W, H int
|
||||
CellM float64 // metres per overlay pixel
|
||||
|
||||
// HeightM is the surface in metres and Sea is which cells are under water, both over the painted rows
|
||||
// only - the polar pad is scaffolding and has no marks on it.
|
||||
HeightM []float32
|
||||
Sea []bool
|
||||
|
||||
// FlowM2 is drainage area in square metres. Nil is allowed: rivers then contribute nothing to a
|
||||
// settlement's score, which is worth saying out loud rather than silently scoring zero everywhere.
|
||||
FlowM2 []float32
|
||||
|
||||
// ClassAt is the class legend's index per cell, and ClassNames the names those indices mean. Both are
|
||||
// optional together: without them only_classes and not_classes cannot be honoured, and asking for one is
|
||||
// then an error rather than a filter that quietly does nothing.
|
||||
ClassAt []uint8
|
||||
ClassNames []string
|
||||
|
||||
Seed int64
|
||||
|
||||
// Existing is the overlay as it stands. Its painted pixels are preserved exactly and generation fills
|
||||
// around them. Nil is a blank sheet.
|
||||
Existing *Raster
|
||||
}
|
||||
|
||||
// GenReport is what was placed, for the run summary.
|
||||
type GenReport struct {
|
||||
Marks []GenMarkReport
|
||||
Kept int // pixels that were already painted and were left alone
|
||||
Painted int // pixels this generation filled
|
||||
TreelineM float64
|
||||
Settlement []Placed
|
||||
}
|
||||
|
||||
// GenMarkReport is one mark's share of a generation.
|
||||
type GenMarkReport struct {
|
||||
Name string
|
||||
Kind string
|
||||
Cells int
|
||||
Pieces int // settlements placed, or roads traced
|
||||
|
||||
// Wanted is how many were asked for, when that is a number the legend gave. Reported separately from
|
||||
// Pieces so a run that could not fit them all says so: the spacing and the amount of flat ground are
|
||||
// what ration settlements, and an author who asked for forty and got eighteen needs to be told, not left
|
||||
// to count the dots.
|
||||
Wanted int
|
||||
}
|
||||
|
||||
// Placed is one settlement, kept so the roads can be run between them and so the summary can say where they
|
||||
// went.
|
||||
type Placed struct {
|
||||
Mark int // raster index
|
||||
X, Y int
|
||||
Score float64
|
||||
RadPx int
|
||||
Region int // which connected landmass, so roads never try to cross open water
|
||||
}
|
||||
|
||||
// wantedFor is how many of a mark the legend asked for, or zero when it is not a counted kind.
|
||||
func wantedFor(m *Mark) int {
|
||||
if m.Generate == nil {
|
||||
return 0
|
||||
}
|
||||
return m.Generate.Count
|
||||
}
|
||||
|
||||
// Generate fills the blank parts of an overlay from a baked world.
|
||||
func (l *Legend) Generate(in GenInputs) (*Raster, GenReport, error) {
|
||||
var rep GenReport
|
||||
if in.W <= 0 || in.H <= 0 {
|
||||
return nil, rep, fmt.Errorf("overlay generation needs a size, got %dx%d", in.W, in.H)
|
||||
}
|
||||
if len(in.HeightM) != in.W*in.H || len(in.Sea) != in.W*in.H {
|
||||
return nil, rep, fmt.Errorf("overlay generation: height and sea must be %d cells", in.W*in.H)
|
||||
}
|
||||
for i := range l.Marks {
|
||||
if g := l.Marks[i].Generate; g != nil {
|
||||
if err := g.validate(l.Marks[i].Name); err != nil {
|
||||
return nil, rep, err
|
||||
}
|
||||
if err := g.resolveClasses(l.Marks[i].Name, in.ClassNames); err != nil {
|
||||
return nil, rep, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if in.ClassAt != nil && len(in.ClassAt) != in.W*in.H {
|
||||
return nil, rep, fmt.Errorf("overlay generation: the class raster is %d cells and the grid is %d",
|
||||
len(in.ClassAt), in.W*in.H)
|
||||
}
|
||||
|
||||
out := &Raster{W: in.W, H: in.H, Mark: make([]uint8, in.W*in.H)}
|
||||
// What was on the sheet before this run, kept separately from what is on it now. The distinction is the
|
||||
// whole layering rule: a hand-painted pixel is never touched, while a mark this run has just put down
|
||||
// may be built over by a later one - a road through generated woodland is a road, and a town on it is a
|
||||
// town. Without the two being different, whichever kind painted first would block every kind after it,
|
||||
// which is exactly what happened on the first run: a coastal band claimed a fifth of the world and the
|
||||
// settlements and roads placed inside it painted nothing at all.
|
||||
protectedPx := make([]bool, in.W*in.H)
|
||||
if in.Existing != nil {
|
||||
if in.Existing.W != in.W || in.Existing.H != in.H {
|
||||
return nil, rep, fmt.Errorf("the overlay on disk is %dx%d and the generator is working at %dx%d",
|
||||
in.Existing.W, in.Existing.H, in.W, in.H)
|
||||
}
|
||||
copy(out.Mark, in.Existing.Mark)
|
||||
for i, m := range out.Mark {
|
||||
if m != Blank {
|
||||
protectedPx[i] = true
|
||||
rep.Kept++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
d := newGenData(in)
|
||||
d.protected = protectedPx
|
||||
rep.TreelineM = d.treelineM
|
||||
|
||||
// Painting order is coarse to fine: the coastal band, then woodland, then the roads across it, then the
|
||||
// settlements the roads run between.
|
||||
//
|
||||
// Two area marks never overwrite each other - the first in legend order claims the overlap, because
|
||||
// deciding that a forest beats a coastline or the reverse is an authoring judgement and not one a
|
||||
// generator should make silently. Roads and settlements do overwrite generated areas, because they are
|
||||
// the thing being placed and the area is the ground it stands on.
|
||||
order := []string{GenCoast, GenForest, GenRoad, GenSettlement}
|
||||
byKind := map[string][]int{}
|
||||
for i := range l.Marks {
|
||||
if g := l.Marks[i].Generate; g != nil {
|
||||
byKind[g.Kind] = append(byKind[g.Kind], i)
|
||||
}
|
||||
}
|
||||
|
||||
// Settlements are placed before the roads are drawn even though they are painted after, because the
|
||||
// roads are the paths between them and cannot be traced until they exist.
|
||||
if len(byKind[GenSettlement]) > 0 {
|
||||
rep.Settlement = l.placeSettlements(byKind[GenSettlement], d)
|
||||
}
|
||||
|
||||
for _, kind := range order {
|
||||
for _, mi := range byKind[kind] {
|
||||
m := &l.Marks[mi]
|
||||
idx := uint8(mi + 1)
|
||||
var cells, pieces int
|
||||
switch kind {
|
||||
case GenCoast:
|
||||
cells = l.paintCoastBand(m, d, out, idx)
|
||||
case GenForest:
|
||||
cells = l.paintForest(m, d, out, idx)
|
||||
case GenRoad:
|
||||
cells, pieces = l.paintRoads(m, d, out, idx, rep.Settlement)
|
||||
case GenSettlement:
|
||||
cells, pieces = paintSettlements(d, out, idx, rep.Settlement, l.MinArea(m))
|
||||
}
|
||||
rep.Marks = append(rep.Marks, GenMarkReport{
|
||||
Name: m.Name, Kind: kind, Cells: cells, Pieces: pieces, Wanted: wantedFor(m),
|
||||
})
|
||||
rep.Painted += cells
|
||||
}
|
||||
}
|
||||
return out, rep, nil
|
||||
}
|
||||
|
||||
// genData is everything derived once and shared by the kinds: slope, distance to the sea, the treeline and
|
||||
// the landmass labels.
|
||||
type genData struct {
|
||||
in GenInputs
|
||||
|
||||
// protected marks the pixels that were already painted when this run started. Nothing here may write to
|
||||
// one, whatever kind it is.
|
||||
protected []bool
|
||||
|
||||
slopeDeg []float32
|
||||
coastKm []float32 // distance to the nearest sea cell, kilometres; land only
|
||||
landID []int32 // connected landmass, -1 at sea
|
||||
treelineM float64
|
||||
landMaxM float64
|
||||
flowLog []float32 // log10 of drainage area, normalised 0..1 over the land
|
||||
}
|
||||
|
||||
func newGenData(in GenInputs) *genData {
|
||||
d := &genData{in: in}
|
||||
d.slopeDeg = slopeField(in.HeightM, in.W, in.H, in.CellM)
|
||||
d.coastKm = coastDistanceKm(in.Sea, in.W, in.H, in.CellM)
|
||||
d.landID = labelLandmasses(in.Sea, in.W, in.H)
|
||||
|
||||
// The treeline is a quantile of the land's own heights rather than a number in metres, because a metre
|
||||
// means nothing until the world is baked: the same legend over a 47 m plain and a 2800 m range has to
|
||||
// put trees on both. Two thirds of the way up leaves the summits bare on a world that has summits and
|
||||
// takes almost nothing off a world that does not - which is correct, a lowland has no treeline.
|
||||
var hs []float32
|
||||
for i, s := range in.Sea {
|
||||
if !s {
|
||||
hs = append(hs, in.HeightM[i])
|
||||
}
|
||||
}
|
||||
if len(hs) > 0 {
|
||||
sort.Slice(hs, func(a, b int) bool { return hs[a] < hs[b] })
|
||||
d.landMaxM = float64(hs[len(hs)-1])
|
||||
d.treelineM = float64(hs[int(float64(len(hs)-1)*0.94)])
|
||||
}
|
||||
|
||||
if in.FlowM2 != nil && len(in.FlowM2) == in.W*in.H {
|
||||
d.flowLog = make([]float32, in.W*in.H)
|
||||
cell := in.CellM * in.CellM
|
||||
// Normalised against a trunk river's catchment rather than the map's largest, so one enormous basin
|
||||
// cannot flatten every other river to nothing.
|
||||
hi := math.Log10(math.Max(cell*4, 5e7))
|
||||
lo := math.Log10(math.Max(cell, 1))
|
||||
for i, f := range in.FlowM2 {
|
||||
if in.Sea[i] || f <= 0 {
|
||||
continue
|
||||
}
|
||||
t := (math.Log10(float64(f)) - lo) / (hi - lo)
|
||||
d.flowLog[i] = float32(math.Max(0, math.Min(1, t)))
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// slopeField is the surface gradient in degrees, central differences, X wrapped because the world is a
|
||||
// cylinder and Y clamped because it is not a sphere.
|
||||
func slopeField(h []float32, w, hgt int, cellM float64) []float32 {
|
||||
out := make([]float32, w*hgt)
|
||||
field.Rows(hgt, func(y0, y1 int) {
|
||||
for y := y0; y < y1; y++ {
|
||||
ym := y - 1
|
||||
if ym < 0 {
|
||||
ym = 0
|
||||
}
|
||||
yp := y + 1
|
||||
if yp >= hgt {
|
||||
yp = hgt - 1
|
||||
}
|
||||
for x := 0; x < w; x++ {
|
||||
xm := (x - 1 + w) % w
|
||||
xp := (x + 1) % w
|
||||
dzdx := float64(h[y*w+xp]-h[y*w+xm]) / (2 * cellM)
|
||||
dzdy := float64(h[yp*w+x]-h[ym*w+x]) / (2 * cellM)
|
||||
out[y*w+x] = float32(math.Atan(math.Hypot(dzdx, dzdy)) * 180 / math.Pi)
|
||||
}
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// coastDistanceKm is how far each land cell is from the sea, by a multi-source breadth-first walk over the
|
||||
// eight neighbours with X wrapped. Hop distance rather than Euclidean: it is a score input, and a BFS over
|
||||
// 29 million cells costs one pass where a distance transform costs several.
|
||||
func coastDistanceKm(sea []bool, w, h int, cellM float64) []float32 {
|
||||
out := make([]float32, w*h)
|
||||
for i := range out {
|
||||
out[i] = -1
|
||||
}
|
||||
queue := make([]int32, 0, w*8)
|
||||
for i, s := range sea {
|
||||
if s {
|
||||
continue
|
||||
}
|
||||
x, y := i%w, i/w
|
||||
if touchesSea(sea, w, h, x, y) {
|
||||
out[i] = 0
|
||||
queue = append(queue, int32(i))
|
||||
}
|
||||
}
|
||||
hop := float32(cellM / 1000)
|
||||
for head := 0; head < len(queue); head++ {
|
||||
c := int(queue[head])
|
||||
cx, cy := c%w, c/w
|
||||
d := out[c] + hop
|
||||
for _, o := range neighbours8 {
|
||||
nx := (cx + o[0] + w) % w
|
||||
ny := cy + o[1]
|
||||
if ny < 0 || ny >= h {
|
||||
continue
|
||||
}
|
||||
n := ny*w + nx
|
||||
if sea[n] || out[n] >= 0 {
|
||||
continue
|
||||
}
|
||||
out[n] = d
|
||||
queue = append(queue, int32(n))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var neighbours8 = [8][2]int{{-1, -1}, {0, -1}, {1, -1}, {-1, 0}, {1, 0}, {-1, 1}, {0, 1}, {1, 1}}
|
||||
|
||||
func touchesSea(sea []bool, w, h, x, y int) bool {
|
||||
for _, o := range neighbours8 {
|
||||
nx := (x + o[0] + w) % w
|
||||
ny := y + o[1]
|
||||
if ny < 0 || ny >= h {
|
||||
continue
|
||||
}
|
||||
if sea[ny*w+nx] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// labelLandmasses numbers the connected land components, X wrapped, so a landmass across the seam is one
|
||||
// landmass. Roads are built per component, which is what stops them crossing open water.
|
||||
func labelLandmasses(sea []bool, w, h int) []int32 {
|
||||
out := make([]int32, w*h)
|
||||
for i := range out {
|
||||
out[i] = -1
|
||||
}
|
||||
var stack []int32
|
||||
next := int32(0)
|
||||
for start := range sea {
|
||||
if sea[start] || out[start] >= 0 {
|
||||
continue
|
||||
}
|
||||
id := next
|
||||
next++
|
||||
out[start] = id
|
||||
stack = append(stack[:0], int32(start))
|
||||
for len(stack) > 0 {
|
||||
c := int(stack[len(stack)-1])
|
||||
stack = stack[:len(stack)-1]
|
||||
cx, cy := c%w, c/w
|
||||
for _, o := range neighbours8 {
|
||||
nx := (cx + o[0] + w) % w
|
||||
ny := cy + o[1]
|
||||
if ny < 0 || ny >= h {
|
||||
continue
|
||||
}
|
||||
n := ny*w + nx
|
||||
if sea[n] || out[n] >= 0 {
|
||||
continue
|
||||
}
|
||||
out[n] = id
|
||||
stack = append(stack, int32(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// eligible is the shared test every kind starts from: on land, not too steep, inside the height band.
|
||||
func (d *genData) eligible(i int, g *GenSpec, maxDefault float64) bool {
|
||||
if d.in.Sea[i] {
|
||||
return false
|
||||
}
|
||||
maxSlope := g.MaxSlopeDeg
|
||||
if maxSlope <= 0 {
|
||||
maxSlope = maxDefault
|
||||
}
|
||||
if float64(d.slopeDeg[i]) > maxSlope {
|
||||
return false
|
||||
}
|
||||
if !d.classAllows(i, g) {
|
||||
return false
|
||||
}
|
||||
hm := float64(d.in.HeightM[i])
|
||||
if hm < g.MinHeightM {
|
||||
return false
|
||||
}
|
||||
top := g.MaxHeightM
|
||||
if top <= 0 {
|
||||
top = d.treelineM
|
||||
}
|
||||
return top <= 0 || hm <= top
|
||||
}
|
||||
|
||||
// classAllows applies a mark's only_classes and not_classes to one cell.
|
||||
func (d *genData) classAllows(i int, g *GenSpec) bool {
|
||||
if d.in.ClassAt == nil || (g.onlyIdx == nil && g.notIdx == nil) {
|
||||
return true
|
||||
}
|
||||
c := int(d.in.ClassAt[i])
|
||||
if g.notIdx != nil && g.notIdx[c] {
|
||||
return false
|
||||
}
|
||||
if g.onlyIdx != nil && !g.onlyIdx[c] {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// paintForest fills eligible ground where a noise field stands above a quantile, so woodland has an outline
|
||||
// rather than a contour edge. The field is indexed by world position (cross-cutting rule 1), so the same
|
||||
// ground gets the same trees whatever else changes.
|
||||
func (l *Legend) paintForest(m *Mark, d *genData, out *Raster, idx uint8) int {
|
||||
g := m.Generate
|
||||
cover := g.Cover
|
||||
if cover <= 0 {
|
||||
cover = 0.45
|
||||
}
|
||||
wavelengthKm := g.WavelengthKm
|
||||
if wavelengthKm <= 0 {
|
||||
wavelengthKm = 6
|
||||
}
|
||||
in := d.in
|
||||
circM := float64(in.W) * in.CellM
|
||||
u, v := noise.WorldUV(in.W, in.H, in.CellM, 0, 0, math.Max(circM, 1))
|
||||
cells := math.Max(1, math.Round(circM/(wavelengthKm*1000)))
|
||||
f := noise.FBMAt(u, v, noise.NewSource(in.Seed, srcOverlayForest),
|
||||
noise.Params{BaseCells: int(cells), Octaves: 4, Gain: 0.5})
|
||||
|
||||
// The threshold is a quantile of the noise *over the eligible ground*, so `cover` means what it says on a
|
||||
// world whose eligible ground is a thin strip as much as on one where it is everything.
|
||||
// The quantile is taken over the ground this mark can actually take - eligible and not already claimed -
|
||||
// so `cover` means the same fraction whether or not another area mark got there first.
|
||||
var vals []float32
|
||||
for i := range out.Mark {
|
||||
if out.Mark[i] == Blank && d.eligible(i, g, 25) {
|
||||
vals = append(vals, f.Data[i])
|
||||
}
|
||||
}
|
||||
if len(vals) == 0 {
|
||||
return 0
|
||||
}
|
||||
sort.Slice(vals, func(a, b int) bool { return vals[a] < vals[b] })
|
||||
cut := vals[int(float64(len(vals)-1)*(1-cover))]
|
||||
|
||||
n := 0
|
||||
for i := range out.Mark {
|
||||
if out.Mark[i] != Blank || f.Data[i] < cut || !d.eligible(i, g, 25) {
|
||||
continue
|
||||
}
|
||||
out.Mark[i] = idx
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// paintCoastBand marks a strip inland of the waterline. Its usual purpose is to carry `coast_jitter`, so it
|
||||
// deliberately follows the shore rather than any other feature.
|
||||
func (l *Legend) paintCoastBand(m *Mark, d *genData, out *Raster, idx uint8) int {
|
||||
g := m.Generate
|
||||
reachKm := g.CoastKm
|
||||
if reachKm <= 0 {
|
||||
if g.WidthM > 0 {
|
||||
reachKm = g.WidthM / 1000
|
||||
} else {
|
||||
reachKm = 1.5
|
||||
}
|
||||
}
|
||||
n := 0
|
||||
for i := range out.Mark {
|
||||
if out.Mark[i] != Blank || d.in.Sea[i] {
|
||||
continue
|
||||
}
|
||||
if c := d.coastKm[i]; c >= 0 && float64(c) <= reachKm {
|
||||
out.Mark[i] = idx
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// placeSettlements scores the land and takes the best sites, largest tier first, with one spacing rule
|
||||
// shared by every settlement mark so a village never lands inside a city.
|
||||
//
|
||||
// The score is the part of "where would a town be" that terrain can answer: fresh water, flat ground, and
|
||||
// the sea. Everything else about a settlement - trade, history, who won a war - is the author's, which is
|
||||
// why these are proposals in an editable sheet rather than a placement the bake bakes in.
|
||||
func (l *Legend) placeSettlements(marks []int, d *genData) []Placed {
|
||||
in := d.in
|
||||
spacingKm := 0.0
|
||||
for _, mi := range marks {
|
||||
if s := l.Marks[mi].Generate.MinSpacingKm; s > spacingKm {
|
||||
spacingKm = s
|
||||
}
|
||||
}
|
||||
if spacingKm <= 0 {
|
||||
spacingKm = 4
|
||||
}
|
||||
spacingPx := math.Max(2, spacingKm*1000/in.CellM)
|
||||
|
||||
// Tiers in the order the legend lists them, which is how an author already writes them: city, town,
|
||||
// village. The first listed takes the best sites.
|
||||
type tier struct {
|
||||
mi int
|
||||
g *GenSpec
|
||||
radPx int
|
||||
}
|
||||
var tiers []tier
|
||||
for _, mi := range marks {
|
||||
g := l.Marks[mi].Generate
|
||||
radM := g.RadiusM
|
||||
if radM <= 0 {
|
||||
// Big enough that the feature reducer will not drop it as a speck. The margin is generous on
|
||||
// purpose: a disc loses area wherever it meets ground that is already painted, and a settlement
|
||||
// that came out just under its own min_area_px would be placed, reported, and then silently
|
||||
// dropped by the feature pass - which is what happened to a city on the first real run. 1.6
|
||||
// linear is 2.6x the area, so it survives losing more than half of itself.
|
||||
minArea := float64(l.MinArea(&l.Marks[mi]))
|
||||
radM = math.Sqrt(minArea/math.Pi) * in.CellM * 1.6
|
||||
}
|
||||
radPx := int(math.Max(1, math.Round(radM/in.CellM)))
|
||||
tiers = append(tiers, tier{mi: mi, g: g, radPx: radPx})
|
||||
}
|
||||
|
||||
// Candidates are taken on a stride rather than from every cell: two sites a quarter of the spacing apart
|
||||
// are the same site, and sorting 29 million scores to throw away all but fifty is work for nothing.
|
||||
stride := int(math.Max(1, math.Floor(spacingPx/4)))
|
||||
type cand struct {
|
||||
i int
|
||||
score float64
|
||||
}
|
||||
var cands []cand
|
||||
for y := 0; y < in.H; y += stride {
|
||||
for x := 0; x < in.W; x += stride {
|
||||
i := y*in.W + x
|
||||
s := d.settlementScore(i)
|
||||
if s > 0 {
|
||||
// The seed picks among the plausible sites; the terrain decides which sites are plausible at
|
||||
// all. Without this the score is a pure function of the ground, so every press of the
|
||||
// studio's generate button proposes exactly the same towns and a re-roll re-rolls nothing.
|
||||
// A third either way reshuffles the ranking among comparable ground while still leaving a
|
||||
// river mouth on a plain beating a hillside.
|
||||
s *= 1 + settlementJitter*(hash01(uint64(i), uint64(in.Seed))-0.5)
|
||||
cands = append(cands, cand{i: i, score: s})
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sorted by score, ties broken by index so the result does not depend on the sort's stability.
|
||||
sort.Slice(cands, func(a, b int) bool {
|
||||
if cands[a].score != cands[b].score {
|
||||
return cands[a].score > cands[b].score
|
||||
}
|
||||
return cands[a].i < cands[b].i
|
||||
})
|
||||
|
||||
var placed []Placed
|
||||
taken := make([][2]int, 0, 64)
|
||||
sp2 := spacingPx * spacingPx
|
||||
farEnough := func(x, y int) bool {
|
||||
for _, t := range taken {
|
||||
dx := float64(wrapDelta(x-t[0], in.W))
|
||||
dy := float64(y - t[1])
|
||||
if dx*dx+dy*dy < sp2 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
for _, t := range tiers {
|
||||
want := t.g.Count
|
||||
if want <= 0 {
|
||||
continue
|
||||
}
|
||||
got := 0
|
||||
maxSlope := t.g.MaxSlopeDeg
|
||||
if maxSlope <= 0 {
|
||||
maxSlope = 8
|
||||
}
|
||||
for _, c := range cands {
|
||||
if got >= want {
|
||||
break
|
||||
}
|
||||
if float64(d.slopeDeg[c.i]) > maxSlope {
|
||||
continue
|
||||
}
|
||||
x, y := c.i%in.W, c.i/in.W
|
||||
if !farEnough(x, y) {
|
||||
continue
|
||||
}
|
||||
taken = append(taken, [2]int{x, y})
|
||||
placed = append(placed, Placed{
|
||||
Mark: t.mi + 1, X: x, Y: y, Score: c.score, RadPx: t.radPx,
|
||||
Region: int(d.landID[c.i]),
|
||||
})
|
||||
got++
|
||||
}
|
||||
}
|
||||
return placed
|
||||
}
|
||||
|
||||
// settlementJitter is how far the seed may move a site's score, as a fraction. Large enough that the
|
||||
// ranking among comparable ground genuinely reshuffles between presses, small enough that a site three times
|
||||
// better than its neighbour still wins every time.
|
||||
const settlementJitter = 0.65
|
||||
|
||||
// hash01 is a deterministic value in [0,1) from two integers: splitmix64 finalised. Not a stream, so it does
|
||||
// not matter which order the cells are visited in, which is cross-cutting rule 12.
|
||||
func hash01(a, b uint64) float64 {
|
||||
x := a*0x9e3779b97f4a7c15 + b*0xbf58476d1ce4e5b9
|
||||
x ^= x >> 30
|
||||
x *= 0xbf58476d1ce4e5b9
|
||||
x ^= x >> 27
|
||||
x *= 0x94d049bb133111eb
|
||||
x ^= x >> 31
|
||||
return float64(x>>11) / float64(1<<53)
|
||||
}
|
||||
|
||||
// settlementScore is 0 where nobody would build and rises with the three things the terrain knows.
|
||||
//
|
||||
// Ground that is already painted scores zero, which is not a judgement about the ground: a site there cannot
|
||||
// be stamped, because nothing may overwrite a hand-painted pixel. Scoring it anyway is how a settlement gets
|
||||
// placed, counted and reported and then paints nothing at all - measured on the shipped template, one city of
|
||||
// three and six villages of eighteen came out as empty blobs that the feature pass then dropped, so the run
|
||||
// summary and `terrain plan` disagreed with each other and neither was wrong.
|
||||
func (d *genData) settlementScore(i int) float64 {
|
||||
if d.in.Sea[i] || d.protected[i] {
|
||||
return 0
|
||||
}
|
||||
slope := float64(d.slopeDeg[i])
|
||||
if slope > 12 {
|
||||
return 0
|
||||
}
|
||||
flat := 1 - slope/12
|
||||
|
||||
river := 0.0
|
||||
if d.flowLog != nil {
|
||||
river = float64(d.flowLog[i])
|
||||
}
|
||||
|
||||
// A coast bonus that falls off over a few kilometres: a harbour is worth a great deal, being forty
|
||||
// kilometres inland is worth nothing either way.
|
||||
coast := 0.0
|
||||
if c := d.coastKm[i]; c >= 0 {
|
||||
coast = math.Max(0, 1-float64(c)/5)
|
||||
}
|
||||
|
||||
// Flat ground is a precondition rather than an attraction, so it multiplies; water and the sea are the
|
||||
// reasons to be here, so they add.
|
||||
return flat * (0.15 + 1.5*river + 1.0*coast)
|
||||
}
|
||||
|
||||
// paintSettlements stamps each placed site, growing the disc until the blob is big enough to survive the
|
||||
// feature pass.
|
||||
//
|
||||
// The growth loop is not a flourish. A disc loses whatever part of itself falls on a coastline somebody has
|
||||
// already painted, or on the sea, and settlements are scored *towards* the coast, so the loss is routine
|
||||
// rather than rare. Without it the generator places a town, reports it, writes it, and the feature reducer
|
||||
// then drops it as a speck - so `terrain plan` lists fewer settlements than the run said it made, with
|
||||
// nothing anywhere to explain the difference. Measured on the shipped template: three cities placed and two
|
||||
// reported, eighteen villages placed and twelve reported.
|
||||
//
|
||||
// It gives up after a few tries rather than growing without limit: a site hemmed in on every side is telling
|
||||
// you it is a bad site, and a village the size of a county is worse than a missing one.
|
||||
func paintSettlements(d *genData, out *Raster, idx uint8, placed []Placed, minArea int) (int, int) {
|
||||
n, pieces := 0, 0
|
||||
for _, p := range placed {
|
||||
if uint8(p.Mark) != idx {
|
||||
continue
|
||||
}
|
||||
pieces++
|
||||
got, r := 0, p.RadPx
|
||||
for try := 0; try < 4; try++ {
|
||||
// Re-stamping a larger disc only adds the new ring, because the cells already taken carry this
|
||||
// mark, so the area accumulates rather than being recounted.
|
||||
got += stampDisc(out, d, p.X, p.Y, r, idx, true)
|
||||
if got >= minArea {
|
||||
break
|
||||
}
|
||||
r = int(math.Ceil(float64(r) * 1.5))
|
||||
}
|
||||
n += got
|
||||
}
|
||||
return n, pieces
|
||||
}
|
||||
|
||||
// stampDisc paints a filled circle, wrapping in X.
|
||||
//
|
||||
// overArea says whether this mark may cover ground another generated mark has already taken. A hand-painted
|
||||
// pixel is never covered either way, which is what keeps a drawn stroke intact underneath a generated town.
|
||||
func stampDisc(out *Raster, d *genData, cx, cy, r int, idx uint8, overArea bool) int {
|
||||
n := 0
|
||||
r2 := r * r
|
||||
for dy := -r; dy <= r; dy++ {
|
||||
y := cy + dy
|
||||
if y < 0 || y >= out.H {
|
||||
continue
|
||||
}
|
||||
for dx := -r; dx <= r; dx++ {
|
||||
if dx*dx+dy*dy > r2 {
|
||||
continue
|
||||
}
|
||||
x := ((cx+dx)%out.W + out.W) % out.W
|
||||
i := y*out.W + x
|
||||
if d.in.Sea[i] || d.protected[i] || (!overArea && out.Mark[i] != Blank) {
|
||||
continue
|
||||
}
|
||||
out.Mark[i] = idx
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func wrapDelta(d, w int) int {
|
||||
if d > w/2 {
|
||||
d -= w
|
||||
} else if d < -w/2 {
|
||||
d += w
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// srcOverlayForest is this pass's seeded noise stream. It sits above the detail passes' 40s and the
|
||||
// tectonic 50s so that adding one here cannot reshuffle any existing field.
|
||||
const srcOverlayForest = 60
|
||||
@@ -0,0 +1,335 @@
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Roads: the least-cost paths between the settlements that were just placed.
|
||||
//
|
||||
// A road is the one mark whose shape is not a judgement at all. Given where two towns are, the line between
|
||||
// them is whatever the ground allows - up the valley, round the spur, across the saddle - and that is a
|
||||
// shortest-path problem with a cost function, not a drawing. It is also the single most tedious thing to
|
||||
// paint by hand, because getting it right means reading a heightmap pixel by pixel.
|
||||
//
|
||||
// Three decisions worth stating:
|
||||
//
|
||||
// - **Water is impassable, so roads never swim.** Each landmass gets its own network. A bridge or a ferry
|
||||
// is a deliberate act and belongs to the author, and a generator that guessed at them would put a
|
||||
// motorway across a strait it has no idea is thirty kilometres wide.
|
||||
// - **A minimum spanning tree, not every pair.** Joining all pairs gives a cobweb; the tree gives exactly
|
||||
// enough road to reach everywhere, which is both what a road network minimally is and the thing an
|
||||
// author can most easily add to. Edges are weighted by path *cost*, not by straight-line distance, so
|
||||
// two towns either side of a range are correctly further apart than the map says.
|
||||
// - **It runs on a coarsened grid.** A road at the overlay's full resolution would be a Dijkstra over
|
||||
// twenty-nine million cells per settlement. The cost surface is smooth at the scale a road cares about,
|
||||
// so it is pooled to a few hundred cells across, solved there, and the resulting polyline is stamped
|
||||
// back at full resolution with the mark's real width.
|
||||
|
||||
// roadGrid is the coarsened cost surface the paths are solved on.
|
||||
type roadGrid struct {
|
||||
w, h int
|
||||
step int // overlay pixels per coarse cell
|
||||
cost []float32 // per coarse cell, +Inf where impassable
|
||||
scale float64 // overlay pixels per coarse cell, as a float
|
||||
}
|
||||
|
||||
func buildRoadGrid(d *genData, maxSlopeDeg float64) *roadGrid {
|
||||
in := d.in
|
||||
// About six hundred cells around the world: fine enough that a coarse cell is well under a kilometre on
|
||||
// any world this tool makes, coarse enough that fifty Dijkstras are a second's work.
|
||||
step := int(math.Max(1, math.Round(float64(in.W)/600)))
|
||||
gw := (in.W + step - 1) / step
|
||||
gh := (in.H + step - 1) / step
|
||||
g := &roadGrid{w: gw, h: gh, step: step, scale: float64(step), cost: make([]float32, gw*gh)}
|
||||
|
||||
inf := float32(math.Inf(1))
|
||||
for gy := 0; gy < gh; gy++ {
|
||||
for gx := 0; gx < gw; gx++ {
|
||||
// Pool the block: any sea in it makes the cell water, because a road that clips a bay is a road
|
||||
// in the sea. The slope taken is the worst in the block, for the same reason.
|
||||
var worst float64
|
||||
wet := false
|
||||
for y := gy * step; y < (gy+1)*step && y < in.H; y++ {
|
||||
for x := gx * step; x < (gx+1)*step && x < in.W; x++ {
|
||||
i := y*in.W + x
|
||||
if in.Sea[i] {
|
||||
wet = true
|
||||
break
|
||||
}
|
||||
if s := float64(d.slopeDeg[i]); s > worst {
|
||||
worst = s
|
||||
}
|
||||
}
|
||||
if wet {
|
||||
break
|
||||
}
|
||||
}
|
||||
gi := gy*gw + gx
|
||||
switch {
|
||||
case wet:
|
||||
g.cost[gi] = inf
|
||||
case worst > maxSlopeDeg:
|
||||
g.cost[gi] = inf
|
||||
default:
|
||||
// Slope is what a road pays for. Quadratic rather than linear so that a route prefers a long
|
||||
// gentle way round to a short steep one, which is what a real road does.
|
||||
t := worst / math.Max(maxSlopeDeg, 1e-6)
|
||||
g.cost[gi] = float32(1 + 12*t*t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func (g *roadGrid) idx(x, y int) int { return y*g.w + x }
|
||||
|
||||
// dijkstra returns the cost to every reachable coarse cell from a source, and the predecessor chain to walk
|
||||
// a path back. A binary heap over a few hundred thousand cells; the graph is eight-connected and X wraps.
|
||||
func (g *roadGrid) dijkstra(src int) (cost []float32, pred []int32) {
|
||||
n := g.w * g.h
|
||||
cost = make([]float32, n)
|
||||
pred = make([]int32, n)
|
||||
inf := float32(math.Inf(1))
|
||||
for i := range cost {
|
||||
cost[i] = inf
|
||||
pred[i] = -1
|
||||
}
|
||||
if math.IsInf(float64(g.cost[src]), 1) {
|
||||
return cost, pred
|
||||
}
|
||||
cost[src] = 0
|
||||
h := &costHeap{keys: []float32{0}, items: []int32{int32(src)}}
|
||||
for h.Len() > 0 {
|
||||
c := int(h.pop())
|
||||
cx, cy := c%g.w, c/g.w
|
||||
base := cost[c]
|
||||
for _, o := range neighbours8 {
|
||||
nx := (cx + o[0] + g.w) % g.w
|
||||
ny := cy + o[1]
|
||||
if ny < 0 || ny >= g.h {
|
||||
continue
|
||||
}
|
||||
n := g.idx(nx, ny)
|
||||
cc := g.cost[n]
|
||||
if math.IsInf(float64(cc), 1) {
|
||||
continue
|
||||
}
|
||||
// Diagonal steps cost their real length, or the network shows a bias along the axes.
|
||||
step := float32(1.0)
|
||||
if o[0] != 0 && o[1] != 0 {
|
||||
step = float32(math.Sqrt2)
|
||||
}
|
||||
next := base + cc*step
|
||||
if next < cost[n] {
|
||||
cost[n] = next
|
||||
pred[n] = int32(c)
|
||||
h.push(int32(n), next)
|
||||
}
|
||||
}
|
||||
}
|
||||
return cost, pred
|
||||
}
|
||||
|
||||
// costHeap is a binary min-heap of coarse cells. Lazy deletion is not needed because a cell is only pushed
|
||||
// when its cost strictly improves, and a stale entry pops with a cost no better than the settled one.
|
||||
type costHeap struct {
|
||||
keys []float32
|
||||
items []int32
|
||||
}
|
||||
|
||||
func (h *costHeap) Len() int { return len(h.items) }
|
||||
|
||||
func (h *costHeap) push(item int32, key float32) {
|
||||
h.keys = append(h.keys, key)
|
||||
h.items = append(h.items, item)
|
||||
i := len(h.items) - 1
|
||||
for i > 0 {
|
||||
p := (i - 1) / 2
|
||||
if h.keys[p] <= h.keys[i] {
|
||||
break
|
||||
}
|
||||
h.keys[p], h.keys[i] = h.keys[i], h.keys[p]
|
||||
h.items[p], h.items[i] = h.items[i], h.items[p]
|
||||
i = p
|
||||
}
|
||||
}
|
||||
|
||||
func (h *costHeap) pop() int32 {
|
||||
top := h.items[0]
|
||||
last := len(h.items) - 1
|
||||
h.keys[0], h.items[0] = h.keys[last], h.items[last]
|
||||
h.keys = h.keys[:last]
|
||||
h.items = h.items[:last]
|
||||
i := 0
|
||||
for {
|
||||
l := 2*i + 1
|
||||
if l >= last {
|
||||
break
|
||||
}
|
||||
if r := l + 1; r < last && h.keys[r] < h.keys[l] {
|
||||
l = r
|
||||
}
|
||||
if h.keys[l] >= h.keys[i] {
|
||||
break
|
||||
}
|
||||
h.keys[l], h.keys[i] = h.keys[i], h.keys[l]
|
||||
h.items[l], h.items[i] = h.items[i], h.items[l]
|
||||
i = l
|
||||
}
|
||||
return top
|
||||
}
|
||||
|
||||
// paintRoads traces a spanning tree over the settlements of each landmass and stamps it.
|
||||
func (l *Legend) paintRoads(m *Mark, d *genData, out *Raster, idx uint8, placed []Placed) (int, int) {
|
||||
if len(placed) < 2 {
|
||||
return 0, 0
|
||||
}
|
||||
g := m.Generate
|
||||
maxSlope := g.MaxSlopeDeg
|
||||
if maxSlope <= 0 {
|
||||
maxSlope = 22
|
||||
}
|
||||
widthM := g.WidthM
|
||||
if widthM <= 0 {
|
||||
widthM = m.WidthM
|
||||
}
|
||||
if widthM <= 0 {
|
||||
widthM = 8
|
||||
}
|
||||
// A road eight metres wide is less than one overlay pixel at 12.9 m, and a mark thinner than a pixel is
|
||||
// not a mark. It is painted at least one pixel wide and the true width travels in the legend, which is
|
||||
// exactly how `width_m` is meant to be read.
|
||||
halfPx := int(math.Max(0, math.Round(widthM/d.in.CellM/2)))
|
||||
|
||||
rg := buildRoadGrid(d, maxSlope)
|
||||
|
||||
// Settlements grouped by landmass: a spanning tree per island, never between islands.
|
||||
byRegion := map[int][]int{}
|
||||
for i, p := range placed {
|
||||
if p.Region < 0 {
|
||||
continue
|
||||
}
|
||||
byRegion[p.Region] = append(byRegion[p.Region], i)
|
||||
}
|
||||
regions := make([]int, 0, len(byRegion))
|
||||
for r := range byRegion {
|
||||
regions = append(regions, r)
|
||||
}
|
||||
sort.Ints(regions) // deterministic order, cross-cutting rule 12
|
||||
|
||||
total, pieces := 0, 0
|
||||
for _, r := range regions {
|
||||
members := byRegion[r]
|
||||
if len(members) < 2 {
|
||||
continue
|
||||
}
|
||||
total += l.connectRegion(rg, d, out, idx, placed, members, halfPx, &pieces)
|
||||
}
|
||||
return total, pieces
|
||||
}
|
||||
|
||||
// connectRegion solves the paths among one landmass's settlements and stamps its spanning tree.
|
||||
func (l *Legend) connectRegion(rg *roadGrid, d *genData, out *Raster, idx uint8,
|
||||
placed []Placed, members []int, halfPx int, pieces *int) int {
|
||||
|
||||
n := len(members)
|
||||
src := make([]int, n)
|
||||
for k, pi := range members {
|
||||
p := placed[pi]
|
||||
gx := (p.X / rg.step) % rg.w
|
||||
gy := p.Y / rg.step
|
||||
if gy >= rg.h {
|
||||
gy = rg.h - 1
|
||||
}
|
||||
src[k] = rg.idx(gx, gy)
|
||||
}
|
||||
|
||||
// One Dijkstra per settlement, kept: the coarse grid is a few hundred thousand cells and a landmass has
|
||||
// a handful of towns, so holding the predecessor chains costs a few megabytes and saves solving twice.
|
||||
costs := make([][]float32, n)
|
||||
preds := make([][]int32, n)
|
||||
for k := range members {
|
||||
costs[k], preds[k] = rg.dijkstra(src[k])
|
||||
}
|
||||
|
||||
// Prim's, on path cost. Unreachable pairs are skipped, so a landmass whose towns are separated by ground
|
||||
// too steep for a road comes out as two networks rather than one impossible line.
|
||||
inTree := make([]bool, n)
|
||||
inTree[0] = true
|
||||
painted := 0
|
||||
for added := 1; added < n; added++ {
|
||||
bestA, bestB := -1, -1
|
||||
best := float32(math.Inf(1))
|
||||
for a := 0; a < n; a++ {
|
||||
if !inTree[a] {
|
||||
continue
|
||||
}
|
||||
for b := 0; b < n; b++ {
|
||||
if inTree[b] {
|
||||
continue
|
||||
}
|
||||
if c := costs[a][src[b]]; c < best {
|
||||
best, bestA, bestB = c, a, b
|
||||
}
|
||||
}
|
||||
}
|
||||
if bestA < 0 || math.IsInf(float64(best), 1) {
|
||||
break // nothing else on this landmass is reachable by road
|
||||
}
|
||||
inTree[bestB] = true
|
||||
painted += stampPath(rg, preds[bestA], src[bestA], src[bestB], d, out, idx, halfPx)
|
||||
*pieces++
|
||||
}
|
||||
return painted
|
||||
}
|
||||
|
||||
// stampPath walks the predecessor chain back from dst to src and paints it at full resolution.
|
||||
func stampPath(rg *roadGrid, pred []int32, src, dst int, d *genData, out *Raster, idx uint8, halfPx int) int {
|
||||
var chain []int
|
||||
for c := dst; c >= 0; {
|
||||
chain = append(chain, c)
|
||||
if c == src {
|
||||
break
|
||||
}
|
||||
p := pred[c]
|
||||
if p < 0 {
|
||||
return 0 // no route; leave the ground unpainted rather than drawing a guess
|
||||
}
|
||||
c = int(p)
|
||||
}
|
||||
painted := 0
|
||||
for k := 0; k+1 < len(chain); k++ {
|
||||
ax, ay := coarseCentre(rg, chain[k])
|
||||
bx, by := coarseCentre(rg, chain[k+1])
|
||||
painted += stampSegment(out, d, ax, ay, bx, by, halfPx, idx)
|
||||
}
|
||||
return painted
|
||||
}
|
||||
|
||||
func coarseCentre(rg *roadGrid, c int) (int, int) {
|
||||
gx, gy := c%rg.w, c/rg.w
|
||||
return gx*rg.step + rg.step/2, gy*rg.step + rg.step/2
|
||||
}
|
||||
|
||||
// stampSegment draws one straight run between two coarse-cell centres, wrapping in X the short way so a road
|
||||
// crossing the seam is one road rather than a line back across the whole map.
|
||||
func stampSegment(out *Raster, d *genData, ax, ay, bx, by, halfPx int, idx uint8) int {
|
||||
dx := wrapDelta(bx-ax, out.W)
|
||||
dy := by - ay
|
||||
steps := int(math.Max(math.Abs(float64(dx)), math.Abs(float64(dy))))
|
||||
if steps == 0 {
|
||||
return stampDisc(out, d, ax, ay, halfPx, idx, true)
|
||||
}
|
||||
painted := 0
|
||||
for s := 0; s <= steps; s++ {
|
||||
t := float64(s) / float64(steps)
|
||||
x := ax + int(math.Round(float64(dx)*t))
|
||||
y := ay + int(math.Round(float64(dy)*t))
|
||||
if y < 0 || y >= out.H {
|
||||
continue
|
||||
}
|
||||
painted += stampDisc(out, d, x, y, halfPx, idx, true)
|
||||
}
|
||||
return painted
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A small world with a sea on the left, a flat coastal plain, and a steep ridge on the right, so every
|
||||
// generated kind has somewhere it should go and somewhere it should not.
|
||||
func testWorld(w, h int) GenInputs {
|
||||
height := make([]float32, w*h)
|
||||
sea := make([]bool, w*h)
|
||||
flow := make([]float32, w*h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
switch {
|
||||
case x < w/5:
|
||||
sea[i] = true
|
||||
height[i] = -50
|
||||
case x < 3*w/5:
|
||||
height[i] = float32(x-w/5) * 0.2 // a gentle plain
|
||||
default:
|
||||
height[i] = float32(w/5)*0.2 + float32(x-3*w/5)*12 // a wall
|
||||
}
|
||||
// One river down the middle row of the plain.
|
||||
if y == h/2 && !sea[i] {
|
||||
flow[i] = 5e7
|
||||
}
|
||||
}
|
||||
}
|
||||
return GenInputs{W: w, H: h, CellM: 100, HeightM: height, Sea: sea, FlowM2: flow, Seed: 11}
|
||||
}
|
||||
|
||||
func genLegend(specs map[string]*GenSpec) *Legend {
|
||||
l := &Legend{
|
||||
MatchDistance: DefaultMatchDistance,
|
||||
MinAreaPx: DefaultMinAreaPx,
|
||||
Marks: []Mark{
|
||||
{Name: "forest", RGB: [3]int{0, 128, 0}},
|
||||
{Name: "town", RGB: [3]int{220, 30, 30}},
|
||||
{Name: "road", RGB: [3]int{90, 60, 30}, Kind: KindPath, WidthM: 8},
|
||||
{Name: "wild_coast", RGB: [3]int{255, 128, 0}},
|
||||
{Name: "hand", RGB: [3]int{10, 10, 200}},
|
||||
},
|
||||
}
|
||||
for i := range l.Marks {
|
||||
if g, ok := specs[l.Marks[i].Name]; ok {
|
||||
l.Marks[i].Generate = g
|
||||
}
|
||||
}
|
||||
if err := l.resolve(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// The property the whole feature rests on: generation never touches a pixel somebody painted. Without it,
|
||||
// re-running the generator would quietly destroy an author's work, and the round trip would be unusable.
|
||||
func TestGenerationNeverOverwritesPaintedPixels(t *testing.T) {
|
||||
in := testWorld(200, 120)
|
||||
l := genLegend(map[string]*GenSpec{
|
||||
"forest": {Kind: GenForest, Cover: 0.9},
|
||||
"town": {Kind: GenSettlement, Count: 6, MinSpacingKm: 2},
|
||||
"road": {Kind: GenRoad},
|
||||
"wild_coast": {Kind: GenCoast, CoastKm: 3},
|
||||
})
|
||||
|
||||
// A hand-painted stripe right across the plain, where the generator badly wants to put things.
|
||||
handIdx := uint8(l.Index("hand"))
|
||||
existing := &Raster{W: in.W, H: in.H, Mark: make([]uint8, in.W*in.H)}
|
||||
handAt := map[int]bool{}
|
||||
for y := 0; y < in.H; y++ {
|
||||
for x := in.W / 5; x < in.W/2; x += 3 {
|
||||
i := y*in.W + x
|
||||
existing.Mark[i] = handIdx
|
||||
handAt[i] = true
|
||||
}
|
||||
}
|
||||
in.Existing = existing
|
||||
|
||||
out, rep, err := l.Generate(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range handAt {
|
||||
if out.Mark[i] != handIdx {
|
||||
t.Fatalf("cell %d was painted %d by hand and the generator changed it to %d", i, handIdx, out.Mark[i])
|
||||
}
|
||||
}
|
||||
if rep.Kept != len(handAt) {
|
||||
t.Errorf("kept %d painted pixels, want %d", rep.Kept, len(handAt))
|
||||
}
|
||||
if rep.Painted == 0 {
|
||||
t.Error("the generator filled nothing at all; the test world should have room for every kind")
|
||||
}
|
||||
}
|
||||
|
||||
// A mark with no generate block is only ever painted by hand. This is what makes the feature opt-in and what
|
||||
// keeps every legend written before it producing exactly the blank sheet it always did.
|
||||
func TestMarksWithoutAGenerateBlockAreNeverGenerated(t *testing.T) {
|
||||
in := testWorld(160, 100)
|
||||
l := genLegend(map[string]*GenSpec{"forest": {Kind: GenForest, Cover: 0.8}})
|
||||
|
||||
out, _, err := l.Generate(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forest := uint8(l.Index("forest"))
|
||||
for i, m := range out.Mark {
|
||||
if m != Blank && m != forest {
|
||||
t.Fatalf("cell %d got mark %d, but only %q asked to be generated", i, m, "forest")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing is ever put in the sea. A forest, a town or a road on open water is the one output that is simply
|
||||
// wrong rather than merely a matter of taste.
|
||||
func TestNothingIsGeneratedAtSea(t *testing.T) {
|
||||
in := testWorld(200, 120)
|
||||
l := genLegend(map[string]*GenSpec{
|
||||
"forest": {Kind: GenForest, Cover: 1},
|
||||
"town": {Kind: GenSettlement, Count: 8, MinSpacingKm: 2},
|
||||
"road": {Kind: GenRoad},
|
||||
"wild_coast": {Kind: GenCoast, CoastKm: 4},
|
||||
})
|
||||
out, _, err := l.Generate(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, m := range out.Mark {
|
||||
if m != Blank && in.Sea[i] {
|
||||
t.Fatalf("cell %d is sea and was marked %d", i, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Settlements keep their spacing, across tiers as well as within one. A village inside a city is two marks
|
||||
// for one place.
|
||||
func TestSettlementsKeepTheirSpacing(t *testing.T) {
|
||||
in := testWorld(300, 160)
|
||||
l := &Legend{MatchDistance: DefaultMatchDistance, MinAreaPx: DefaultMinAreaPx, Marks: []Mark{
|
||||
{Name: "city", RGB: [3]int{220, 30, 30}, Generate: &GenSpec{Kind: GenSettlement, Count: 3, MinSpacingKm: 5}},
|
||||
{Name: "village", RGB: [3]int{150, 90, 200}, Generate: &GenSpec{Kind: GenSettlement, Count: 12}},
|
||||
}}
|
||||
if err := l.resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, rep, err := l.Generate(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rep.Settlement) < 2 {
|
||||
t.Fatalf("only %d settlements placed; the test world should hold more", len(rep.Settlement))
|
||||
}
|
||||
minPx := 5 * 1000 / in.CellM
|
||||
for a := range rep.Settlement {
|
||||
for b := a + 1; b < len(rep.Settlement); b++ {
|
||||
p, q := rep.Settlement[a], rep.Settlement[b]
|
||||
dx := float64(wrapDelta(p.X-q.X, in.W))
|
||||
dy := float64(p.Y - q.Y)
|
||||
if d := math.Hypot(dx, dy); d < minPx-1e-9 {
|
||||
t.Fatalf("settlements %d and %d are %.1f px apart, closer than the %.1f px spacing", a, b, d, minPx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Roads connect only what is on the same landmass. Water is impassable, so a two-island world gets no road
|
||||
// between the islands however close they are.
|
||||
func TestRoadsNeverCrossWater(t *testing.T) {
|
||||
w, h := 240, 120
|
||||
height := make([]float32, w*h)
|
||||
sea := make([]bool, w*h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
// Two flat islands with a channel between them.
|
||||
island := (x > 20 && x < 100) || (x > 140 && x < 220)
|
||||
if !island {
|
||||
sea[i] = true
|
||||
height[i] = -30
|
||||
} else {
|
||||
height[i] = 10
|
||||
}
|
||||
}
|
||||
}
|
||||
in := GenInputs{W: w, H: h, CellM: 100, HeightM: height, Sea: sea, Seed: 3}
|
||||
l := &Legend{MatchDistance: DefaultMatchDistance, MinAreaPx: DefaultMinAreaPx, Marks: []Mark{
|
||||
{Name: "town", RGB: [3]int{220, 30, 30}, Generate: &GenSpec{Kind: GenSettlement, Count: 8, MinSpacingKm: 3}},
|
||||
{Name: "road", RGB: [3]int{90, 60, 30}, Kind: KindPath, WidthM: 8, Generate: &GenSpec{Kind: GenRoad}},
|
||||
}}
|
||||
if err := l.resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, rep, err := l.Generate(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Towns landed on both islands, so a network that ignored water would have had a reason to cross.
|
||||
regions := map[int]bool{}
|
||||
for _, p := range rep.Settlement {
|
||||
regions[p.Region] = true
|
||||
}
|
||||
if len(regions) < 2 {
|
||||
t.Fatalf("settlements only landed on %d landmass(es); the test cannot show anything", len(regions))
|
||||
}
|
||||
road := uint8(l.Index("road"))
|
||||
for i, m := range out.Mark {
|
||||
if m == road && sea[i] {
|
||||
t.Fatalf("a road was painted at sea, cell %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The same world and seed generate the same sheet. Determinism is cross-cutting rule 12 and it is what makes
|
||||
// a regenerated overlay reviewable in a diff.
|
||||
func TestGenerationIsDeterministic(t *testing.T) {
|
||||
l := genLegend(map[string]*GenSpec{
|
||||
"forest": {Kind: GenForest, Cover: 0.5},
|
||||
"town": {Kind: GenSettlement, Count: 5, MinSpacingKm: 2},
|
||||
"road": {Kind: GenRoad},
|
||||
"wild_coast": {Kind: GenCoast, CoastKm: 2},
|
||||
})
|
||||
a, repA, err := l.Generate(testWorld(200, 120))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, repB, err := l.Generate(testWorld(200, 120))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range a.Mark {
|
||||
if a.Mark[i] != b.Mark[i] {
|
||||
t.Fatalf("two runs disagree at cell %d: %d against %d", i, a.Mark[i], b.Mark[i])
|
||||
}
|
||||
}
|
||||
if repA.Painted != repB.Painted || len(repA.Settlement) != len(repB.Settlement) {
|
||||
t.Errorf("reports differ: %d/%d painted, %d/%d settlements",
|
||||
repA.Painted, repB.Painted, len(repA.Settlement), len(repB.Settlement))
|
||||
}
|
||||
}
|
||||
|
||||
// A generated sheet has to survive the round trip: encoded to RGBA and classified back, it must be the same
|
||||
// raster. If it did not, what the studio opened would not be what the generator wrote.
|
||||
func TestGeneratedSheetSurvivesClassifyingItBack(t *testing.T) {
|
||||
in := testWorld(200, 120)
|
||||
l := genLegend(map[string]*GenSpec{
|
||||
"forest": {Kind: GenForest, Cover: 0.5},
|
||||
"town": {Kind: GenSettlement, Count: 5, MinSpacingKm: 2},
|
||||
"road": {Kind: GenRoad},
|
||||
"wild_coast": {Kind: GenCoast, CoastKm: 2},
|
||||
})
|
||||
out, _, err := l.Generate(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
px, alpha := l.Encode(out)
|
||||
back, match := l.Classify(px, alpha, out.W, out.H)
|
||||
if match.Far != 0 {
|
||||
t.Errorf("%d pixels of a sheet this legend wrote matched no mark", match.Far)
|
||||
}
|
||||
for i := range out.Mark {
|
||||
if out.Mark[i] != back.Mark[i] {
|
||||
t.Fatalf("round trip changed cell %d from %d to %d", i, out.Mark[i], back.Mark[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRejectsAnUnknownKind(t *testing.T) {
|
||||
l := genLegend(map[string]*GenSpec{"forest": {Kind: "woods"}})
|
||||
if _, _, err := l.Generate(testWorld(80, 40)); err == nil {
|
||||
t.Fatal("a kind the generator does not know should be an error, not a silent no-op")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRejectsAMismatchedExistingSheet(t *testing.T) {
|
||||
in := testWorld(80, 40)
|
||||
in.Existing = &Raster{W: 40, H: 20, Mark: make([]uint8, 800)}
|
||||
l := genLegend(map[string]*GenSpec{"forest": {Kind: GenForest}})
|
||||
if _, _, err := l.Generate(in); err == nil {
|
||||
t.Fatal("an existing sheet of the wrong size should be an error; it is registered to the template")
|
||||
}
|
||||
}
|
||||
|
||||
// A re-roll must actually re-roll. The studio's Generate button hands a fresh seed every press, and if the
|
||||
// placement does not move, the button does nothing an author can see: the forest count is a quantile and so
|
||||
// is invariant by construction, which makes the settlements the only visible difference between two drafts.
|
||||
func TestASecondSeedMovesTheSettlements(t *testing.T) {
|
||||
l := &Legend{MatchDistance: DefaultMatchDistance, MinAreaPx: DefaultMinAreaPx, Marks: []Mark{
|
||||
{Name: "town", RGB: [3]int{220, 30, 30},
|
||||
Generate: &GenSpec{Kind: GenSettlement, Count: 8, MinSpacingKm: 3}},
|
||||
}}
|
||||
if err := l.resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
place := func(seed int64) []Placed {
|
||||
in := testWorld(300, 160)
|
||||
in.Seed = seed
|
||||
_, rep, err := l.Generate(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return rep.Settlement
|
||||
}
|
||||
a, b := place(11), place(20260920)
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
t.Fatalf("no settlements placed (%d, %d); the test world should hold some", len(a), len(b))
|
||||
}
|
||||
same := 0
|
||||
for i := range a {
|
||||
if i < len(b) && a[i].X == b[i].X && a[i].Y == b[i].Y {
|
||||
same++
|
||||
}
|
||||
}
|
||||
if same == len(a) && len(a) == len(b) {
|
||||
t.Fatalf("both seeds placed the same %d settlements in the same places; the seed is not reaching "+
|
||||
"the placement", len(a))
|
||||
}
|
||||
|
||||
// And the same seed twice is still the same world, or nothing is reproducible.
|
||||
c := place(11)
|
||||
for i := range a {
|
||||
if a[i].X != c[i].X || a[i].Y != c[i].Y {
|
||||
t.Fatalf("the same seed placed settlement %d differently on two runs", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
// Package overlay is the second painting: a layer over the same cylinder whose colours name things the
|
||||
// geology does not simulate.
|
||||
//
|
||||
// The class legend answers "what is the rock doing here" and every colour on it changes the terrain. That is
|
||||
// the wrong place to say "a forest grows here", "this is the village", "a road runs along this valley" or
|
||||
// "leave this stretch of coast exactly as I drew it": three of those four are not geology at all, and the
|
||||
// fourth is a constraint on a pass rather than a rate. Painting them as classes would mean inventing an
|
||||
// uplift rate for a town.
|
||||
//
|
||||
// So there is a second image, registered to the first, painted in the same studio, with a legend of its own.
|
||||
// Its marks are sparse - most of the sheet is nothing - and unlike a class a mark is allowed to mean nothing
|
||||
// to the generator at all. Two rules follow from that and they are the whole design:
|
||||
//
|
||||
// - **A mark that no pass reads still travels.** Every mark comes out as an index in a per-tile raster and,
|
||||
// where it has a shape worth naming, as a feature in world metres in overlay.json. The engine reads those;
|
||||
// the generator never does. That is what makes the layer useful for content an author places by hand and
|
||||
// the simulation has no opinion about.
|
||||
// - **A mark that a pass does read changes one number and never the terrain's shape directly.** The one
|
||||
// built is `coast_jitter`, which scales how far the waterline roughening may move the shore inside the
|
||||
// mark - zero pins a hand-drawn coastline exactly as painted. The list is meant to stay short: anything
|
||||
// that wants to *make* terrain belongs in the class legend, where it is an uplift rate and the solve
|
||||
// answers for it.
|
||||
//
|
||||
// Blank is decided by alpha, not by a colour. An overlay is a transparent sheet with strokes on it, which is
|
||||
// what every image editor gives you and what the studio paints; reserving a background colour instead would
|
||||
// spend one of the author's colours on nothing and would break the moment they exported with a white matte.
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
)
|
||||
|
||||
// KindArea and KindPath are what a mark's shape is taken to mean. An area keeps its outline - a forest, a
|
||||
// district, a stretch of coast to leave alone - and comes out as a region with a centre and an extent. A path
|
||||
// is a stroke whose *width is not the point*: it is thinned to a centreline and comes out as an ordered
|
||||
// polyline, because a road drawn eight pixels wide is a spline with a width, not a ribbon-shaped polygon.
|
||||
const (
|
||||
KindArea = "area"
|
||||
KindPath = "path"
|
||||
)
|
||||
|
||||
// Mark is one painted colour on the overlay and everything it means.
|
||||
type Mark struct {
|
||||
Name string `json:"name"`
|
||||
RGB [3]int `json:"rgb"`
|
||||
|
||||
// Kind is "area" or "path"; empty is "area".
|
||||
Kind string `json:"kind"`
|
||||
|
||||
// CoastJitter scales the waterline roughening inside this mark. 1 is the planet's own amplitude, 0 pins
|
||||
// the shore exactly where it was painted, and above 1 chews it harder than the rest of the world.
|
||||
//
|
||||
// It is a pointer so that "not set" and "set to zero" are different things: zero is the whole reason the
|
||||
// key exists. A mark that says nothing about the coast leaves the amplitude alone.
|
||||
//
|
||||
// Painting either side of the waterline is enough. The roughening already knows, for every cell it might
|
||||
// move, which cell on the other side it would take its class from, so a stroke that covers only the water
|
||||
// or only the land still protects the shore between them - see template.Coast.
|
||||
CoastJitter *float64 `json:"coast_jitter"`
|
||||
|
||||
// WidthM is how wide the thing this stroke stands for really is, in metres. Paths only, and it is
|
||||
// carried rather than used: the generator has no opinion about how wide a road is, the engine that builds
|
||||
// the spline does. Zero means unstated.
|
||||
WidthM float64 `json:"width_m"`
|
||||
|
||||
// MinAreaPx drops components smaller than this many painted pixels. A brush leaves specks, a save through
|
||||
// a lossy codec leaves more, and a speck in overlay.json is a village the author never placed.
|
||||
// Zero takes the legend's own default.
|
||||
MinAreaPx int `json:"min_area_px"`
|
||||
|
||||
// Note is for the author and for whatever reads overlay.json. Nothing here parses it.
|
||||
Note string `json:"note"`
|
||||
|
||||
// Generate, when set, lets `terrain overlay` propose this mark from a baked world - woodland where trees
|
||||
// would grow, towns where somebody would build, the roads between them. It is a starting point an author
|
||||
// then edits, and it is opt-in per mark: without this block the mark is only ever painted by hand, which
|
||||
// is what every mark was before it existed. Generation never touches a pixel that is already painted.
|
||||
// See generate.go.
|
||||
Generate *GenSpec `json:"generate,omitempty"`
|
||||
}
|
||||
|
||||
// Area reports whether this mark keeps its outline rather than being thinned to a line.
|
||||
func (m Mark) Area() bool { return m.Kind != KindPath }
|
||||
|
||||
// Jitter is the coast jitter multiplier this mark asks for, and whether it asks for one at all.
|
||||
func (m Mark) Jitter() (float64, bool) {
|
||||
if m.CoastJitter == nil {
|
||||
return 1, false
|
||||
}
|
||||
return *m.CoastJitter, true
|
||||
}
|
||||
|
||||
// Legend is the overlay image and what its colours mean. It sits beside the class legend and has the same
|
||||
// shape, deliberately: an author who has edited one can edit the other without learning a second file format.
|
||||
type Legend struct {
|
||||
// Image is the painted overlay, relative to this file unless it is absolute. The manifest's
|
||||
// planet.overlay overrides it, which is how the studio's versioned saves repoint without rewriting this.
|
||||
Image string `json:"image"`
|
||||
|
||||
// MatchDistance is how far, in RGB, an opaque pixel may sit from the nearest mark before it is treated as
|
||||
// blank rather than as that mark. It is a *tolerance* and not the class legend's warn distance: there,
|
||||
// every pixel must become something, so the nearest class always wins and the distance only warns. Here
|
||||
// most of the sheet is nothing, so a pixel that matches nothing has an obvious right answer.
|
||||
MatchDistance float64 `json:"match_distance"`
|
||||
|
||||
// MinAreaPx is the default for every mark that does not set its own.
|
||||
MinAreaPx int `json:"min_area_px"`
|
||||
|
||||
Marks []Mark `json:"marks"`
|
||||
}
|
||||
|
||||
// DefaultMatchDistance is tight compared with the class legend's 60, because an overlay painted in the studio
|
||||
// is exact to the byte and one brought in from elsewhere is a flat stroke rather than a scanned wash. Wide
|
||||
// tolerances here would swallow an unrelated colour into whichever mark it happened to be nearest.
|
||||
const DefaultMatchDistance = 40
|
||||
|
||||
// DefaultMinAreaPx is about a brush tip. Below it a component is a speck.
|
||||
const DefaultMinAreaPx = 24
|
||||
|
||||
// Blank is the raster index for a pixel with no mark on it. Marks are numbered from 1 so that the raster can
|
||||
// be written straight out as an 8-bit image whose zero means "nothing here".
|
||||
const Blank = 0
|
||||
|
||||
// Load reads an overlay legend from JSON.
|
||||
func Load(path string) (*Legend, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l, err := Parse(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// Parse reads an overlay legend already in memory. Unknown fields are refused for the same reason the class
|
||||
// legend refuses them: a misspelt key is a mark quietly running on the default rather than on what was
|
||||
// written. Keys beginning with an underscore carry the commentary and are allowed.
|
||||
func Parse(data []byte) (*Legend, error) {
|
||||
clean, err := field.StripJSONComments(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var l Legend
|
||||
dec := json.NewDecoder(bytes.NewReader(clean))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := l.resolve(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
func (l *Legend) resolve() error {
|
||||
if l.MatchDistance <= 0 {
|
||||
l.MatchDistance = DefaultMatchDistance
|
||||
}
|
||||
if l.MinAreaPx <= 0 {
|
||||
l.MinAreaPx = DefaultMinAreaPx
|
||||
}
|
||||
if len(l.Marks) > 254 {
|
||||
return fmt.Errorf("overlay has %d marks; the raster holds 254 plus blank", len(l.Marks))
|
||||
}
|
||||
seen := make(map[string]int, len(l.Marks))
|
||||
byRGB := make(map[[3]int]string, len(l.Marks))
|
||||
for i := range l.Marks {
|
||||
m := &l.Marks[i]
|
||||
if m.Name == "" {
|
||||
return fmt.Errorf("mark %d has no name", i)
|
||||
}
|
||||
if j, dup := seen[m.Name]; dup {
|
||||
return fmt.Errorf("marks %d and %d are both named %q", j, i, m.Name)
|
||||
}
|
||||
seen[m.Name] = i
|
||||
for k, v := range m.RGB {
|
||||
if v < 0 || v > 255 {
|
||||
return fmt.Errorf("mark %q: rgb[%d] is %d, outside 0..255", m.Name, k, v)
|
||||
}
|
||||
}
|
||||
if other, dup := byRGB[m.RGB]; dup {
|
||||
return fmt.Errorf("marks %q and %q share the colour %v; nothing could tell them apart",
|
||||
other, m.Name, m.RGB)
|
||||
}
|
||||
byRGB[m.RGB] = m.Name
|
||||
switch m.Kind {
|
||||
case "", KindArea:
|
||||
m.Kind = KindArea
|
||||
case KindPath:
|
||||
default:
|
||||
return fmt.Errorf("mark %q: kind %q is neither %q nor %q", m.Name, m.Kind, KindArea, KindPath)
|
||||
}
|
||||
if m.CoastJitter != nil && *m.CoastJitter < 0 {
|
||||
return fmt.Errorf("mark %q: coast_jitter is %v; it is a multiplier on how far the waterline "+
|
||||
"may move, so it is never negative", m.Name, *m.CoastJitter)
|
||||
}
|
||||
if m.WidthM < 0 {
|
||||
return fmt.Errorf("mark %q: width_m is %v", m.Name, m.WidthM)
|
||||
}
|
||||
if m.WidthM > 0 && m.Area() {
|
||||
return fmt.Errorf("mark %q: width_m is for a path's spline, and this mark is an area; give it "+
|
||||
"kind %q or drop the width", m.Name, KindPath)
|
||||
}
|
||||
if m.MinAreaPx < 0 {
|
||||
return fmt.Errorf("mark %q: min_area_px is %d", m.Name, m.MinAreaPx)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Index is the raster index of the mark with this name, or Blank when there is none. Marks are numbered
|
||||
// from 1 in legend order.
|
||||
func (l *Legend) Index(name string) int {
|
||||
for i := range l.Marks {
|
||||
if l.Marks[i].Name == name {
|
||||
return i + 1
|
||||
}
|
||||
}
|
||||
return Blank
|
||||
}
|
||||
|
||||
// MinArea is how many painted pixels a component of this mark must have to be reported.
|
||||
func (l *Legend) MinArea(m *Mark) int {
|
||||
if m != nil && m.MinAreaPx > 0 {
|
||||
return m.MinAreaPx
|
||||
}
|
||||
return l.MinAreaPx
|
||||
}
|
||||
|
||||
// TouchesCoast reports whether any mark changes the waterline roughening, so a caller can skip building the
|
||||
// scale field when nothing would read it.
|
||||
func (l *Legend) TouchesCoast() bool {
|
||||
for i := range l.Marks {
|
||||
if _, set := l.Marks[i].Jitter(); set {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Raster is one mark index per overlay pixel, row-major, at the overlay image's own resolution. X wraps;
|
||||
// Y does not, the same convention as every other cylindrical raster here.
|
||||
type Raster struct {
|
||||
W, H int
|
||||
Mark []uint8
|
||||
}
|
||||
|
||||
// At reads a pixel, wrapping X and clamping Y.
|
||||
func (r *Raster) At(x, y int) uint8 {
|
||||
x = ((x % r.W) + r.W) % r.W
|
||||
if y < 0 {
|
||||
y = 0
|
||||
} else if y >= r.H {
|
||||
y = r.H - 1
|
||||
}
|
||||
return r.Mark[y*r.W+x]
|
||||
}
|
||||
|
||||
// Match is what the overlay classifier saw.
|
||||
type Match struct {
|
||||
Total int
|
||||
Blank int
|
||||
Counts []int // per mark index, so Counts[0] is blank
|
||||
// Far is opaque pixels that matched no mark inside the tolerance and were therefore treated as blank.
|
||||
// It is the one number that catches a colour the legend forgot, and unlike the class legend's Far it is
|
||||
// not merely advisory: those pixels are painted and are being thrown away.
|
||||
Far int
|
||||
MaxDist float64
|
||||
MaxAt [2]int
|
||||
}
|
||||
|
||||
func (m Match) String() string {
|
||||
if m.Total == 0 {
|
||||
return "no overlay"
|
||||
}
|
||||
painted := m.Total - m.Blank
|
||||
s := fmt.Sprintf("%d px painted of %d (%.1f%%)", painted, m.Total,
|
||||
100*float64(painted)/float64(m.Total))
|
||||
if m.Far > 0 {
|
||||
s += fmt.Sprintf("; %d px match no mark and were dropped (worst %.0f at %d,%d)",
|
||||
m.Far, m.MaxDist, m.MaxAt[0], m.MaxAt[1])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Classify assigns every pixel to a mark, or to Blank.
|
||||
//
|
||||
// Two ways to be blank, and both are needed. A pixel whose alpha is below half is unpainted, which is what a
|
||||
// transparent sheet gives and what the studio writes. A pixel that is opaque but sits further than the
|
||||
// legend's tolerance from every mark is a colour the legend has never heard of - a flattened matte, an
|
||||
// anti-aliased edge between two strokes, a JPEG artefact - and taking the nearest mark there is how a halo
|
||||
// round a road becomes a road.
|
||||
func (l *Legend) Classify(px []uint8, alpha []uint8, w, h int) (*Raster, Match) {
|
||||
r := &Raster{W: w, H: h, Mark: make([]uint8, w*h)}
|
||||
partial := make([]Match, field.BandCount(h))
|
||||
for i := range partial {
|
||||
partial[i].Counts = make([]int, len(l.Marks)+1)
|
||||
}
|
||||
tol2 := l.MatchDistance * l.MatchDistance
|
||||
|
||||
field.RowsIndexed(h, func(band, y0, y1 int) {
|
||||
p := &partial[band]
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
p.Total++
|
||||
if alpha != nil && alpha[i] < 128 {
|
||||
p.Blank++
|
||||
p.Counts[Blank]++
|
||||
continue
|
||||
}
|
||||
o := i * 3
|
||||
cr, cg, cb := int(px[o]), int(px[o+1]), int(px[o+2])
|
||||
best, bestD := -1, 1<<30
|
||||
for mi := range l.Marks {
|
||||
m := &l.Marks[mi]
|
||||
dr, dg, db := cr-m.RGB[0], cg-m.RGB[1], cb-m.RGB[2]
|
||||
if d := dr*dr + dg*dg + db*db; d < bestD {
|
||||
bestD, best = d, mi
|
||||
}
|
||||
}
|
||||
if best < 0 || float64(bestD) > tol2 {
|
||||
p.Blank++
|
||||
p.Counts[Blank]++
|
||||
if alpha != nil || best >= 0 {
|
||||
p.Far++
|
||||
if float64(bestD) > p.MaxDist {
|
||||
p.MaxDist = float64(bestD)
|
||||
p.MaxAt = [2]int{x, y}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
r.Mark[i] = uint8(best + 1)
|
||||
p.Counts[best+1]++
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
out := Match{Counts: make([]int, len(l.Marks)+1)}
|
||||
out.MaxAt = [2]int{-1, -1}
|
||||
for i := range partial {
|
||||
p := &partial[i]
|
||||
out.Total += p.Total
|
||||
out.Blank += p.Blank
|
||||
out.Far += p.Far
|
||||
for c, n := range p.Counts {
|
||||
out.Counts[c] += n
|
||||
}
|
||||
// Tie-broken by position so the report does not depend on GOMAXPROCS (cross-cutting rule 12).
|
||||
if p.MaxDist > out.MaxDist || (p.MaxDist == out.MaxDist && earlier(p.MaxAt, out.MaxAt)) {
|
||||
out.MaxDist = p.MaxDist
|
||||
out.MaxAt = p.MaxAt
|
||||
}
|
||||
}
|
||||
out.MaxDist = math.Sqrt(out.MaxDist)
|
||||
return r, out
|
||||
}
|
||||
|
||||
func earlier(a, b [2]int) bool {
|
||||
if b[1] < 0 {
|
||||
return true
|
||||
}
|
||||
if a[1] != b[1] {
|
||||
return a[1] < b[1]
|
||||
}
|
||||
return a[0] < b[0]
|
||||
}
|
||||
|
||||
// Encode turns a raster back into the RGBA sheet an author opens: each mark in its own legend colour, fully
|
||||
// opaque, and blank left transparent.
|
||||
//
|
||||
// It is the exact inverse of Classify for anything this package wrote, and that has to stay true: a sheet
|
||||
// written here is read back by Classify on the next plan, so a colour that did not survive the round trip
|
||||
// would be a mark that vanished between writing the file and reading it. Nothing is blended or antialiased,
|
||||
// for the reason the studio's brush is hard-edged - a pixel between two mark colours is not a blend of two
|
||||
// marks, it is a pixel that classifies as whichever one it happens to sit nearer, or as nothing at all.
|
||||
func (l *Legend) Encode(r *Raster) (px []uint8, alpha []uint8) {
|
||||
n := r.W * r.H
|
||||
px = make([]uint8, n*3)
|
||||
alpha = make([]uint8, n)
|
||||
for i, m := range r.Mark {
|
||||
if m == Blank || int(m) > len(l.Marks) {
|
||||
continue
|
||||
}
|
||||
rgb := l.Marks[m-1].RGB
|
||||
px[i*3] = uint8(rgb[0])
|
||||
px[i*3+1] = uint8(rgb[1])
|
||||
px[i*3+2] = uint8(rgb[2])
|
||||
alpha[i] = 255
|
||||
}
|
||||
return px, alpha
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A legend with one of each kind of mark, written the way an author would.
|
||||
const legendJSON = `{
|
||||
"_comment": "commentary survives a parse",
|
||||
"image": "sheet.png",
|
||||
"marks": [
|
||||
{ "name": "drawn_coast", "rgb": [255, 0, 255], "coast_jitter": 0 },
|
||||
{ "name": "wild_coast", "rgb": [255, 128, 0], "coast_jitter": 2.5 },
|
||||
{ "name": "forest", "rgb": [0, 128, 0] },
|
||||
{ "name": "road", "rgb": [90, 60, 30], "kind": "path", "width_m": 8 }
|
||||
]
|
||||
}`
|
||||
|
||||
func mustLegend(t *testing.T) *Legend {
|
||||
t.Helper()
|
||||
l, err := Parse([]byte(legendJSON))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func TestParseFillsDefaultsAndRefusesNonsense(t *testing.T) {
|
||||
l := mustLegend(t)
|
||||
if l.MatchDistance != DefaultMatchDistance || l.MinAreaPx != DefaultMinAreaPx {
|
||||
t.Fatalf("defaults not filled: %v %v", l.MatchDistance, l.MinAreaPx)
|
||||
}
|
||||
if l.Index("forest") != 3 || l.Index("nope") != Blank {
|
||||
t.Fatalf("marks are numbered from 1 in legend order, got %d", l.Index("forest"))
|
||||
}
|
||||
if !l.TouchesCoast() {
|
||||
t.Fatal("this legend has a coast mark, so the roughening has a scale field to build")
|
||||
}
|
||||
if j, set := l.Marks[0].Jitter(); !set || j != 0 {
|
||||
t.Fatalf("a zero coast_jitter is the whole reason the key is a pointer; got %v set=%v", j, set)
|
||||
}
|
||||
if j, set := l.Marks[2].Jitter(); set || j != 1 {
|
||||
t.Fatalf("a mark that says nothing about the coast leaves the amplitude alone; got %v set=%v", j, set)
|
||||
}
|
||||
|
||||
for _, bad := range []struct{ what, src string }{
|
||||
{"two marks one colour", `{"marks":[{"name":"a","rgb":[1,2,3]},{"name":"b","rgb":[1,2,3]}]}`},
|
||||
{"two marks one name", `{"marks":[{"name":"a","rgb":[1,2,3]},{"name":"a","rgb":[4,5,6]}]}`},
|
||||
{"a width on an area", `{"marks":[{"name":"a","rgb":[1,2,3],"width_m":4}]}`},
|
||||
{"a negative jitter", `{"marks":[{"name":"a","rgb":[1,2,3],"coast_jitter":-1}]}`},
|
||||
{"an unknown kind", `{"marks":[{"name":"a","rgb":[1,2,3],"kind":"blob"}]}`},
|
||||
{"a misspelt key", `{"marks":[{"name":"a","rgb":[1,2,3],"coastjitter":0}]}`},
|
||||
} {
|
||||
if _, err := Parse([]byte(bad.src)); err == nil {
|
||||
t.Errorf("%s should not parse", bad.what)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// paint builds an RGBA sheet the size asked for, all transparent, and returns setters.
|
||||
func paint(w, h int) (px, alpha []uint8, set func(x, y int, rgb [3]int)) {
|
||||
px = make([]uint8, w*h*3)
|
||||
alpha = make([]uint8, w*h)
|
||||
return px, alpha, func(x, y int, rgb [3]int) {
|
||||
i := y*w + x
|
||||
px[i*3], px[i*3+1], px[i*3+2] = uint8(rgb[0]), uint8(rgb[1]), uint8(rgb[2])
|
||||
alpha[i] = 255
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlankIsAlphaAndTolerance is the rule the whole layer rests on: most of the sheet is nothing, and there
|
||||
// are two ways to be nothing. An opaque pixel near no mark is dropped rather than snapped to the nearest,
|
||||
// which is the opposite of what the class legend does and is why they are different code.
|
||||
func TestBlankIsAlphaAndTolerance(t *testing.T) {
|
||||
l := mustLegend(t)
|
||||
const w, h = 8, 4
|
||||
px, alpha, set := paint(w, h)
|
||||
set(1, 1, [3]int{0, 128, 0}) // forest, exactly
|
||||
set(2, 1, [3]int{6, 132, 4}) // forest, near enough
|
||||
set(3, 1, [3]int{0, 0, 255}) // a colour the legend has never heard of
|
||||
// A transparent pixel that happens to carry a mark's colour: alpha wins.
|
||||
i := 1*w + 4
|
||||
px[i*3], px[i*3+1], px[i*3+2] = 0, 128, 0
|
||||
|
||||
r, m := l.Classify(px, alpha, w, h)
|
||||
if got := r.At(1, 1); got != 3 {
|
||||
t.Fatalf("an exact colour is its mark; got %d", got)
|
||||
}
|
||||
if got := r.At(2, 1); got != 3 {
|
||||
t.Fatalf("within the tolerance is its mark; got %d", got)
|
||||
}
|
||||
if got := r.At(3, 1); got != Blank {
|
||||
t.Fatalf("a colour no mark is near is blank, not the nearest mark; got %d", got)
|
||||
}
|
||||
if got := r.At(4, 1); got != Blank {
|
||||
t.Fatalf("transparent is blank whatever colour is under it; got %d", got)
|
||||
}
|
||||
if m.Far != 1 {
|
||||
t.Fatalf("the one unmatched opaque pixel should be reported; Far=%d", m.Far)
|
||||
}
|
||||
if m.Total != w*h || m.Blank != w*h-2 {
|
||||
t.Fatalf("counts: total %d blank %d", m.Total, m.Blank)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCoastScaleLeavesUnmarkedPixelsUninstructed is the contract template.Coast.Scale depends on. An
|
||||
// unmarked cell must come back negative rather than 1, or a stroke painted on the land would be overruled by
|
||||
// the water beside it and the coastline would move anyway.
|
||||
func TestCoastScaleLeavesUnmarkedPixelsUninstructed(t *testing.T) {
|
||||
l := mustLegend(t)
|
||||
const w, h = 6, 2
|
||||
px, alpha, set := paint(w, h)
|
||||
set(0, 0, [3]int{255, 0, 255}) // drawn_coast: pinned
|
||||
set(1, 0, [3]int{255, 128, 0}) // wild_coast: chewed harder
|
||||
set(2, 0, [3]int{0, 128, 0}) // forest: says nothing about the coast
|
||||
r, _ := l.Classify(px, alpha, w, h)
|
||||
|
||||
sc := l.CoastScale(r)
|
||||
if sc == nil {
|
||||
t.Fatal("this legend has coast marks, so there is a scale")
|
||||
}
|
||||
if sc[0] != 0 {
|
||||
t.Errorf("a pinned coast is exactly zero, got %v", sc[0])
|
||||
}
|
||||
if sc[1] != 2.5 {
|
||||
t.Errorf("wild_coast is 2.5, got %v", sc[1])
|
||||
}
|
||||
if sc[2] >= 0 {
|
||||
t.Errorf("a mark that says nothing about the coast is uninstructed, got %v", sc[2])
|
||||
}
|
||||
if sc[3] >= 0 {
|
||||
t.Errorf("blank is uninstructed, got %v", sc[3])
|
||||
}
|
||||
|
||||
// And a legend with no coast marks builds nothing at all, so the roughening pays nothing.
|
||||
plain, err := Parse([]byte(`{"marks":[{"name":"forest","rgb":[0,128,0]}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pr, _ := plain.Classify(px, alpha, w, h)
|
||||
if plain.CoastScale(pr) != nil {
|
||||
t.Error("no mark asks about the coast, so there should be no scale field")
|
||||
}
|
||||
}
|
||||
|
||||
func testScale(w, h int) Scale {
|
||||
return Scale{MetresPerPxX: 10, MetresPerPxY: 10, CircumferenceM: float64(w) * 10}
|
||||
}
|
||||
|
||||
// TestFeaturesMeasureAreasInWorldMetres covers the ordinary case and the speck filter.
|
||||
func TestFeaturesMeasureAreasInWorldMetres(t *testing.T) {
|
||||
l := mustLegend(t)
|
||||
const w, h = 40, 20
|
||||
px, alpha, set := paint(w, h)
|
||||
// A 6x6 block of forest, and a single speck of it far away.
|
||||
for y := 4; y < 10; y++ {
|
||||
for x := 10; x < 16; x++ {
|
||||
set(x, y, [3]int{0, 128, 0})
|
||||
}
|
||||
}
|
||||
set(30, 15, [3]int{0, 128, 0})
|
||||
|
||||
r, _ := l.Classify(px, alpha, w, h)
|
||||
feats := l.Features(r, testScale(w, h))
|
||||
if len(feats) != 1 {
|
||||
t.Fatalf("the 36 px block is a feature and the 1 px speck is below min_area_px; got %d", len(feats))
|
||||
}
|
||||
f := feats[0]
|
||||
if f.Mark != "forest" || f.Kind != KindArea {
|
||||
t.Fatalf("wrong mark: %+v", f)
|
||||
}
|
||||
if f.Cells != 36 || math.Abs(f.AreaM2-3600) > 1 {
|
||||
t.Fatalf("36 px at 10x10 m is 3600 m2; got %d px %v m2", f.Cells, f.AreaM2)
|
||||
}
|
||||
if math.Abs(f.CentreM[0]-125) > 1 || math.Abs(f.CentreM[1]-65) > 1 {
|
||||
t.Fatalf("centre should be the middle of the block in metres; got %v", f.CentreM)
|
||||
}
|
||||
if math.Abs(f.ExtentM[0]-60) > 1 || math.Abs(f.ExtentM[1]-60) > 1 {
|
||||
t.Fatalf("a 6x6 block is 60x60 m; got %v", f.ExtentM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestASeamCrossingFeatureIsOneThing is the failure a cylindrical map has and nobody notices: a plain mean of
|
||||
// the longitudes puts the centre of a blob straddling the seam on the opposite side of the world.
|
||||
func TestASeamCrossingFeatureIsOneThing(t *testing.T) {
|
||||
l := mustLegend(t)
|
||||
const w, h = 40, 20
|
||||
px, alpha, set := paint(w, h)
|
||||
for y := 6; y < 14; y++ {
|
||||
for _, x := range []int{38, 39, 0, 1} {
|
||||
set(x, y, [3]int{0, 128, 0})
|
||||
}
|
||||
}
|
||||
r, _ := l.Classify(px, alpha, w, h)
|
||||
feats := l.Features(r, testScale(w, h))
|
||||
if len(feats) != 1 {
|
||||
t.Fatalf("the blob crosses the seam and is one thing; got %d features", len(feats))
|
||||
}
|
||||
f := feats[0]
|
||||
if f.Cells != 32 {
|
||||
t.Fatalf("all 32 px belong to it; got %d", f.Cells)
|
||||
}
|
||||
// Columns 38, 39, 0, 1 have their circular centre at 39.5, which is 395 m.
|
||||
if d := math.Abs(f.CentreM[0] - 395); d > 6 && math.Abs(f.CentreM[0]-395+400) > 6 {
|
||||
t.Fatalf("the centre should sit on the blob, near 395 m; got %v", f.CentreM[0])
|
||||
}
|
||||
if math.Abs(f.ExtentM[0]-40) > 1 {
|
||||
t.Fatalf("the extent is measured the short way round: 4 px is 40 m; got %v", f.ExtentM[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPathBecomesACentrelineNotAnOutline is the difference between a road and a ribbon-shaped polygon.
|
||||
func TestAPathBecomesACentrelineNotAnOutline(t *testing.T) {
|
||||
l := mustLegend(t)
|
||||
const w, h = 60, 20
|
||||
px, alpha, set := paint(w, h)
|
||||
// A horizontal stroke three pixels thick from x=5 to x=50.
|
||||
for x := 5; x <= 50; x++ {
|
||||
for y := 9; y <= 11; y++ {
|
||||
set(x, y, [3]int{90, 60, 30})
|
||||
}
|
||||
}
|
||||
r, _ := l.Classify(px, alpha, w, h)
|
||||
feats := l.Features(r, testScale(w, h))
|
||||
if len(feats) != 1 {
|
||||
t.Fatalf("one stroke is one path; got %d", len(feats))
|
||||
}
|
||||
f := feats[0]
|
||||
if f.Kind != KindPath || f.WidthM != 8 {
|
||||
t.Fatalf("the path's width travels with it: %+v", f)
|
||||
}
|
||||
if len(f.PointsM) < 2 {
|
||||
t.Fatalf("a path needs at least two points; got %d", len(f.PointsM))
|
||||
}
|
||||
// Simplified, so a straight stroke is a handful of points and not one per pixel.
|
||||
if len(f.PointsM) > 8 {
|
||||
t.Errorf("a straight stroke should simplify to a few points; got %d", len(f.PointsM))
|
||||
}
|
||||
// It runs the length of the stroke, not round its outline: 45 px is 450 m, an outline would be ~960.
|
||||
if f.LengthM < 400 || f.LengthM > 500 {
|
||||
t.Errorf("a 45 px stroke at 10 m a pixel is about 450 m of centreline; got %v", f.LengthM)
|
||||
}
|
||||
for _, p := range f.PointsM {
|
||||
if p[1] < 85 || p[1] > 115 {
|
||||
t.Errorf("every point should sit on the stroke, y near 100 m; got %v", p)
|
||||
}
|
||||
}
|
||||
// An area mark never gets points, whatever shape it is drawn in.
|
||||
for _, g := range feats {
|
||||
if g.Kind == KindArea && len(g.PointsM) > 0 {
|
||||
t.Error("an area keeps its outline and is not thinned")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSampleWorldIsIndependentOfTheWindow is rule 1 for a raster: a cell gets the same mark whichever tile
|
||||
// reaches it, because the lookup goes through world metres rather than through a tile-local index.
|
||||
func TestSampleWorldIsIndependentOfTheWindow(t *testing.T) {
|
||||
l := mustLegend(t)
|
||||
const w, h = 40, 20
|
||||
px, alpha, set := paint(w, h)
|
||||
for y := 4; y < 10; y++ {
|
||||
for x := 10; x < 16; x++ {
|
||||
set(x, y, [3]int{0, 128, 0})
|
||||
}
|
||||
}
|
||||
r, _ := l.Classify(px, alpha, w, h)
|
||||
s := testScale(w, h)
|
||||
|
||||
// Two windows of a 2 m grid overlapping the same ground: one starting at 100 m, one at 60 m.
|
||||
a := r.SampleWorld(100, 40, 2, 40, 40, s)
|
||||
b := r.SampleWorld(60, 40, 2, 60, 40, s)
|
||||
for y := 0; y < 40; y++ {
|
||||
for x := 0; x < 40; x++ {
|
||||
if a[y*40+x] != b[y*60+x+20] {
|
||||
t.Fatalf("the same ground read two marks at (%d,%d): %d vs %d",
|
||||
x, y, a[y*40+x], b[y*60+x+20])
|
||||
}
|
||||
}
|
||||
}
|
||||
// And it wraps, rather than clamping, past the seam.
|
||||
past := r.SampleWorld(s.CircumferenceM+100, 40, 2, 40, 40, s)
|
||||
for i := range a {
|
||||
if a[i] != past[i] {
|
||||
t.Fatalf("a window a whole world to the east must read the same ground; differ at %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentReportsEveryMarkPaintedOrNot(t *testing.T) {
|
||||
l := mustLegend(t)
|
||||
const w, h = 40, 20
|
||||
px, alpha, set := paint(w, h)
|
||||
for y := 4; y < 10; y++ {
|
||||
for x := 10; x < 16; x++ {
|
||||
set(x, y, [3]int{0, 128, 0})
|
||||
}
|
||||
}
|
||||
r, m := l.Classify(px, alpha, w, h)
|
||||
doc := l.Describe(r, m, testScale(w, h), "sheet.png", "sheet.json")
|
||||
if len(doc.Marks) != 4 {
|
||||
t.Fatalf("every mark is reported, painted or not; got %d", len(doc.Marks))
|
||||
}
|
||||
byName := map[string]MarkShare{}
|
||||
for _, mk := range doc.Marks {
|
||||
byName[mk.Name] = mk
|
||||
}
|
||||
if f := byName["forest"]; f.Cells != 36 || f.Pieces != 1 || math.Abs(f.AreaKm2-0.0036) > 1e-6 {
|
||||
t.Errorf("forest: %+v", f)
|
||||
}
|
||||
if c := byName["drawn_coast"]; !c.HasJitter || c.Jitter != 0 || c.Pieces != 0 {
|
||||
t.Errorf("an unpainted coast mark still reports what it would ask for: %+v", c)
|
||||
}
|
||||
if rd := byName["road"]; rd.Kind != KindPath || rd.WidthM != 8 {
|
||||
t.Errorf("road: %+v", rd)
|
||||
}
|
||||
if doc.CircumferenceM != 400 || doc.PaintW != w {
|
||||
t.Errorf("the frame is the overlay's own: %v x %d", doc.CircumferenceM, doc.PaintW)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user