package field import ( "encoding/json" "fmt" "os" "strconv" "strings" ) // Palette is how a preview is drawn: the hypsometric ramp, the water, the rivers, the ice and the light. // // It is a file rather than a set of constants because it is the one part of a bake that is purely a matter of // taste, and taste is the thing most likely to want swapping. Nothing in it changes a height; a palette is // read only by WritePreview, and two bakes of the same world under two palettes are the same terrain. // // The zero value is not usable - use DefaultPalette, which holds the numbers the generator shipped with. type Palette struct { // LandStops is the hypsometric ramp, from sea level at t = 0 to the top of the land at t = 1. Stops must // be in ascending t; the ends are clamped rather than extrapolated. LandStops []Stop `json:"land_stops"` SeaShallow RGB `json:"sea_shallow"` SeaDeep RGB `json:"sea_deep"` River RGB `json:"river"` // Ice is drawn wherever the snow mask says so, whatever height the ground is. Pure white is a poor // choice: it has nowhere left to go under the hillshade, so an ice sheet comes out as a flat cut-out // with no shape in it. Ice RGB `json:"ice"` // LandTopPercentile is where the ramp's top is taken from, over land elevations. Not the maximum: one // 2800 m summit over a continent whose land is mostly under 300 m puts every other cell in the bottom // tenth of the ramp, and the map then says far more about one pixel than about the terrain. LandTopPercentile float64 `json:"land_top_percentile"` // LandTopM puts the top of the ramp at a fixed height instead, in metres above sea level. Zero keeps // the percentile, which is what every preview did before this existed. // // It is here because a relative ramp is a picture that lies about scale, and it lies hardest exactly // where it matters. A lowland continent 47 m high, drawn against its own 99.5th percentile, gets the // whole ramp - green, tan, bare rock and snow - so its 40 m hills come out with the same white caps a // 2800 m range would, and a plain whose median slope is 0.6 degrees reads as an alpine massif. That was // measured on this planet's central landmass and it is the most misleading thing the generator draws. // // The percentile stays the default all the same, because the alternative fails the other way: an // absolute ramp over a world with no mountains is a flat green shape with nothing legible on it, and // judging "is there drainage here" needs the contrast. What is added is the *choice*, plus a line in // the run summary saying which ceiling a picture was drawn against - a relative picture is fine as long // as nobody reads it as an absolute one. LandTopM float64 `json:"land_top_m"` // The light. Azimuth is degrees clockwise from north and altitude is degrees above the horizon; the // north-west at 45 degrees is the convention every DEM hillshade uses and is what these default to. SunAzimuthDeg float64 `json:"sun_azimuth_deg"` SunAltitudeDeg float64 `json:"sun_altitude_deg"` // Ambient is how lit the fully shaded side is and Gain how much the lit side brightens. Ambient at zero // makes a shadow a hole. Ambient float64 `json:"ambient"` Gain float64 `json:"gain"` } // Stop is one entry in the hypsometric ramp. type Stop struct { T float64 `json:"t"` RGB RGB `json:"rgb"` } // RGB is a colour in 0..255, kept as float64 so the hillshade can multiply it before it is clamped. type RGB [3]float64 func (c RGB) String() string { return fmt.Sprintf("[%s, %s, %s]", num(c[0]), num(c[1]), num(c[2])) } // num prints a float without trailing zeros, so a palette reads as numbers rather than as measurements. func num(v float64) string { return strconv.FormatFloat(v, 'g', -1, 64) } // DefaultPalette is what the generator ships with: salt-marsh green at sea level through farmland and rock to // snow, with the stops chosen so the lowland does not read as one flat colour - which is where most of a map // is, and where a badly chosen ramp hides everything. func DefaultPalette() *Palette { return &Palette{ LandStops: []Stop{ {0.00, RGB{72, 106, 68}}, {0.08, RGB{104, 132, 74}}, {0.20, RGB{142, 152, 88}}, {0.38, RGB{164, 148, 104}}, {0.58, RGB{150, 128, 106}}, {0.75, RGB{138, 130, 128}}, {0.88, RGB{176, 174, 174}}, {1.00, RGB{246, 246, 250}}, }, SeaShallow: RGB{56, 104, 136}, SeaDeep: RGB{18, 40, 72}, River: RGB{70, 132, 180}, Ice: RGB{232, 238, 245}, LandTopPercentile: 99.5, SunAzimuthDeg: 315, // north-west SunAltitudeDeg: 45, Ambient: 0.45, Gain: 0.75, } } // LoadPalette reads a palette, filling anything the file leaves out from the default. Unknown keys are an // error: a misspelt colour that silently keeps the default is a palette that does not do what it says. func LoadPalette(path string) (*Palette, error) { raw, err := os.ReadFile(path) if err != nil { return nil, err } clean, err := StripJSONComments(raw) if err != nil { return nil, fmt.Errorf("%s: %w", path, err) } p := DefaultPalette() dec := json.NewDecoder(strings.NewReader(string(clean))) dec.DisallowUnknownFields() if err := dec.Decode(p); err != nil { return nil, fmt.Errorf("%s: %w", path, err) } if err := p.Validate(); err != nil { return nil, fmt.Errorf("%s: %w", path, err) } return p, nil } // Validate refuses a palette that would draw nonsense. func (p *Palette) Validate() error { if len(p.LandStops) < 2 { return fmt.Errorf("land_stops needs at least two entries, got %d", len(p.LandStops)) } for i, s := range p.LandStops { if i > 0 && s.T <= p.LandStops[i-1].T { return fmt.Errorf("land_stops must ascend: stop %d is at t %.3f, after %.3f", i, s.T, p.LandStops[i-1].T) } for k, v := range s.RGB { if v < 0 || v > 255 { return fmt.Errorf("land_stops[%d].rgb[%d] is %v, outside 0..255", i, k, v) } } } if p.LandTopPercentile <= 0 || p.LandTopPercentile > 100 { return fmt.Errorf("land_top_percentile is %v, outside 0..100", p.LandTopPercentile) } if p.LandTopM < 0 { return fmt.Errorf("land_top_m is %v; it is metres above sea level, so positive, or zero to use "+ "land_top_percentile instead", p.LandTopM) } if p.SunAltitudeDeg <= 0 || p.SunAltitudeDeg >= 90 { return fmt.Errorf("sun_altitude_deg is %v; it is degrees above the horizon", p.SunAltitudeDeg) } if p.Ambient < 0 || p.Ambient > 1 { return fmt.Errorf("ambient is %v, outside 0..1", p.Ambient) } if p.Gain < 0 { return fmt.Errorf("gain is %v", p.Gain) } return nil } // ramp samples the hypsometric stops, clamped at both ends. func (p *Palette) ramp(t float64) RGB { if t <= p.LandStops[0].T { return p.LandStops[0].RGB } for i := 1; i < len(p.LandStops); i++ { if t <= p.LandStops[i].T { a, b := p.LandStops[i-1], p.LandStops[i] u := (t - a.T) / (b.T - a.T) return RGB{ a.RGB[0] + (b.RGB[0]-a.RGB[0])*u, a.RGB[1] + (b.RGB[1]-a.RGB[1])*u, a.RGB[2] + (b.RGB[2]-a.RGB[2])*u, } } } return p.LandStops[len(p.LandStops)-1].RGB } // Write saves a palette as something a person can read and edit. // // Hand-formatted rather than through MarshalIndent, and that is not stubbornness: MarshalIndent re-indents // whatever a custom marshaler returns, so there is no way to keep a colour on one line through it, and its // default puts every channel of every stop on a line of its own - sixty lines for eight stops, a table whose // shape is invisible. The comments are the other half: this is a file somebody opens to change one number, // and it should say what the numbers are. func (p *Palette) Write(path string) error { var b strings.Builder line := func(format string, a ...any) { fmt.Fprintf(&b, format+"\n", a...) } line("{") line(` "_comment": "How a preview is drawn. Nothing here changes a height - two bakes of the same ` + `world under two palettes are the same terrain. Point a planet manifest at this file with ` + `\"palette\": \"\"; leave it out and these numbers are used ` + `anyway. Keys beginning with an underscore are comments.",`) line("") line(` "_comment_land_stops": "The hypsometric ramp: sea level at t 0 to the top of the land at t 1. ` + `The top is a percentile rather than the maximum, so one high summit cannot push a whole continent ` + `into the bottom of the ramp.",`) line(` "land_stops": [`) for i, s := range p.LandStops { comma := "," if i == len(p.LandStops)-1 { comma = "" } line(` { "t": %-6s "rgb": %s }%s`, num(s.T)+",", s.RGB, comma) } line(" ],") line("") line(` "sea_shallow": %s,`, p.SeaShallow) line(` "sea_deep": %s,`, p.SeaDeep) line(` "river": %s,`, p.River) line(` "_comment_ice": "Drawn wherever a class is marked snow, whatever height the ground stands at. ` + `Not pure white: white has nowhere left to go under the hillshade, so an ice sheet comes out as a ` + `flat cut-out with no shape in it at all.",`) line(` "ice": %s,`, p.Ice) line("") line(` "_comment_top": "Where the top of the hypsometric ramp sits. The percentile is relative to the ` + `world being drawn, which is the only way a low continent is legible at all and is also a picture ` + `that lies about scale: a 47 m lowland gets the same rock and snow a 2800 m range would. Set ` + `land_top_m to a height in metres for an absolute ramp instead; the run summary says which ceiling ` + `every preview was drawn against.",`) line(` "land_top_percentile": %s,`, num(p.LandTopPercentile)) line(` "land_top_m": %s,`, num(p.LandTopM)) line("") line(` "_comment_light": "Azimuth is degrees clockwise from north and altitude degrees above the ` + `horizon; north-west at 45 is what every DEM hillshade uses. Ambient is how lit the shaded side ` + `is - at zero a shadow is a hole - and gain how much the lit side brightens.",`) line(` "sun_azimuth_deg": %s,`, num(p.SunAzimuthDeg)) line(` "sun_altitude_deg": %s,`, num(p.SunAltitudeDeg)) line(` "ambient": %s,`, num(p.Ambient)) line(` "gain": %s`, num(p.Gain)) line("}") return os.WriteFile(path, []byte(b.String()), 0o644) } // StripJSONComments removes every object key beginning with an underscore, at any depth. // // Every manifest in this repository carries its commentary that way, and a loader that refuses unknown // fields - which both the legend and the palette do, because a misspelt key silently ignored is a setting // that does not do what it says - has to let them through. func StripJSONComments(data []byte) ([]byte, error) { var v any if err := json.Unmarshal(data, &v); err != nil { return nil, err } return json.Marshal(stripUnderscored(v)) } func stripUnderscored(v any) any { switch t := v.(type) { case map[string]any: out := make(map[string]any, len(t)) for k, val := range t { if strings.HasPrefix(k, "_") { continue } out[k] = stripUnderscored(val) } return out case []any: for i := range t { t[i] = stripUnderscored(t[i]) } return t default: return v } }