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