355 lines
13 KiB
Go
355 lines
13 KiB
Go
package planet
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"salty/terrain/internal/field"
|
|
"salty/terrain/internal/fluvial"
|
|
"salty/terrain/internal/manifest"
|
|
"salty/terrain/internal/overlay"
|
|
)
|
|
|
|
// Proposing an overlay from a finished bake.
|
|
//
|
|
// The annotation layer is hand-painted and starts blank, and the three things most worth putting on it -
|
|
// woodland, settlements and the roads between them - are all consequences of ground the author cannot see
|
|
// while painting. The classes are painted before the solve exists, and once it does exist it is a
|
|
// seventy-six-million-cell heightmap. So this reads the bake back and hands internal/overlay the four
|
|
// fields it needs to have an opinion: height, slope, the sea, and where the water collects.
|
|
//
|
|
// It reads the bake from disk rather than hooking into one, for the same reason `terrain tiles` does: the
|
|
// geology is two hours and this is seconds, so an author can regenerate the sheet as often as they like
|
|
// against a bake they already have.
|
|
//
|
|
// **It works at the template's resolution, not the geology grid's.** The overlay is registered to the
|
|
// template and must be exactly its size, so generating anywhere else would mean resampling the output - and
|
|
// a resampled mark is a blend of two colours, which the classifier reads as a third mark or as nothing. The
|
|
// geology is pooled down to the template once, here, and everything downstream is at that scale.
|
|
|
|
// OverlayGenOptions is what the generator is pointed at.
|
|
type OverlayGenOptions struct {
|
|
In *Inputs
|
|
BakeDir string
|
|
|
|
// Replace ignores the overlay already on disk instead of filling in around it. The default is to keep
|
|
// every painted pixel, because regenerating must never cost an author their work; this is the flag for
|
|
// "throw away the last generation and start again", and it says so at the call site.
|
|
Replace bool
|
|
|
|
// Seed overrides the manifest's, which is what a re-roll is: the painting fixes where the land is and
|
|
// the seed decides everything it does not - which patch of eligible ground becomes woodland, and which
|
|
// of the equally good sites gets the town.
|
|
Seed int64
|
|
|
|
// Existing overrides the overlay loaded from disk. The studio sets it, because the sheet an author is
|
|
// looking at includes strokes they have not saved, and generating around the file instead of around the
|
|
// screen would put marks on top of work that is visibly there.
|
|
Existing *overlay.Raster
|
|
|
|
Log func(string, ...any)
|
|
}
|
|
|
|
// GenerateOverlay reads a bake and proposes the marks whose legend asks for them.
|
|
func GenerateOverlay(opt OverlayGenOptions) (*overlay.Raster, overlay.GenReport, error) {
|
|
var none overlay.GenReport
|
|
in := opt.In
|
|
log := opt.Log
|
|
if log == nil {
|
|
log = func(string, ...any) {}
|
|
}
|
|
if in.Overlay == nil {
|
|
return nil, none, fmt.Errorf("%s has no overlay legend; set planet.overlay_legend and say which "+
|
|
"marks to generate", in.M.Path)
|
|
}
|
|
wants := false
|
|
for i := range in.Overlay.Marks {
|
|
if in.Overlay.Marks[i].Generate != nil {
|
|
wants = true
|
|
break
|
|
}
|
|
}
|
|
if !wants {
|
|
return nil, none, fmt.Errorf("no mark in %s has a `generate` block, so there is nothing to "+
|
|
"generate. Generation is opt-in per mark; see the overlay section of the templates README",
|
|
in.M.Planet.OverlayLegend)
|
|
}
|
|
|
|
ow, oh := in.PaintW, in.PaintH
|
|
cellM := in.P.CircumferenceM() / float64(ow)
|
|
|
|
if opt.BakeDir != "" {
|
|
if ok, why := BakeIsOfThisPainting(opt.BakeDir, in.M); !ok {
|
|
log("ignoring %s: %s, so its terrain is not this world's", filepath.Base(opt.BakeDir), why)
|
|
opt.BakeDir = ""
|
|
}
|
|
}
|
|
|
|
if opt.BakeDir == "" {
|
|
// No bake: the painting is all there is.
|
|
//
|
|
// Worth supporting rather than refusing, because the first thing an author wants after drawing a
|
|
// world is to see something placed on it, and the bake is two hours away. What survives without a
|
|
// solve is everything the *painting* knows - where the land is, where the sea is, and which class
|
|
// each cell was painted - so the coast still shapes where towns go and the class filters still keep
|
|
// woodland off the ice. What is lost is everything the terrain knows: there are no rivers to sit on,
|
|
// no slope to avoid, and therefore no reason for a road to bend. Say which of the two ran; a draft
|
|
// made this way is a sketch, and reading it as the other is how somebody concludes the generator
|
|
// ignores the terrain.
|
|
log("no bake: generating from the painting alone, so there are no rivers and no slope to read")
|
|
gin := flatInputs(in, ow, oh, cellM, opt.Replace)
|
|
opt.apply(&gin)
|
|
return in.Overlay.Generate(gin)
|
|
}
|
|
|
|
if err := CheckBake(opt.BakeDir, in.M, log); err != nil {
|
|
return nil, none, err
|
|
}
|
|
hpath := filepath.Join(opt.BakeDir, "planet_height.png")
|
|
values, gw, gh, err := field.ReadHeightmap(hpath, 0)
|
|
if err != nil {
|
|
return nil, none, fmt.Errorf("%s: %w (run `terrain bake` first)", hpath, err)
|
|
}
|
|
if gw != in.P.W || gh != in.P.PaintH() {
|
|
return nil, none, fmt.Errorf("%s is %dx%d but the manifest describes a %dx%d planet; the bake and "+
|
|
"the manifest have drifted apart", hpath, gw, gh, in.P.W, in.P.PaintH())
|
|
}
|
|
geology := in.M.Decode(values)
|
|
log("read %s: %d x %d at %.1f m", filepath.Base(hpath), gw, gh, in.P.CellM)
|
|
|
|
heightM := poolTo(geology, gw, gh, ow, oh)
|
|
sea := make([]bool, ow*oh)
|
|
seaCells := 0
|
|
for i, v := range heightM {
|
|
if float64(v) < in.M.SeaLevelM {
|
|
sea[i] = true
|
|
seaCells++
|
|
}
|
|
}
|
|
log("overlay grid %d x %d at %.1f m a pixel, %.0f%% sea",
|
|
ow, oh, cellM, 100*float64(seaCells)/float64(ow*oh))
|
|
|
|
flow := drainage(heightM, sea, ow, oh, cellM, in.M.Pipeline.Fluvial.MFDExponent)
|
|
|
|
// The class each overlay cell was painted, so a mark can be kept off ground its author called ice or
|
|
// desert. Nearest-sampled rather than averaged: a class is a name, and the mean of two names is not one.
|
|
classAt := make([]uint8, ow*oh)
|
|
for y := 0; y < oh; y++ {
|
|
sy := y*gh/oh + in.P.PadY
|
|
for x := 0; x < ow; x++ {
|
|
classAt[y*ow+x] = in.Map.Class[sy*in.P.W+x*gw/ow]
|
|
}
|
|
}
|
|
names := make([]string, len(in.Legend.Classes))
|
|
for i, c := range in.Legend.Classes {
|
|
names[i] = c.Name
|
|
}
|
|
|
|
gin := overlay.GenInputs{
|
|
W: ow, H: oh, CellM: cellM,
|
|
HeightM: heightM, Sea: sea, FlowM2: flow,
|
|
ClassAt: classAt, ClassNames: names,
|
|
Seed: in.M.Source.Seed,
|
|
}
|
|
if !opt.Replace {
|
|
gin.Existing = in.OverlayRaster
|
|
}
|
|
opt.apply(&gin)
|
|
return in.Overlay.Generate(gin)
|
|
}
|
|
|
|
// apply puts the caller's overrides onto the inputs, after the world has been read.
|
|
func (opt OverlayGenOptions) apply(gin *overlay.GenInputs) {
|
|
if opt.Seed != 0 {
|
|
gin.Seed = opt.Seed
|
|
}
|
|
if opt.Existing != nil {
|
|
gin.Existing = opt.Existing
|
|
}
|
|
}
|
|
|
|
// poolTo box-averages a field onto a smaller grid. The two grids cover exactly the same painted rows, so
|
|
// this is a straight ratio in each axis with no registration to work out.
|
|
//
|
|
// Averaging rather than sampling, because a single sample of an 8 m grid at 12.9 m spacing would alias every
|
|
// ridge it stepped over and put woodland in stripes.
|
|
func poolTo(src []float32, sw, sh, dw, dh int) []float32 {
|
|
out := make([]float32, dw*dh)
|
|
field.Rows(dh, func(y0, y1 int) {
|
|
for dy := y0; dy < y1; dy++ {
|
|
sy0 := dy * sh / dh
|
|
sy1 := (dy + 1) * sh / dh
|
|
if sy1 <= sy0 {
|
|
sy1 = sy0 + 1
|
|
}
|
|
for dx := 0; dx < dw; dx++ {
|
|
sx0 := dx * sw / dw
|
|
sx1 := (dx + 1) * sw / dw
|
|
if sx1 <= sx0 {
|
|
sx1 = sx0 + 1
|
|
}
|
|
sum, n := 0.0, 0
|
|
for sy := sy0; sy < sy1 && sy < sh; sy++ {
|
|
row := sy * sw
|
|
for sx := sx0; sx < sx1 && sx < sw; sx++ {
|
|
sum += float64(src[row+sx])
|
|
n++
|
|
}
|
|
}
|
|
if n > 0 {
|
|
out[dy*dw+dx] = float32(sum / float64(n))
|
|
}
|
|
}
|
|
}
|
|
})
|
|
return out
|
|
}
|
|
|
|
// drainage is the catchment area per cell at the overlay's resolution, which is what tells a settlement
|
|
// where the water is.
|
|
//
|
|
// Recomputed rather than read from the bake: `map_flow.png` is a picture a few hundred pixels wide, scaled
|
|
// for looking at, and what is wanted here is a number per overlay cell. Re-deriving it from the pooled
|
|
// height is cheap - one fill and one multiple-flow accumulation - and it is self-consistent with the slope
|
|
// and the sea mask beside it, which a resampled flow map would not be.
|
|
func drainage(heightM []float32, sea []bool, w, h int, cellM, mfdExp float64) []float32 {
|
|
g := fluvial.NewGrid(w, h, cellM, sea)
|
|
|
|
// The fill runs on a copy: it raises every pit to its spill level, which is right for routing water and
|
|
// wrong for everything else here. Slope and the treeline must see the surface the bake actually made.
|
|
filled := make([]float32, len(heightM))
|
|
copy(filled, heightM)
|
|
g.FillDepressions(filled, 1e-3)
|
|
g.AccumulateMFD(filled, mfdExp)
|
|
|
|
out := make([]float32, len(heightM))
|
|
copy(out, g.Area)
|
|
return out
|
|
}
|
|
|
|
// OverlaySummary is the run's report, as lines to print.
|
|
func OverlaySummary(rep overlay.GenReport, ow, oh int) []string {
|
|
var lines []string
|
|
total := float64(ow * oh)
|
|
if rep.Kept > 0 {
|
|
lines = append(lines, fmt.Sprintf("kept %d px already painted (%.2f%% of the sheet); "+
|
|
"generation only fills blank ground", rep.Kept, 100*float64(rep.Kept)/total))
|
|
}
|
|
if rep.TreelineM > 0 {
|
|
lines = append(lines, fmt.Sprintf("treeline %.0f m, from the land's own heights", rep.TreelineM))
|
|
}
|
|
for _, m := range rep.Marks {
|
|
switch m.Kind {
|
|
case overlay.GenSettlement:
|
|
line := fmt.Sprintf(" %-14s %d placed, %d px", m.Name, m.Pieces, m.Cells)
|
|
if m.Wanted > m.Pieces {
|
|
// The two things that ration settlements are the spacing and how much flat ground there is,
|
|
// and neither is visible in the output, so the shortfall is said here rather than left to be
|
|
// counted off the sheet.
|
|
line += fmt.Sprintf(" (asked for %d; the spacing or the flat ground ran out)", m.Wanted)
|
|
}
|
|
lines = append(lines, line)
|
|
case overlay.GenRoad:
|
|
lines = append(lines, fmt.Sprintf(" %-14s %d %s, %d px",
|
|
m.Name, m.Pieces, plural(m.Pieces, "link", "links"), m.Cells))
|
|
default:
|
|
lines = append(lines, fmt.Sprintf(" %-14s %d px (%.2f%% of the sheet)",
|
|
m.Name, m.Cells, 100*float64(m.Cells)/total))
|
|
}
|
|
}
|
|
lines = append(lines, fmt.Sprintf("painted %d px (%.2f%% of the sheet)",
|
|
rep.Painted, 100*float64(rep.Painted)/total))
|
|
return lines
|
|
}
|
|
|
|
func plural(n int, one, many string) string {
|
|
if n == 1 {
|
|
return one
|
|
}
|
|
return many
|
|
}
|
|
|
|
// flatInputs builds the generator's world from the painting alone, for a planet that has not been baked.
|
|
//
|
|
// Height is a flat plateau on land and a flat floor at sea, which makes the slope field zero everywhere and
|
|
// the treeline meaningless - both correct rather than approximate, because a world with no solve genuinely
|
|
// has no relief to read. Drainage is nil rather than zero, which the generator treats as "rivers contribute
|
|
// nothing" instead of "every cell is equally dry"; the difference matters, because a score of zero
|
|
// everywhere would still be a score and would silently reweight the coast against it.
|
|
func flatInputs(in *Inputs, ow, oh int, cellM float64, replace bool) overlay.GenInputs {
|
|
n := ow * oh
|
|
heightM := make([]float32, n)
|
|
sea := make([]bool, n)
|
|
classAt := make([]uint8, n)
|
|
|
|
// The class raster is already at the template's resolution, which is the overlay's, so this is a direct
|
|
// read with no resampling at all.
|
|
for i := 0; i < n && i < len(in.Raster.Class); i++ {
|
|
c := in.Raster.Class[i]
|
|
classAt[i] = c
|
|
if int(c) < len(in.Legend.Classes) && in.Legend.Classes[c].Sea {
|
|
sea[i] = true
|
|
heightM[i] = float32(in.M.SeaLevelM - 100)
|
|
} else {
|
|
heightM[i] = float32(in.M.SeaLevelM + 10)
|
|
}
|
|
}
|
|
|
|
names := make([]string, len(in.Legend.Classes))
|
|
for i, c := range in.Legend.Classes {
|
|
names[i] = c.Name
|
|
}
|
|
|
|
gin := overlay.GenInputs{
|
|
W: ow, H: oh, CellM: cellM,
|
|
HeightM: heightM, Sea: sea,
|
|
ClassAt: classAt, ClassNames: names,
|
|
Seed: in.M.Source.Seed,
|
|
}
|
|
if !replace {
|
|
gin.Existing = in.OverlayRaster
|
|
}
|
|
return gin
|
|
}
|
|
|
|
// bakeTemplate is the template a bake was made from, or "" when the bake does not record one.
|
|
//
|
|
// It exists because CheckBake cannot catch this. That check compares the numbers a heightmap is *encoded*
|
|
// with - the elevation range, the circumference, the cell size, the seed - and two different paintings of
|
|
// the same planet agree on every one of them. So a bake of one world passes every test and is then read as
|
|
// another, and the marks come out placed against terrain that is not there: rivers in the wrong valleys,
|
|
// towns on coasts that do not exist. Nothing in the output says so, which is what makes it worth a check of
|
|
// its own rather than a note in a README.
|
|
func bakeTemplate(dir string) string {
|
|
raw, err := os.ReadFile(filepath.Join(dir, "meta.json"))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
var meta struct {
|
|
Plan struct {
|
|
Template string `json:"template"`
|
|
} `json:"plan"`
|
|
}
|
|
if json.Unmarshal(raw, &meta) != nil {
|
|
return ""
|
|
}
|
|
return meta.Plan.Template
|
|
}
|
|
|
|
// BakeIsOfThisPainting reports whether a bake was made from the template the manifest now names, and why not
|
|
// when it was not. A bake that does not record its template is taken on trust, because it predates the
|
|
// field; that is said rather than assumed.
|
|
func BakeIsOfThisPainting(dir string, m *manifest.Manifest) (bool, string) {
|
|
was := bakeTemplate(dir)
|
|
if was == "" {
|
|
return true, ""
|
|
}
|
|
if filepath.Base(was) == filepath.Base(m.Planet.Template) {
|
|
return true, ""
|
|
}
|
|
return false, fmt.Sprintf("%s was baked from %s and this planet is painted on %s",
|
|
filepath.Base(dir), filepath.Base(was), filepath.Base(m.Planet.Template))
|
|
}
|