Files
2026-09-25 17:02:24 +03:00

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})
}