292 lines
9.1 KiB
Go
292 lines
9.1 KiB
Go
package field
|
|
|
|
import (
|
|
"image"
|
|
"image/png"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The hypsometric ramp tops out at snow by *elevation*, so an ice cap fifty metres above the water came out
|
|
// the same green as a meadow - a map lying about the one thing it is for. The snow mask fixes the colour and
|
|
// nothing else, and it must still take the hillshade rather than being stamped flat, or a dome and the
|
|
// valleys cut into it read as a white cut-out.
|
|
func TestSnowRendersAsIceAndStillTakesTheHillshade(t *testing.T) {
|
|
// The ramp's top is the 99.5th percentile of *land* elevation, so the ice cap only reads as meadow when
|
|
// there is real high ground on the map to set that percentile. A cap alone on an empty map is the highest
|
|
// thing there is and the ramp would call it snow anyway - which is how the first version of this test
|
|
// managed to pass for the wrong reason.
|
|
const w, h = 96, 64
|
|
f := New(w, h, 8)
|
|
sea := make([]bool, w*h)
|
|
snow := make([]bool, w*h)
|
|
for y := 0; y < h; y++ {
|
|
for x := 0; x < w; x++ {
|
|
i := y*w + x
|
|
d2 := float64((x-24)*(x-24) + (y-32)*(y-32))
|
|
v := 40 - d2/60 // a low ice dome on the left
|
|
if x > 56 {
|
|
// and a 700 m range on the right, which is what sets the top of the ramp
|
|
v = 700 - float64((x-76)*(x-76)+(y-32)*(y-32))*0.7
|
|
}
|
|
if v < 0 {
|
|
v = 0
|
|
sea[i] = true
|
|
}
|
|
f.Data[i] = float32(v)
|
|
snow[i] = !sea[i] && x <= 56
|
|
}
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
plain := filepath.Join(dir, "plain.png")
|
|
iced := filepath.Join(dir, "iced.png")
|
|
opt := PreviewOptions{Sea: sea, SeaLevelM: 0, Size: w}
|
|
if _, err := WritePreview(plain, f, opt); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
opt.Snow = snow
|
|
if _, err := WritePreview(iced, f, opt); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
a, b := readRGBA(t, plain), readRGBA(t, iced)
|
|
cx, cy := 24, 32 // the ice dome's summit
|
|
|
|
pr, pg, pb, _ := a.At(cx, cy).RGBA()
|
|
sr, sg, sb, _ := b.At(cx, cy).RGBA()
|
|
t.Logf("land at the summit: plain rgb(%d,%d,%d), iced rgb(%d,%d,%d)",
|
|
pr>>8, pg>>8, pb>>8, sr>>8, sg>>8, sb>>8)
|
|
|
|
// Ice is much lighter than the ramp's low-ground green, and it is not green: blue is at least green.
|
|
if sr <= pr || sb <= pb {
|
|
t.Errorf("the iced summit is not lighter than the plain one")
|
|
}
|
|
if sb < sg {
|
|
t.Errorf("the ice reads green (b %d < g %d); it should be neutral to slightly blue", sb>>8, sg>>8)
|
|
}
|
|
|
|
// It still takes the hillshade: the lit and shaded flanks of the dome must differ.
|
|
lr, _, _, _ := b.At(cx-12, cy-12).RGBA() // north-west flank, towards the light
|
|
dr, _, _, _ := b.At(cx+12, cy+12).RGBA() // south-east flank, away from it
|
|
t.Logf("ice flanks: lit %d, shaded %d", lr>>8, dr>>8)
|
|
if lr <= dr {
|
|
t.Errorf("the ice is flat: lit flank %d against shaded %d, so it was stamped rather than shaded",
|
|
lr>>8, dr>>8)
|
|
}
|
|
|
|
// And the water is untouched. The probe has to be a cell that really is sea - the first version used the
|
|
// corner, which on this map is land, so it was comparing two ice pixels and calling the difference a bug.
|
|
sx, sy := -1, -1
|
|
for i, isSea := range sea {
|
|
if isSea {
|
|
sx, sy = i%w, i/w
|
|
break
|
|
}
|
|
}
|
|
if sx < 0 {
|
|
t.Fatal("the test terrain has no sea in it")
|
|
}
|
|
wr, wg, wb, _ := a.At(sx, sy).RGBA()
|
|
xr, xg, xb, _ := b.At(sx, sy).RGBA()
|
|
if wr != xr || wg != xg || wb != xb {
|
|
t.Errorf("the sea at %d,%d changed: rgb(%d,%d,%d) became rgb(%d,%d,%d); the mask should only touch land",
|
|
sx, sy, wr>>8, wg>>8, wb>>8, xr>>8, xg>>8, xb>>8)
|
|
}
|
|
}
|
|
|
|
func readRGBA(t *testing.T, path string) image.Image {
|
|
t.Helper()
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
img, err := png.Decode(f)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return img
|
|
}
|
|
|
|
// A palette is a file somebody edits, so it has to survive the trip to disk and back unchanged - and it has
|
|
// to keep the comments the writer puts in, because a loader that refuses unknown keys would otherwise choke
|
|
// on its own output.
|
|
func TestPaletteRoundTripsThroughDiskWithItsComments(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "p.json")
|
|
want := DefaultPalette()
|
|
if err := want.Write(path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(raw), "_comment") {
|
|
t.Error("the written palette carries no commentary")
|
|
}
|
|
got, err := LoadPalette(path)
|
|
if err != nil {
|
|
t.Fatalf("reading back what Write produced: %v", err)
|
|
}
|
|
if len(got.LandStops) != len(want.LandStops) {
|
|
t.Fatalf("%d stops, want %d", len(got.LandStops), len(want.LandStops))
|
|
}
|
|
for i := range want.LandStops {
|
|
if got.LandStops[i] != want.LandStops[i] {
|
|
t.Errorf("stop %d: %v, want %v", i, got.LandStops[i], want.LandStops[i])
|
|
}
|
|
}
|
|
if got.Ice != want.Ice || got.SeaDeep != want.SeaDeep || got.River != want.River {
|
|
t.Errorf("colours differ: %v %v %v", got.Ice, got.SeaDeep, got.River)
|
|
}
|
|
if got.SunAzimuthDeg != want.SunAzimuthDeg || got.Ambient != want.Ambient {
|
|
t.Errorf("light differs: %v %v", got.SunAzimuthDeg, got.Ambient)
|
|
}
|
|
}
|
|
|
|
// A palette fills what it leaves out from the default, so a two-line file is a valid one.
|
|
func TestAPartialPaletteKeepsTheDefaults(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "p.json")
|
|
if err := os.WriteFile(path, []byte(`{"_why": "just the sea", "sea_deep": [1, 2, 3]}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := LoadPalette(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.SeaDeep != (RGB{1, 2, 3}) {
|
|
t.Errorf("sea_deep = %v, want the file's", got.SeaDeep)
|
|
}
|
|
if got.Ice != DefaultPalette().Ice {
|
|
t.Errorf("ice = %v, want the default", got.Ice)
|
|
}
|
|
}
|
|
|
|
// And a misspelt key is an error rather than a setting that silently does nothing.
|
|
func TestAMisspeltPaletteKeyIsRefused(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "p.json")
|
|
if err := os.WriteFile(path, []byte(`{"sea_dep": [1,2,3]}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := LoadPalette(path); err == nil {
|
|
t.Fatal("accepted a misspelt key")
|
|
}
|
|
}
|
|
|
|
// The palette actually reaches the picture: swapping the sea colour changes the sea.
|
|
func TestThePaletteIsWhatGetsDrawn(t *testing.T) {
|
|
const w, h = 32, 32
|
|
f := New(w, h, 8)
|
|
sea := make([]bool, w*h)
|
|
for i := range sea {
|
|
sea[i] = i%w < w/2
|
|
if !sea[i] {
|
|
f.Data[i] = 50
|
|
}
|
|
}
|
|
dir := t.TempDir()
|
|
a := filepath.Join(dir, "a.png")
|
|
b := filepath.Join(dir, "b.png")
|
|
if _, err := WritePreview(a, f, PreviewOptions{Sea: sea, Size: w}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pal := DefaultPalette()
|
|
pal.SeaShallow, pal.SeaDeep = RGB{255, 0, 0}, RGB{255, 0, 0}
|
|
if _, err := WritePreview(b, f, PreviewOptions{Sea: sea, Size: w, Palette: pal}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ar, ag, ab, _ := readRGBA(t, a).At(2, 2).RGBA()
|
|
br, bg, bb, _ := readRGBA(t, b).At(2, 2).RGBA()
|
|
if br>>8 != 255 || bg>>8 != 0 || bb>>8 != 0 {
|
|
t.Errorf("the sea is rgb(%d,%d,%d), want the palette's red", br>>8, bg>>8, bb>>8)
|
|
}
|
|
if ar == br && ag == bg && ab == bb {
|
|
t.Error("the palette changed nothing")
|
|
}
|
|
}
|
|
|
|
// The ramp is relative by default and that is a picture which lies about scale: a lowland continent 47 m high
|
|
// gets the same rock and snow a 2800 m range would, because the top of the ramp is a percentile of whatever
|
|
// world it is drawing. land_top_m is the way out, and the point of the test is that the two differ.
|
|
func TestAnAbsoluteRampDrawsALowContinentAsLowGround(t *testing.T) {
|
|
const w = 96
|
|
f := New(w, w, 10)
|
|
sea := make([]bool, w*w)
|
|
for y := 0; y < w; y++ {
|
|
for x := 0; x < w; x++ {
|
|
i := y*w + x
|
|
dx, dy := float64(x-w/2)/float64(w/2), float64(y-w/2)/float64(w/2)
|
|
d := dx*dx + dy*dy
|
|
if d > 0.8 {
|
|
sea[i] = true
|
|
f.Data[i] = -50
|
|
continue
|
|
}
|
|
// A 40 m hill on a continent, which is a plain by any reading.
|
|
f.Data[i] = float32(40 * (1 - d/0.8))
|
|
}
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
rel := filepath.Join(dir, "relative.png")
|
|
abs := filepath.Join(dir, "absolute.png")
|
|
|
|
top, err := WritePreview(rel, f, PreviewOptions{Sea: sea, Size: w})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if top > 45 {
|
|
t.Fatalf("the relative ramp should top out near the highest land, about 40 m; got %.1f", top)
|
|
}
|
|
|
|
pal := DefaultPalette()
|
|
pal.LandTopM = 2000
|
|
top, err = WritePreview(abs, f, PreviewOptions{Sea: sea, Size: w, Palette: pal})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if top != 2000 {
|
|
t.Fatalf("an absolute ramp tops out where it is told: got %.1f, want 2000", top)
|
|
}
|
|
|
|
// And the pictures differ: the summit is high on the ramp in one and at the bottom of it in the other.
|
|
relTop := brightestLand(t, rel, sea, w)
|
|
absTop := brightestLand(t, abs, sea, w)
|
|
if relTop <= absTop {
|
|
t.Errorf("the relative picture should carry the summit far higher up the ramp: %d vs %d",
|
|
relTop, absTop)
|
|
}
|
|
}
|
|
|
|
// brightestLand is the highest luma any land pixel reached, which is how far up the hypsometric ramp the
|
|
// summit got: the ramp ends in near-white snow and starts in dark green.
|
|
func brightestLand(t *testing.T, path string, sea []bool, w int) int {
|
|
t.Helper()
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
img, err := png.Decode(f)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
best := 0
|
|
b := img.Bounds()
|
|
for y := 0; y < b.Dy(); y++ {
|
|
for x := 0; x < b.Dx(); x++ {
|
|
if sea[y*w+x] {
|
|
continue
|
|
}
|
|
r, g, bl, _ := img.At(b.Min.X+x, b.Min.Y+y).RGBA()
|
|
if v := int(r+g+bl) >> 8; v > best {
|
|
best = v
|
|
}
|
|
}
|
|
}
|
|
return best
|
|
}
|