Added BUFFER option for Within and Intersects

This commit allows for buffering any GeoJSON object.

For example:

    INTERSECTS fleet BUFFER 1000 OBJECT {...LineString...}

This will buffer add a 1 kilometer buffer to a linesting and
search the 'fleet' collection for all objects that
intersect the buffered linestring.

This commit also allows for performing INTERSECTS with a POINT
type. Thus allowing for a polygon-over-point operation, which is
an inverted point-in-polygon.
This commit is contained in:
tidwall 2021-12-09 18:14:50 -07:00
parent 29a6d05f3f
commit 241117c7ba
6 changed files with 394 additions and 40 deletions

169
internal/buffer/buffer.go Normal file
View File

@ -0,0 +1,169 @@
package buffer
import (
"errors"
"math"
"github.com/tidwall/geojson"
"github.com/tidwall/geojson/geo"
"github.com/tidwall/geojson/geometry"
"github.com/tidwall/gjson"
)
// TODO: detect of pole and antimeridian crossing and generate
// valid multigeometries
const bufferSteps = 15
// Simple performs a very simple buffer operation on a geojson object.
func Simple(g geojson.Object, meters float64) (geojson.Object, error) {
if meters <= 0 {
return g, nil
}
if math.IsInf(meters, 0) || math.IsNaN(meters) {
return g, errors.New("invalid meters")
}
switch g := g.(type) {
case *geojson.Point:
return bufferSimplePoint(g.Base(), meters), nil
case *geojson.SimplePoint:
return bufferSimplePoint(g.Base(), meters), nil
case *geojson.MultiPoint:
return bufferSimpleGeometries(g.Base(), meters)
case *geojson.LineString:
return bufferSimpleLineString(g, meters)
case *geojson.MultiLineString:
return bufferSimpleGeometries(g.Base(), meters)
case *geojson.Polygon:
return bufferSimplePolygon(g, meters)
case *geojson.MultiPolygon:
return bufferSimpleGeometries(g.Base(), meters)
case *geojson.FeatureCollection:
return bufferSimpleFeatures(g.Base(), meters)
case *geojson.Feature:
bg, err := Simple(g.Base(), meters)
if err != nil {
return nil, err
}
return geojson.NewFeature(bg, g.Members()), nil
case *geojson.Circle:
return Simple(g.Primative(), meters)
case nil:
return nil, errors.New("cannot buffer nil object")
default:
typ := gjson.Get(g.JSON(), "type").String()
return nil, errors.New("cannot buffer " + typ + " type")
}
}
func bufferSimplePoint(p geometry.Point, meters float64) *geojson.Polygon {
meters = geo.NormalizeDistance(meters)
points := make([]geometry.Point, 0, bufferSteps+1)
// calc the four corners
maxY, _ := geo.DestinationPoint(p.Y, p.X, meters, 0)
_, maxX := geo.DestinationPoint(p.Y, p.X, meters, 90)
minY, _ := geo.DestinationPoint(p.Y, p.X, meters, 180)
_, minX := geo.DestinationPoint(p.Y, p.X, meters, 270)
// use the half width of the lat and lon
lons := (maxX - minX) / 2
lats := (maxY - minY) / 2
// generate the circle polygon
for th := 0.0; th <= 360.0; th += 360.0 / float64(bufferSteps) {
radians := (math.Pi / 180) * th
x := p.X + lons*math.Cos(radians)
y := p.Y + lats*math.Sin(radians)
points = append(points, geometry.Point{X: x, Y: y})
}
// add last connecting point, make a total of steps+1
points = append(points, points[0])
poly := geojson.NewPolygon(
geometry.NewPoly(points, nil, &geometry.IndexOptions{
Kind: geometry.None,
}),
)
return poly
}
func bufferSimpleGeometries(objs []geojson.Object, meters float64,
) (*geojson.GeometryCollection, error) {
geoms := make([]geojson.Object, len(objs))
for i := 0; i < len(objs); i++ {
g, err := Simple(objs[i], meters)
if err != nil {
return nil, err
}
geoms[i] = g
}
return geojson.NewGeometryCollection(geoms), nil
}
func bufferSimpleFeatures(objs []geojson.Object, meters float64,
) (*geojson.FeatureCollection, error) {
geoms := make([]geojson.Object, len(objs))
for i := 0; i < len(objs); i++ {
g, err := Simple(objs[i], meters)
if err != nil {
return nil, err
}
geoms[i] = g
}
return geojson.NewFeatureCollection(geoms), nil
}
// appendBufferSimpleSeries buffers a series and appends its parts to dst
func appendBufferSimpleSeries(dst []geojson.Object, s geometry.Series, meters float64) []geojson.Object {
nsegs := s.NumSegments()
for i := 0; i < nsegs; i++ {
dst = appendSimpleBufferSegment(dst, s.SegmentAt(i), meters, i == 0)
}
return dst
}
// appendSimpleBufferSegment buffers a segment and appends its parts to dst
func appendSimpleBufferSegment(dst []geojson.Object, seg geometry.Segment,
meters float64, first bool,
) []geojson.Object {
if first {
// endcap A
dst = append(dst, bufferSimplePoint(seg.A, meters))
}
// line polygon
bear1 := geo.BearingTo(seg.A.Y, seg.A.X, seg.B.Y, seg.B.X)
lat1, lon1 := geo.DestinationPoint(seg.A.Y, seg.A.X, meters, bear1-90)
lat2, lon2 := geo.DestinationPoint(seg.A.Y, seg.A.X, meters, bear1+90)
bear2 := geo.BearingTo(seg.B.Y, seg.B.X, seg.A.Y, seg.A.X)
lat3, lon3 := geo.DestinationPoint(seg.B.Y, seg.B.X, meters, bear2-90)
lat4, lon4 := geo.DestinationPoint(seg.B.Y, seg.B.X, meters, bear2+90)
dst = append(dst, geojson.NewPolygon(
geometry.NewPoly([]geometry.Point{
{X: lon1, Y: lat1},
{X: lon2, Y: lat2},
{X: lon3, Y: lat3},
{X: lon4, Y: lat4},
{X: lon1, Y: lat1},
}, nil, nil)))
// endcap B
dst = append(dst, bufferSimplePoint(seg.B, meters))
return dst
}
func bufferSimplePolygon(p *geojson.Polygon, meters float64,
) (*geojson.GeometryCollection, error) {
var geoms []geojson.Object
b := p.Base()
geoms = appendBufferSimpleSeries(geoms, b.Exterior, meters)
for _, hole := range b.Holes {
geoms = appendBufferSimpleSeries(geoms, hole, meters)
}
geoms = append(geoms, p)
return geojson.NewGeometryCollection(geoms), nil
}
func bufferSimpleLineString(l *geojson.LineString, meters float64,
) (*geojson.GeometryCollection, error) {
geoms := appendBufferSimpleSeries(nil, l.Base(), meters)
return geojson.NewGeometryCollection(geoms), nil
}

