diff --git a/docs/generated/sql/functions.md b/docs/generated/sql/functions.md index 450a6127dc53..5a89dde4cffc 100644 --- a/docs/generated/sql/functions.md +++ b/docs/generated/sql/functions.md @@ -1235,6 +1235,10 @@ Bottom Left.

st_force2d(geometry: geometry) → geometry

Returns a Geometry which only contains X and Y coordinates.

+st_forcepolygonccw(geometry: geometry) → geometry

Returns a Geometry where all Polygon objects have exterior rings in the counter-clockwise orientation and interior rings in the clockwise orientation. Non-Polygon objects are unchanged.

+
+st_forcepolygoncw(geometry: geometry) → geometry

Returns a Geometry where all Polygon objects have exterior rings in the clockwise orientation and interior rings in the counter-clockwise orientation. Non-Polygon objects are unchanged.

+
st_geogfromewkb(val: bytes) → geography

Returns the Geography from an EWKB representation.

st_geogfromewkt(val: string) → geography

Returns the Geography from an EWKT representation.

@@ -1338,6 +1342,10 @@ calculated, the result is transformed back into a Geography with SRID 4326.

st_isempty(geometry: geometry) → bool

Returns whether the geometry is empty.

+st_ispolygonccw(geometry: geometry) → bool

Returns whether the Polygon objects inside the Geometry have exterior rings in the counter-clockwise orientation and interior rings in the clockwise orientation. Non-Polygon objects are considered counter-clockwise.

+
+st_ispolygoncw(geometry: geometry) → bool

Returns whether the Polygon objects inside the Geometry have exterior rings in the clockwise orientation and interior rings in the counter-clockwise orientation. Non-Polygon objects are considered clockwise.

+
st_isring(geometry: geometry) → bool

Returns whether the geometry is a single linestring that is closed and simple, as defined by ST_IsClosed and ST_IsSimple.

This function utilizes the GEOS module.

