Tooling
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user