Tooling
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/planet"
|
||||
)
|
||||
|
||||
// Watching a bake, which is the part `terrain bake` on a terminal cannot do.
|
||||
//
|
||||
// A bake is two hours, and for most of that the only thing on screen is a percentage. The question an author
|
||||
// actually has - *is this the world I meant* - is answerable long before the end, because the solve is
|
||||
// decomposed per landmass (D-53) and each one comes out whole. So the studio hangs a hook on the composite:
|
||||
// every time a region's land is written back, the planet as it stands is rendered to a preview, and the world
|
||||
// fills in one landmass at a time while the rest of it is still running. If the first continent out is wrong,
|
||||
// the other seventeen do not need to finish.
|
||||
//
|
||||
// Two consequences worth stating. The hook holds the composite lock, so every worker is stopped while it
|
||||
// draws - about a second a region against a run measured in hours, which is the right trade for being able to
|
||||
// see it at all. And a bake can be **cancelled**, because a two-hour job with no way out is not a button
|
||||
// anybody should press: the solve checks once a step, so the longest wait is one step of the biggest region,
|
||||
// and a cancelled result is for looking at rather than for writing out.
|
||||
|
||||
// bakeRun is the one bake a studio will run at a time.
|
||||
type bakeRun struct {
|
||||
mu sync.Mutex
|
||||
|
||||
running bool
|
||||
finished bool
|
||||
cancelCh chan struct{}
|
||||
|
||||
started time.Time
|
||||
steps int
|
||||
total int // regions this run will solve
|
||||
done []planet.RegionResult
|
||||
lines []string
|
||||
stamp int64 // bumped every time a new preview lands, so the browser knows to re-fetch
|
||||
outDir string
|
||||
err string
|
||||
note string
|
||||
}
|
||||
|
||||
// maxBakeLines caps the log the browser is shown. A thousand-step bake over eighteen regions prints a couple
|
||||
// of hundred lines; the cap is only so that a pathological run cannot grow without bound.
|
||||
const maxBakeLines = 400
|
||||
|
||||
func (b *bakeRun) logf(format string, a ...any) {
|
||||
line := fmt.Sprintf(format, a...)
|
||||
b.mu.Lock()
|
||||
b.lines = append(b.lines, line)
|
||||
if len(b.lines) > maxBakeLines {
|
||||
b.lines = b.lines[len(b.lines)-maxBakeLines:]
|
||||
}
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
type bakeStatus struct {
|
||||
Running bool `json:"running"`
|
||||
Finished bool `json:"finished"`
|
||||
Seconds float64 `json:"seconds"`
|
||||
Steps int `json:"steps"`
|
||||
Total int `json:"total"`
|
||||
Done []planet.RegionResult `json:"done"`
|
||||
Lines []string `json:"lines"`
|
||||
Stamp int64 `json:"stamp"`
|
||||
OutDir string `json:"out_dir"`
|
||||
Err string `json:"err"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
func (s *Server) handleBake(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
s.bake.mu.Lock()
|
||||
// Both slices start empty rather than nil, because a nil slice marshals to `null` and the page does
|
||||
// `j.done.length` on it - which is fine for every poll after the first region lands and throws on
|
||||
// every poll before it, which is exactly the window a person watches most closely.
|
||||
st := bakeStatus{
|
||||
Running: s.bake.running, Finished: s.bake.finished,
|
||||
Steps: s.bake.steps, Total: s.bake.total,
|
||||
Done: append(make([]planet.RegionResult, 0, len(s.bake.done)), s.bake.done...),
|
||||
Lines: append(make([]string, 0, len(s.bake.lines)), s.bake.lines...),
|
||||
Stamp: s.bake.stamp,
|
||||
OutDir: s.bake.outDir, Err: s.bake.err, Note: s.bake.note,
|
||||
}
|
||||
if !s.bake.started.IsZero() {
|
||||
st.Seconds = time.Since(s.bake.started).Seconds()
|
||||
}
|
||||
s.bake.mu.Unlock()
|
||||
writeJSON(w, st)
|
||||
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
Only []int `json:"only"`
|
||||
Steps int `json:"steps"`
|
||||
Jobs int `json:"jobs"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if err := s.startBake(req.Only, req.Steps, req.Jobs); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
|
||||
default:
|
||||
http.Error(w, "GET or POST", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleBakeCancel(w http.ResponseWriter, r *http.Request) {
|
||||
s.bake.mu.Lock()
|
||||
if s.bake.running && s.bake.cancelCh != nil {
|
||||
select {
|
||||
case <-s.bake.cancelCh: // already asked
|
||||
default:
|
||||
close(s.bake.cancelCh)
|
||||
s.bake.note = "cancelling: regions in flight stop at the end of their current step"
|
||||
}
|
||||
}
|
||||
s.bake.mu.Unlock()
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleBakePreview(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.ServeFile(w, r, filepath.Join(s.planDir, bakePreviewName))
|
||||
}
|
||||
|
||||
const bakePreviewName = "bake_preview.png"
|
||||
|
||||
// bakePreviewWidth is what the live preview is drawn at. Small on purpose: it is redrawn with every worker
|
||||
// stopped, so it is charged against the bake's wall time, and 1400 px is enough to answer "is this the world
|
||||
// I meant" while costing well under a second.
|
||||
const bakePreviewWidth = 1400
|
||||
|
||||
// startBake takes a snapshot of everything the run needs and hands it to a goroutine.
|
||||
//
|
||||
// A snapshot rather than a reference, because the whole point of the studio is that the painting keeps being
|
||||
// edited: a bake is of the world as it was when the button was pressed, and it says so.
|
||||
func (s *Server) startBake(only []int, steps, jobs int) error {
|
||||
s.bake.mu.Lock()
|
||||
if s.bake.running {
|
||||
s.bake.mu.Unlock()
|
||||
return fmt.Errorf("a bake is already running; cancel it first")
|
||||
}
|
||||
s.bake.running, s.bake.finished = true, false
|
||||
s.bake.cancelCh = make(chan struct{})
|
||||
s.bake.started = time.Now()
|
||||
s.bake.done, s.bake.lines, s.bake.err, s.bake.outDir, s.bake.note = nil, nil, "", "", ""
|
||||
s.bake.total, s.bake.steps = 0, steps
|
||||
cancel := s.bake.cancelCh
|
||||
s.bake.mu.Unlock()
|
||||
|
||||
s.mu.Lock()
|
||||
// Copied rather than shared: a bake is hours and the author keeps painting through it, so what it solves
|
||||
// has to be the world as it was when they pressed the button.
|
||||
art := &planet.Painting{
|
||||
Class: append([]uint8(nil), s.paint...),
|
||||
ClassW: s.paintW, ClassH: s.paintH,
|
||||
}
|
||||
if s.ov != nil {
|
||||
art.Overlay = append([]uint8(nil), s.ovPaint...)
|
||||
art.OverlayAlpha = append([]uint8(nil), s.ovAlpha...)
|
||||
art.OverlayW, art.OverlayH = s.paintW, s.paintH
|
||||
}
|
||||
mPath := s.manifestPath
|
||||
s.mu.Unlock()
|
||||
|
||||
go s.runBake(art, mPath, only, steps, jobs, cancel)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) runBake(art *planet.Painting, mPath string, only []int, steps, jobs int,
|
||||
cancel chan struct{}) {
|
||||
|
||||
fail := func(err error) {
|
||||
s.bake.mu.Lock()
|
||||
s.bake.err = err.Error()
|
||||
s.bake.running, s.bake.finished = false, true
|
||||
s.bake.mu.Unlock()
|
||||
}
|
||||
|
||||
// Loaded fresh rather than reusing the server's copy: a bake is long enough that the manifest may be
|
||||
// edited while it runs, and it should be of the numbers that were in force when it started.
|
||||
m, err := manifest.Load(mPath)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
|
||||
s.bake.logf("preparing")
|
||||
in, err := planet.PrepareWith(m, art, s.bake.logf)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
|
||||
wanted := len(in.Part.Regions)
|
||||
if len(only) > 0 {
|
||||
wanted = len(only)
|
||||
}
|
||||
s.bake.mu.Lock()
|
||||
s.bake.total = wanted
|
||||
s.bake.mu.Unlock()
|
||||
|
||||
res, err := planet.Bake(in, planet.BakeOptions{
|
||||
Only: only, Steps: steps, Jobs: jobs, Log: s.bake.logf, Cancel: cancel,
|
||||
OnRegion: func(res *planet.Result, rr planet.RegionResult) {
|
||||
s.writeBakePreview(res)
|
||||
s.bake.mu.Lock()
|
||||
s.bake.done = append(s.bake.done, rr)
|
||||
s.bake.stamp = time.Now().UnixNano()
|
||||
s.bake.mu.Unlock()
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
|
||||
note := ""
|
||||
out := ""
|
||||
if res.Cancelled() {
|
||||
// A cancelled run is not worthless, and the first version of this threw it away, which was wrong: the
|
||||
// solve is per landmass, so a region that *finished* is finished - only the one or two still in
|
||||
// flight stopped mid-step. Cancelling an eighteen-region bake after fifteen of them had landed and
|
||||
// getting nothing for it is exactly the outcome a cancel button should not have.
|
||||
//
|
||||
// So it is written, under a name of its own. Not Bake_NNN, because a directory that looked like
|
||||
// every other bake while holding sea level where three continents should be is a trap for whatever
|
||||
// reads it next, and `tiles --bake` picks the newest Bake_NNN by default.
|
||||
done := 0
|
||||
for _, rr := range res.Regions {
|
||||
if rr.Seconds > 0 {
|
||||
done++
|
||||
}
|
||||
}
|
||||
if done == 0 {
|
||||
note = "cancelled before any region finished; nothing to write"
|
||||
} else {
|
||||
out = nextPartialDir(filepath.Dir(mPath))
|
||||
if err := res.Write(out, 3000, s.bake.logf); err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
note = fmt.Sprintf("cancelled after %d region(s); those are complete and written to %s. "+
|
||||
"Everything else in it is still at sea level, which is why it is not a Bake_NNN", done, out)
|
||||
}
|
||||
} else {
|
||||
out = planet.NextBakeDir(filepath.Dir(mPath))
|
||||
if err := res.Write(out, 3000, s.bake.logf); err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
s.writeBakePreview(res)
|
||||
note = "wrote " + out
|
||||
}
|
||||
|
||||
s.bake.mu.Lock()
|
||||
s.bake.running, s.bake.finished = false, true
|
||||
s.bake.outDir, s.bake.note = out, note
|
||||
s.bake.stamp = time.Now().UnixNano()
|
||||
s.bake.mu.Unlock()
|
||||
}
|
||||
|
||||
// writeBakePreview draws the planet as it currently stands.
|
||||
//
|
||||
// The height and flow fields are *views* over the result's own arrays rather than copies: Painted() allocates
|
||||
// three hundred megabytes, and doing that once a region while every worker is stopped is a cost with nothing
|
||||
// to show for it. Safe because this only ever runs holding the composite lock.
|
||||
func (s *Server) writeBakePreview(res *planet.Result) {
|
||||
p := res.In.P
|
||||
lo, hi := p.PadY*p.W, (p.H-p.PadY)*p.W
|
||||
h := &field.Field{W: p.W, H: p.PaintH(), CellM: p.CellM, Data: res.Height.Data[lo:hi]}
|
||||
flow := &field.Field{W: p.W, H: p.PaintH(), CellM: p.CellM, Data: res.Flow[lo:hi]}
|
||||
|
||||
// The painted sea, not the baked one: res.Sea is only computed once the whole run is over, and the
|
||||
// painting already knows which cells are water.
|
||||
sea := res.In.Map.Sea[lo:hi]
|
||||
|
||||
_, err := field.WritePreview(filepath.Join(s.planDir, bakePreviewName), h, field.PreviewOptions{
|
||||
Flow: flow, Sea: sea, Snow: res.In.Map.SnowMask(), Palette: res.In.Palette,
|
||||
SeaLevelM: res.In.M.SeaLevelM, RiverKm2: 0.5, Size: bakePreviewWidth,
|
||||
})
|
||||
if err != nil {
|
||||
s.bake.logf("preview failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// partialPrefix is deliberately outside the Bake_NNN namespace: latestBakeDir parses the suffix after
|
||||
// "Bake_" as an integer, so this could never be mistaken for a finished bake even by accident, and a person
|
||||
// reading the directory listing can see which is which without opening anything.
|
||||
const partialPrefix = "Partial_"
|
||||
|
||||
func nextPartialDir(base string) string {
|
||||
for n := 1; n < 10000; n++ {
|
||||
dir := filepath.Join(base, fmt.Sprintf("%s%03d", partialPrefix, n))
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
return filepath.Join(base, partialPrefix+"overflow")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,370 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Editing a legend in place, without reformatting it.
|
||||
//
|
||||
// The obvious way to save a legend the studio has changed is to unmarshal it, set the fields and marshal it
|
||||
// back. That destroys the file. A legend is mostly *commentary* - the `_comment_massif` block on `lowland` is
|
||||
// four lines explaining why its floor is a tenth of its rate - and unmarshalling into the Class struct drops
|
||||
// every underscore key on the floor. Unmarshalling into map[string]any keeps them and loses the order
|
||||
// instead, because encoding/json sorts map keys, so the hand-laid table comes back alphabetised with every
|
||||
// comment moved away from the thing it was commenting on.
|
||||
//
|
||||
// The same argument the palette writer already makes, one file over: this repository does not let
|
||||
// MarshalIndent near a file a person wrote. So the studio patches the *text*. It finds the object for a named
|
||||
// class and replaces one key's value inside it, or inserts the key if it is not there, and every byte it did
|
||||
// not deliberately change comes out identical. That also means a legend edited here still diffs usefully,
|
||||
// which for a file under review is most of the point.
|
||||
|
||||
// patchClassNumber sets one numeric key on one class, adding it if it is absent. The returned text is the
|
||||
// input with exactly that value changed.
|
||||
func patchClassNumber(src, class, key string, value float64) (string, error) {
|
||||
start, end, err := classObject(src, class)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body := src[start:end]
|
||||
num := formatNumber(value)
|
||||
|
||||
if ks, ke, ok := keyValue(body, key); ok {
|
||||
return src[:start] + body[:ks] + fmt.Sprintf("%q: %s", key, num) + body[ke:] + src[end:], nil
|
||||
}
|
||||
// Not present: put it after the class's name, which is where a reader looks for it and which every class
|
||||
// is guaranteed to have.
|
||||
ns, ne, ok := keyValue(body, "name")
|
||||
if !ok {
|
||||
return "", fmt.Errorf("class %q has no name key to insert %q after", class, key)
|
||||
}
|
||||
_ = ns
|
||||
ins := fmt.Sprintf(", %q: %s", key, num)
|
||||
return src[:start] + body[:ne] + ins + body[ne:] + src[end:], nil
|
||||
}
|
||||
|
||||
// patchClassRemove deletes one key from one class, taking its separating comma with it. Absent is not an
|
||||
// error: the studio sends "this mark no longer says anything about the coast" whether or not it ever did.
|
||||
func patchClassRemove(src, class, key string) (string, error) {
|
||||
start, end, err := classObject(src, class)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body := src[start:end]
|
||||
ks, ke, ok := keyValue(body, key)
|
||||
if !ok {
|
||||
return src, nil
|
||||
}
|
||||
// A key goes with exactly one of the two separators around it, and which one depends on where it sits.
|
||||
// The test is a round trip: adding a key and taking it away again has to give the file back byte for
|
||||
// byte, or every later diff carries the scar of a setting somebody tried once.
|
||||
s, e := ks, ke
|
||||
j := e
|
||||
for j < len(body) && isJSONSpace(body[j]) {
|
||||
j++
|
||||
}
|
||||
if j < len(body) && body[j] == ',' {
|
||||
// Not the last key: take the comma after it, and the space that followed that comma in place of the
|
||||
// one that preceded this key.
|
||||
e = j + 1
|
||||
if e < len(body) && body[e] == ' ' && s > 0 && body[s-1] == ' ' {
|
||||
e++
|
||||
}
|
||||
} else {
|
||||
// The last key in the object: there is no comma after it, so take the one before - and nothing
|
||||
// forward, or the space in front of the closing brace goes with it.
|
||||
for s > 0 && isJSONSpace(body[s-1]) {
|
||||
s--
|
||||
}
|
||||
if s > 0 && body[s-1] == ',' {
|
||||
s--
|
||||
}
|
||||
}
|
||||
return src[:start] + body[:s] + body[e:] + src[end:], nil
|
||||
}
|
||||
|
||||
func isJSONSpace(c byte) bool { return c == ' ' || c == '\n' || c == '\r' || c == '\t' }
|
||||
|
||||
// patchClassObject sets one object-valued key on one class - the massif block - or removes it when nil.
|
||||
func patchClassObject(src, class, key string, fields map[string]float64, order []string) (string, error) {
|
||||
start, end, err := classObject(src, class)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body := src[start:end]
|
||||
|
||||
var lit string
|
||||
if fields != nil {
|
||||
parts := make([]string, 0, len(order))
|
||||
for _, k := range order {
|
||||
v, ok := fields[k]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%q: %s", k, formatNumber(v)))
|
||||
}
|
||||
lit = fmt.Sprintf("%q: { %s }", key, strings.Join(parts, ", "))
|
||||
}
|
||||
|
||||
if ks, ke, ok := keyValue(body, key); ok {
|
||||
if lit == "" {
|
||||
// Removing it: take the separating comma with it, whichever side it is on.
|
||||
s, e := ks, ke
|
||||
for e < len(body) && (body[e] == ' ' || body[e] == '\n' || body[e] == '\r' || body[e] == '\t') {
|
||||
e++
|
||||
}
|
||||
if e < len(body) && body[e] == ',' {
|
||||
e++
|
||||
} else {
|
||||
for s > 0 && (body[s-1] == ' ' || body[s-1] == '\n' || body[s-1] == '\r' || body[s-1] == '\t') {
|
||||
s--
|
||||
}
|
||||
if s > 0 && body[s-1] == ',' {
|
||||
s--
|
||||
}
|
||||
}
|
||||
return src[:start] + body[:s] + body[e:] + src[end:], nil
|
||||
}
|
||||
return src[:start] + body[:ks] + lit + body[ke:] + src[end:], nil
|
||||
}
|
||||
if lit == "" {
|
||||
return src, nil // asked to remove something that is not there
|
||||
}
|
||||
// Inserted at the end of the class object, on a line of its own. Straight after the name would read
|
||||
// better in a one-line class and reads badly in exactly the ones that matter: a class carrying
|
||||
// commentary is written over several lines, and splicing into the middle of the first one leaves the
|
||||
// rest of that line dangling behind the insertion.
|
||||
brace := len(body) - 1
|
||||
for brace > 0 && body[brace] != '}' {
|
||||
brace--
|
||||
}
|
||||
head := strings.TrimRight(body[:brace], " \t\r\n")
|
||||
return src[:start] + head + ",\n " + lit + "\n " + body[brace:] + src[end:], nil
|
||||
}
|
||||
|
||||
// patchTopNumber sets a numeric key inside a named top-level object, such as the manifest's planet block.
|
||||
func patchTopNumber(src, object, key string, value float64) (string, error) {
|
||||
os, oe, err := objectAfterKey(src, object, 0)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body := src[os:oe]
|
||||
num := formatNumber(value)
|
||||
if ks, ke, ok := keyValue(body, key); ok {
|
||||
return src[:os] + body[:ks] + fmt.Sprintf("%q: %s", key, num) + body[ke:] + src[oe:], nil
|
||||
}
|
||||
// Insert just inside the opening brace, on its own line.
|
||||
return src[:os+1] + fmt.Sprintf("\n %q: %s,", key, num) + src[os+1:], nil
|
||||
}
|
||||
|
||||
// patchTopString is patchTopNumber for a string value.
|
||||
func patchTopString(src, object, key, value string) (string, error) {
|
||||
os, oe, err := objectAfterKey(src, object, 0)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body := src[os:oe]
|
||||
if ks, ke, ok := keyValue(body, key); ok {
|
||||
return src[:os] + body[:ks] + fmt.Sprintf("%q: %q", key, value) + body[ke:] + src[oe:], nil
|
||||
}
|
||||
return src[:os+1] + fmt.Sprintf("\n %q: %q,", key, value) + src[os+1:], nil
|
||||
}
|
||||
|
||||
// classObject is the byte range of the object in the classes array whose "name" is the one asked for,
|
||||
// from its opening brace to just past its closing one.
|
||||
func classObject(src, class string) (start, end int, err error) {
|
||||
want := fmt.Sprintf("%q", class)
|
||||
from := 0
|
||||
for {
|
||||
i := indexKeyValue(src, "name", want, from)
|
||||
if i < 0 {
|
||||
return 0, 0, fmt.Errorf("no class named %q in the legend", class)
|
||||
}
|
||||
// Walk back to the opening brace of the object this key sits in.
|
||||
depth := 0
|
||||
j := i
|
||||
for ; j >= 0; j-- {
|
||||
switch src[j] {
|
||||
case '}':
|
||||
depth++
|
||||
case '{':
|
||||
if depth == 0 {
|
||||
s, e, ok := matchBrace(src, j)
|
||||
if ok {
|
||||
return s, e, nil
|
||||
}
|
||||
return 0, 0, fmt.Errorf("class %q: unbalanced braces", class)
|
||||
}
|
||||
depth--
|
||||
}
|
||||
}
|
||||
from = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
// objectAfterKey is the byte range of the object that is the value of the given key.
|
||||
func objectAfterKey(src, key string, from int) (start, end int, err error) {
|
||||
i := indexKey(src, key, from)
|
||||
if i < 0 {
|
||||
return 0, 0, fmt.Errorf("no %q object", key)
|
||||
}
|
||||
j := i
|
||||
for j < len(src) && src[j] != '{' {
|
||||
if src[j] == ',' || src[j] == '}' {
|
||||
return 0, 0, fmt.Errorf("%q is not an object", key)
|
||||
}
|
||||
j++
|
||||
}
|
||||
if j >= len(src) {
|
||||
return 0, 0, fmt.Errorf("%q is not an object", key)
|
||||
}
|
||||
s, e, ok := matchBrace(src, j)
|
||||
if !ok {
|
||||
return 0, 0, fmt.Errorf("%q: unbalanced braces", key)
|
||||
}
|
||||
return s, e, nil
|
||||
}
|
||||
|
||||
// keyValue finds "key": value inside a body and returns the range covering both, value included.
|
||||
func keyValue(body, key string) (start, end int, ok bool) {
|
||||
i := indexKey(body, key, 0)
|
||||
if i < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
// Past the colon, then over the value.
|
||||
j := i
|
||||
for j < len(body) && body[j] != ':' {
|
||||
j++
|
||||
}
|
||||
j++
|
||||
for j < len(body) && (body[j] == ' ' || body[j] == '\t' || body[j] == '\n' || body[j] == '\r') {
|
||||
j++
|
||||
}
|
||||
if j >= len(body) {
|
||||
return 0, 0, false
|
||||
}
|
||||
switch body[j] {
|
||||
case '{':
|
||||
_, e, ok := matchBrace(body, j)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
return i, e, true
|
||||
case '[':
|
||||
depth, k := 0, j
|
||||
for ; k < len(body); k++ {
|
||||
if body[k] == '[' {
|
||||
depth++
|
||||
} else if body[k] == ']' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return i, k + 1, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
case '"':
|
||||
k := j + 1
|
||||
for ; k < len(body); k++ {
|
||||
if body[k] == '\\' {
|
||||
k++
|
||||
continue
|
||||
}
|
||||
if body[k] == '"' {
|
||||
return i, k + 1, true
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
default:
|
||||
k := j
|
||||
for k < len(body) && body[k] != ',' && body[k] != '}' && body[k] != '\n' {
|
||||
k++
|
||||
}
|
||||
// A bare number or keyword ends where the scan stopped, but the scan does not stop on a space, so
|
||||
// `"width_m": 8 }` would otherwise hand back the space in front of the brace as part of the value -
|
||||
// and every edit of a last-in-object key would quietly close it up to `8}`.
|
||||
for k > j && isJSONSpace(body[k-1]) {
|
||||
k--
|
||||
}
|
||||
return i, k, true
|
||||
}
|
||||
}
|
||||
|
||||
// indexKey finds the offset of a "key" token at object level, skipping any inside a string value.
|
||||
func indexKey(s, key string, from int) int {
|
||||
needle := fmt.Sprintf("%q", key)
|
||||
for i := from; ; {
|
||||
j := strings.Index(s[i:], needle)
|
||||
if j < 0 {
|
||||
return -1
|
||||
}
|
||||
at := i + j
|
||||
// It is a key only if the next non-space character is a colon.
|
||||
k := at + len(needle)
|
||||
for k < len(s) && (s[k] == ' ' || s[k] == '\t') {
|
||||
k++
|
||||
}
|
||||
if k < len(s) && s[k] == ':' {
|
||||
return at
|
||||
}
|
||||
i = at + len(needle)
|
||||
}
|
||||
}
|
||||
|
||||
// indexKeyValue finds a "key": "value" pair and returns the offset of the key.
|
||||
func indexKeyValue(s, key, quotedValue string, from int) int {
|
||||
for i := from; ; {
|
||||
at := indexKey(s, key, i)
|
||||
if at < 0 {
|
||||
return -1
|
||||
}
|
||||
_, e, ok := keyValue(s[at:], key)
|
||||
if ok {
|
||||
seg := strings.TrimSpace(s[at : at+e])
|
||||
if strings.HasSuffix(seg, quotedValue) {
|
||||
return at
|
||||
}
|
||||
}
|
||||
i = at + 1
|
||||
}
|
||||
}
|
||||
|
||||
// matchBrace returns the range of the object opening at i.
|
||||
func matchBrace(s string, i int) (start, end int, ok bool) {
|
||||
depth := 0
|
||||
inStr := false
|
||||
for j := i; j < len(s); j++ {
|
||||
c := s[j]
|
||||
if inStr {
|
||||
if c == '\\' {
|
||||
j++
|
||||
} else if c == '"' {
|
||||
inStr = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case '"':
|
||||
inStr = true
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return i, j + 1, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// formatNumber writes a number the way a person would: no exponent, no trailing zeros, and never bare "0."
|
||||
func formatNumber(v float64) string {
|
||||
s := strconv.FormatFloat(v, 'f', -1, 64)
|
||||
if s == "-0" {
|
||||
return "0"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const legendSrc = `{
|
||||
"_comment": "what the colours mean",
|
||||
"image": "Map3.jpg",
|
||||
"classes": [
|
||||
{ "name": "ocean", "rgb": [91, 175, 185], "sea": true, "depth_m": 512 },
|
||||
|
||||
{ "_comment_plain": "why the floor is a tenth of the rate, at length",
|
||||
"name": "lowland", "rgb": [153, 204, 102], "uplift_mm_yr": 0.08, "k_mult": 1.0,
|
||||
"massif": { "floor_mm_yr": 0.012, "fraction": 0.16 },
|
||||
"coastal_plain_km": 1.0 },
|
||||
|
||||
{ "name": "highland", "rgb": [68, 170, 102], "uplift_mm_yr": 0.25, "k_mult": 1.0 }
|
||||
]
|
||||
}`
|
||||
|
||||
// The whole reason this is text surgery and not MarshalIndent: a legend is mostly commentary, and the
|
||||
// commentary has to survive a save byte for byte.
|
||||
func TestPatchingKeepsEverythingItDidNotChange(t *testing.T) {
|
||||
out, err := patchClassNumber(legendSrc, "lowland", "uplift_mm_yr", 0.12)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `"uplift_mm_yr": 0.12`) {
|
||||
t.Error("the new value is not there")
|
||||
}
|
||||
if strings.Contains(out, `"uplift_mm_yr": 0.08`) {
|
||||
t.Error("the old value is still there")
|
||||
}
|
||||
if !strings.Contains(out, `"_comment_plain": "why the floor is a tenth of the rate, at length"`) {
|
||||
t.Error("the comment was dropped")
|
||||
}
|
||||
if !strings.Contains(out, `"uplift_mm_yr": 0.25`) {
|
||||
t.Error("the other class's rate was touched")
|
||||
}
|
||||
// And nothing else moved: the only difference from the original is those four characters.
|
||||
if a, b := strings.Replace(out, "0.12", "0.08", 1), legendSrc; a != b {
|
||||
t.Errorf("the file changed somewhere else:\n--- got\n%s\n--- want\n%s", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchingAddsAKeyThatIsNotThere(t *testing.T) {
|
||||
out, err := patchClassNumber(legendSrc, "highland", "coastal_plain_km", 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `"coastal_plain_km": 4`) {
|
||||
t.Fatalf("the key was not added:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"name": "highland", "coastal_plain_km": 4`) {
|
||||
t.Errorf("it did not go in after the name:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchingAMassifBlock(t *testing.T) {
|
||||
order := []string{"floor_mm_yr", "fraction"}
|
||||
out, err := patchClassObject(legendSrc, "lowland", "massif",
|
||||
map[string]float64{"floor_mm_yr": 0.02, "fraction": 0.25}, order)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `"massif": { "floor_mm_yr": 0.02, "fraction": 0.25 }`) {
|
||||
t.Fatalf("the massif block was not rewritten:\n%s", out)
|
||||
}
|
||||
|
||||
// Adding one to a class that has none.
|
||||
out, err = patchClassObject(legendSrc, "highland", "massif",
|
||||
map[string]float64{"floor_mm_yr": 0.045, "fraction": 0.3}, order)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `"massif": { "floor_mm_yr": 0.045, "fraction": 0.3 }`) {
|
||||
t.Fatalf("the massif block was not added:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemovingAMassifBlock(t *testing.T) {
|
||||
out, err := patchClassObject(legendSrc, "lowland", "massif", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out, "massif") {
|
||||
t.Fatalf("the massif block is still there:\n%s", out)
|
||||
}
|
||||
// The class has to still parse: no doubled or dangling comma where it was.
|
||||
if strings.Contains(out, ",,") || strings.Contains(out, ", }") && !strings.Contains(legendSrc, ", }") {
|
||||
t.Errorf("the comma was left in a bad state:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchingARefusesAClassThatIsNotThere(t *testing.T) {
|
||||
if _, err := patchClassNumber(legendSrc, "tundra", "uplift_mm_yr", 0.1); err == nil {
|
||||
t.Fatal("it accepted a class the legend does not have")
|
||||
}
|
||||
}
|
||||
|
||||
const manifestSrc = `{
|
||||
"level": "/Game/Maps/L_Planet",
|
||||
"planet": {
|
||||
"_comment_scale": "why the cell is eight metres",
|
||||
"template": "Templates/Map3.jpg",
|
||||
"circumference_km": 100,
|
||||
"coast_jitter_px": 48
|
||||
}
|
||||
}`
|
||||
|
||||
func TestPatchingTheManifestPlanetBlock(t *testing.T) {
|
||||
out, err := patchTopNumber(manifestSrc, "planet", "coast_jitter_px", 64)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `"coast_jitter_px": 64`) {
|
||||
t.Fatalf("not patched:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"_comment_scale"`) {
|
||||
t.Error("the comment was dropped")
|
||||
}
|
||||
|
||||
out, err = patchTopNumber(manifestSrc, "planet", "massif_wavelength_km", 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `"massif_wavelength_km": 7`) {
|
||||
t.Fatalf("a missing key was not added:\n%s", out)
|
||||
}
|
||||
|
||||
out, err = patchTopString(manifestSrc, "planet", "template", "Templates/Map3.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `"template": "Templates/Map3.png"`) {
|
||||
t.Fatalf("the template path was not repointed:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// The base map is an input a person made by hand and there is no undo for it outside this process. Saving
|
||||
// versions rather than overwriting is the whole contract, and the second half of it is that saving twice
|
||||
// gives _001 and _002 rather than _001 and _001_002.
|
||||
func TestSavingNeverOverwritesAndNumbersUpwards(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
base := filepath.Join(dir, "Map3.jpg")
|
||||
if err := os.WriteFile(base, []byte("the base map"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
first, err := nextVersion(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filepath.Base(first) != "Map3_001.png" {
|
||||
t.Errorf("first save went to %q, want Map3_001.png", filepath.Base(first))
|
||||
}
|
||||
if err := os.WriteFile(first, []byte("v1"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Asked again from the *versioned* path, which is what the manifest now points at.
|
||||
second, err := nextVersion(first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filepath.Base(second) != "Map3_002.png" {
|
||||
t.Errorf("second save went to %q, want Map3_002.png", filepath.Base(second))
|
||||
}
|
||||
|
||||
// And the base map is still exactly what it was.
|
||||
if b, err := os.ReadFile(base); err != nil || string(b) != "the base map" {
|
||||
t.Errorf("the base map was touched: %q %v", b, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The overlay legend is patched by the same three functions - a mark is an object with a "name" like a class
|
||||
// is - so the thing worth testing separately is the one that is new: removing a key.
|
||||
const overlaySrc = `{
|
||||
"image": "Map3.overlay.png",
|
||||
"marks": [
|
||||
{ "_comment": "why this shore is pinned, at length",
|
||||
"name": "drawn_coast", "rgb": [255, 0, 255], "coast_jitter": 0 },
|
||||
{ "name": "forest", "rgb": [0, 128, 0] },
|
||||
{ "name": "road", "rgb": [90, 60, 30], "kind": "path", "width_m": 8 }
|
||||
]
|
||||
}`
|
||||
|
||||
func TestRemovingAKeyLeavesNoTrace(t *testing.T) {
|
||||
// Add one, then take it away: the file has to come back exactly as it started, or every later diff
|
||||
// carries the scar of a setting somebody tried once.
|
||||
with, err := patchClassNumber(overlaySrc, "forest", "coast_jitter", 0.5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(with, `"coast_jitter": 0.5`) {
|
||||
t.Fatalf("the key was not added:\n%s", with)
|
||||
}
|
||||
back, err := patchClassRemove(with, "forest", "coast_jitter")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back != overlaySrc {
|
||||
t.Errorf("a round trip changed the file:\n--- want ---\n%s\n--- got ---\n%s", overlaySrc, back)
|
||||
}
|
||||
|
||||
// Removing one that is not there is not an error: the studio sends "this mark says nothing about the
|
||||
// coast" whether or not it ever did.
|
||||
same, err := patchClassRemove(overlaySrc, "road", "coast_jitter")
|
||||
if err != nil || same != overlaySrc {
|
||||
t.Errorf("removing an absent key should be a no-op; err=%v changed=%v", err, same != overlaySrc)
|
||||
}
|
||||
|
||||
// And the one that is there, on a mark carrying commentary.
|
||||
out, err := patchClassRemove(overlaySrc, "drawn_coast", "coast_jitter")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out, "coast_jitter") {
|
||||
t.Error("the key is still there")
|
||||
}
|
||||
if !strings.Contains(out, `"_comment": "why this shore is pinned, at length"`) {
|
||||
t.Error("the comment went with it")
|
||||
}
|
||||
if !strings.Contains(out, `"name": "drawn_coast", "rgb": [255, 0, 255] }`) {
|
||||
t.Errorf("the trailing comma and its space were not cleaned up:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"salty/terrain/internal/plates"
|
||||
"salty/terrain/internal/template"
|
||||
)
|
||||
|
||||
// The tectonic layer in the studio: a third sheet beside the geology and the annotation.
|
||||
//
|
||||
// It is held, served and saved exactly as the other two are, and the one thing worth writing down is why it
|
||||
// is resampled on the way in.
|
||||
//
|
||||
// A tectonic layer does not have to be the template's size. plates.FromPainting registers it by *extent*, and
|
||||
// the layer `terrain plan --propose-plates` writes is a few thousand pixels wide because a plate is tens of
|
||||
// kilometres across and nothing downstream reads finer than the 250 m tectonic grid. The studio's canvas, its
|
||||
// brush, its undo and its tile upload all assume every sheet is the template's size, though - D-61's whole
|
||||
// design rests on one geometry shared by every layer - and generalising them to three resolutions would be a
|
||||
// great deal of code for a picture of seven blobs. So the layer is upsampled to the template's size on the
|
||||
// way in and saved at that size. It costs nothing on disk: it is a handful of flat colours, and PNG stores
|
||||
// that in a few kilobytes however large the canvas is.
|
||||
//
|
||||
// **A blank tectonic layer is not empty, it is one plate.** The overlay starts transparent because most of an
|
||||
// annotation is nothing; here every pixel is some piece of lithosphere, so a sheet that has never been
|
||||
// painted starts as the legend's first plate all over. That is the class template's rule rather than the
|
||||
// overlay's, and it is the same rule plates.nearestPlate follows when it refuses to leave a pixel unassigned.
|
||||
|
||||
// loadPlates reads the tectonic layer, or starts a blank one the right size.
|
||||
func (s *Server) loadPlates() error {
|
||||
if s.m.PlatesLegendPath() == "" {
|
||||
return nil
|
||||
}
|
||||
lg, err := plates.LoadPaintLegend(s.m.PlatesLegendPath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.pl = lg
|
||||
|
||||
if path := s.platesImagePath(); path != "" {
|
||||
if _, statErr := os.Stat(path); statErr == nil {
|
||||
px, w, h, err := template.DecodeRGB(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.plPaint = resampleRGB(px, w, h, s.paintW, s.paintH)
|
||||
s.plOnDisk = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
s.plPaint = blankPlates(lg, s.paintW*s.paintH)
|
||||
return nil
|
||||
}
|
||||
|
||||
// blankPlates is a sheet of the legend's first plate: see the note above about a blank layer being one plate
|
||||
// rather than nothing.
|
||||
func blankPlates(lg *plates.PaintLegend, cells int) []uint8 {
|
||||
out := make([]uint8, cells*3)
|
||||
if len(lg.Plates) == 0 {
|
||||
return out
|
||||
}
|
||||
c := lg.Plates[0].RGB
|
||||
for i := 0; i < cells; i++ {
|
||||
out[i*3], out[i*3+1], out[i*3+2] = uint8(c[0]), uint8(c[1]), uint8(c[2])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resampleRGB scales a sheet to a new size by nearest neighbour.
|
||||
//
|
||||
// Nearest, never interpolated. Every pixel of this layer is a plate id wearing a colour, and a blend of two
|
||||
// plate colours is a third plate as far as nearestPlate is concerned - so a bilinear resample would paint a
|
||||
// one-pixel ribbon of some unrelated plate down every margin on the planet.
|
||||
func resampleRGB(src []uint8, sw, sh, dw, dh int) []uint8 {
|
||||
if sw == dw && sh == dh {
|
||||
out := make([]uint8, len(src))
|
||||
copy(out, src)
|
||||
return out
|
||||
}
|
||||
out := make([]uint8, dw*dh*3)
|
||||
for y := 0; y < dh; y++ {
|
||||
sy := y * sh / dh
|
||||
for x := 0; x < dw; x++ {
|
||||
sx := x * sw / dw
|
||||
s := (sy*sw + sx) * 3
|
||||
d := (y*dw + x) * 3
|
||||
out[d], out[d+1], out[d+2] = src[s], src[s+1], src[s+2]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// platesImagePath is the layer the manifest names, or the one its legend names beside itself.
|
||||
func (s *Server) platesImagePath() string {
|
||||
if p := s.m.PlatesLayerPath(); p != "" {
|
||||
return p
|
||||
}
|
||||
if s.pl != nil && s.pl.Image != "" {
|
||||
return filepath.Join(filepath.Dir(s.m.PlatesLegendPath()), s.pl.Image)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) handlePlatesPNG(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
if s.pl == nil {
|
||||
s.mu.Unlock()
|
||||
http.Error(w, "no tectonic layer is configured", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
img := rgbaFrom(s.plPaint, s.paintW, s.paintH)
|
||||
s.mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = (&png.Encoder{CompressionLevel: png.BestSpeed}).Encode(w, img)
|
||||
}
|
||||
|
||||
// rgbaFrom turns an RGB sheet into an opaque image ready to encode.
|
||||
func rgbaFrom(px []uint8, w, h int) *image.RGBA {
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for i, n := 0, w*h; i < n; i++ {
|
||||
img.Pix[i*4] = px[i*3]
|
||||
img.Pix[i*4+1] = px[i*3+1]
|
||||
img.Pix[i*4+2] = px[i*3+2]
|
||||
img.Pix[i*4+3] = 255
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// handlePlatesPost takes the browser's tectonic canvas back, and saves it when asked.
|
||||
func (s *Server) handlePlatesPost(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 256<<20))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
img, err := png.Decode(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
http.Error(w, "the body is not a PNG: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
b := img.Bounds()
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.pl == nil {
|
||||
http.Error(w, "no tectonic layer is configured", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if b.Dx() != s.paintW || b.Dy() != s.paintH {
|
||||
http.Error(w, fmt.Sprintf("the tectonic canvas is %dx%d and the template is %dx%d",
|
||||
b.Dx(), b.Dy(), s.paintW, s.paintH), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
changed := false
|
||||
for y := 0; y < s.paintH; y++ {
|
||||
for x := 0; x < s.paintW; x++ {
|
||||
cr, cg, cb, _ := img.At(b.Min.X+x, b.Min.Y+y).RGBA()
|
||||
o := (y*s.paintW + x) * 3
|
||||
r8, g8, b8 := uint8(cr>>8), uint8(cg>>8), uint8(cb>>8)
|
||||
if s.plPaint[o] != r8 || s.plPaint[o+1] != g8 || s.plPaint[o+2] != b8 {
|
||||
changed = true
|
||||
}
|
||||
s.plPaint[o], s.plPaint[o+1], s.plPaint[o+2] = r8, g8, b8
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
s.plSeq++
|
||||
s.plDirty = true
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("save") == "1" {
|
||||
path, repointed, err := s.savePlates()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.plDirty, s.plOnDisk = false, true
|
||||
writeJSON(w, map[string]any{"saved": path, "repointed": repointed})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// savePlates writes the layer to the next free numbered PNG and points the manifest at it. Same rule as the
|
||||
// other two sheets and for the same reason: it never overwrites, because there is no undo for a painting
|
||||
// outside this process. A layer that has never existed is written at the name already asked for, which is not
|
||||
// an overwrite because nothing is there.
|
||||
func (s *Server) savePlates() (path string, repointed bool, err error) {
|
||||
src := s.platesImagePath()
|
||||
if src == "" {
|
||||
return "", false, fmt.Errorf("%s names no tectonic layer and its legend names none either; set "+
|
||||
"planet.plates.layer or the legend's \"image\"", s.manifestPath)
|
||||
}
|
||||
if _, statErr := os.Stat(src); os.IsNotExist(statErr) {
|
||||
path = src
|
||||
} else {
|
||||
if path, err = nextVersion(src); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
enc := png.Encoder{CompressionLevel: png.BestCompression}
|
||||
if err := enc.Encode(&buf, rgbaFrom(s.plPaint, s.paintW, s.paintH)); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
rel := filepath.ToSlash(filepath.Join(filepath.Dir(s.m.Planet.Plates.Legend), filepath.Base(path)))
|
||||
if rel != s.m.Planet.Plates.Layer {
|
||||
if err := s.patchManifest(func(text string) (string, error) {
|
||||
return patchTopString(text, "plates", "layer", rel)
|
||||
}); err != nil {
|
||||
return path, false, err
|
||||
}
|
||||
repointed = true
|
||||
}
|
||||
return path, repointed, nil
|
||||
}
|
||||
|
||||
// plateEdit is one plate's motion as the page sends it back.
|
||||
type plateEdit struct {
|
||||
Plate string `json:"plate"`
|
||||
SpeedCmYr *float64 `json:"speed_cm_yr"`
|
||||
HeadingDeg *float64 `json:"heading_deg"`
|
||||
SpinDegMyr *float64 `json:"spin_deg_myr"`
|
||||
}
|
||||
|
||||
// handlePlatesLegend writes the tectonic legend by patching its text, the same way the class and overlay
|
||||
// legends are written: the commentary at the top of that file is the only place the heading convention is
|
||||
// written down, and marshalling the struct back would delete it.
|
||||
func (s *Server) handlePlatesLegend(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
serveJSONFile(w, s.m.PlatesLegendPath())
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var edits []plateEdit
|
||||
if err := json.NewDecoder(r.Body).Decode(&edits); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.m.PlatesLegendPath()
|
||||
if path == "" || s.pl == nil {
|
||||
http.Error(w, "no tectonic layer is configured", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
text, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
out := string(text)
|
||||
for _, e := range edits {
|
||||
for key, v := range map[string]*float64{
|
||||
"speed_cm_yr": e.SpeedCmYr,
|
||||
"heading_deg": e.HeadingDeg,
|
||||
"spin_deg_myr": e.SpinDegMyr,
|
||||
} {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
if out, err = patchClassNumber(out, e.Plate, key, *v); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(out), 0o644); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.reload(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"saved": path})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Sharing the studio's files with another tool on the same machine.
|
||||
//
|
||||
// World Orogen (Tools/Orogen, D-66) reads the same painting and the same legends this studio edits, and until
|
||||
// now the only way to get them there was a file picker: choose the PNG, choose the legend, choose the overlay,
|
||||
// choose its legend, retype the manifest's numbers. The studio already holds every one of those - the painting
|
||||
// in memory, exactly as the next plan will read it - so it serves them, and Orogen loads a planet with one
|
||||
// button.
|
||||
//
|
||||
// Two rules keep this from turning the studio into something a web page can drive:
|
||||
//
|
||||
// - **Only GET is shared.** The CORS header goes on GET responses and nothing else, and no preflight is ever
|
||||
// answered. A cross-origin POST with a JSON body needs a preflight, so every endpoint that paints, saves,
|
||||
// plans or bakes stays reachable from this page and from nothing else. The studio listens on loopback, but
|
||||
// a browser on the same machine visits other origins all day, and "any tab can start a two-hour bake" is
|
||||
// not a property to give away for a convenience.
|
||||
// - **The files are served as they are on disk.** The legend and the manifest are text somebody wrote, with
|
||||
// commentary; Orogen reads the same keys the plan does and ignores the rest. Nothing is re-marshalled, so
|
||||
// what Orogen sees is byte for byte what `terrain plan` will see.
|
||||
|
||||
// readOnlyCORS lets any origin *read* the API and touches nothing else.
|
||||
func readOnlyCORS(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// serveJSONFile answers a GET for one of the planet's JSON files, or says there is none. The POST handlers
|
||||
// call it first and return, so an endpoint that edits a file also hands the file out.
|
||||
func serveJSONFile(w http.ResponseWriter, path string) {
|
||||
if path == "" {
|
||||
http.Error(w, "the manifest names no such file", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The one property share.go promises: another origin can read, and cannot do anything else.
|
||||
func TestCORSIsReadOnly(t *testing.T) {
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
h := readOnlyCORS(inner)
|
||||
|
||||
for _, tc := range []struct {
|
||||
method string
|
||||
want string
|
||||
}{
|
||||
{http.MethodGet, "*"},
|
||||
{http.MethodHead, "*"},
|
||||
{http.MethodPost, ""},
|
||||
{http.MethodOptions, ""},
|
||||
{http.MethodDelete, ""},
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(tc.method, "/api/legend", nil))
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != tc.want {
|
||||
t.Errorf("%s: Access-Control-Allow-Origin = %q, want %q", tc.method, got, tc.want)
|
||||
}
|
||||
// No preflight is answered: a browser needs Allow-Methods to send a cross-origin POST, and it never
|
||||
// gets one.
|
||||
if got := rec.Header().Get("Access-Control-Allow-Methods"); got != "" {
|
||||
t.Errorf("%s: Access-Control-Allow-Methods = %q, want none", tc.method, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeJSONFileIsTheFileOnDisk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "legend.json")
|
||||
// Commentary and formatting are the point: what Orogen reads is the text the author wrote.
|
||||
text := "{\n \"_comment\": \"kept\",\n \"classes\": []\n}\n"
|
||||
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
serveJSONFile(rec, path)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
if got := rec.Body.String(); got != text {
|
||||
t.Errorf("body changed:\n%s\nwant\n%s", got, text)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("Content-Type %q", ct)
|
||||
}
|
||||
|
||||
// A planet with no overlay legend has "" for its path, and that is a 404 rather than a read of "".
|
||||
rec = httptest.NewRecorder()
|
||||
serveJSONFile(rec, "")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("empty path: status %d, want 404", rec.Code)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
serveJSONFile(rec, filepath.Join(dir, "missing.json"))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("missing file: status %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user