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