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
+271
View File
@@ -0,0 +1,271 @@
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\": \"<path relative to the manifest>\"; 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
}
}