Files
2026-09-25 17:02:24 +03:00

200 lines
7.0 KiB
Go

package studio
import (
"encoding/json"
"fmt"
"math/rand/v2"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"salty/terrain/internal/overlay"
"salty/terrain/internal/planet"
)
// Generating the annotation layer from inside the studio.
//
// `terrain overlay` already does this from the command line, and the reason to have it here as well is that
// generation is not a step in a pipeline - it is a *draft*. An author presses it, looks at where the towns
// landed, presses it again, and keeps the third one. That loop only works where the sheet is already on
// screen and editable, which is here.
//
// Two things it does that the command does not, and both exist because a button is pressed repeatedly:
//
// - **Every press is a new seed.** The painting fixes where the land is; the seed decides everything it
// does not. So the button is a re-roll by construction, which is what makes looking at three drafts
// cheap.
// - **It replaces the last draft rather than piling on top of it.** The generator never overwrites a
// painted pixel, and after one press its own output *is* painted pixels - so a second press would
// generate around the first and the sheet would silt up. The server remembers exactly which pixels the
// last generation put down and clears those, and only those, before generating again. Hand-painted work
// is never in that set and so is never touched.
//
// It uses the newest bake when there is one and the painting alone when there is not, and says which. The
// difference is not cosmetic: without a solve there are no rivers to sit on and no slope to avoid, so the
// draft is a sketch.
// bakePrefix matches the command's. A directory is a bake if it is this plus an integer.
const bakePrefix = "Bake_"
// latestBake is the newest Bake_NNN beside the manifest, or "" when the planet has never been baked.
//
// Newest by *number* rather than by modification time: the numbers are the order the bakes were made, and a
// directory touched by a backup tool is not a newer bake.
func latestBake(base string) string {
entries, err := os.ReadDir(base)
if err != nil {
return ""
}
best, bestN := "", -1
for _, e := range entries {
if !e.IsDir() || !strings.HasPrefix(e.Name(), bakePrefix) {
continue
}
n, err := strconv.Atoi(strings.TrimPrefix(e.Name(), bakePrefix))
if err != nil || n <= bestN {
continue
}
// A directory that has no heightmap in it is a bake that was interrupted, and reading one would fail
// later with a worse message than simply not choosing it.
if _, err := os.Stat(filepath.Join(base, e.Name(), "planet_height.png")); err != nil {
continue
}
best, bestN = filepath.Join(base, e.Name()), n
}
return best
}
type genReply struct {
OK bool `json:"ok"`
Seed int64 `json:"seed"`
Bake string `json:"bake"`
FromBake bool `json:"from_bake"`
Lines []string `json:"lines"`
Marks []string `json:"marks"`
}
// handleOverlayGenerate fills the annotation sheet in from the world, and hands the page back a summary.
func (s *Server) handleOverlayGenerate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST", http.StatusMethodNotAllowed)
return
}
var req struct {
Seed int64 `json:"seed"`
}
// An empty body is allowed: it means "pick a seed for me", which is what the button sends.
_ = json.NewDecoder(r.Body).Decode(&req)
s.mu.Lock()
defer s.mu.Unlock()
if s.ov == nil {
http.Error(w, "this planet has no overlay legend; set planet.overlay_legend first",
http.StatusNotFound)
return
}
wants := false
for i := range s.ov.Marks {
if s.ov.Marks[i].Generate != nil {
wants = true
break
}
}
if !wants {
http.Error(w, "no mark in the overlay legend has a `generate` block, so there is nothing to "+
"generate. Generation is opt-in per mark; see the overlay section of the templates README",
http.StatusBadRequest)
return
}
seed := req.Seed
if seed == 0 {
seed = int64(rand.Uint64()>>16) + 1
}
// The plan's prepare, which is where the class raster and the projected map come from. Reused when it is
// warm - the usual case, because an author plans before they look at anything - and built when it is not.
in := s.cache
if in == nil || s.cacheKey != s.planKey() {
var err error
in, err = planet.Prepare(s.m, func(string, ...any) {})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.cache, s.cacheKey = in, s.planKey()
}
// The sheet as it is on screen, with the last generation taken back out of it. Everything an author
// painted stays; everything the previous press put down goes, which is what makes this a re-roll rather
// than an accumulation.
existing := s.overlayRasterLocked()
cleared := 0
for i, m := range s.ovGen {
if m != overlay.Blank && i < len(existing.Mark) && existing.Mark[i] == m {
existing.Mark[i] = overlay.Blank
cleared++
}
}
// The newest bake, but only if it is a bake of *this* painting: two paintings of the same planet encode
// their heightmaps identically, so nothing else would catch it and the draft would be placed against
// terrain from another world. Decided here as well as inside the generator so that the line the page
// prints says which path actually ran.
bake := latestBake(filepath.Dir(s.manifestPath))
if bake != "" {
if ok, _ := planet.BakeIsOfThisPainting(bake, s.m); !ok {
bake = ""
}
}
var lines []string
log := func(format string, a ...any) { lines = append(lines, fmt.Sprintf(format, a...)) }
ras, rep, err := planet.GenerateOverlay(planet.OverlayGenOptions{
In: in, BakeDir: bake, Seed: seed, Existing: existing, Log: log,
})
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Back onto the sheet the author is looking at. The raster is the whole truth here - it already contains
// the pixels that were kept - so this is a straight encode rather than a merge.
px, alpha := s.ov.Encode(ras)
copy(s.ovPaint, px)
copy(s.ovAlpha, alpha)
s.ovDirty = true
// What this generation put down, so the next press can take it back out again. Only the cells that were
// blank before it ran: a mark sitting where the author painted one is theirs, not ours.
if s.ovGen == nil {
s.ovGen = make([]uint8, s.paintW*s.paintH)
}
for i := range s.ovGen {
if i < len(existing.Mark) && existing.Mark[i] == overlay.Blank && ras.Mark[i] != overlay.Blank {
s.ovGen[i] = ras.Mark[i]
} else {
s.ovGen[i] = overlay.Blank
}
}
reply := genReply{OK: true, Seed: seed, FromBake: bake != "", Lines: lines}
if bake != "" {
reply.Bake = filepath.Base(bake)
}
if cleared > 0 {
reply.Lines = append(reply.Lines, fmt.Sprintf("re-rolled: %d px of the last draft cleared first", cleared))
}
reply.Marks = planet.OverlaySummary(rep, ras.W, ras.H)
writeJSON(w, reply)
}
// overlayRasterLocked classifies the live sheet into marks. s.mu must be held.
func (s *Server) overlayRasterLocked() *overlay.Raster {
ras, _ := s.ov.Classify(s.ovPaint, s.ovAlpha, s.paintW, s.paintH)
return ras
}