Files
UnrealPrototyping/Tools/Terrain/internal/planet/labels_test.go
T
2026-09-25 17:02:24 +03:00

158 lines
6.1 KiB
Go

package planet
import (
"math"
"testing"
"salty/terrain/internal/region"
"salty/terrain/internal/template"
"salty/terrain/internal/world"
)
// A region label is a number written on a picture, so the only thing worth asserting about it is that it lands
// on the region it names.
//
// The seam is the case that fails silently. A landmass with land at x=0 and at x=W-1 has an arithmetic mean
// halfway round the planet - on some other continent entirely - and the map would look perfectly plausible
// while being wrong about the one thing the number is for. Everything else here is a guard on the arithmetic
// around it: the polar pad is not painted rows, and a region too small to catch a sample still has to get a
// position rather than a NaN.
const (
labelW = 64
labelPadY = 4
labelPaint = 32
)
// labelFixture is a planet with three regions painted onto it: a compact island in the middle, an island over
// the seam, and a single cell small enough that the stride walks straight past it.
func labelFixture() *Inputs {
p := world.Planet{
CellM: 1, W: labelW, H: labelPaint + 2*labelPadY, PadY: labelPadY,
NoisePeriodM: float64(labelW),
}
n := p.W * p.H
m := &template.Map{P: p, Class: make([]uint8, n), Sea: make([]bool, n)}
for i := range m.Sea {
m.Sea[i] = true
}
part := &region.Partition{P: p, MarginCells: 1, Owner: make([]int32, n)}
for i := range part.Owner {
part.Owner[i] = -1
}
land := func(r int32, x0, x1, py0, py1 int) {
for py := py0; py <= py1; py++ {
for x := x0; x <= x1; x++ {
i := (labelPadY+py)*p.W + ((x%p.W)+p.W)%p.W
part.Owner[i], m.Sea[i] = r, false
}
}
}
// 0: a compact island around the middle of the map, centred on x = 31.5, painted row 15.5.
land(0, 28, 35, 12, 19)
part.Regions = append(part.Regions, region.Region{
ID: 0, Frame: world.Frame{P: p, X0: 28, Y0: labelPadY + 12, W: 8, H: 8}, LandCells: 64,
})
// 1: the same island moved onto the seam, centred on x = 63.5 - half of it at x >= 60 and half at x <= 3.
land(1, 60, 67, 12, 19)
part.Regions = append(part.Regions, region.Region{
ID: 1, Frame: world.Frame{P: p, X0: 60, Y0: labelPadY + 12, W: 8, H: 8}, LandCells: 64, Seam: true,
})
// 2: one cell, at coordinates the stride never samples, so this is the fallback path.
land(2, 1, 1, 1, 1)
part.Regions = append(part.Regions, region.Region{
ID: 2, Frame: world.Frame{P: p, X0: 1, Y0: labelPadY + 1, W: 1, H: 1}, LandCells: 1,
})
return &Inputs{P: p, Map: m, Part: part}
}
// circDist is the distance between two positions round the cylinder, in fractions of a turn.
func circDist(a, b float64) float64 {
d := math.Abs(a - b)
return math.Min(d, 1-d)
}
func TestRegionLabelsLandOnTheirRegion(t *testing.T) {
in := labelFixture()
got := in.RegionLabels()
if len(got) != 3 {
t.Fatalf("got %d labels for 3 regions", len(got))
}
// The compact island. The stride costs up to two cells of accuracy in each axis, which is 0.03 of a turn
// across and 0.06 down, and a label is allowed to be that far from dead centre.
const wantU0, wantV0 = 31.5 / labelW, 15.5 / labelPaint
if d := circDist(got[0][0], wantU0); d > 0.05 {
t.Errorf("region 0 u = %.4f, want within 0.05 of %.4f (off by %.4f)", got[0][0], wantU0, d)
}
if d := math.Abs(got[0][1] - wantV0); d > 0.08 {
t.Errorf("region 0 v = %.4f, want within 0.08 of %.4f", got[0][1], wantV0)
}
// The seam island, and the reason this file exists. Its land is centred on x = 63.5, a quarter of a cell
// short of the seam. An arithmetic mean of those columns gives 31.5, which is region 0's island: if this
// assertion ever fails by landing near 0.49, the circular mean has been lost.
const wantU1 = 63.5 / labelW
if d := circDist(got[1][0], wantU1); d > 0.06 {
t.Errorf("region 1 u = %.4f, want within 0.06 of %.4f (off by %.4f); "+
"0.49 means the mean across is no longer circular", got[1][0], wantU1, d)
}
if d := math.Abs(got[1][1] - wantV0); d > 0.08 {
t.Errorf("region 1 v = %.4f, want within 0.08 of %.4f", got[1][1], wantV0)
}
// The single cell, which no sample touches: the frame centre, and in particular a v measured from the
// painted rows rather than from the top of the polar pad.
const wantU2, wantV2 = 1.5 / labelW, 1.5 / labelPaint
if d := circDist(got[2][0], wantU2); d > 0.01 {
t.Errorf("region 2 u = %.4f, want %.4f from its frame", got[2][0], wantU2)
}
if d := math.Abs(got[2][1] - wantV2); d > 0.01 {
t.Errorf("region 2 v = %.4f, want %.4f from its frame; a v of %.4f would be measuring from the "+
"top of the pad instead of the first painted row",
got[2][1], wantV2, float64(labelPadY+1)/labelPaint)
}
for i, l := range got {
if math.IsNaN(l[0]) || math.IsNaN(l[1]) ||
l[0] < 0 || l[0] > 1 || l[1] < 0 || l[1] > 1 {
t.Errorf("region %d label %v is outside the drawn map", i, l)
}
}
}
// The hues have to be far enough apart to tell one landmass from another, which is the whole reason they
// stopped coming out of a hash of the index. See RegionHues for the argument; this is the measurement.
func TestRegionHuesStayApart(t *testing.T) {
const regions = 40
in := &Inputs{Part: &region.Partition{Regions: make([]region.Region, regions)}}
hues := in.RegionHues()
worst, wa, wb := math.MaxFloat64, 0, 0
for a := 0; a < regions; a++ {
for b := a + 1; b < regions; b++ {
dr := float64(hues[a][0]) - float64(hues[b][0])
dg := float64(hues[a][1]) - float64(hues[b][1])
db := float64(hues[a][2]) - float64(hues[b][2])
if d := math.Sqrt(dr*dr + dg*dg + db*db); d < worst {
worst, wa, wb = d, a, b
}
}
}
t.Logf("closest of %d hues: %.1f, between region %d and region %d", regions, worst, wa, wb)
// Measured: the walk gives 44.0 at twenty regions, 41.9 at twenty-six and 37.7 at forty, against 8.5 for
// the hash this replaced. The floor is set well below what the walk achieves rather than at it, so that a
// change to the saturation or value cycle is free to move the number a little and not free to collapse it.
const floor = 25
if worst < floor {
t.Errorf("closest two hues are %.1f apart (regions %d and %d), want at least %d: "+
"two landmasses that colour alike is the defect this walk exists to prevent", worst, wa, wb, floor)
}
}