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) } }