package plates import ( "encoding/json" "fmt" "math" "os" "regexp" "salty/terrain/internal/world" ) // The painted tectonic layer: a third painting beside the template and the overlay, where a colour is a // plate and the legend says how that plate is moving. // // **You paint the cause, not the conclusion.** A colour does not say "there is a collision here" - it says // "this piece of lithosphere is moving north-east at three centimetres a year", and where two of them meet, // what happens is worked out from the two motions and the shape of the contact. That is the whole reason to // paint plates rather than to paint boundary lines: a drawn line has to be told what it is, while a contact // between two painted plates *becomes* a collision, a transform or a rift by itself, and changes character // along its own length wherever it turns relative to the motion. The Alpide belt is a collision at the // Himalaya and a strike-slip fault through Anatolia for exactly that reason, and no author should have to // hand-annotate it. // // It also means the tracer needs no new code. Build's weighted Voronoi and this both produce the same thing - // a plate id at every cell of the tectonic grid - and everything downstream reads that. // // **Registration is by extent, not by pixel.** The layer is stretched over the painted map's own rectangle, // so it does not have to be the template's size. Paint plates at a quarter of it if you like: the tectonic // grid is a few hundred metres a cell and a plate is tens of kilometres across, so detail below that is // detail nothing will ever read. The polar pad has no painting under it and takes the nearest painted row, // which is right - a plate does not stop at the top of the author's canvas. // PaintLegend is what the colours on a tectonic layer mean. type PaintLegend struct { // Comment is the legend's own note to whoever opens it next. Propose writes the conventions into it, // because "which way does heading 90 point" is the first thing an author needs and the last thing they // should have to find in a source file. Comment string `json:"_comment,omitempty"` // Image is the layer's file name, resolved beside the legend. The manifest may name one instead. Image string `json:"image"` // WarnDistance is how far, in RGB, a sampled pixel may sit from the nearest plate before the run says so. // It exists for the same reason the class template's does: a JPEG bleeds several units of each channel // across a painted edge, and a silent mismatch is a plate boundary in the wrong place. WarnDistance float64 `json:"warn_distance"` Plates []PaintPlate `json:"plates"` } // PaintPlate is one painted plate: a colour, and how that piece of lithosphere is moving. type PaintPlate struct { Name string `json:"name"` // RGB is the colour on the layer. Every sampled pixel becomes the *nearest* plate in RGB, because on a // tectonic layer every pixel has to be some plate - the same rule the class template uses, and the // opposite of the overlay's, where most of the image is deliberately nothing. RGB [3]int `json:"rgb"` // SpeedCmYr and HeadingDeg are the plate's drift. The heading is a compass bearing over the map: 0 points // at the top of the image, 90 to the right, 180 to the bottom. Earth's plates run 1 to 10 cm/yr, and what // matters at a margin is the *difference* between two of these, so two plates both drifting east at 4 are // a boundary doing nothing at all. SpeedCmYr float64 `json:"speed_cm_yr"` HeadingDeg float64 `json:"heading_deg"` // SpinDegMyr turns the plate about its own centre, in degrees per million years, positive clockwise on // the map. // // It is worth setting on at least one plate. A planet of plates that only drift has margins that are the // same all the way along, because the relative velocity is then one constant vector and the only thing // that varies is where the contact happens to point. A little spin is what makes one end of a margin // collide while the other slides - which is the Anatolia case, and the most useful thing a tectonic map // can give a fault set. SpinDegMyr float64 `json:"spin_deg_myr"` // Continental overrides what the painting says. Left out - which is the usual case - a plate is // continental when enough of its painted area is land, so the template decides and the two paintings // cannot contradict each other. Set it when they should: an oceanic plate carrying a chain of islands, or // a continental fragment currently underwater. Continental *bool `json:"continental,omitempty"` } // rgbOneLine finds an indented colour triple so MarshalLegend can put it back on one line. var rgbOneLine = regexp.MustCompile(`"rgb": \[\s*(\d+),\s*(\d+),\s*(\d+)\s*\]`) // MarshalLegend writes a legend as JSON somebody will want to edit. // // json.MarshalIndent puts every colour on five lines, because Indent reformats every array whatever a custom // marshaller does, and a seven-plate legend then runs to ninety lines of mostly punctuation. Putting the // triples back on one line each is cosmetic and it is worth the ten lines: this file is meant to be opened // and changed by hand, beside the painting, and a legend nobody can read at a glance is a legend nobody // keeps in step with the picture. func MarshalLegend(lg *PaintLegend) ([]byte, error) { data, err := json.MarshalIndent(lg, "", " ") if err != nil { return nil, err } return append(rgbOneLine.ReplaceAll(data, []byte(`"rgb": [$1, $2, $3]`)), '\n'), nil } // LoadPaintLegend reads a tectonic layer's legend. func LoadPaintLegend(path string) (*PaintLegend, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var lg PaintLegend if err := json.Unmarshal(data, &lg); err != nil { return nil, fmt.Errorf("%s: %w", path, err) } if err := lg.validate(path); err != nil { return nil, err } return &lg, nil } func (l *PaintLegend) validate(path string) error { if len(l.Plates) < 2 { return fmt.Errorf("%s: %d plate(s); a planet in one plate has no boundaries", path, len(l.Plates)) } if l.WarnDistance <= 0 { l.WarnDistance = 60 } seen := map[[3]int]string{} for i := range l.Plates { p := &l.Plates[i] if p.Name == "" { return fmt.Errorf("%s: plate %d has no name", path, i) } for c := range 3 { if p.RGB[c] < 0 || p.RGB[c] > 255 { return fmt.Errorf("%s: plate %q has rgb %v", path, p.Name, p.RGB) } } if prev, dup := seen[p.RGB]; dup { return fmt.Errorf("%s: plates %q and %q are both rgb %v; a colour is one plate", path, prev, p.Name, p.RGB) } seen[p.RGB] = p.Name if p.SpeedCmYr < 0 { return fmt.Errorf("%s: plate %q moves at %v cm/yr; speed is a magnitude and the heading is "+ "where it points", path, p.Name, p.SpeedCmYr) } } return nil } // PaintMatch is how well the painting matched the legend, reported the way the class template's match is: a // layer whose colours have drifted is a tectonic model quietly built on the wrong plates. type PaintMatch struct { Cells int `json:"cells"` Far int `json:"far"` MaxDistance float64 `json:"max_distance"` } // FromPainting builds a tectonic model from a painted layer instead of from a seed. // // px is the layer decoded to RGB triples, pw by ph. Decoding happens in the caller so that this package keeps // knowing nothing about files or image formats - the same reason land is a callback. func FromPainting(p world.Planet, cfg Config, lg *PaintLegend, px []uint8, pw, ph int, land func(xM, yM float64) bool) (*Model, PaintMatch, error) { var match PaintMatch if lg == nil || len(lg.Plates) < 2 { return nil, match, fmt.Errorf("a tectonic layer needs at least two plates") } if pw <= 0 || ph <= 0 || len(px) < pw*ph*3 { return nil, match, fmt.Errorf("the tectonic layer is %dx%d with %d bytes", pw, ph, len(px)) } cfg = cfg.withDefaults() circ := p.CircumferenceM() gw := int(circ/cfg.ResolutionM + 0.5) if gw < 8 { gw = 8 } gcell := circ / float64(gw) gh := int(float64(p.H)*p.CellM/gcell + 0.5) if gh < 2 { gh = 2 } m := &Model{P: p, Cfg: cfg, GW: gw, GH: gh, GCellM: gcell, Cell: make([]int16, gw*gh)} m.Plates = make([]Plate, len(lg.Plates)) for i := range m.Plates { m.Plates[i] = Plate{ID: i, Weight: 1} } heightM := p.HeightM() for gy := range gh { yM := m.GridYM(gy) // The painted map covers 0..heightM; the polar pad above and below it takes the nearest painted row. v := clamp01(yM / heightM) py := int(v * float64(ph-1)) row := gy * gw for gx := range gw { xM := m.GridXM(gx) pxi := int(xM / circ * float64(pw)) if pxi >= pw { pxi = pw - 1 } o := (py*pw + pxi) * 3 id, dist := nearestPlate(lg.Plates, px[o], px[o+1], px[o+2]) m.Cell[row+gx] = int16(id) match.Cells++ if dist > lg.WarnDistance { match.Far++ } if dist > match.MaxDistance { match.MaxDistance = dist } } } for i := range m.Plates { pl := &m.Plates[i] src := lg.Plates[i] // Compass bearing over the map: 0 points at the top of the image, which is -Y, and 90 to the right. speed := src.SpeedCmYr / 100 bearing := src.HeadingDeg * math.Pi / 180 pl.TransXM = speed * math.Sin(bearing) pl.TransYM = -speed * math.Cos(bearing) // Positive spin is clockwise on the map: with Y running down the image, v = T + omega x r sends the // point east of the centre southwards. The centre itself comes from measure, below. pl.OmegaRadYr = src.SpinDegMyr * math.Pi / 180 / 1e6 } // measure fills in the area, the land fraction and the centre of each plate - and the centre is the pole // every one of them turns about, so nothing has a usable velocity field until this has run. m.measure(land) for i := range m.Plates { // A painted plate has no Voronoi site. Its centre of area is the only position it has, and it is what // the map and the reports point at. m.Plates[i].SiteXM = m.Plates[i].CentroidXM m.Plates[i].SiteYM = m.Plates[i].CentroidYM } // The painting has the last word where it asks for one, after measure has read the template's land. for i := range m.Plates { if c := lg.Plates[i].Continental; c != nil { m.Plates[i].Continental = *c } } m.Boundaries = m.buildBoundaries() return m, match, nil } // nearestPlate is the legend entry closest to a colour, and how far away it was. // // Nearest rather than exact, and unlike the overlay there is no "no plate" answer: every pixel of a tectonic // layer is some piece of lithosphere, so a colour that matches nothing is a painting mistake to report rather // than a hole to leave. WarnDistance is what reports it. func nearestPlate(ps []PaintPlate, r, g, b uint8) (id int, dist float64) { best, bestID := math.Inf(1), 0 for i := range ps { dr := float64(int(r) - ps[i].RGB[0]) dg := float64(int(g) - ps[i].RGB[1]) db := float64(int(b) - ps[i].RGB[2]) if d := dr*dr + dg*dg + db*db; d < best { best, bestID = d, i } } return bestID, math.Sqrt(best) } func clamp01(v float64) float64 { if v < 0 { return 0 } if v > 1 { return 1 } return v } // Propose turns a generated model into a painting and a legend to start from. // // An author should not face a blank canvas for this. Seven plates with plausible motions is a minute's work // for the Voronoi and an afternoon's by hand, and what an author actually wants to do is move two of them and // change a heading - which is editing, not authoring from nothing. // // The returned pixels are the layer at the given width, and the legend has one entry per plate carrying the // motion the generator drew. Writing them out is the caller's job. func (m *Model) Propose(width int) (px []uint8, w, h int, lg *PaintLegend) { if width < 64 { width = 64 } h = int(float64(width) * m.P.HeightM() / m.P.CircumferenceM()) if h < 1 { h = 1 } w = width lg = &PaintLegend{ Comment: "A painted tectonic layer: one colour per plate, and how that plate is moving. " + "heading_deg is a compass bearing over the map - 0 points at the top of the image, 90 to the " + "right, 180 to the bottom. speed_cm_yr is drift; what happens at a margin is the difference " + "between the two plates either side of it, so two plates drifting the same way are a boundary " + "doing nothing. spin_deg_myr turns a plate about its own centre, positive clockwise, and it is " + "worth setting on at least one: without it every margin is the same all the way along, and " + "with it one end collides while the other slides. Paint the plates, not the mountains - where " + "two of these meet, the collision, the belt and its faults are worked out from the motions. " + "Repaint the blobs freely; only the colours have to keep matching this file.", WarnDistance: 60, Plates: make([]PaintPlate, len(m.Plates)), } colours := make([][3]uint8, len(m.Plates)) for i := range m.Plates { pl := &m.Plates[i] // Hues walked by the golden ratio, so that neighbouring ids are not neighbouring colours and an // author can tell two touching plates apart at a glance. c := hsvBytes(math.Mod(float64(i)*0.61803398875, 1)*360, 0.62, 0.86) colours[i] = c speed := math.Hypot(pl.TransXM, pl.TransYM) * 100 // m/yr to cm/yr // Back to a compass bearing: 0 at the top of the image, 90 to the right. bearing := math.Atan2(pl.TransXM, -pl.TransYM) * 180 / math.Pi if bearing < 0 { bearing += 360 } lg.Plates[i] = PaintPlate{ Name: fmt.Sprintf("plate_%d", i), RGB: [3]int{int(c[0]), int(c[1]), int(c[2])}, SpeedCmYr: math.Round(speed*10) / 10, HeadingDeg: math.Round(bearing), SpinDegMyr: math.Round(pl.OmegaRadYr*180/math.Pi*1e6*100) / 100, } } px = make([]uint8, w*h*3) for y := range h { yM := m.P.HeightM() * (float64(y) + 0.5) / float64(h) for x := range w { xM := m.P.CircumferenceM() * (float64(x) + 0.5) / float64(w) c := colours[m.PlateAt(xM, yM)] o := (y*w + x) * 3 px[o], px[o+1], px[o+2] = c[0], c[1], c[2] } } return px, w, h, lg } // hsvBytes is a hue in degrees, saturation and value in 0..1, as an RGB triple. func hsvBytes(hue, sat, val float64) [3]uint8 { hue = math.Mod(math.Mod(hue, 360)+360, 360) / 60 i := math.Floor(hue) f := hue - i p := val * (1 - sat) q := val * (1 - sat*f) t := val * (1 - sat*(1-f)) var r, g, b float64 switch int(i) % 6 { case 0: r, g, b = val, t, p case 1: r, g, b = q, val, p case 2: r, g, b = p, val, t case 3: r, g, b = p, q, val case 4: r, g, b = t, p, val default: r, g, b = val, p, q } return [3]uint8{byte(r*255 + 0.5), byte(g*255 + 0.5), byte(b*255 + 0.5)} }