View File

@ -0,0 +1,113 @@
package buffer
import (
"testing"
"github.com/tidwall/geojson"
"github.com/tidwall/geojson/geometry"
)
const lineString = `{"type":"LineString","coordinates":[
[-116.40289306640624,34.125447565116126],
[-116.36444091796875,34.14818102254435],
[-116.0980224609375,34.15045403191448],
[-115.74920654296874,34.127721186043985],
[-115.54870605468749,34.075412438417395],
[-115.5267333984375,34.11407854333859],
[-115.21911621093749,34.048108084909835],
[-115.25207519531249,33.8339199536547],
[-115.40588378906249,33.71748624018193]
]}`
var lineInPoints = []geometry.Point{
{X: -115.64363479614258, Y: 34.108251327293296},
{X: -115.54355621337892, Y: 34.07199987534163},
{X: -115.21482467651367, Y: 34.051237154976164},
{X: -115.4110336303711, Y: 33.715201644740844},
{X: -116.40701293945311, Y: 34.12345809664606},
}
func TestBufferLineString(t *testing.T) {
g, err := geojson.Parse(lineString, nil)
if err != nil {
t.Fatal(err)
}
g2, err := Simple(g, 1000)
if err != nil {
t.Fatal(err)
}
for _, pt := range lineInPoints {
ok := g2.Contains(geojson.NewPoint(pt))
if !ok {
t.Fatalf("!ok")
}
}
}
const polygon = `{"type": "Polygon","coordinates":[
[
[116.46881103515624,34.277644878733824],
[115.87280273437499,34.20953080048952],
[115.70251464843749,34.397844946449865],
[115.9881591796875,34.61286625296406],
[116.46881103515624,34.277644878733824]
],
[
[115.90438842773436,34.38651267795365],
[116.05270385742188,34.35023911062779],
[115.99914550781249,34.44655621402982],
[115.90438842773436,34.38651267795365]
]
]}`
var polyInPoints = []geometry.Point{
{X: 115.95837593078612, Y: 34.59887847065301},
{X: 115.98755836486816, Y: 34.61879975173954},
{X: 115.98833084106445, Y: 34.59795999847678},
{X: 116.04536533355714, Y: 34.58082509817638},
{X: 116.47567749023438, Y: 34.27651009584797},
{X: 116.42005920410155, Y: 34.32018817684490},
{X: 116.33216857910156, Y: 34.25948651450623},
{X: 115.89340209960939, Y: 34.24132422972854},
{X: 115.95588684082033, Y: 34.42786803680155},
{X: 115.97236633300783, Y: 34.42107129982385},
{X: 115.99639892578125, Y: 34.43579686485573},
{X: 116.04652404785155, Y: 34.35364042469895},
{X: 115.92155456542967, Y: 34.38877925439021},
{X: 115.96755981445311, Y: 34.37687904351907},
{X: 115.88859558105467, Y: 34.42956713470528},
{X: 115.97511291503906, Y: 34.36327673174518},
{X: 115.69564819335938, Y: 34.39784494644986},
{X: 115.87005615234375, Y: 34.20385213966983},
{X: 115.76980590820312, Y: 34.31678550602221},
}
var polyOutPoints = []geometry.Point{
{X: 115.68534851074217, Y: 34.40917568058836},
{X: 115.98953247070312, Y: 34.63038297923298},
{X: 115.98541259765624, Y: 34.39671178864245},
{X: 116.31500244140626, Y: 34.22145474280257},
{X: 115.85426330566406, Y: 34.18510984477340},
}
func TestBufferPolygon(t *testing.T) {
g, err := geojson.Parse(polygon, nil)
if err != nil {
t.Fatal(err)
}
g2, err := Simple(g, 1000)
if err != nil {
t.Fatal(err)
}
for _, pt := range polyInPoints {
ok := g2.Contains(geojson.NewPoint(pt))
if !ok {
t.Fatalf("!ok")
}
}
for _, pt := range polyOutPoints {
ok := g2.Contains(geojson.NewPoint(pt))
if ok {
t.Fatalf("ok")
}
}
}

