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
@@ -0,0 +1,321 @@
package overlay
import (
"math"
"testing"
)
// A legend with one of each kind of mark, written the way an author would.
const legendJSON = `{
"_comment": "commentary survives a parse",
"image": "sheet.png",
"marks": [
{ "name": "drawn_coast", "rgb": [255, 0, 255], "coast_jitter": 0 },
{ "name": "wild_coast", "rgb": [255, 128, 0], "coast_jitter": 2.5 },
{ "name": "forest", "rgb": [0, 128, 0] },
{ "name": "road", "rgb": [90, 60, 30], "kind": "path", "width_m": 8 }
]
}`
func mustLegend(t *testing.T) *Legend {
t.Helper()
l, err := Parse([]byte(legendJSON))
if err != nil {
t.Fatalf("parse: %v", err)
}
return l
}
func TestParseFillsDefaultsAndRefusesNonsense(t *testing.T) {
l := mustLegend(t)
if l.MatchDistance != DefaultMatchDistance || l.MinAreaPx != DefaultMinAreaPx {
t.Fatalf("defaults not filled: %v %v", l.MatchDistance, l.MinAreaPx)
}
if l.Index("forest") != 3 || l.Index("nope") != Blank {
t.Fatalf("marks are numbered from 1 in legend order, got %d", l.Index("forest"))
}
if !l.TouchesCoast() {
t.Fatal("this legend has a coast mark, so the roughening has a scale field to build")
}
if j, set := l.Marks[0].Jitter(); !set || j != 0 {
t.Fatalf("a zero coast_jitter is the whole reason the key is a pointer; got %v set=%v", j, set)
}
if j, set := l.Marks[2].Jitter(); set || j != 1 {
t.Fatalf("a mark that says nothing about the coast leaves the amplitude alone; got %v set=%v", j, set)
}
for _, bad := range []struct{ what, src string }{
{"two marks one colour", `{"marks":[{"name":"a","rgb":[1,2,3]},{"name":"b","rgb":[1,2,3]}]}`},
{"two marks one name", `{"marks":[{"name":"a","rgb":[1,2,3]},{"name":"a","rgb":[4,5,6]}]}`},
{"a width on an area", `{"marks":[{"name":"a","rgb":[1,2,3],"width_m":4}]}`},
{"a negative jitter", `{"marks":[{"name":"a","rgb":[1,2,3],"coast_jitter":-1}]}`},
{"an unknown kind", `{"marks":[{"name":"a","rgb":[1,2,3],"kind":"blob"}]}`},
{"a misspelt key", `{"marks":[{"name":"a","rgb":[1,2,3],"coastjitter":0}]}`},
} {
if _, err := Parse([]byte(bad.src)); err == nil {
t.Errorf("%s should not parse", bad.what)
}
}
}
// paint builds an RGBA sheet the size asked for, all transparent, and returns setters.
func paint(w, h int) (px, alpha []uint8, set func(x, y int, rgb [3]int)) {
px = make([]uint8, w*h*3)
alpha = make([]uint8, w*h)
return px, alpha, func(x, y int, rgb [3]int) {
i := y*w + x
px[i*3], px[i*3+1], px[i*3+2] = uint8(rgb[0]), uint8(rgb[1]), uint8(rgb[2])
alpha[i] = 255
}
}
// TestBlankIsAlphaAndTolerance is the rule the whole layer rests on: most of the sheet is nothing, and there
// are two ways to be nothing. An opaque pixel near no mark is dropped rather than snapped to the nearest,
// which is the opposite of what the class legend does and is why they are different code.
func TestBlankIsAlphaAndTolerance(t *testing.T) {
l := mustLegend(t)
const w, h = 8, 4
px, alpha, set := paint(w, h)
set(1, 1, [3]int{0, 128, 0}) // forest, exactly
set(2, 1, [3]int{6, 132, 4}) // forest, near enough
set(3, 1, [3]int{0, 0, 255}) // a colour the legend has never heard of
// A transparent pixel that happens to carry a mark's colour: alpha wins.
i := 1*w + 4
px[i*3], px[i*3+1], px[i*3+2] = 0, 128, 0
r, m := l.Classify(px, alpha, w, h)
if got := r.At(1, 1); got != 3 {
t.Fatalf("an exact colour is its mark; got %d", got)
}
if got := r.At(2, 1); got != 3 {
t.Fatalf("within the tolerance is its mark; got %d", got)
}
if got := r.At(3, 1); got != Blank {
t.Fatalf("a colour no mark is near is blank, not the nearest mark; got %d", got)
}
if got := r.At(4, 1); got != Blank {
t.Fatalf("transparent is blank whatever colour is under it; got %d", got)
}
if m.Far != 1 {
t.Fatalf("the one unmatched opaque pixel should be reported; Far=%d", m.Far)
}
if m.Total != w*h || m.Blank != w*h-2 {
t.Fatalf("counts: total %d blank %d", m.Total, m.Blank)
}
}
// TestCoastScaleLeavesUnmarkedPixelsUninstructed is the contract template.Coast.Scale depends on. An
// unmarked cell must come back negative rather than 1, or a stroke painted on the land would be overruled by
// the water beside it and the coastline would move anyway.
func TestCoastScaleLeavesUnmarkedPixelsUninstructed(t *testing.T) {
l := mustLegend(t)
const w, h = 6, 2
px, alpha, set := paint(w, h)
set(0, 0, [3]int{255, 0, 255}) // drawn_coast: pinned
set(1, 0, [3]int{255, 128, 0}) // wild_coast: chewed harder
set(2, 0, [3]int{0, 128, 0}) // forest: says nothing about the coast
r, _ := l.Classify(px, alpha, w, h)
sc := l.CoastScale(r)
if sc == nil {
t.Fatal("this legend has coast marks, so there is a scale")
}
if sc[0] != 0 {
t.Errorf("a pinned coast is exactly zero, got %v", sc[0])
}
if sc[1] != 2.5 {
t.Errorf("wild_coast is 2.5, got %v", sc[1])
}
if sc[2] >= 0 {
t.Errorf("a mark that says nothing about the coast is uninstructed, got %v", sc[2])
}
if sc[3] >= 0 {
t.Errorf("blank is uninstructed, got %v", sc[3])
}
// And a legend with no coast marks builds nothing at all, so the roughening pays nothing.
plain, err := Parse([]byte(`{"marks":[{"name":"forest","rgb":[0,128,0]}]}`))
if err != nil {
t.Fatal(err)
}
pr, _ := plain.Classify(px, alpha, w, h)
if plain.CoastScale(pr) != nil {
t.Error("no mark asks about the coast, so there should be no scale field")
}
}
func testScale(w, h int) Scale {
return Scale{MetresPerPxX: 10, MetresPerPxY: 10, CircumferenceM: float64(w) * 10}
}
// TestFeaturesMeasureAreasInWorldMetres covers the ordinary case and the speck filter.
func TestFeaturesMeasureAreasInWorldMetres(t *testing.T) {
l := mustLegend(t)
const w, h = 40, 20
px, alpha, set := paint(w, h)
// A 6x6 block of forest, and a single speck of it far away.
for y := 4; y < 10; y++ {
for x := 10; x < 16; x++ {
set(x, y, [3]int{0, 128, 0})
}
}
set(30, 15, [3]int{0, 128, 0})
r, _ := l.Classify(px, alpha, w, h)
feats := l.Features(r, testScale(w, h))
if len(feats) != 1 {
t.Fatalf("the 36 px block is a feature and the 1 px speck is below min_area_px; got %d", len(feats))
}
f := feats[0]
if f.Mark != "forest" || f.Kind != KindArea {
t.Fatalf("wrong mark: %+v", f)
}
if f.Cells != 36 || math.Abs(f.AreaM2-3600) > 1 {
t.Fatalf("36 px at 10x10 m is 3600 m2; got %d px %v m2", f.Cells, f.AreaM2)
}
if math.Abs(f.CentreM[0]-125) > 1 || math.Abs(f.CentreM[1]-65) > 1 {
t.Fatalf("centre should be the middle of the block in metres; got %v", f.CentreM)
}
if math.Abs(f.ExtentM[0]-60) > 1 || math.Abs(f.ExtentM[1]-60) > 1 {
t.Fatalf("a 6x6 block is 60x60 m; got %v", f.ExtentM)
}
}
// TestASeamCrossingFeatureIsOneThing is the failure a cylindrical map has and nobody notices: a plain mean of
// the longitudes puts the centre of a blob straddling the seam on the opposite side of the world.
func TestASeamCrossingFeatureIsOneThing(t *testing.T) {
l := mustLegend(t)
const w, h = 40, 20
px, alpha, set := paint(w, h)
for y := 6; y < 14; y++ {
for _, x := range []int{38, 39, 0, 1} {
set(x, y, [3]int{0, 128, 0})
}
}
r, _ := l.Classify(px, alpha, w, h)
feats := l.Features(r, testScale(w, h))
if len(feats) != 1 {
t.Fatalf("the blob crosses the seam and is one thing; got %d features", len(feats))
}
f := feats[0]
if f.Cells != 32 {
t.Fatalf("all 32 px belong to it; got %d", f.Cells)
}
// Columns 38, 39, 0, 1 have their circular centre at 39.5, which is 395 m.
if d := math.Abs(f.CentreM[0] - 395); d > 6 && math.Abs(f.CentreM[0]-395+400) > 6 {
t.Fatalf("the centre should sit on the blob, near 395 m; got %v", f.CentreM[0])
}
if math.Abs(f.ExtentM[0]-40) > 1 {
t.Fatalf("the extent is measured the short way round: 4 px is 40 m; got %v", f.ExtentM[0])
}
}
// TestAPathBecomesACentrelineNotAnOutline is the difference between a road and a ribbon-shaped polygon.
func TestAPathBecomesACentrelineNotAnOutline(t *testing.T) {
l := mustLegend(t)
const w, h = 60, 20
px, alpha, set := paint(w, h)
// A horizontal stroke three pixels thick from x=5 to x=50.
for x := 5; x <= 50; x++ {
for y := 9; y <= 11; y++ {
set(x, y, [3]int{90, 60, 30})
}
}
r, _ := l.Classify(px, alpha, w, h)
feats := l.Features(r, testScale(w, h))
if len(feats) != 1 {
t.Fatalf("one stroke is one path; got %d", len(feats))
}
f := feats[0]
if f.Kind != KindPath || f.WidthM != 8 {
t.Fatalf("the path's width travels with it: %+v", f)
}
if len(f.PointsM) < 2 {
t.Fatalf("a path needs at least two points; got %d", len(f.PointsM))
}
// Simplified, so a straight stroke is a handful of points and not one per pixel.
if len(f.PointsM) > 8 {
t.Errorf("a straight stroke should simplify to a few points; got %d", len(f.PointsM))
}
// It runs the length of the stroke, not round its outline: 45 px is 450 m, an outline would be ~960.
if f.LengthM < 400 || f.LengthM > 500 {
t.Errorf("a 45 px stroke at 10 m a pixel is about 450 m of centreline; got %v", f.LengthM)
}
for _, p := range f.PointsM {
if p[1] < 85 || p[1] > 115 {
t.Errorf("every point should sit on the stroke, y near 100 m; got %v", p)
}
}
// An area mark never gets points, whatever shape it is drawn in.
for _, g := range feats {
if g.Kind == KindArea && len(g.PointsM) > 0 {
t.Error("an area keeps its outline and is not thinned")
}
}
}
// TestSampleWorldIsIndependentOfTheWindow is rule 1 for a raster: a cell gets the same mark whichever tile
// reaches it, because the lookup goes through world metres rather than through a tile-local index.
func TestSampleWorldIsIndependentOfTheWindow(t *testing.T) {
l := mustLegend(t)
const w, h = 40, 20
px, alpha, set := paint(w, h)
for y := 4; y < 10; y++ {
for x := 10; x < 16; x++ {
set(x, y, [3]int{0, 128, 0})
}
}
r, _ := l.Classify(px, alpha, w, h)
s := testScale(w, h)
// Two windows of a 2 m grid overlapping the same ground: one starting at 100 m, one at 60 m.
a := r.SampleWorld(100, 40, 2, 40, 40, s)
b := r.SampleWorld(60, 40, 2, 60, 40, s)
for y := 0; y < 40; y++ {
for x := 0; x < 40; x++ {
if a[y*40+x] != b[y*60+x+20] {
t.Fatalf("the same ground read two marks at (%d,%d): %d vs %d",
x, y, a[y*40+x], b[y*60+x+20])
}
}
}
// And it wraps, rather than clamping, past the seam.
past := r.SampleWorld(s.CircumferenceM+100, 40, 2, 40, 40, s)
for i := range a {
if a[i] != past[i] {
t.Fatalf("a window a whole world to the east must read the same ground; differ at %d", i)
}
}
}
func TestDocumentReportsEveryMarkPaintedOrNot(t *testing.T) {
l := mustLegend(t)
const w, h = 40, 20
px, alpha, set := paint(w, h)
for y := 4; y < 10; y++ {
for x := 10; x < 16; x++ {
set(x, y, [3]int{0, 128, 0})
}
}
r, m := l.Classify(px, alpha, w, h)
doc := l.Describe(r, m, testScale(w, h), "sheet.png", "sheet.json")
if len(doc.Marks) != 4 {
t.Fatalf("every mark is reported, painted or not; got %d", len(doc.Marks))
}
byName := map[string]MarkShare{}
for _, mk := range doc.Marks {
byName[mk.Name] = mk
}
if f := byName["forest"]; f.Cells != 36 || f.Pieces != 1 || math.Abs(f.AreaKm2-0.0036) > 1e-6 {
t.Errorf("forest: %+v", f)
}
if c := byName["drawn_coast"]; !c.HasJitter || c.Jitter != 0 || c.Pieces != 0 {
t.Errorf("an unpainted coast mark still reports what it would ask for: %+v", c)
}
if rd := byName["road"]; rd.Kind != KindPath || rd.WidthM != 8 {
t.Errorf("road: %+v", rd)
}
if doc.CircumferenceM != 400 || doc.PaintW != w {
t.Errorf("the frame is the overlay's own: %v x %d", doc.CircumferenceM, doc.PaintW)
}
}