Tooling
This commit is contained in:
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user