View File

@ -56,7 +56,7 @@ func (s *Server) cmdSetHook(msg *Message) (
}
var commandvs []string
var cmdlc string
var types []string
var types map[string]bool
var expires float64
var expiresSet bool
metaMap := make(map[string]string)

View File

@ -14,6 +14,7 @@ import (
"github.com/tidwall/geojson/geometry"
"github.com/tidwall/resp"
"github.com/tidwall/tile38/internal/bing"
"github.com/tidwall/tile38/internal/buffer"
"github.com/tidwall/tile38/internal/clip"
"github.com/tidwall/tile38/internal/glob"
)
@ -170,7 +171,7 @@ func parseRectArea(ltyp string, vs []string) (nvs []string, rect *geojson.Rect,
}
func (s *Server) cmdSearchArgs(
fromFenceCmd bool, cmd string, vs []string, types []string,
fromFenceCmd bool, cmd string, vs []string, types map[string]bool,
) (lfs liveFenceSwitches, err error) {
var t searchScanBaseTokens
if fromFenceCmd {
@ -198,13 +199,7 @@ func (s *Server) cmdSearchArgs(
}
}
ltyp := strings.ToLower(typ)
var found bool
for _, t := range types {
if ltyp == t {
found = true
break
}
}
found := types[ltyp]
if !found && lfs.searchScanBaseTokens.fence && ltyp == "roam" && cmd == "nearby" {
// allow roaming for nearby fence searches.
found = true
@ -215,7 +210,41 @@ func (s *Server) cmdSearchArgs(
}
switch ltyp {
case "point":
fallthrough
var slat, slon, smeters string
if vs, slat, ok = tokenval(vs); !ok || slat == "" {
err = errInvalidNumberOfArguments
return
}
if vs, slon, ok = tokenval(vs); !ok || slon == "" {
err = errInvalidNumberOfArguments
return
}
var lat, lon, meters float64
if lat, err = strconv.ParseFloat(slat, 64); err != nil {
err = errInvalidArgument(slat)
return
}
if lon, err = strconv.ParseFloat(slon, 64); err != nil {
err = errInvalidArgument(slon)
return
}
// radius is optional for nearby, but mandatory for others
if cmd == "nearby" {
if vs, smeters, ok = tokenval(vs); ok && smeters != "" {
meters, err = strconv.ParseFloat(smeters, 64)
if err != nil || meters < 0 {
err = errInvalidArgument(smeters)
return
}
} else {
meters = -1
}
// Nearby used the Circle type
lfs.obj = geojson.NewCircle(geometry.Point{X: lon, Y: lat}, meters, defaultCircleSteps)
} else {
// Intersects and Within use the Point type
lfs.obj = geojson.NewPoint(geometry.Point{X: lon, Y: lat})
}
case "circle":
if lfs.clip {
err = errInvalidArgument("cannot clip with " + ltyp)
@ -239,34 +268,15 @@ func (s *Server) cmdSearchArgs(
err = errInvalidArgument(slon)
return
}
// radius is optional for nearby, but mandatory for others
if cmd == "nearby" {
if vs, smeters, ok = tokenval(vs); ok && smeters != "" {
if meters, err = strconv.ParseFloat(smeters, 64); err != nil {
err = errInvalidArgument(smeters)
return
}
if meters < 0 {
err = errInvalidArgument(smeters)
return
}
} else {
meters = -1
}
} else {
if vs, smeters, ok = tokenval(vs); !ok || smeters == "" {
err = errInvalidNumberOfArguments
return
}
if meters, err = strconv.ParseFloat(smeters, 64); err != nil {
meters, err = strconv.ParseFloat(smeters, 64)
if err != nil || meters < 0 {
err = errInvalidArgument(smeters)
return
}
if meters < 0 {
err = errInvalidArgument(smeters)
return
}
}
lfs.obj = geojson.NewCircle(geometry.Point{X: lon, Y: lat}, meters, defaultCircleSteps)
case "object":
if lfs.clip {
@ -436,12 +446,24 @@ func (s *Server) cmdSearchArgs(
return
}
}
if lfs.hasbuffer {
lfs.obj, err = buffer.Simple(lfs.obj, lfs.buffer)
if err != nil {
return
}
}
return
}
var nearbyTypes = []string{"point"}
var withinOrIntersectsTypes = []string{
"geo", "bounds", "hash", "tile", "quadkey", "get", "object", "circle", "sector"}
var nearbyTypes = map[string]bool{
"point": true,
}
var withinOrIntersectsTypes = map[string]bool{
"geo": true, "bounds": true, "hash": true, "tile": true, "quadkey": true,
"get": true, "object": true, "circle": true, "point": true, "sector": true,
}
func (s *Server) cmdNearby(msg *Message) (res resp.Value, err error) {
start := time.Now()

View File

@ -211,6 +211,8 @@ type searchScanBaseTokens struct {
sparse uint8
desc bool
clip bool
buffer float64
hasbuffer bool
}
func (s *Server) parseSearchScanBaseTokens(
@ -234,6 +236,22 @@ func (s *Server) parseSearchScanBaseTokens(
nvs, wtok, ok := tokenval(vs)
if ok && len(wtok) > 0 {
switch strings.ToLower(wtok) {
case "buffer":
vs = nvs
var sbuf string
if vs, sbuf, ok = tokenval(vs); !ok || sbuf == "" {
err = errInvalidNumberOfArguments
return
}
var buf float64
buf, err = strconv.ParseFloat(sbuf, 64)
if err != nil || buf < 0 || math.IsInf(buf, 0) || math.IsNaN(buf) {
err = errInvalidArgument(sbuf)
return
}
t.buffer = buf
t.hasbuffer = true
continue
case "cursor":
vs = nvs
if scursor != "" {

View File

@ -32,6 +32,7 @@ func subTestSearch(t *testing.T, mc *mockServer) {
runStep(t, mc, "SEARCH_CURSOR", keys_SEARCH_CURSOR_test)
runStep(t, mc, "MATCH", keys_MATCH_test)
runStep(t, mc, "FIELDS", keys_FIELDS_search_test)
runStep(t, mc, "BUFFER", keys_BUFFER_search_test)
}
func keys_KNN_basic_test(mc *mockServer) error {
@ -696,6 +697,37 @@ func keys_FIELDS_search_test(mc *mockServer) error {
})
}
func keys_BUFFER_search_test(mc *mockServer) error {
const lineString = `{"type":"LineString","coordinates":[
[-116.40289306640624,34.125447565116126],
[-116.36444091796875,34.14818102254435],
[-116.0980224609375,34.15045403191448],
[-115.74920654296874,34.127721186043985],
[-115.54870605468749,34.075412438417395],
[-115.5267333984375,34.11407854333859],
[-115.21911621093749,34.048108084909835],
[-115.25207519531249,33.8339199536547],
[-115.40588378906249,33.71748624018193]
]}`
return mc.DoBatch([][]interface{}{
// points in
{"SET", "fleet", "truck01", "POINT", "34.10825132729329", "-115.6436347961428"}, {"OK"},
{"SET", "fleet", "truck02", "POINT", "34.07199987534163", "-115.5435562133782"}, {"OK"},
{"SET", "fleet", "truck03", "POINT", "34.05123715497616", "-115.2148246765137"}, {"OK"},
{"SET", "fleet", "truck04", "POINT", "33.71520164474084", "-115.4110336303711"}, {"OK"},
{"SET", "fleet", "truck05", "POINT", "34.12345809664606", "-116.4070129394531"}, {"OK"},
// points out
{"SET", "fleet", "truck06", "POINT", "35.10825132729329", "-115.6436347961428"}, {"OK"},
{"SET", "fleet", "truck07", "POINT", "35.07199987534163", "-115.5435562133782"}, {"OK"},
{"SET", "fleet", "truck08", "POINT", "35.05123715497616", "-115.2148246765137"}, {"OK"},
{"SET", "fleet", "truck09", "POINT", "35.71520164474084", "-115.4110336303711"}, {"OK"},
{"SET", "fleet", "truck10", "POINT", "35.12345809664606", "-116.4070129394531"}, {"OK"},
// buffered intersects
{"INTERSECTS", "fleet", "BUFFER", "1000", "COUNT", "OBJECT", lineString}, {"5"},
})
}
// match sorts the response and compares to the expected input
func match(expectIn string) func(org, v interface{}) (resp, expect interface{}) {
return func(v, org interface{}) (resp, expect interface{}) {