This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
+370
View File
@@ -0,0 +1,370 @@
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)}
}