402 lines
15 KiB
Go
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
|
|
}
|