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

402 lines
15 KiB
Go

// 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
}