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