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 }