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