package planet import ( "math" "os" "path/filepath" "salty/terrain/internal/field" "salty/terrain/internal/plates" ) // The tectonic map: which plate every place belongs to, and what is happening where two of them meet. // // It earns its place the same way map_uplift does. Every belt, every fault and every basin this model // produces is a consequence of one line and what is happening across it, so when a range comes out in the // wrong place the question is always "what did the boundary there do", and this is the only picture that // answers it. The lines are drawn by *kind* rather than by rate, for the reason drawFaults gives about // stroking traces: a collision belt is tens of kilometres wide, a map of a whole planet is a few thousand // pixels, and the thing an author needs from it is where the margins are and which ones are closing. // plateInk is the colour of each kind of margin. They are picked to be distinguishable from each other and // from the plate fills underneath, which are pastel by construction so that these read on top of them. var plateInk = map[plates.Kind][3]uint8{ plates.Collision: {255, 64, 48}, // red: two continents, the thing that makes mountains plates.Subduction: {255, 156, 32}, // orange: an ocean going under plates.Rift: {96, 240, 120}, // green: a continent pulling apart plates.Ridge: {72, 196, 255}, // blue: new ocean floor plates.Transform: {236, 232, 128}, // yellow: sliding, neither up nor down } // WritePlateMap draws the tectonic model over the painted land. func WritePlateMap(dir string, in *Inputs, width int) error { m := in.Plates if m == nil { return nil } // A hue per plate, walked round the wheel by a step coprime-ish with the count so that neighbouring ids // are not neighbouring hues - plates 3 and 4 are usually adjacent on the map, and two greens meeting // would hide the very boundary this map exists to show. n := len(m.Plates) hue := make([]float64, n) for i := range hue { hue[i] = math.Mod(float64(i)*0.61803398875, 1) } px, w, h := renderRGB(in, width, func(i, img int) [3]uint8 { id := m.PlateAt(in.P.XM(i%in.P.W), in.P.YM(i/in.P.W)) if id < 0 || id >= n { return water } // Land is the plate's hue at full strength and sea is the same hue dimmed, so the painting stays // legible underneath: a margin is only interesting relative to where the coasts are. sat, val := 0.55, 0.78 if in.Map.Sea[i] { sat, val = 0.38, 0.34 } // A continental plate is warmer than an oceanic one at the same hue, because which of the two a // plate is decides what every convergent margin around it does. if !m.Plates[id].Continental { sat *= 0.5 } c := field.HSV(hue[id]*360, sat, val) return [3]uint8{clamp8(c[0]), clamp8(c[1]), clamp8(c[2])} }) drawBoundaries(m, in, px, w, h) return write(filepath.Join(dir, "map_plates.png"), px, w, h) } // WritePlateProposal writes a tectonic layer and its legend for an author to open and edit. // // A blank canvas is the wrong place to start this. Seven plates with plausible motions is a second's work for // the generator 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. The pair it writes is exactly what `planet.plates.layer` and // `planet.plates.legend` take, so adopting it is two lines in the manifest. func WritePlateProposal(dir string, in *Inputs, width int) error { if in.Plates == nil { return nil } px, w, h, lg := in.Plates.Propose(width) const name = "plates_proposal" if err := write(filepath.Join(dir, name+".png"), px, w, h); err != nil { return err } lg.Image = name + ".png" data, err := plates.MarshalLegend(lg) if err != nil { return err } return os.WriteFile(filepath.Join(dir, name+".json"), data, 0o644) } // boundaryWidthMYr is the closing rate, in metres a year, at which a margin is drawn at its full width. A // margin at a tenth of it is still one pixel, so a slow boundary is visible without a fast one being a blot. const boundaryWidthMYr = 0.08 // drawBoundaries strokes every margin, coloured by what it is doing and thickened by how fast. func drawBoundaries(m *plates.Model, in *Inputs, px []uint8, w, h int) { p := in.P sx := float64(w) / p.CircumferenceM() sy := float64(h) / p.HeightM() set := func(x, y int, c [3]uint8) { if y < 0 || y >= h { return } x = ((x % w) + w) % w // X wraps, because the boundaries do o := (y*w + x) * 3 px[o], px[o+1], px[o+2] = c[0], c[1], c[2] } disc := func(x, y, r int, c [3]uint8) { for dy := -r; dy <= r; dy++ { for dx := -r; dx <= r; dx++ { if dx*dx+dy*dy <= r*r { set(x+dx, y+dy, c) } } } } for _, b := range m.Boundaries { for j := 0; j+1 < len(b.V); j++ { v := b.V[j] ink := plateInk[v.Kind] r := int(math.Abs(v.ClosingMYr)/boundaryWidthMYr*2 + 0.5) if r > 2 { r = 2 } ax, ay := v.XM*sx, v.YM*sy bx, by := b.V[j+1].XM*sx, b.V[j+1].YM*sy steps := int(math.Hypot(bx-ax, by-ay)) + 1 for k := 0; k <= steps; k++ { t := float64(k) / float64(steps) disc(int(ax+(bx-ax)*t), int(ay+(by-ay)*t), r, ink) } } } }