Tooling
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user