diff --git a/pkg/geo/geomfn/force.go b/pkg/geo/geomfn/force_layout.go similarity index 100% rename from pkg/geo/geomfn/force.go rename to pkg/geo/geomfn/force_layout.go diff --git a/pkg/geo/geomfn/force_test.go b/pkg/geo/geomfn/force_layout_test.go similarity index 100% rename from pkg/geo/geomfn/force_test.go rename to pkg/geo/geomfn/force_layout_test.go diff --git a/pkg/geo/geomfn/orientation.go b/pkg/geo/geomfn/orientation.go new file mode 100644 index 000000000000..a06d651d2ef9 --- /dev/null +++ b/pkg/geo/geomfn/orientation.go @@ -0,0 +1,154 @@ +// Copyright 2020 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package geomfn + +import ( + "github.com/cockroachdb/cockroach/pkg/geo" + "github.com/cockroachdb/errors" + "github.com/twpayne/go-geom" +) + +// Orientation defines an orientation of a shape. +type Orientation int + +const ( + // OrientationCW denotes a clockwise orientation. + OrientationCW Orientation = iota + // OrientationCCW denotes a counter-clockwise orientation + OrientationCCW +) + +// HasPolygonOrientation checks whether a given Geometry have polygons +// that matches the given Orientation. +// Non-Polygon objects +func HasPolygonOrientation(g geo.Geometry, o Orientation) (bool, error) { + t, err := g.AsGeomT() + if err != nil { + return false, err + } + return hasPolygonOrientation(t, o) +} + +func hasPolygonOrientation(g geom.T, o Orientation) (bool, error) { + switch g := g.(type) { + case *geom.Polygon: + for i := 0; i < g.NumLinearRings(); i++ { + isCCW := geo.IsLinearRingCCW(g.LinearRing(i)) + // Interior rings should be the reverse orientation of the exterior ring. + if i > 0 { + isCCW = !isCCW + } + switch o { + case OrientationCW: + if isCCW { + return false, nil + } + case OrientationCCW: + if !isCCW { + return false, nil + } + default: + return false, errors.Newf("unexpected orientation: %v", o) + } + } + return true, nil + case *geom.MultiPolygon: + for i := 0; i < g.NumPolygons(); i++ { + if ret, err := hasPolygonOrientation(g.Polygon(i), o); !ret || err != nil { + return ret, err + } + } + return true, nil + case *geom.GeometryCollection: + for i := 0; i < g.NumGeoms(); i++ { + if ret, err := hasPolygonOrientation(g.Geom(i), o); !ret || err != nil { + return ret, err + } + } + return true, nil + case *geom.Point, *geom.MultiPoint, *geom.LineString, *geom.MultiLineString: + return true, nil + default: + return false, errors.Newf("unhandled geometry type: %T", g) + } +} + +// ForcePolygonOrientation forces orientations within polygons +// to be oriented the prescribed way. +func ForcePolygonOrientation(g geo.Geometry, o Orientation) (geo.Geometry, error) { + t, err := g.AsGeomT() + if err != nil { + return geo.Geometry{}, err + } + + if err := forcePolygonOrientation(t, o); err != nil { + return geo.Geometry{}, err + } + return geo.MakeGeometryFromGeomT(t) +} + +func forcePolygonOrientation(g geom.T, o Orientation) error { + switch g := g.(type) { + case *geom.Polygon: + for i := 0; i < g.NumLinearRings(); i++ { + isCCW := geo.IsLinearRingCCW(g.LinearRing(i)) + // Interior rings should be the reverse orientation of the exterior ring. + if i > 0 { + isCCW = !isCCW + } + reverse := false + switch o { + case OrientationCW: + if isCCW { + reverse = true + } + case OrientationCCW: + if !isCCW { + reverse = true + } + default: + return errors.Newf("unexpected orientation: %v", o) + } + + if reverse { + // Reverse coordinates from both ends. + // Do this by swapping up to the middle of the array of elements, which guarantees + // each end get swapped. This works for an odd number of elements as well as + // the middle element ends swapping with itself, which is ok. + coords := g.LinearRing(i).FlatCoords() + for cIdx := 0; cIdx < len(coords)/2; cIdx += g.Stride() { + for sIdx := 0; sIdx < g.Stride(); sIdx++ { + coords[cIdx+sIdx], coords[len(coords)-cIdx-g.Stride()+sIdx] = coords[len(coords)-cIdx-g.Stride()+sIdx], coords[cIdx+sIdx] + } + } + } + } + return nil + case *geom.MultiPolygon: + for i := 0; i < g.NumPolygons(); i++ { + if err := forcePolygonOrientation(g.Polygon(i), o); err != nil { + return err + } + } + return nil + case *geom.GeometryCollection: + for i := 0; i < g.NumGeoms(); i++ { + if err := forcePolygonOrientation(g.Geom(i), o); err != nil { + return err + } + } + return nil + case *geom.Point, *geom.MultiPoint, *geom.LineString, *geom.MultiLineString: + return nil + default: + return errors.Newf("unhandled geometry type: %T", g) + } +} diff --git a/pkg/geo/geomfn/orientation_test.go b/pkg/geo/geomfn/orientation_test.go new file mode 100644 index 000000000000..126fee805603 --- /dev/null +++ b/pkg/geo/geomfn/orientation_test.go @@ -0,0 +1,226 @@ +// Copyright 2020 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package geomfn + +import ( + "testing" + + "github.com/cockroachdb/cockroach/pkg/geo" + "github.com/stretchr/testify/require" +) + +var orientationTestCases = []struct { + desc string + wkt string + isCCW bool + isCW bool + forcedCCWWKT string + forcedCWWKT string +}{ + { + desc: "POINT", + wkt: "POINT(10 20)", + isCCW: true, + isCW: true, + forcedCCWWKT: "POINT(10 20)", + forcedCWWKT: "POINT(10 20)", + }, + { + desc: "MULTIPOINT", + wkt: "MULTIPOINT((10 20), (20 30))", + isCCW: true, + isCW: true, + forcedCCWWKT: "MULTIPOINT((10 20), (20 30))", + forcedCWWKT: "MULTIPOINT((10 20), (20 30))", + }, + { + desc: "LINESTRING", + wkt: "LINESTRING(10 20, 20 30)", + isCCW: true, + isCW: true, + forcedCCWWKT: "LINESTRING(10 20, 20 30)", + forcedCWWKT: "LINESTRING(10 20, 20 30)", + }, + { + desc: "MULTILINESTRING", + wkt: "MULTILINESTRING((10 20, 20 30), (20 30, 30 40))", + isCCW: true, + isCW: true, + forcedCCWWKT: "MULTILINESTRING((10 20, 20 30), (20 30, 30 40))", + forcedCWWKT: "MULTILINESTRING((10 20, 20 30), (20 30, 30 40))", + }, + { + desc: "POLYGON from wikipedia", + wkt: "POLYGON ((30 10, 40 40, 20 40, 10 20, 30 10))", + isCCW: true, + isCW: false, + forcedCCWWKT: "POLYGON ((30 10, 40 40, 20 40, 10 20, 30 10))", + forcedCWWKT: "POLYGON ((30 10, 10 20, 20 40, 40 40, 30 10))", + }, + { + desc: "POLYGON with interior rings correctly CW", + wkt: "POLYGON((0 0, 0 1, 1 1, 1 0, 0 0), (0.1 0.1, 0.9 0.1, 0.9 0.9, 0.1 0.9, 0.1 0.1))", + isCCW: false, + isCW: true, + forcedCCWWKT: "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0), (0.1 0.1, 0.1 0.9, 0.9 0.9, 0.9 0.1, 0.1 0.1))", + forcedCWWKT: "POLYGON((0 0, 0 1, 1 1, 1 0, 0 0), (0.1 0.1, 0.9 0.1, 0.9 0.9, 0.1 0.9, 0.1 0.1))", + }, + { + desc: "POLYGON with interior rings correctly CCW", + wkt: "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0), (0.1 0.1, 0.1 0.9, 0.9 0.9, 0.9 0.1, 0.1 0.1))", + isCCW: true, + isCW: false, + forcedCCWWKT: "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0), (0.1 0.1, 0.1 0.9, 0.9 0.9, 0.9 0.1, 0.1 0.1))", + forcedCWWKT: "POLYGON((0 0, 0 1, 1 1, 1 0, 0 0), (0.1 0.1, 0.9 0.1, 0.9 0.9, 0.1 0.9, 0.1 0.1))", + }, + { + desc: "POLYGON with all exterior and interior rings CW", + wkt: "POLYGON((0 0, 0 1, 1 1, 1 0, 0 0), (0.1 0.1, 0.1 0.9, 0.9 0.9, 0.9 0.1, 0.1 0.1))", + isCCW: false, + isCW: false, + forcedCCWWKT: "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0), (0.1 0.1, 0.1 0.9, 0.9 0.9, 0.9 0.1, 0.1 0.1))", + forcedCWWKT: "POLYGON((0 0, 0 1, 1 1, 1 0, 0 0), (0.1 0.1, 0.9 0.1, 0.9 0.9, 0.1 0.9, 0.1 0.1))", + }, + { + desc: "MultiPolygon all CW", + wkt: `MULTIPOLYGON( + ((0 0, 0 1, 1 1, 1 0, 0 0), (0.1 0.1, 0.9 0.1, 0.9 0.9, 0.1 0.9, 0.1 0.1)), + ((10 10, 10 11, 11 11, 11 10, 10 10), (10.1 10.1, 10.9 10.1, 10.9 10.9, 10.1 10.9, 10.1 10.1)) + )`, + isCCW: false, + isCW: true, + forcedCCWWKT: `MULTIPOLYGON( + ((0 0, 1 0, 1 1, 0 1, 0 0), (0.1 0.1, 0.1 0.9, 0.9 0.9, 0.9 0.1, 0.1 0.1)), + ((10 10, 11 10, 11 11, 10 11, 10 10), (10.1 10.1, 10.1 10.9, 10.9 10.9, 10.9 10.1, 10.1 10.1)) + )`, + forcedCWWKT: `MULTIPOLYGON( + ((0 0, 0 1, 1 1, 1 0, 0 0), (0.1 0.1, 0.9 0.1, 0.9 0.9, 0.1 0.9, 0.1 0.1)), + ((10 10, 10 11, 11 11, 11 10, 10 10), (10.1 10.1, 10.9 10.1, 10.9 10.9, 10.1 10.9, 10.1 10.1)) + )`, + }, + { + desc: "MultiPolygon mixed everything", + wkt: `MULTIPOLYGON( + ((0 0, 0 1, 1 1, 1 0, 0 0), (0.1 0.1, 0.1 0.9, 0.9 0.9, 0.9 0.1, 0.1 0.1)), + ((10 10, 11 10, 11 11, 10 11, 10 10), (10.1 10.1, 10.9 10.1, 10.9 10.9, 10.1 10.9, 10.1 10.1)) + )`, + isCCW: false, + isCW: false, + forcedCCWWKT: `MULTIPOLYGON( + ((0 0, 1 0, 1 1, 0 1, 0 0), (0.1 0.1, 0.1 0.9, 0.9 0.9, 0.9 0.1, 0.1 0.1)), + ((10 10, 11 10, 11 11, 10 11, 10 10), (10.1 10.1, 10.1 10.9, 10.9 10.9, 10.9 10.1, 10.1 10.1)) + )`, + forcedCWWKT: `MULTIPOLYGON( + ((0 0, 0 1, 1 1, 1 0, 0 0), (0.1 0.1, 0.9 0.1, 0.9 0.9, 0.1 0.9, 0.1 0.1)), + ((10 10, 10 11, 11 11, 11 10, 10 10), (10.1 10.1, 10.9 10.1, 10.9 10.9, 10.1 10.9, 10.1 10.1)) + )`, + }, + { + desc: "GEOMETRYCOLLECTION all CW", + wkt: `GEOMETRYCOLLECTION( + LINESTRING(0 0, 1 0), + MULTIPOLYGON( + ((0 0, 0 1, 1 1, 1 0, 0 0), (0.1 0.1, 0.9 0.1, 0.9 0.9, 0.1 0.9, 0.1 0.1)), + ((10 10, 10 11, 11 11, 11 10, 10 10), (10.1 10.1, 10.9 10.1, 10.9 10.9, 10.1 10.9, 10.1 10.1)) + ), + POLYGON((0 0, 0 1, 1 1, 1 0, 0 0), (0.1 0.1, 0.9 0.1, 0.9 0.9, 0.1 0.9, 0.1 0.1)) + )`, + isCCW: false, + isCW: true, + forcedCCWWKT: `GEOMETRYCOLLECTION( + LINESTRING(0 0, 1 0), + MULTIPOLYGON( + ((0 0, 1 0, 1 1, 0 1, 0 0), (0.1 0.1, 0.1 0.9, 0.9 0.9, 0.9 0.1, 0.1 0.1)), + ((10 10, 11 10, 11 11, 10 11, 10 10), (10.1 10.1, 10.1 10.9, 10.9 10.9, 10.9 10.1, 10.1 10.1)) + ), + POLYGON((0 0, 1 0, 1 1, 0 1, 0 0), (0.1 0.1, 0.1 0.9, 0.9 0.9, 0.9 0.1, 0.1 0.1)) + )`, + forcedCWWKT: `GEOMETRYCOLLECTION( + LINESTRING(0 0, 1 0), + MULTIPOLYGON( + ((0 0, 0 1, 1 1, 1 0, 0 0), (0.1 0.1, 0.9 0.1, 0.9 0.9, 0.1 0.9, 0.1 0.1)), + ((10 10, 10 11, 11 11, 11 10, 10 10), (10.1 10.1, 10.9 10.1, 10.9 10.9, 10.1 10.9, 10.1 10.1)) + ), + POLYGON((0 0, 0 1, 1 1, 1 0, 0 0), (0.1 0.1, 0.9 0.1, 0.9 0.9, 0.1 0.9, 0.1 0.1)) + )`, + }, + { + desc: "GEOMETRYCOLLECTION mixed everything", + wkt: `GEOMETRYCOLLECTION( + LINESTRING(0 0, 1 0), + MULTIPOLYGON( + ((0 0, 0 1, 1 1, 1 0, 0 0), (0.1 0.1, 0.1 0.9, 0.9 0.9, 0.9 0.1, 0.1 0.1)), + ((10 10, 11 10, 11 11, 10 11, 10 10), (10.1 10.1, 10.9 10.1, 10.9 10.9, 10.1 10.9, 10.1 10.1)) + ), + POLYGON((0 0, 0 1, 1 1, 1 0, 0 0), (0.1 0.1, 0.9 0.1, 0.9 0.9, 0.1 0.9, 0.1 0.1)) + )`, + isCCW: false, + isCW: false, + forcedCCWWKT: `GEOMETRYCOLLECTION( + LINESTRING(0 0, 1 0), + MULTIPOLYGON( + ((0 0, 1 0, 1 1, 0 1, 0 0), (0.1 0.1, 0.1 0.9, 0.9 0.9, 0.9 0.1, 0.1 0.1)), + ((10 10, 11 10, 11 11, 10 11, 10 10), (10.1 10.1, 10.1 10.9, 10.9 10.9, 10.9 10.1, 10.1 10.1)) + ), + POLYGON((0 0, 1 0, 1 1, 0 1, 0 0), (0.1 0.1, 0.1 0.9, 0.9 0.9, 0.9 0.1, 0.1 0.1)) + )`, + forcedCWWKT: `GEOMETRYCOLLECTION( + LINESTRING(0 0, 1 0), + MULTIPOLYGON( + ((0 0, 0 1, 1 1, 1 0, 0 0), (0.1 0.1, 0.9 0.1, 0.9 0.9, 0.1 0.9, 0.1 0.1)), + ((10 10, 10 11, 11 11, 11 10, 10 10), (10.1 10.1, 10.9 10.1, 10.9 10.9, 10.1 10.9, 10.1 10.1)) + ), + POLYGON((0 0, 0 1, 1 1, 1 0, 0 0), (0.1 0.1, 0.9 0.1, 0.9 0.9, 0.1 0.9, 0.1 0.1)) + )`, + }, +} + +func TestHasPolygonOrientation(t *testing.T) { + for _, tc := range orientationTestCases { + t.Run(tc.desc, func(t *testing.T) { + g, err := geo.ParseGeometry(tc.wkt) + require.NoError(t, err) + + t.Run("ccw", func(t *testing.T) { + ret, err := HasPolygonOrientation(g, OrientationCCW) + require.NoError(t, err) + require.Equal(t, tc.isCCW, ret) + }) + + t.Run("cw", func(t *testing.T) { + ret, err := HasPolygonOrientation(g, OrientationCW) + require.NoError(t, err) + require.Equal(t, tc.isCW, ret) + }) + }) + } +} + +func TestForcePolygonOrientation(t *testing.T) { + for _, tc := range orientationTestCases { + t.Run(tc.desc, func(t *testing.T) { + g, err := geo.ParseGeometry(tc.wkt) + require.NoError(t, err) + + t.Run("ccw", func(t *testing.T) { + ret, err := ForcePolygonOrientation(g, OrientationCCW) + require.NoError(t, err) + require.Equal(t, geo.MustParseGeometry(tc.forcedCCWWKT), ret) + }) + + t.Run("cw", func(t *testing.T) { + ret, err := ForcePolygonOrientation(g, OrientationCW) + require.NoError(t, err) + require.Equal(t, geo.MustParseGeometry(tc.forcedCWWKT), ret) + }) + }) + } +} diff --git a/pkg/sql/logictest/testdata/logic_test/geospatial b/pkg/sql/logictest/testdata/logic_test/geospatial index be49bb8d26eb..abb1c55dd21e 100644 --- a/pkg/sql/logictest/testdata/logic_test/geospatial +++ b/pkg/sql/logictest/testdata/logic_test/geospatial @@ -1213,6 +1213,30 @@ Square (left) GEOMETRYCOLLECTION EMPTY Square (right) POLYGON ((0 0, 0 0.5, 0.5 0.5, 0.5 0, 0 0)) Square overlapping left and right square POLYGON ((0 0, 0 0.5, 0.5 0.5, 0.5 0, 0 0)) +# CW/CCW predicates. +query TBBTT +SELECT + dsc, + ST_IsPolygonCW(geom), + ST_IsPolygonCCW(geom), + ST_AsText(ST_ForcePolygonCW(geom)), + ST_AsText(ST_ForcePolygonCCW(geom)) +FROM geom_operators_test +ORDER BY dsc +---- +Empty GeometryCollection true true GEOMETRYCOLLECTION EMPTY GEOMETRYCOLLECTION EMPTY +Empty LineString true true LINESTRING EMPTY LINESTRING EMPTY +Empty Point true true POINT EMPTY POINT EMPTY +Faraway point true true POINT (5 5) POINT (5 5) +Line going through left and right square true true LINESTRING (-0.5 0.5, 0.5 0.5) LINESTRING (-0.5 0.5, 0.5 0.5) +NULL NULL NULL NULL NULL +Nested Geometry Collection true true GEOMETRYCOLLECTION (GEOMETRYCOLLECTION (POINT (0 0))) GEOMETRYCOLLECTION (GEOMETRYCOLLECTION (POINT (0 0))) +Point middle of Left Square true true POINT (-0.5 0.5) POINT (-0.5 0.5) +Point middle of Right Square true true POINT (0.5 0.5) POINT (0.5 0.5) +Square (left) false true POLYGON ((-1 0, -1 1, 0 1, 0 0, -1 0)) POLYGON ((-1 0, 0 0, 0 1, -1 1, -1 0)) +Square (right) false true POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0)) POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0)) +Square overlapping left and right square false true POLYGON ((-0.1 0, -0.1 1, 1 1, 1 0, -0.1 0)) POLYGON ((-0.1 0, 1 0, 1 1, -0.1 1, -0.1 0)) + # Functions which take in strings as input as well. query TT SELECT diff --git a/pkg/sql/sem/builtins/geo_builtins.go b/pkg/sql/sem/builtins/geo_builtins.go index 05295a19075e..194f5f43c8a5 100644 --- a/pkg/sql/sem/builtins/geo_builtins.go +++ b/pkg/sql/sem/builtins/geo_builtins.go @@ -2008,6 +2008,62 @@ Flags shown square brackets after the geometry type have the following meaning: tree.VolatilityImmutable, ), ), + "st_forcepolygoncw": makeBuiltin( + defProps(), + geometryOverload1( + func(ctx *tree.EvalContext, g *tree.DGeometry) (tree.Datum, error) { + ret, err := geomfn.ForcePolygonOrientation(g.Geometry, geomfn.OrientationCW) + if err != nil { + return nil, err + } + return tree.NewDGeometry(ret), nil + }, + types.Geometry, + infoBuilder{ + info: "Returns a Geometry where all Polygon objects have exterior rings in the clockwise orientation and interior rings in the counter-clockwise orientation. Non-Polygon objects are unchanged.", + }, + tree.VolatilityImmutable, + ), + ), + "st_forcepolygonccw": makeBuiltin( + defProps(), + geometryOverload1( + func(ctx *tree.EvalContext, g *tree.DGeometry) (tree.Datum, error) { + ret, err := geomfn.ForcePolygonOrientation(g.Geometry, geomfn.OrientationCCW) + if err != nil { + return nil, err + } + return tree.NewDGeometry(ret), nil + }, + types.Geometry, + infoBuilder{ + info: "Returns a Geometry where all Polygon objects have exterior rings in the counter-clockwise orientation and interior rings in the clockwise orientation. Non-Polygon objects are unchanged.", + }, + tree.VolatilityImmutable, + ), + ), + "st_ispolygoncw": makeBuiltin( + defProps(), + geometryOverload1UnaryPredicate( + func(g geo.Geometry) (bool, error) { + return geomfn.HasPolygonOrientation(g, geomfn.OrientationCW) + }, + infoBuilder{ + info: "Returns whether the Polygon objects inside the Geometry have exterior rings in the clockwise orientation and interior rings in the counter-clockwise orientation. Non-Polygon objects are considered clockwise.", + }, + ), + ), + "st_ispolygonccw": makeBuiltin( + defProps(), + geometryOverload1UnaryPredicate( + func(g geo.Geometry) (bool, error) { + return geomfn.HasPolygonOrientation(g, geomfn.OrientationCCW) + }, + infoBuilder{ + info: "Returns whether the Polygon objects inside the Geometry have exterior rings in the counter-clockwise orientation and interior rings in the clockwise orientation. Non-Polygon objects are considered counter-clockwise.", + }, + ), + ), "st_numgeometries": makeBuiltin( defProps(), geometryOverload1(