304 lines
9.5 KiB
Go
304 lines
9.5 KiB
Go
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})
|
|
}
|