200 lines
7.0 KiB
Go
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
|
|
}
|