Tooling
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user