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