1144 lines
40 KiB
Go
1144 lines
40 KiB
Go
// Package studio is a painting tool for a planet template, served over HTTP to a browser.
|
|
//
|
|
// The problem it solves is that the two halves of a painted world live in different programs. The shapes are
|
|
// in an image editor, which knows nothing about uplift rates; the meanings are in a JSON legend, which cannot
|
|
// show you where they land. So an author paints a colour, alt-tabs, edits a number, runs `terrain plan`,
|
|
// reads a table, and tries to hold the connection between the two in their head. The studio puts a brush and
|
|
// the number it carries in the same window: pick `highland` and you are painting 0.25 mm/yr, which the panel
|
|
// tells you is 11.7 degrees of hillslope and reads as hill country - with the 32 degree divide angle beside
|
|
// it, because that is where the number comes from - and `plan` is a button rather than a context switch.
|
|
//
|
|
// There are two sheets and only one of them is geology. The class painting is what the solve reads; the
|
|
// annotation layer (D-57, internal/overlay) rides over it carrying forests, settlements, roads and the
|
|
// coastlines to leave alone, and apart from `coast_jitter` it changes no height anywhere. It is dimmed while
|
|
// the brush is on the classes so that a road can never be mistaken for something the solve will act on.
|
|
//
|
|
// Three deliberate limits, because the alternative to each one is much bigger:
|
|
//
|
|
// - **It does not solve anything.** `plan` is four seconds and answers where the classes landed and how the
|
|
// planet is cut up; a bake is two hours. The studio is for the questions the four-second answer settles.
|
|
// - **It paints hard-edged, exact colours.** No antialiasing, no soft brushes. A blended pixel is not a
|
|
// colour between two classes, it is a pixel that classifies as whichever third class happens to sit near
|
|
// the midpoint - which is the defect internal/template's despeckle pass exists to clean up after, and it
|
|
// is better not to create it.
|
|
// - **It saves by patching the text** of the legend and the manifest rather than re-marshalling them, so
|
|
// the commentary in both survives and the files still diff. See patch.go.
|
|
package studio
|
|
|
|
import (
|
|
"bytes"
|
|
_ "embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"image"
|
|
"image/png"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"salty/terrain/internal/manifest"
|
|
"salty/terrain/internal/overlay"
|
|
"salty/terrain/internal/planet"
|
|
"salty/terrain/internal/plates"
|
|
"salty/terrain/internal/template"
|
|
)
|
|
|
|
//go:embed page.html
|
|
var page []byte
|
|
|
|
// handlePage serves the whole front end, which is one file on purpose: the studio is a tool for one person
|
|
// on one machine, and a build step between editing it and seeing it would cost more than it saved.
|
|
func (s *Server) handlePage(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_, _ = w.Write(page)
|
|
}
|
|
|
|
// Server holds the one template being edited and the last plan run against it.
|
|
type Server struct {
|
|
mu sync.Mutex
|
|
|
|
manifestPath string
|
|
m *manifest.Manifest
|
|
|
|
// paint is the painting as RGB, three bytes a pixel, the same layout template.DecodeRGB returns. It is
|
|
// the authority while the studio is open; the file on disk is only written when a save is asked for.
|
|
paint []uint8
|
|
paintW int
|
|
paintH int
|
|
dirty bool
|
|
planDir string
|
|
|
|
// paintSeq counts *changes* to the painting rather than uploads of it. The browser pushes the whole
|
|
// canvas before every plan, so counting uploads would defeat the cache below on its first use.
|
|
paintSeq int
|
|
|
|
// The annotation layer, held the same way and separately: it is a second sheet registered to the first,
|
|
// the same size, and it carries alpha because most of it is nothing. ov is nil when the manifest
|
|
// configures no overlay at all, which is the state every planet was in before D-57.
|
|
ov *overlay.Legend
|
|
ovPaint []uint8 // RGB, three bytes a pixel
|
|
ovAlpha []uint8 // one byte a pixel; below 128 is unpainted
|
|
ovDirty bool
|
|
ovSeq int
|
|
ovOnDisk bool // whether the image the manifest names exists yet
|
|
|
|
// ovGen is what the last generation put down, per pixel, and Blank where it put nothing. It exists so a
|
|
// second press of Generate can take the first draft back out before making another, instead of silting
|
|
// the sheet up with every draft ever made. See overlaygen.go.
|
|
ovGen []uint8
|
|
|
|
// The tectonic layer, held the same way again: a colour is a plate and the legend says how it moves. No
|
|
// alpha, because every pixel of it is some piece of lithosphere - see plates.go. pl is nil when the
|
|
// manifest configures no tectonic layer at all.
|
|
pl *plates.PaintLegend
|
|
plPaint []uint8 // RGB, three bytes a pixel, resampled to the template's size
|
|
plDirty bool
|
|
plSeq int
|
|
plOnDisk bool
|
|
|
|
// The last prepare, and the fingerprint of everything that went into it. See planKey.
|
|
cache *planet.Inputs
|
|
cacheKey string
|
|
|
|
// The bake, which runs in a goroutine and has a lock of its own: a two-hour job must not hold the one
|
|
// that serves the painting. See bake.go.
|
|
bake bakeRun
|
|
|
|
log func(string, ...any)
|
|
}
|
|
|
|
// New reads the manifest, the legend and the painting, and returns a server ready to listen.
|
|
func New(manifestPath string, log func(string, ...any)) (*Server, error) {
|
|
m, err := manifest.Load(manifestPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !m.IsPlanet() {
|
|
return nil, fmt.Errorf("%s has no planet block; the studio edits a painted planet", manifestPath)
|
|
}
|
|
px, w, h, err := template.DecodeRGB(m.TemplatePath())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dir, err := os.MkdirTemp("", "terrain-studio-")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s := &Server{
|
|
manifestPath: manifestPath, m: m,
|
|
paint: px, paintW: w, paintH: h,
|
|
planDir: dir, log: log,
|
|
}
|
|
if err := s.loadOverlay(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.loadPlates(); err != nil {
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// loadOverlay reads the annotation layer, or starts a blank one the right size.
|
|
//
|
|
// A configured overlay with no image yet is the normal way to begin: the legend says what the marks mean and
|
|
// the sheet is empty until somebody paints on it. Starting blank rather than refusing is what lets an author
|
|
// add the layer by writing four lines of JSON and then picking up a brush.
|
|
func (s *Server) loadOverlay() error {
|
|
if !s.m.HasOverlay() {
|
|
return nil
|
|
}
|
|
ov, err := overlay.Load(s.m.OverlayLegendPath())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.ov = ov
|
|
path := s.overlayImagePath()
|
|
if path != "" {
|
|
if _, statErr := os.Stat(path); statErr == nil {
|
|
px, alpha, w, h, err := template.DecodeRGBA(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if w != s.paintW || h != s.paintH {
|
|
return fmt.Errorf("the overlay %s is %dx%d and the template is %dx%d; they are registered "+
|
|
"to each other, so they have to be the same size", filepath.Base(path), w, h,
|
|
s.paintW, s.paintH)
|
|
}
|
|
s.ovPaint, s.ovAlpha, s.ovOnDisk = px, alpha, true
|
|
return nil
|
|
}
|
|
}
|
|
s.ovPaint = make([]uint8, s.paintW*s.paintH*3)
|
|
s.ovAlpha = make([]uint8, s.paintW*s.paintH)
|
|
return nil
|
|
}
|
|
|
|
// overlayImagePath is the overlay image the manifest names, or the one its legend names beside itself.
|
|
func (s *Server) overlayImagePath() string {
|
|
if p := s.m.OverlayPath(); p != "" {
|
|
return p
|
|
}
|
|
if s.ov != nil && s.ov.Image != "" {
|
|
return filepath.Join(filepath.Dir(s.m.OverlayLegendPath()), s.ov.Image)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// art is the paintings as the planet package wants them. Called with the lock held.
|
|
func (s *Server) art() *planet.Painting {
|
|
a := &planet.Painting{Class: s.paint, ClassW: s.paintW, ClassH: s.paintH}
|
|
if s.ov != nil {
|
|
a.Overlay, a.OverlayAlpha = s.ovPaint, s.ovAlpha
|
|
a.OverlayW, a.OverlayH = s.paintW, s.paintH
|
|
}
|
|
if s.pl != nil {
|
|
a.Plates, a.PlatesW, a.PlatesH = s.plPaint, s.paintW, s.paintH
|
|
}
|
|
return a
|
|
}
|
|
|
|
// Close removes the scratch directory the plan maps are written into.
|
|
func (s *Server) Close() error { return os.RemoveAll(s.planDir) }
|
|
|
|
// Handler is the whole API plus the page.
|
|
func (s *Server) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/", s.handlePage)
|
|
mux.HandleFunc("/api/state", s.handleState)
|
|
mux.HandleFunc("/api/paint.png", s.handlePaintPNG)
|
|
mux.HandleFunc("/api/paint", s.handlePaintPost)
|
|
mux.HandleFunc("/api/legend", s.handleLegend)
|
|
mux.HandleFunc("/api/overlay.png", s.handleOverlayPNG)
|
|
mux.HandleFunc("/api/overlay", s.handleOverlayPost)
|
|
mux.HandleFunc("/api/overlay/legend", s.handleOverlayLegend)
|
|
mux.HandleFunc("/api/overlay/generate", s.handleOverlayGenerate)
|
|
mux.HandleFunc("/api/plates.png", s.handlePlatesPNG)
|
|
mux.HandleFunc("/api/plates", s.handlePlatesPost)
|
|
mux.HandleFunc("/api/plates/legend", s.handlePlatesLegend)
|
|
mux.HandleFunc("/api/planet", s.handlePlanet)
|
|
mux.HandleFunc("/api/plan", s.handlePlan)
|
|
mux.HandleFunc("/api/map/", s.handleMap)
|
|
mux.HandleFunc("/api/bake", s.handleBake)
|
|
mux.HandleFunc("/api/bake/cancel", s.handleBakeCancel)
|
|
mux.HandleFunc("/api/bake/preview.png", s.handleBakePreview)
|
|
// Readable from another origin, never writable: see share.go.
|
|
return readOnlyCORS(mux)
|
|
}
|
|
|
|
// Listen serves until the process is stopped, and reports the address it got.
|
|
func (s *Server) Listen(addr string) error {
|
|
ln, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.log("studio http://%s", ln.Addr())
|
|
s.log(" %d x %d painting, %d classes; painting is held in memory until you press Save",
|
|
s.paintW, s.paintH, len(s.legend().Classes))
|
|
return http.Serve(ln, s.Handler())
|
|
}
|
|
|
|
func (s *Server) legend() *template.Legend {
|
|
lg, err := template.Load(s.m.LegendPath())
|
|
if err != nil {
|
|
return &template.Legend{}
|
|
}
|
|
return lg
|
|
}
|
|
|
|
// overlayLegend is the marks as they are on disk, re-read for the same reason legend() is re-read: either
|
|
// file may be edited by hand while the studio is open, and here that is the *expected* workflow rather than
|
|
// an edge case - the studio edits a mark's numbers but cannot add one, so adding a mark means opening the
|
|
// JSON. A stale copy would make the new brush appear only after a restart. A file that will not parse keeps
|
|
// the last good one, so a half-typed legend does not empty the palette mid-edit.
|
|
func (s *Server) overlayLegend() *overlay.Legend {
|
|
if s.ov == nil {
|
|
return nil
|
|
}
|
|
if lg, err := overlay.Load(s.m.OverlayLegendPath()); err == nil {
|
|
s.ov = lg
|
|
}
|
|
return s.ov
|
|
}
|
|
|
|
// --- state -------------------------------------------------------------------------------------------
|
|
|
|
type classState struct {
|
|
Name string `json:"name"`
|
|
RGB [3]int `json:"rgb"`
|
|
Sea bool `json:"sea"`
|
|
Derived bool `json:"derived"`
|
|
Stroke bool `json:"stroke"`
|
|
DepthM float64 `json:"depth_m"`
|
|
UpliftMm float64 `json:"uplift_mm_yr"`
|
|
KMult float64 `json:"k_mult"`
|
|
HasMassif bool `json:"has_massif"`
|
|
FloorMm float64 `json:"floor_mm_yr"`
|
|
Fraction float64 `json:"fraction"`
|
|
PlainKm float64 `json:"coastal_plain_km"`
|
|
PlainFl float64 `json:"coastal_floor_mm_yr"`
|
|
}
|
|
|
|
type stateReply struct {
|
|
Manifest string `json:"manifest"`
|
|
Template string `json:"template"`
|
|
Legend string `json:"legend"`
|
|
PaintW int `json:"paint_w"`
|
|
PaintH int `json:"paint_h"`
|
|
MetresPerPx float64 `json:"metres_per_px"`
|
|
CellM float64 `json:"cell_m"`
|
|
K float64 `json:"k"`
|
|
M float64 `json:"m"`
|
|
TalusDeg float64 `json:"talus_deg"`
|
|
Circum float64 `json:"circumference_km"`
|
|
Seed int64 `json:"seed"`
|
|
MassifKm float64 `json:"massif_wavelength_km"`
|
|
LithologyKm float64 `json:"lithology_wavelength_km"`
|
|
FaultGrainKm float64 `json:"fault_grain_km"`
|
|
CoastPx float64 `json:"coast_jitter_px"`
|
|
CoastWave float64 `json:"coast_jitter_wavelength_px"`
|
|
CoastOct int `json:"coast_jitter_octaves"`
|
|
CoastGain float64 `json:"coast_jitter_gain"`
|
|
Dirty bool `json:"dirty"`
|
|
Plates *platesState `json:"plates"`
|
|
Classes []classState `json:"classes"`
|
|
|
|
// Overlay is nil when the manifest configures no annotation layer, which is how the page decides whether
|
|
// to offer the second brush set at all.
|
|
Overlay *overlayState `json:"overlay"`
|
|
}
|
|
|
|
type markState struct {
|
|
Name string `json:"name"`
|
|
RGB [3]int `json:"rgb"`
|
|
Kind string `json:"kind"`
|
|
HasJitter bool `json:"has_coast_jitter"`
|
|
CoastJitter float64 `json:"coast_jitter"`
|
|
WidthM float64 `json:"width_m"`
|
|
Note string `json:"note"`
|
|
}
|
|
|
|
type overlayState struct {
|
|
Legend string `json:"legend"`
|
|
Image string `json:"image"`
|
|
OnDisk bool `json:"on_disk"`
|
|
Dirty bool `json:"dirty"`
|
|
Marks []markState `json:"marks"`
|
|
}
|
|
|
|
// plateState is one plate as the panel shows it: a colour to paint with and the motion that colour carries.
|
|
type plateState struct {
|
|
Name string `json:"name"`
|
|
RGB [3]int `json:"rgb"`
|
|
SpeedCmYr float64 `json:"speed_cm_yr"`
|
|
HeadingDeg float64 `json:"heading_deg"`
|
|
SpinDegMyr float64 `json:"spin_deg_myr"`
|
|
|
|
// Continental is what the plate *is*, after the land mask and any legend override; Forced says the
|
|
// legend named it rather than the painting deciding. The panel shows both, because "this came out
|
|
// oceanic" and "you said this is oceanic" answer different questions about a margin.
|
|
Continental bool `json:"continental"`
|
|
Forced bool `json:"forced"`
|
|
}
|
|
|
|
type platesState struct {
|
|
Legend string `json:"legend"`
|
|
Image string `json:"image"`
|
|
OnDisk bool `json:"on_disk"`
|
|
Dirty bool `json:"dirty"`
|
|
Plates []plateState `json:"plates"`
|
|
}
|
|
|
|
func (s *Server) handleState(w http.ResponseWriter, r *http.Request) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
lg := s.legend()
|
|
st := stateReply{
|
|
Manifest: s.manifestPath, Template: s.m.Planet.Template, Legend: s.m.Planet.Legend,
|
|
PaintW: s.paintW, PaintH: s.paintH,
|
|
MetresPerPx: s.m.Planet.CircumferenceKm * 1000 / float64(s.paintW),
|
|
CellM: s.m.GeologyCellM(),
|
|
K: s.m.Pipeline.Fluvial.K,
|
|
M: s.m.Pipeline.Fluvial.M,
|
|
TalusDeg: s.m.Pipeline.Thermal.TalusDeg,
|
|
Circum: s.m.Planet.CircumferenceKm,
|
|
Seed: s.m.Source.Seed,
|
|
MassifKm: s.m.Planet.MassifWavelengthKm,
|
|
LithologyKm: s.m.Planet.LithologyWavelengthKm,
|
|
FaultGrainKm: s.m.Planet.FaultGrainKm,
|
|
CoastPx: s.m.Planet.CoastJitterPx,
|
|
CoastWave: s.m.Planet.CoastJitterWavelengthPx,
|
|
CoastOct: s.m.Planet.CoastJitterOctaves,
|
|
CoastGain: s.m.Planet.CoastJitterGain,
|
|
Dirty: s.dirty,
|
|
}
|
|
if ov := s.overlayLegend(); ov != nil {
|
|
os := &overlayState{
|
|
Legend: s.m.Planet.OverlayLegend, Image: s.m.Planet.Overlay,
|
|
OnDisk: s.ovOnDisk, Dirty: s.ovDirty,
|
|
}
|
|
if os.Image == "" {
|
|
os.Image = ov.Image
|
|
}
|
|
for i := range ov.Marks {
|
|
mk := ov.Marks[i]
|
|
ms := markState{Name: mk.Name, RGB: mk.RGB, Kind: mk.Kind, WidthM: mk.WidthM, Note: mk.Note}
|
|
ms.CoastJitter, ms.HasJitter = mk.Jitter()
|
|
os.Marks = append(os.Marks, ms)
|
|
}
|
|
st.Overlay = os
|
|
}
|
|
if s.pl != nil {
|
|
ps := &platesState{
|
|
Legend: s.m.Planet.Plates.Legend, Image: s.m.Planet.Plates.Layer,
|
|
OnDisk: s.plOnDisk, Dirty: s.plDirty,
|
|
}
|
|
if ps.Image == "" {
|
|
ps.Image = s.pl.Image
|
|
}
|
|
for i := range s.pl.Plates {
|
|
p := s.pl.Plates[i]
|
|
st := plateState{
|
|
Name: p.Name, RGB: p.RGB, SpeedCmYr: p.SpeedCmYr,
|
|
HeadingDeg: p.HeadingDeg, SpinDegMyr: p.SpinDegMyr,
|
|
}
|
|
if p.Continental != nil {
|
|
st.Continental, st.Forced = *p.Continental, true
|
|
} else if s.cache != nil && s.cache.Plates != nil && i < len(s.cache.Plates.Plates) {
|
|
// What the land mask decided, when the last plan worked it out. Before the first plan there
|
|
// is nothing honest to say, so it stays false rather than guessing.
|
|
st.Continental = s.cache.Plates.Plates[i].Continental
|
|
}
|
|
ps.Plates = append(ps.Plates, st)
|
|
}
|
|
st.Plates = ps
|
|
}
|
|
for i := range lg.Classes {
|
|
c := lg.Classes[i]
|
|
cs := classState{
|
|
Name: c.Name, RGB: c.RGB, Sea: c.Sea, Derived: c.Derived, Stroke: c.Stroke,
|
|
DepthM: c.DepthM, UpliftMm: c.UpliftMmYr, KMult: c.K(),
|
|
PlainKm: c.CoastalPlainKm, PlainFl: c.CoastalFloorMmYr,
|
|
}
|
|
if c.Massif != nil {
|
|
cs.HasMassif = true
|
|
cs.FloorMm = c.Massif.FloorMmYr
|
|
cs.Fraction = c.Massif.Fraction
|
|
}
|
|
st.Classes = append(st.Classes, cs)
|
|
}
|
|
writeJSON(w, st)
|
|
}
|
|
|
|
// --- the painting ------------------------------------------------------------------------------------
|
|
|
|
func (s *Server) handlePaintPNG(w http.ResponseWriter, r *http.Request) {
|
|
s.mu.Lock()
|
|
img := image.NewRGBA(image.Rect(0, 0, s.paintW, s.paintH))
|
|
for i, n := 0, s.paintW*s.paintH; i < n; i++ {
|
|
img.Pix[i*4] = s.paint[i*3]
|
|
img.Pix[i*4+1] = s.paint[i*3+1]
|
|
img.Pix[i*4+2] = s.paint[i*3+2]
|
|
img.Pix[i*4+3] = 255
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
w.Header().Set("Content-Type", "image/png")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
enc := png.Encoder{CompressionLevel: png.BestSpeed}
|
|
_ = enc.Encode(w, img)
|
|
}
|
|
|
|
// handlePaintPost takes the browser's canvas back, either as the whole painting or as one dirty tile.
|
|
func (s *Server) handlePaintPost(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 b.Dx() != s.paintW || b.Dy() != s.paintH {
|
|
http.Error(w, fmt.Sprintf("the canvas is %dx%d and the painting 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.paint[o] != r8 || s.paint[o+1] != g8 || s.paint[o+2] != b8 {
|
|
changed = true
|
|
}
|
|
s.paint[o], s.paint[o+1], s.paint[o+2] = r8, g8, b8
|
|
}
|
|
}
|
|
if changed {
|
|
s.paintSeq++
|
|
s.dirty = true
|
|
}
|
|
|
|
if r.URL.Query().Get("save") == "1" {
|
|
path, repointed, err := s.savePainting()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.dirty = false
|
|
writeJSON(w, map[string]any{"saved": path, "repointed": repointed})
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{"ok": true})
|
|
}
|
|
|
|
// savePainting writes the painting to the next free numbered PNG beside the template it came from, and
|
|
// points the manifest at it.
|
|
//
|
|
// **It never overwrites anything**, and the base map least of all. Two reasons, and the second is the one
|
|
// that bites. A painting is an input a person made by hand and there is no undo for it outside this process,
|
|
// so a tool that writes over it is one misclick away from destroying work nothing can rebuild. And a save
|
|
// through a *lossy* codec - which writing back over the JPEG this template started as would be - re-creates
|
|
// exactly the blended boundary pixels the despeckle pass exists to remove, compounding them on every save
|
|
// until the classifier is reading the tool's own artefacts instead of the painting.
|
|
//
|
|
// So the files go Map3_001.png, Map3_002.png, and so on, for the same reason bakes go Bake_001, Bake_002:
|
|
// the interesting question is almost always "what did that change", and answering it needs both. Rolling
|
|
// back is repointing planet.template at an earlier one.
|
|
func (s *Server) savePainting() (path string, repointed bool, err error) {
|
|
src := s.m.TemplatePath()
|
|
path, err = nextVersion(src)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
|
|
img := image.NewNRGBA(image.Rect(0, 0, s.paintW, s.paintH))
|
|
for i, n := 0, s.paintW*s.paintH; i < n; i++ {
|
|
img.Pix[i*4] = s.paint[i*3]
|
|
img.Pix[i*4+1] = s.paint[i*3+1]
|
|
img.Pix[i*4+2] = s.paint[i*3+2]
|
|
img.Pix[i*4+3] = 255
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := (&png.Encoder{CompressionLevel: png.BestCompression}).Encode(&buf, img); 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.Template), filepath.Base(path)))
|
|
if rel != s.m.Planet.Template {
|
|
if err := s.patchManifest(func(text string) (string, error) {
|
|
return patchTopString(text, "planet", "template", rel)
|
|
}); err != nil {
|
|
return path, false, err
|
|
}
|
|
repointed = true
|
|
}
|
|
return path, repointed, nil
|
|
}
|
|
|
|
// --- the annotation layer -----------------------------------------------------------------------------
|
|
|
|
// handleOverlayPNG serves the overlay as RGBA, alpha and all. The class painting goes out opaque because
|
|
// every pixel of it is some class; this one is a transparent sheet and the browser needs to know which
|
|
// pixels are nothing, or an unpainted overlay would cover the world in black.
|
|
func (s *Server) handleOverlayPNG(w http.ResponseWriter, r *http.Request) {
|
|
s.mu.Lock()
|
|
if s.ov == nil {
|
|
s.mu.Unlock()
|
|
http.Error(w, "no overlay is configured", http.StatusNotFound)
|
|
return
|
|
}
|
|
img := image.NewNRGBA(image.Rect(0, 0, s.paintW, s.paintH))
|
|
for i, n := 0, s.paintW*s.paintH; i < n; i++ {
|
|
img.Pix[i*4] = s.ovPaint[i*3]
|
|
img.Pix[i*4+1] = s.ovPaint[i*3+1]
|
|
img.Pix[i*4+2] = s.ovPaint[i*3+2]
|
|
img.Pix[i*4+3] = s.ovAlpha[i]
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
w.Header().Set("Content-Type", "image/png")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
enc := png.Encoder{CompressionLevel: png.BestSpeed}
|
|
_ = enc.Encode(w, img)
|
|
}
|
|
|
|
// handleOverlayPost takes the browser's overlay canvas back, and saves it when asked.
|
|
func (s *Server) handleOverlayPost(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.ov == nil {
|
|
http.Error(w, "no overlay is configured", http.StatusNotFound)
|
|
return
|
|
}
|
|
if b.Dx() != s.paintW || b.Dy() != s.paintH {
|
|
http.Error(w, fmt.Sprintf("the overlay 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, ca := img.At(b.Min.X+x, b.Min.Y+y).RGBA()
|
|
i := y*s.paintW + x
|
|
o := i * 3
|
|
r8, g8, b8, a8 := uint8(cr>>8), uint8(cg>>8), uint8(cb>>8), uint8(ca>>8)
|
|
// A fully transparent pixel has no colour worth comparing - a canvas that cleared to
|
|
// transparent black and one that cleared to transparent white are the same sheet - so alpha
|
|
// decides on its own wherever it is zero.
|
|
if s.ovAlpha[i] != a8 || (a8 >= 128 &&
|
|
(s.ovPaint[o] != r8 || s.ovPaint[o+1] != g8 || s.ovPaint[o+2] != b8)) {
|
|
changed = true
|
|
}
|
|
s.ovPaint[o], s.ovPaint[o+1], s.ovPaint[o+2], s.ovAlpha[i] = r8, g8, b8, a8
|
|
}
|
|
}
|
|
if changed {
|
|
s.ovSeq++
|
|
s.ovDirty = true
|
|
}
|
|
|
|
if r.URL.Query().Get("save") == "1" {
|
|
path, repointed, err := s.saveOverlay()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.ovDirty, s.ovOnDisk = false, true
|
|
writeJSON(w, map[string]any{"saved": path, "repointed": repointed})
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{"ok": true})
|
|
}
|
|
|
|
// saveOverlay writes the overlay to the next free numbered PNG and points the manifest at it. Same rule as
|
|
// the class painting and for the same reason: it never overwrites, because there is no undo for a painting
|
|
// outside this process.
|
|
//
|
|
// The first save is the exception that is not one. An overlay that has never existed has no file to version
|
|
// away from, so it is written at the name the legend or the manifest already asks for - which is not an
|
|
// overwrite, because nothing is there.
|
|
func (s *Server) saveOverlay() (path string, repointed bool, err error) {
|
|
src := s.overlayImagePath()
|
|
if src == "" {
|
|
return "", false, fmt.Errorf("%s names no overlay image and its legend names none either; "+
|
|
"set planet.overlay 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
|
|
}
|
|
}
|
|
|
|
img := image.NewNRGBA(image.Rect(0, 0, s.paintW, s.paintH))
|
|
for i, n := 0, s.paintW*s.paintH; i < n; i++ {
|
|
img.Pix[i*4] = s.ovPaint[i*3]
|
|
img.Pix[i*4+1] = s.ovPaint[i*3+1]
|
|
img.Pix[i*4+2] = s.ovPaint[i*3+2]
|
|
img.Pix[i*4+3] = s.ovAlpha[i]
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := (&png.Encoder{CompressionLevel: png.BestCompression}).Encode(&buf, img); 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.OverlayLegend), filepath.Base(path)))
|
|
if rel != s.m.Planet.Overlay {
|
|
if err := s.patchManifest(func(text string) (string, error) {
|
|
return patchTopString(text, "planet", "overlay", rel)
|
|
}); err != nil {
|
|
return path, false, err
|
|
}
|
|
repointed = true
|
|
}
|
|
return path, repointed, nil
|
|
}
|
|
|
|
// markEdit is one mark's numbers as the page sends them back.
|
|
type markEdit struct {
|
|
Mark string `json:"mark"`
|
|
HasJitter *bool `json:"has_coast_jitter"`
|
|
CoastJitter *float64 `json:"coast_jitter"`
|
|
WidthM *float64 `json:"width_m"`
|
|
}
|
|
|
|
// handleOverlayLegend writes the overlay legend the same way handleLegend writes the class one: by patching
|
|
// the text, so the commentary survives and the file still diffs.
|
|
func (s *Server) handleOverlayLegend(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet {
|
|
serveJSONFile(w, s.m.OverlayLegendPath())
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var edits []markEdit
|
|
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()
|
|
if s.ov == nil {
|
|
http.Error(w, "no overlay is configured", http.StatusNotFound)
|
|
return
|
|
}
|
|
path := s.m.OverlayLegendPath()
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
text := string(raw)
|
|
for _, e := range edits {
|
|
if e.HasJitter != nil {
|
|
if *e.HasJitter && e.CoastJitter != nil {
|
|
text, err = patchClassNumber(text, e.Mark, "coast_jitter", *e.CoastJitter)
|
|
} else if !*e.HasJitter {
|
|
text, err = patchClassRemove(text, e.Mark, "coast_jitter")
|
|
}
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
if e.WidthM != nil {
|
|
if text, err = patchClassNumber(text, e.Mark, "width_m", *e.WidthM); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
ov, err := overlay.Parse([]byte(text))
|
|
if err != nil {
|
|
http.Error(w, "that overlay legend would not load: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.ov = ov
|
|
writeJSON(w, map[string]any{"ok": true})
|
|
}
|
|
|
|
// versionSuffix matches the _NNN this appends, so that saving twice gives Map3_001 and Map3_002 rather than
|
|
// Map3_001 and Map3_001_002.
|
|
var versionSuffix = regexp.MustCompile(`_([0-9]{3})$`)
|
|
|
|
// nextVersion is the first <stem>_NNN.png beside a path that does not exist yet.
|
|
func nextVersion(src string) (string, error) {
|
|
dir := filepath.Dir(src)
|
|
stem := strings.TrimSuffix(filepath.Base(src), filepath.Ext(src))
|
|
stem = versionSuffix.ReplaceAllString(stem, "")
|
|
for n := 1; n < 1000; n++ {
|
|
p := filepath.Join(dir, fmt.Sprintf("%s_%03d.png", stem, n))
|
|
if _, err := os.Stat(p); os.IsNotExist(err) {
|
|
return p, nil
|
|
} else if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
return "", fmt.Errorf("%s_001.png through _999.png all exist; tidy some up", stem)
|
|
}
|
|
|
|
// --- the legend and the planet block ------------------------------------------------------------------
|
|
|
|
type legendEdit struct {
|
|
Class string `json:"class"`
|
|
UpliftMm *float64 `json:"uplift_mm_yr"`
|
|
KMult *float64 `json:"k_mult"`
|
|
PlainKm *float64 `json:"coastal_plain_km"`
|
|
PlainFl *float64 `json:"coastal_floor_mm_yr"`
|
|
HasMassif *bool `json:"has_massif"`
|
|
FloorMm *float64 `json:"floor_mm_yr"`
|
|
Fraction *float64 `json:"fraction"`
|
|
}
|
|
|
|
func (s *Server) handleLegend(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet {
|
|
serveJSONFile(w, s.m.LegendPath())
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var edits []legendEdit
|
|
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.LegendPath()
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
text := string(raw)
|
|
for _, e := range edits {
|
|
set := func(key string, v *float64) error {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
out, err := patchClassNumber(text, e.Class, key, *v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
text = out
|
|
return nil
|
|
}
|
|
for key, v := range map[string]*float64{
|
|
"uplift_mm_yr": e.UpliftMm, "k_mult": e.KMult,
|
|
"coastal_plain_km": e.PlainKm, "coastal_floor_mm_yr": e.PlainFl,
|
|
} {
|
|
if err := set(key, v); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
if e.HasMassif != nil {
|
|
var fields map[string]float64
|
|
if *e.HasMassif {
|
|
fields = map[string]float64{}
|
|
if e.FloorMm != nil {
|
|
fields["floor_mm_yr"] = *e.FloorMm
|
|
}
|
|
if e.Fraction != nil {
|
|
fields["fraction"] = *e.Fraction
|
|
}
|
|
}
|
|
out, err := patchClassObject(text, e.Class, "massif", fields,
|
|
[]string{"floor_mm_yr", "fraction"})
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
text = out
|
|
}
|
|
}
|
|
|
|
// Never write a legend the tool would then refuse to load: the studio is the one place a bad number can
|
|
// be typed, so it is the place to catch it.
|
|
if _, err := template.Parse([]byte(text)); err != nil {
|
|
http.Error(w, "that legend would not load: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := os.WriteFile(path, []byte(text), 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{"ok": true})
|
|
}
|
|
|
|
// handlePlanet writes the numbers that live in the manifest's planet block rather than in the legend.
|
|
func (s *Server) handlePlanet(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet {
|
|
serveJSONFile(w, s.manifestPath)
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var edits map[string]float64
|
|
if err := json.NewDecoder(r.Body).Decode(&edits); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
allowed := map[string]bool{
|
|
"massif_wavelength_km": true, "coast_jitter_px": true,
|
|
"coast_jitter_wavelength_px": true, "coast_jitter_octaves": true, "coast_jitter_gain": true,
|
|
"lithology_wavelength_km": true, "fault_grain_km": true,
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
err := s.patchManifest(func(text string) (string, error) {
|
|
// Sorted, so a save that changes several keys writes them in one order whatever Go's map iteration
|
|
// felt like - the file is under review and a diff that shuffles between runs is a diff nobody reads.
|
|
keys := make([]string, 0, len(edits))
|
|
for k := range edits {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, k := range keys {
|
|
v := edits[k]
|
|
// The seed is not in the planet block. It is `source.seed`, one object over, and it is here
|
|
// rather than in a command of its own because for an author it is the same gesture: re-roll
|
|
// everything the painting does not fix.
|
|
if k == "seed" {
|
|
out, err := patchTopNumber(text, "source", k, v)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
text = out
|
|
continue
|
|
}
|
|
if !allowed[k] {
|
|
return "", fmt.Errorf("%q is not a planet key the studio edits", k)
|
|
}
|
|
out, err := patchTopNumber(text, "planet", k, v)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
text = out
|
|
}
|
|
return text, nil
|
|
})
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{"ok": true})
|
|
}
|
|
|
|
// patchManifest applies a text edit to the manifest, refusing to write one that would not load.
|
|
func (s *Server) patchManifest(edit func(string) (string, error)) error {
|
|
raw, err := os.ReadFile(s.manifestPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
text, err := edit(string(raw))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := s.manifestPath + ".studio-tmp"
|
|
if err := os.WriteFile(tmp, []byte(text), 0o644); err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(tmp)
|
|
if _, err := manifest.Load(tmp); err != nil {
|
|
return fmt.Errorf("that manifest would not load: %w", err)
|
|
}
|
|
if err := os.WriteFile(s.manifestPath, []byte(text), 0o644); err != nil {
|
|
return err
|
|
}
|
|
return s.reload()
|
|
}
|
|
|
|
func (s *Server) reload() error {
|
|
m, err := manifest.Load(s.manifestPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.m = m
|
|
return nil
|
|
}
|
|
|
|
// --- plan --------------------------------------------------------------------------------------------
|
|
|
|
func (s *Server) handlePlan(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
start := time.Now()
|
|
|
|
// Where the seven seconds go, measured on the 100 km template: classify 0.09 s, dissolve strokes 0.93,
|
|
// despeckle 1.52, the coast mask 1.62, project 0.05, and region.Build 2.68. The last one is the biggest
|
|
// and it works on the 76 million cell planet grid, so rendering the *maps* smaller buys nothing at all -
|
|
// measured flat at 6.5 s from a 400 px map to a 2400 px one.
|
|
//
|
|
// What does buy something is noticing that changing a number in the legend changes none of it. The
|
|
// classification, the despeckle, the coast mask, the projection and the region cuts all depend on the
|
|
// painting and on the class *colours*, and on nothing else; an uplift rate only decides how the maps are
|
|
// coloured in. So a plan that differs from the last one only in the legend's numbers reuses the whole
|
|
// prepare and re-renders, which is the tuning loop and is well under a second.
|
|
key := s.planKey()
|
|
cached := s.cache != nil && key == s.cacheKey
|
|
in := s.cache
|
|
if cached {
|
|
// The legend and the manifest are swapped for the fresh ones. Safe precisely because the key covers
|
|
// everything that could have changed the raster: same colours in the same order means the class
|
|
// indices in it still mean what they meant.
|
|
in.Legend = s.legend()
|
|
in.Map.L = in.Legend
|
|
in.M = s.m
|
|
in.Overlay = s.ov
|
|
// The fault set is drawn in prepare and keyed on nothing the cache fingerprints, because a class's
|
|
// `faults` block changes it without changing a pixel of the raster. Redraw it rather than widening
|
|
// the key: it costs milliseconds, and the alternative is a cached plan showing the old traces.
|
|
in.RebuildFaults()
|
|
if err := planet.WriteMaps(s.planDir, in, planMapWidth); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
} else {
|
|
fresh, err := planet.PlanPainting(s.m, s.art(), s.planDir, planMapWidth,
|
|
func(string, ...any) {})
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
in, s.cache, s.cacheKey = fresh, fresh, key
|
|
}
|
|
|
|
upliftHi, upliftRamp := in.UpliftScale(24)
|
|
erodLo, erodHi, erodRamp := in.ErodibilityScale(24)
|
|
hues := in.RegionHues()
|
|
// u,v is where the browser writes the region's id over the map. It comes from here for the same reason the
|
|
// colour does: the page would otherwise have to reproduce the polar row offset and the seam wrap to place a
|
|
// number, and a number in the wrong region is worse than no number.
|
|
labels := in.RegionLabels()
|
|
regions := make([]map[string]any, 0, len(in.Part.Regions))
|
|
for i, rg := range in.Part.Regions {
|
|
regions = append(regions, map[string]any{
|
|
"id": rg.ID, "rgb": hues[i], "u": labels[i][0], "v": labels[i][1],
|
|
})
|
|
}
|
|
|
|
writeJSON(w, map[string]any{
|
|
"report": in.Report(),
|
|
"seconds": time.Since(start).Seconds(),
|
|
"cached": cached,
|
|
"maps": in.MapNames(),
|
|
"overlay": overlayReply(in),
|
|
"stamp": time.Now().UnixNano(),
|
|
// The keys for the four maps, taken from the code that drew them rather than reimplemented in the
|
|
// browser: a legend that is a second copy of the thing it describes is one that will eventually
|
|
// disagree with it.
|
|
"scales": map[string]any{
|
|
"uplift": map[string]any{"hi": upliftHi, "ramp": upliftRamp},
|
|
"erodibility": map[string]any{"lo": erodLo, "hi": erodHi, "ramp": erodRamp},
|
|
"regions": regions,
|
|
},
|
|
})
|
|
}
|
|
|
|
// overlayReply is the annotation layer's share of the plan, or nil when there is none.
|
|
func overlayReply(in *planet.Inputs) any {
|
|
if in.OverlayDoc == nil {
|
|
return nil
|
|
}
|
|
return map[string]any{
|
|
"marks": in.OverlayDoc.Marks,
|
|
"far_px": in.OverlayMatch.Far,
|
|
"features": len(in.OverlayDoc.Features),
|
|
}
|
|
}
|
|
|
|
// planMapWidth is what the maps are rendered at. It is not a speed knob - prepare costs the same whatever
|
|
// this is - it is only how much detail the overlay has when it is zoomed into.
|
|
const planMapWidth = 1600
|
|
|
|
// planKey fingerprints everything a prepare depends on. Anything not in here is something the cache is
|
|
// asserting cannot change the raster or the region cuts: the legend's *numbers* are the whole point, and the
|
|
// class colours, their order and their count are in here because the raster stores class indices.
|
|
func (s *Server) planKey() string {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "p%d|", s.paintSeq)
|
|
pb := s.m.Planet
|
|
fmt.Fprintf(&b, "%v,%v,%v,%v,%v,%v,%v,%v,%v|",
|
|
pb.CircumferenceKm, pb.OceanMarginKm, pb.MinLandCells, pb.NoisePeriodKm,
|
|
pb.CoastJitterPx, pb.CoastJitterWavelengthPx, pb.CoastJitterOctaves, pb.CoastJitterGain,
|
|
s.m.Source.Seed)
|
|
fmt.Fprintf(&b, "%v|%v|%v|%v|", s.m.GeologyCellM(), pb.PadClass,
|
|
pb.LithologyWavelengthKm, pb.FaultGrainKm)
|
|
for _, c := range s.legend().Classes {
|
|
fmt.Fprintf(&b, "%s/%v/%v/%v/%v;", c.Name, c.RGB, c.Sea, c.Derived, c.Stroke)
|
|
}
|
|
// The tectonic layer, whole: the sheet decides the partition, and every plate's motion decides what the
|
|
// margins between them are doing, which decides the whole fault set.
|
|
fmt.Fprintf(&b, "|t%d|", s.plSeq)
|
|
if s.pl != nil {
|
|
for _, p := range s.pl.Plates {
|
|
fmt.Fprintf(&b, "%s/%v/%v/%v/%v/%v;",
|
|
p.Name, p.RGB, p.SpeedCmYr, p.HeadingDeg, p.SpinDegMyr, p.Continental)
|
|
}
|
|
}
|
|
fmt.Fprintf(&b, "|%v|", s.m.Planet.Plates)
|
|
|
|
// The overlay, whole. Its colours decide the raster and its coast_jitter decides where the waterline
|
|
// ends up, so both belong here; width_m and the note do not change a pixel but they do change
|
|
// overlay.json, which the cached path would otherwise hand back stale.
|
|
fmt.Fprintf(&b, "|o%d|", s.ovSeq)
|
|
if ov := s.overlayLegend(); ov != nil {
|
|
for _, mk := range ov.Marks {
|
|
j, set := mk.Jitter()
|
|
fmt.Fprintf(&b, "%s/%v/%s/%v%v/%v;", mk.Name, mk.RGB, mk.Kind, set, j, mk.WidthM)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func (s *Server) handleMap(w http.ResponseWriter, r *http.Request) {
|
|
name := filepath.Base(r.URL.Path)
|
|
if filepath.Ext(name) != ".png" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
http.ServeFile(w, r, filepath.Join(s.planDir, name))
|
|
}
|
|
|
|
// --- helpers -----------------------------------------------------------------------------------------
|
|
|
|
func writeJSON(w http.ResponseWriter, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|