From bcd6c35a8cc6ff02b6646b915647286ce9a13e3b Mon Sep 17 00:00:00 2001 From: Robert Snedegar Date: Sun, 4 May 2025 11:20:10 -0700 Subject: [PATCH 01/22] Update builder_snapper files. Fix comments. Remove outdate maxEdgeDeviation methods. Add TODOs for a few more things to be done. Update some of the inline constant values to match the C++ values. (sqrt(2)/13 --> 0.548, etc. Remove removed methods from test. --- s2/builder_snapper.go | 195 +++++++++++++++++-------------------- s2/builder_snapper_test.go | 4 - 2 files changed, 91 insertions(+), 108 deletions(-) diff --git a/s2/builder_snapper.go b/s2/builder_snapper.go index c48bf02d..9c579463 100644 --- a/s2/builder_snapper.go +++ b/s2/builder_snapper.go @@ -27,32 +27,26 @@ import ( // // A Snapper defines the following methods: // -// 1. The SnapPoint method, which snaps a point P to a nearby point (the +// 1. The SnapPoint method, which snaps a point P to a nearby point (the +// candidate snap site). Any point may be returned, including P +// itself (the identity snap function). // -// candidate snap site). Any point may be returned, including P -// itself (the identity snap function). +// 2. SnapRadius, the maximum distance that vertices can move when +// snapped. The snapRadius must be at least as large as the maximum +// distance between P and SnapPoint(P) for any point P. // -// 2. SnapRadius, the maximum distance that vertices can move when +// Note that the maximum distance that edge interiors can move when +// snapped is slightly larger than "snapRadius", and is reported by +// the Builder options maxEdgeDeviation (see there for details). // -// snapped. The snapRadius must be at least as large as the maximum -// distance between P and SnapPoint(P) for any point P. +// 3. MinVertexSeparation, the guaranteed minimum distance between +// vertices in the output. This is generally a fraction of +// snapRadius where the fraction depends on the snap function. // -// 3. MaxEdgeDeviation, the maximum distance that edges can move when -// -// snapped. It is slightly larger than snapRadius because when a -// geodesic edge is snapped, the center of the edge moves further than -// its endpoints. This value is computed automatically by Builder. -// -// 4. MinVertexSeparation, the guaranteed minimum distance between -// -// vertices in the output. This is generally a fraction of -// snapRadius where the fraction depends on the snap function. -// -// 5. A MinEdgeVertexSeparation, the guaranteed minimum distance -// -// between edges and non-incident vertices in the output. This is -// generally a fraction of snapRadius where the fraction depends on -// the snap function. +// 4. A MinEdgeVertexSeparation, the guaranteed minimum distance +// between edges and non-incident vertices in the output. This is +// generally a fraction of snapRadius where the fraction depends on +// the snap function. // // It is important to note that SnapPoint does not define the actual // mapping from input vertices to output vertices, since the points it @@ -65,41 +59,36 @@ import ( // // Builder makes the following guarantees (within a small error margin): // -// 1. Every vertex is at a location returned by SnapPoint. -// -// 2. Vertices are within snapRadius of the corresponding input vertex. -// -// 3. Edges are within maxEdgeDeviation of the corresponding input edge +// 1. Every vertex is at a location returned by SnapPoint. // -// (a distance slightly larger than snapRadius). +// 2. Vertices are within snapRadius of the corresponding input vertex. // -// 4. Vertices are separated by at least minVertexSeparation +// 3. Edges are within maxEdgeDeviation of the corresponding input edge +// (a distance slightly larger than snapRadius). // -// (a fraction of snapRadius that depends on the snap function). +// 4. Vertices are separated by at least minVertexSeparation +// (a fraction of snapRadius that depends on the snap function). // -// 5. Edges and non-incident vertices are separated by at least +// 5. Edges and non-incident vertices are separated by at least +// minEdgeVertexSeparation (a fraction of snapRadius). // -// minEdgeVertexSeparation (a fraction of snapRadius). +// 6. Vertex and edge locations do not change unless one of the conditions +// above is not already met (idempotency / stability). // -// 6. Vertex and edge locations do not change unless one of the conditions -// -// above is not already met (idempotency / stability). -// -// 7. The topology of the input geometry is preserved (up to the creation -// -// of degeneracies). This means that there exists a continuous -// deformation from the input to the output such that no vertex -// crosses an edge. +// 7. The topology of the input geometry is preserved (up to the creation +// of degeneracies). This means that there exists a continuous +// deformation from the input to the output such that no vertex +// crosses an edge. type Snapper interface { - // SnapRadius reports the maximum distance that vertices can move when snapped. - // This requires that SnapRadius <= maxSnapRadius + // SnapRadius reports the maximum distance that vertices can move when + // snapped. The snap radius can be any value between 0 and maxSnapRadius. + // + // If the snap radius is zero, then vertices are snapped together only if + // they are identical. Edges will not be snapped to any vertices other + // than their endpoints, even if there are vertices whose distance to the + // edge is zero, unless split_crossing_edges() is true (see below). SnapRadius() s1.Angle - - // MaxEdgeDeviation returns the maximum distance that the center of an - // edge can move when snapped. This is slightly larger than SnapRadius - // because when a geodesic edge is snapped, the center of the edge moves - // further than its endpoints. - MaxEdgeDeviation() s1.Angle + // TODO(rsned): Add SetSnapRadius method to allow users to update value. // MinVertexSeparation returns the guaranteed minimum distance between // vertices in the output. This is generally some fraction of SnapRadius. @@ -158,13 +147,10 @@ func (sf IdentitySnapper) SnapRadius() s1.Angle { return sf.snapRadius } -// MaxEdgeDeviation returns the maximum edge deviation this type supports. -func (sf IdentitySnapper) MaxEdgeDeviation() s1.Angle { - return maxEdgeDeviationRatio * sf.snapRadius -} - // MinVertexSeparation returns the minimum vertex separation for this snap type. func (sf IdentitySnapper) MinVertexSeparation() s1.Angle { + // Since SnapFunction does not move the input point, output vertices are + // separated by the full snapRadius. return sf.snapRadius } @@ -172,6 +158,8 @@ func (sf IdentitySnapper) MinVertexSeparation() s1.Angle { // For the identity snap function, edges are separated from all non-incident // vertices by at least 0.5 * snapRadius. func (sf IdentitySnapper) MinEdgeVertexSeparation() s1.Angle { + // In the worst case configuration, the edge-vertex separation is half of the + // vertex separation. return 0.5 * sf.snapRadius } @@ -197,6 +185,8 @@ type CellIDSnapper struct { snapRadius s1.Angle } +// TODO(rsned): Add SetLevel method to allow changes to the type. + // NewCellIDSnapper returns a snap function with the default level set. func NewCellIDSnapper() CellIDSnapper { return CellIDSnapper{ @@ -215,12 +205,6 @@ func CellIDSnapperForLevel(level int) CellIDSnapper { // SnapRadius reports the maximum distance that vertices can move when snapped. // This requires that SnapRadius <= maxSnapRadius -// Defines the snap radius to be used (see Builder). The snap radius -// must be at least the minimum value for the current level, but larger -// values can also be used (e.g., to simplify the geometry). -// -// This requires snapRadius >= MinSnapRadiusForLevel(level) -// and snapRadius <= maxSnapRadius func (sf CellIDSnapper) SnapRadius() s1.Angle { return sf.snapRadius } @@ -247,31 +231,26 @@ func (sf CellIDSnapper) minSnapRadiusForLevel(level int) s1.Angle { // the minimum possible snap radius for the chosen level, do this: // // sf := CellIDSnapperForLevel(f.levelForMaxSnapRadius(distance)); +// +// TODO(rsned): pop this method out to standalone. func (sf CellIDSnapper) levelForMaxSnapRadius(snapRadius s1.Angle) int { // When choosing a level, we need to account for the error bound of // 4 * dblEpsilon that is added by MinSnapRadiusForLevel. return MaxDiagMetric.MinLevel(2 * (snapRadius.Radians() - 4*dblEpsilon)) } -// MaxEdgeDeviation returns the maximum edge deviation this type supports. -func (sf CellIDSnapper) MaxEdgeDeviation() s1.Angle { - return maxEdgeDeviationRatio * sf.snapRadius -} - -// MinVertexSeparation returns the guaranteed minimum distance between -// vertices in the output. This is generally some fraction of SnapRadius. -// For CellID snapping, the minimum separation between vertices depends on -// level and snapRadius. It can vary between 0.5 * snapRadius -// and snapRadius. +// MinVertexSeparation reports the minimum separation between vertices depending +// on level and snapRadius. It can vary between 0.5 * snapRadius and snapRadius. func (sf CellIDSnapper) MinVertexSeparation() s1.Angle { // We have three different bounds for the minimum vertex separation: one is // a constant bound, one is proportional to snapRadius, and one is equal to - // snapRadius minus a constant. These bounds give the best results for - // small, medium, and large snap radii respectively. We return the maximum + // snapRadius minus a constant. These bounds give the best results for + // small, medium, and large snap radii respectively. We return the maximum // of the three bounds. // // 1. Constant bound: Vertices are always separated by at least - // MinEdgeMetric.Value(level), the minimum edge length for the chosen snap level. + // MinEdgeMetric.Value(level), the minimum edge length for the chosen + // snap level. // // 2. Proportional bound: It can be shown that in the plane, the worst-case // configuration has a vertex separation of 2 / sqrt(13) * snapRadius. @@ -279,21 +258,21 @@ func (sf CellIDSnapper) MinVertexSeparation() s1.Angle { // is slightly smaller at cell level 2 (0.54849 vs. 0.55470). We reduce // that value a bit more below to be conservative. // - // 3. Best asymptotic bound: This bound bound is derived by observing we + // 3. Best asymptotic bound: This bound is derived by observing we // only select a new site when it is at least snapRadius away from all // existing sites, and the site can move by at most // 0.5 * MaxDiagMetric.Value(level) when snapped. minEdge := s1.Angle(MinEdgeMetric.Value(sf.level)) maxDiag := s1.Angle(MaxDiagMetric.Value(sf.level)) return maxAngle(minEdge, - maxAngle(s1.Angle(2/math.Sqrt(13))*sf.snapRadius, sf.snapRadius-0.5*maxDiag)) + // per 2 above, a little less than 2 / sqrt(13) + maxAngle(0.548*sf.snapRadius, + sf.snapRadius-0.5*maxDiag)) } // MinEdgeVertexSeparation returns the guaranteed minimum spacing between -// edges and non-incident vertices in the output. -// For CellID snapping, the minimum separation between edges and -// non-incident vertices depends on level and snapRadius. It can -// be as low as 0.219 * snapRadius, but is typically 0.5 * snapRadius +// edges and non-incident vertices in the output depending on level and snapRadius.. +// It can be as low as 0.219 * snapRadius, but is typically 0.5 * snapRadius // or more. func (sf CellIDSnapper) MinEdgeVertexSeparation() s1.Angle { // Similar to MinVertexSeparation, in this case we have four bounds: a @@ -302,15 +281,16 @@ func (sf CellIDSnapper) MinEdgeVertexSeparation() s1.Angle { // snapRadius, and a bound that is equal to snapRadius minus a constant. // // 1. Constant bounds: - // (a) At the minimum snap radius for a given level, it can be shown that - // vertices are separated from edges by at least 0.5 * MinDiagMetric.Value(level) in - // the plane. The unit test verifies this, except that on the sphere the - // worst case is slightly better: 0.5652980068 * MinDiagMetric.Value(level). + // (a) At the minimum snap radius for a given level, it can be shown + // that vertices are separated from edges by at least 0.5 *a + // MinDiagMetric.Value(level) in the plane. The unit test verifies this, + // except that on the sphere the worst case is slightly better: + // 0.5652980068 * MinDiagMetric.Value(level). // // (b) Otherwise, for arbitrary snap radii the worst-case configuration // in the plane has an edge-vertex separation of sqrt(3/19) * - // MinDiagMetric.Value(level), where sqrt(3/19) is about 0.3973597071. The unit - // test verifies that the bound is slightly better on the sphere: + // MinDiagMetric.Value(level), where sqrt(3/19) is about 0.3973597071. + // The unit test verifies that the bound is slightly better on the sphere: // 0.3973595687 * MinDiagMetric.Value(level). // // 2. Proportional bound: In the plane, the worst-case configuration has an @@ -337,8 +317,8 @@ func (sf CellIDSnapper) MinEdgeVertexSeparation() s1.Angle { // Otherwise, these bounds hold for any snapRadius. vertexSep := sf.MinVertexSeparation() - return maxAngle(s1.Angle(math.Sqrt(3.0/19.0))*minDiag, - maxAngle(s1.Angle(2*math.Sqrt(3.0/247.0))*sf.snapRadius, + return maxAngle(0.397*minDiag, // sqrt(3/19) in the plane + maxAngle(0.219*sf.snapRadius, // 2*sqrt(3/247) in the plane 0.5*(vertexSep/sf.snapRadius)*vertexSep)) } @@ -360,6 +340,11 @@ const ( // example, in E6 coordinates the point (23.12345651, -45.65432149) would // become (23123457, -45654321). // +// The main argument of the Snapper is the exponent for the power of 10 +// that coordinates should be multiplied by before rounding. For example, +// NewIntLatLngSnapper(7) is a function that snaps to E7 coordinates. The +// exponent can range from 0 to 10. +// // Each exponent has a corresponding minimum snap radius, which is simply the // maximum distance that a vertex can move when snapped. It is approximately // equal to 1/sqrt(2) times the nominal point spacing; for example, for @@ -388,6 +373,8 @@ func NewIntLatLngSnapper(exponent int) IntLatLngSnapper { return sf } +// TODO(rsned): Add SetExponent() method. + // SnapRadius reports the snap radius to be used. The snap radius // must be at least the minimum value for the current exponent, but larger // values can also be used (e.g., to simplify the geometry). @@ -400,6 +387,8 @@ func (sf IntLatLngSnapper) SnapRadius() s1.Angle { // minSnapRadiusForExponent returns the minimum allowable snap radius for the given // exponent (approximately equal to 10**(-exponent) / sqrt(2)) degrees). +// +// TODO(rsned): Pop this method out so it can be used by other callers. func (sf IntLatLngSnapper) minSnapRadiusForExponent(exponent int) s1.Angle { // snapRadius needs to be an upper bound on the true distance that a // point can move when snapped, taking into account numerical errors. @@ -426,18 +415,21 @@ func (sf IntLatLngSnapper) minSnapRadiusForExponent(exponent int) s1.Angle { // (much larger than the errors above), which can change the position by // up to (sqrt(2) * 0.5 * sf.to) radians. power := math.Pow10(exponent) - return (s1.Degree*s1.Angle((1/math.Sqrt2)/power) + s1.Angle((9*math.Sqrt2+1.5)*dblEpsilon)) + return (s1.Degree*s1.Angle((1/math.Sqrt2)/power) + + s1.Angle((9*math.Sqrt2+1.5)*dblEpsilon)) } // exponentForMaxSnapRadius returns the minimum exponent such that vertices will // not move by more than snapRadius. This can be useful when choosing an appropriate // exponent for snapping. The return value is always a valid exponent (out of // range values are silently clamped). +// +// TODO(rsned): Pop this method out so it can be used by other callers. func (sf IntLatLngSnapper) exponentForMaxSnapRadius(snapRadius s1.Angle) int { // When choosing an exponent, we need to account for the error bound of // (9 * sqrt(2) + 1.5) * dblEpsilon added by minSnapRadiusForExponent. snapRadius -= (9*math.Sqrt2 + 1.5) * dblEpsilon - snapRadius = s1.Angle(math.Max(float64(snapRadius), 1e-30)) + snapRadius = maxAngle(snapRadius, 1e-30) exponent := math.Log10((1 / math.Sqrt2) / snapRadius.Degrees()) // There can be small errors in the calculation above, so to ensure that @@ -447,14 +439,8 @@ func (sf IntLatLngSnapper) exponentForMaxSnapRadius(snapRadius s1.Angle) int { minInt(maxIntSnappingExponent, int(math.Ceil(exponent-2*dblEpsilon)))) } -// MaxEdgeDeviation returns the maximum edge deviation this type supports. -func (sf IntLatLngSnapper) MaxEdgeDeviation() s1.Angle { - return maxEdgeDeviationRatio * sf.snapRadius -} - -// MinVertexSeparation returns the guaranteed minimum distance between vertices -// in the output. For IntLatLng snapping, the minimum separation between vertices -// depends on exponent and snapRadius. +// MinVertexSeparation reports the minimum separation between vertices depending on +// exponent and snapRadius. It can vary between 0.471 * snapRadius and snapRadius. func (sf IntLatLngSnapper) MinVertexSeparation() s1.Angle { // We have two bounds for the minimum vertex separation: one is proportional // to snapRadius, and one is equal to snapRadius minus a constant. These @@ -471,19 +457,18 @@ func (sf IntLatLngSnapper) MinVertexSeparation() s1.Angle { // only select a new site when it is at least snapRadius away from all // existing sites, and snapping a vertex can move it by up to // ((1 / sqrt(2)) * sf.to) degrees. - return maxAngle((math.Sqrt2/3)*sf.snapRadius, + return maxAngle(0.471*sf.snapRadius, // sqrt(2)/3 in the plane sf.snapRadius-s1.Degree*s1.Angle(1/math.Sqrt2)*sf.to) } -// MinEdgeVertexSeparation returns the guaranteed minimum spacing between edges -// and non-incident vertices in the output. For IntLatLng snapping, the minimum -// separation between edges and non-incident vertices depends on level and +// MinEdgeVertexSeparation reports the minimum separation between edges +// and non-incident vertices in the output depending on the level and // snapRadius. It can be as low as 0.222 * snapRadius, but is typically // 0.39 * snapRadius or more. func (sf IntLatLngSnapper) MinEdgeVertexSeparation() s1.Angle { // Similar to MinVertexSeparation, in this case we have three bounds: // one is a constant bound, one is proportional to snapRadius, and one is - // equal to snapRadius minus a constant. + // approaches 0.5 * snapRadius asymptotically. // // 1. Constant bound: In the plane, the worst-case configuration has an // edge-vertex separation of ((1 / sqrt(13)) * sf.to) degrees. @@ -504,12 +489,14 @@ func (sf IntLatLngSnapper) MinEdgeVertexSeparation() s1.Angle { // bound approaches 0.5 * snapRadius as the snap radius becomes large // relative to the grid spacing. vertexSep := sf.MinVertexSeparation() - return maxAngle(s1.Angle(1/math.Sqrt(13))*s1.Degree*sf.to, - maxAngle((2.0/9.0)*sf.snapRadius, 0.5*(vertexSep/sf.snapRadius)*vertexSep)) + return maxAngle(0.277*s1.Degree*sf.to, // 1/sqrt(13) in the plane + maxAngle(0.222*sf.snapRadius, // 2/9 in the plane + 0.5*(vertexSep/sf.snapRadius)*vertexSep)) } // SnapPoint returns a candidate snap site for the given point. func (sf IntLatLngSnapper) SnapPoint(point Point) Point { + // ABSL_DCHECK_GE(exponent_, 0);// Make sure snap function was initialized. input := LatLngFromPoint(point) lat := s1.Angle(roundAngle(input.Lat * sf.from)) lng := s1.Angle(roundAngle(input.Lng * sf.from)) diff --git a/s2/builder_snapper_test.go b/s2/builder_snapper_test.go index 20e618df..33ce22df 100644 --- a/s2/builder_snapper_test.go +++ b/s2/builder_snapper_test.go @@ -36,10 +36,6 @@ func TestIdentitySnapper(t *testing.T) { t.Errorf("identSnap.MinEdgeVertexSeparation() = %v, want %v", i.MinEdgeVertexSeparation(), 0.5*rad) } - if i.MaxEdgeDeviation() != maxEdgeDeviationRatio { - t.Errorf("identSnap.SnapRadius() = %v, want %v", i.MaxEdgeDeviation(), maxEdgeDeviationRatio) - } - p := randomPoint() if got := i.SnapPoint(p); !p.ApproxEqual(got) { t.Errorf("identSnap.SnapPoint(%v) = %v, want %v", p, got, p) From 3ab23c513b65e63899c46b3488ab9b6cd1a25f50 Mon Sep 17 00:00:00 2001 From: Robert Snedegar Date: Sat, 10 May 2025 13:49:03 -0700 Subject: [PATCH 02/22] Define the builder options type and its two helper methods. --- s2/builder_options.go | 198 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 s2/builder_options.go diff --git a/s2/builder_options.go b/s2/builder_options.go new file mode 100644 index 00000000..faedb14e --- /dev/null +++ b/s2/builder_options.go @@ -0,0 +1,198 @@ +package s2 + +import "github.com/golang/geo/s1" + +type builderOptions struct { + // snapFunction holds the desired snap function. + // + // Note that if your input data includes vertices that were created using + // Intersection(), then you should use a "snapRadius" of + // at least intersectionMergeRadius, e.g. by calling + // + // options.setSnapFunction(IdentitySnapFunction(intersectionMergeRadius)); + // + // DEFAULT: IdentitySnapFunction(s1.Angle(0)) + // [This does no snapping and preserves all input vertices exactly.] + snapFunction Snapper + + // splitCrossingEdges determines how crossing edges are handled by Builder. + // If true, then detect all pairs of crossing edges and eliminate them by + // adding a new vertex at their intersection point. See also the + // AddIntersection() method which allows intersection points to be added + // selectively. + // + // When this option if true, intersectionTolerance is automatically set + // to a minimum of intersectionError (see intersectionTolerance + // for why this is necessary). Note that this means that edges can move + // by up to intersectionError even when the specified snap radius is + // zero. The exact distance that edges can move is always given by + // MaxEdgeDeviation(). + // + // Undirected edges should always be used when the output is a polygon, + // since splitting a directed loop at a self-intersection converts it into + // two loops that don't define a consistent interior according to the + // "interior is on the left" rule. (On the other hand, it is fine to use + // directed edges when defining a polygon *mesh* because in that case the + // input consists of sibling edge pairs.) + // + // Self-intersections can also arise when importing data from a 2D + // projection. You can minimize this problem by subdividing the input + // edges so that the S2 edges (which are geodesics) stay close to the + // original projected edges (which are curves on the sphere). This can + // be done using EdgeTessellator, for example. + // + // DEFAULT: false + splitCrossingEdges bool + + // intersectionTolerance specifies the maximum allowable distance between + // a vertex added by AddIntersection() and the edge(s) that it is intended + // to snap to. This method must be called before AddIntersection() can be + // used. It has the effect of increasing the snap radius for edges (but not + // vertices) by the given distance. + // + // The intersection tolerance should be set to the maximum error in the + // intersection calculation used. For example, if Intersection() + // is used then the error should be set to intersectionError. If + // PointOnLine is used then the error should be set to PointOnLineError. + // If Project is used then the error should be set to + // projectPerpendicularError. If more than one method is used then the + // intersection tolerance should be set to the maximum such error. + // + // The reason this option is necessary is that computed intersection + // points are not exact. For example, Intersection(a, b, c, d) + // returns a point up to intersectionError away from the true + // mathematical intersection of the edges AB and CD. Furthermore such + // intersection points are subject to further snapping in order to ensure + // that no pair of vertices is closer than the specified snap radius. For + // example, suppose the computed intersection point X of edges AB and CD + // is 1 nanonmeter away from both edges, and the snap radius is 1 meter. + // In that case X might snap to another vertex Y exactly 1 meter away, + // which would leave us with a vertex Y that could be up to 1.000000001 + // meters from the edges AB and/or CD. This means that AB and/or CD might + // not snap to Y leaving us with two edges that still cross each other. + // + // However if the intersection tolerance is set to 1 nanometer then the + // snap radius for edges is increased to 1.000000001 meters ensuring that + // both edges snap to a common vertex even in this worst case. (Tthis + // technique does not work if the vertex snap radius is increased as well; + // it requires edges and vertices to be handled differently.) + // + // Note that this option allows edges to move by up to the given + // intersection tolerance even when the snap radius is zero. The exact + // distance that edges can move is always given by maxEdgeDeviation() + // defined above. + // + // When splitCrossingEdges is true, the intersection tolerance is + // automatically set to a minimum of intersectionError. A larger + // value can be specified by calling this method explicitly. + // + // DEFAULT: s1.Angle(0) + intersectionTolerance s1.Angle + + // simplifyEdgeChains determines if the output geometry should be simplified + // by replacing nearly straight chains of short edges with a single long edge. + // + // The combined effect of snapping and simplifying will not change the + // input by more than the guaranteed tolerances (see the list documented + // with the SnapFunction class). For example, simplified edges are + // guaranteed to pass within snapRadius() of the *original* positions of + // all vertices that were removed from that edge. This is a much tighter + // guarantee than can be achieved by snapping and simplifying separately. + // + // However, note that this option does not guarantee idempotency. In + // other words, simplifying geometry that has already been simplified once + // may simplify it further. (This is unavoidable, since tolerances are + // measured with respect to the original geometry, which is no longer + // available when the geometry is simplified a second time.) + // + // When the output consists of multiple layers, simplification is + // guaranteed to be consistent: for example, edge chains are simplified in + // the same way across layers, and simplification preserves topological + // relationships between layers (e.g., no crossing edges will be created). + // Note that edge chains in different layers do not need to be identical + // (or even have the same number of vertices, etc) in order to be + // simplified together. All that is required is that they are close + // enough together so that the same simplified edge can meet all of their + // individual snapping guarantees. + // + // Note that edge chains are approximated as parametric curves rather than + // point sets. This means that if an edge chain backtracks on itself (for + // example, ABCDEFEDCDEFGH) then such backtracking will be preserved to + // within snapRadius() (for example, if the preceding point were all in a + // straight line then the edge chain would be simplified to ACFCFH, noting + // that C and F have degree > 2 and therefore can't be simplified away). + // + // Simplified edges are assigned all labels associated with the edges of + // the simplified chain. + // + // For this option to have any effect, a SnapFunction with a non-zero + // snapRadius() must be specified. Also note that vertices specified + // using ForceVertex are never simplified away. + // + // DEFAULT: false + simplifyEdgeChains bool + + // idempotent determines if snapping occurs only when the input geometry + // does not already meet the Builder output guarantees (see the Snapper + // type description for details). This means that if all input vertices + // are at snapped locations, all vertex pairs are separated by at least + // MinVertexSeparation(), and all edge-vertex pairs are separated by at + // least MinEdgeVertexSeparation(), then no snapping is done. + // + // If false, then all vertex pairs and edge-vertex pairs closer than + // "SnapRadius" will be considered for snapping. This can be useful, for + // example, if you know that your geometry contains errors and you want to + // make sure that features closer together than "SnapRadius" are merged. + // + // This option is automatically turned off when simplifyEdgeChains is true + // since simplifying edge chains is never guaranteed to be idempotent. + // + // DEFAULT: true + idempotent bool +} + +// defaultBuilderOptions returns a new instance with the proper defaults. +func defaultBuilderOptions() *builderOptions { + return &builderOptions{ + snapFunction: NewIdentitySnapper(0), + splitCrossingEdges: false, + intersectionTolerance: s1.Angle(0), + simplifyEdgeChains: false, + idempotent: true, + } +} + +// edgeSnapRadius reports the maximum distance from snapped edge vertices to +// the original edge. This is the same as SnapFunction().SnapRadius() except +// when splitCrossingEdges is true (see below), in which case the edge snap +// radius is increased by intersectionError. +func (o builderOptions) edgeSnapRadius() s1.Angle { + return o.snapFunction.SnapRadius() + o.intersectionTolerance +} + +// maxEdgeDeviation returns maximum distance that any point along an edge can +// move when snapped. It is slightly larger than edgeSnapRadius() because when +// a geodesic edge is snapped, the edge center moves further than its endpoints. +// Builder ensures that this distance is at most 10% larger than +// edgeSnapRadius(). +func (o builderOptions) maxEdgeDeviation() s1.Angle { + // We want maxEdgeDeviation to be large enough compared to SnapRadius() + // such that edge splitting is rare. + // + // Using spherical trigonometry, if the endpoints of an edge of length L + // move by at most a distance R, the center of the edge moves by at most + // asin(sin(R) / cos(L / 2)). Thus the (maxEdgeDeviation / SnapRadius) + // ratio increases with both the snap radius R and the edge length L. + // + // We arbitrarily limit the edge deviation to be at most 10% more than the + // snap radius. With the maximum allowed snap radius of 70 degrees, this + // means that edges up to 30.6 degrees long are never split. For smaller + // snap radii, edges up to 49 degrees long are never split. (Edges of any + // length are not split unless their endpoints move far enough so that the + // actual edge deviation exceeds the limit; in practice, splitting is rare + // even with long edges.) Note that it is always possible to split edges + // when maxEdgeDeviation() is exceeded; see maybeAddExtraSites(). + // + // TODO(rsned): What should we do when snapFunction.SnapRadius() > maxSnapRadius); + return maxEdgeDeviationRatio * o.edgeSnapRadius() +} From 1d63448276e6f24d8c43ce2d8caf975e630b3163 Mon Sep 17 00:00:00 2001 From: Robert Snedegar Date: Sat, 10 May 2025 13:51:49 -0700 Subject: [PATCH 03/22] add header text --- s2/builder_options.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/s2/builder_options.go b/s2/builder_options.go index faedb14e..9204d764 100644 --- a/s2/builder_options.go +++ b/s2/builder_options.go @@ -1,3 +1,16 @@ +// Copyright 2025 The S2 Geometry Project Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. package s2 import "github.com/golang/geo/s1" From 09f7532d325f88773a8d219fef5bb823864cb9b8 Mon Sep 17 00:00:00 2001 From: Robert Snedegar Date: Sat, 10 May 2025 13:52:17 -0700 Subject: [PATCH 04/22] Add builders layer interface. --- s2/builder_layer.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 s2/builder_layer.go diff --git a/s2/builder_layer.go b/s2/builder_layer.go new file mode 100644 index 00000000..27bbf5d5 --- /dev/null +++ b/s2/builder_layer.go @@ -0,0 +1,31 @@ +// Copyright 2025 The S2 Geometry Project Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package s2 + +// builderLayer defines the methods Layers must implement. +type builderLayer interface { + // GraphOptions returns the options defined by this layer. + GraphOptions() *graphOptions + + // Build assembles a graph of snapped edges into the geometry type + // implemented by this layer. If an error is encountered, error is + // set appropriately. + // + // Note that when there are multiple layers, the Graph object passed to all + // layers are guaranteed to be valid until the last Build() method returns. + // This makes it easier to write algorithms that gather the output graphs + // from several layers and process them all at once (such as + // closedSetNormalizer). + Build(g *graph) (bool, error) +} From 51ac9e33faa345cabb58eb8d27d0dbc2643a9d40 Mon Sep 17 00:00:00 2001 From: Robert Snedegar Date: Sat, 10 May 2025 14:01:08 -0700 Subject: [PATCH 05/22] Add the base builder type with its extensive comment block. --- s2/builder.go | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/s2/builder.go b/s2/builder.go index 004dd5bf..1d0080e1 100644 --- a/s2/builder.go +++ b/s2/builder.go @@ -33,3 +33,131 @@ const ( // when MaxEdgeDeviation is exceeded. maxEdgeDeviationRatio = 1.1 ) + +// edgeType indicates whether the input edges are undirected. Typically this is +// specified for each output layer (e.g., PolygonBuilderLayer). +// +// Directed edges are preferred, since otherwise the output is ambiguous. +// For example, output polygons may be the *inverse* of the intended result +// (e.g., a polygon intended to represent the world's oceans may instead +// represent the world's land masses). Directed edges are also somewhat +// more efficient. +// +// However even with undirected edges, most Builder layer types try to +// preserve the input edge direction whenever possible. Generally, edges +// are reversed only when it would yield a simpler output. For example, +// PolygonLayer assumes that polygons created from undirected edges should +// cover at most half of the sphere. Similarly, PolylineVectorBuilderLayer +// assembles edges into as few polylines as possible, even if this means +// reversing some of the "undirected" input edges. +// +// For shapes with interiors, directed edges should be oriented so that the +// interior is to the left of all edges. This means that for a polygon with +// holes, the outer loops ("shells") should be directed counter-clockwise +// while the inner loops ("holes") should be directed clockwise. Note that +// AddPolygon() follows this convention automatically. +type edgeType uint8 + +const ( + edgeTypeDirected edgeType = iota + edgeTypeUndirected +) + +// builder is a tool for assembling polygonal geometry from edges. Here are +// some of the things it is designed for: +// +// 1. Building polygons, polylines, and polygon meshes from unsorted +// collections of edges. +// +// 2. Snapping geometry to discrete representations (such as CellID centers +// or E7 lat/lng coordinates) while preserving the input topology and with +// guaranteed error bounds. +// +// 3. Simplifying geometry (e.g. for indexing, display, or storage). +// +// 4. Importing geometry from other formats, including repairing geometry +// that has errors. +// +// 5. As a tool for implementing more complex operations such as polygon +// intersections and unions. +// +// The implementation is based on the framework of "snap rounding". Unlike +// most snap rounding implementations, Builder defines edges as geodesics on +// the sphere (straight lines) and uses the topology of the sphere (i.e., +// there are no "seams" at the poles or 180th meridian). The algorithm is +// designed to be 100% robust for arbitrary input geometry. It offers the +// following properties: +// +// - Guaranteed bounds on how far input vertices and edges can move during +// the snapping process (i.e., at most the given "snapRadius"). +// +// - Guaranteed minimum separation between edges and vertices other than +// their endpoints (similar to the goals of Iterated Snap Rounding). In +// other words, edges that do not intersect in the output are guaranteed +// to have a minimum separation between them. +// +// - Idempotency (similar to the goals of Stable Snap Rounding), i.e. if the +// input already meets the output criteria then it will not be modified. +// +// - Preservation of the input topology (up to the creation of +// degeneracies). This means that there exists a continuous deformation +// from the input to the output such that no vertex crosses an edge. In +// other words, self-intersections won't be created, loops won't change +// orientation, etc. +// +// - The ability to snap to arbitrary discrete point sets (such as CellID +// centers, E7 lat/lng points on the sphere, or simply a subset of the +// input vertices), rather than being limited to an integer grid. +// +// Here are some of its other features: +// +// - It can handle both directed and undirected edges. Undirected edges can +// be useful for importing data from other formats, e.g. where loops have +// unspecified orientations. +// +// - It can eliminate self-intersections by finding all edge pairs that cross +// and adding a new vertex at each intersection point. +// +// - It can simplify polygons to within a specified tolerance. For example, +// if two vertices are close enough they will be merged, and if an edge +// passes nearby a vertex then it will be rerouted through that vertex. +// Optionally, it can also detect nearly straight chains of short edges and +// replace them with a single long edge, while maintaining the same +// accuracy, separation, and topology guarantees ("simplify_edge_chains"). +// +// - It supports many different output types through the concept of "layers" +// (polylines, polygons, polygon meshes, etc). You can build multiple +// layers at once in order to ensure that snapping does not create +// intersections between different objects (for example, you can simplify a +// set of contour lines without the risk of having them cross each other). +// +// - It supports edge labels, which allow you to attach arbitrary information +// to edges and have it preserved during the snapping process. (This can +// also be achieved using layers, at a coarser level of granularity.) +// +// Caveats: +// +// - Because Builder only works with edges, it cannot distinguish between +// the empty and full polygons. If your application can generate both the +// empty and full polygons, you must implement logic outside of this class. +// +// Example showing how to snap a polygon to E7 coordinates: +// +// builder := NewBuilder(BuilderOptions(IntLatLngSnapFunction(7))); +// var output *Polygon +// builder.StartLayer(NewPolygonLayer(output)) +// builder.AddPolygon(input); +// if err := builder.Build(); err != nil { +// fmt.Printf("error building: %v\n"), err +// ... +// } +// +// TODO(rsned): Make the type public when Builder is ready. +type builder struct { + opts *builderOptions +} + +// init initializes this instance with the given options. +func (b *builder) init(opts *builderOptions) { + b.opts = opts +} From a95103b6bb1ddc7bd078138933702a125c3498f7 Mon Sep 17 00:00:00 2001 From: Robert Snedegar Date: Sat, 10 May 2025 14:02:50 -0700 Subject: [PATCH 06/22] Add builder graph options and related enums. --- s2/builder_graph_options.go | 178 ++++++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 s2/builder_graph_options.go diff --git a/s2/builder_graph_options.go b/s2/builder_graph_options.go new file mode 100644 index 00000000..926cc173 --- /dev/null +++ b/s2/builder_graph_options.go @@ -0,0 +1,178 @@ +// Copyright 2025 The S2 Geometry Project Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package s2 + +// degenerateEdges controls how degenerate edges (i.e., an edge from a vertex to +// itself) are handled. Such edges may be present in the input, or they may be +// created when both endpoints of an edge are snapped to the same output vertex. +// The options available are: +type degenerateEdges uint8 + +const ( + // degenerateEdgesDiscard discards all degenerate edges. This is useful for + // layers that/do not support degeneracies, such as PolygonLayer. + degenerateEdgesDiscard degenerateEdges = iota + // degenerateEdgesDiscardExcess discards all degenerate edges that are + // connected to/non-degenerate edges and merges any remaining + // duplicate/degenerate edges. This is useful for simplifying/polygons + // while ensuring that loops that collapse to a/single point do not disappear. + degenerateEdgesDiscardExcess + // degenerateEdgesKeep: Keeps all degenerate edges. Be aware that this + // may create many redundant edges when simplifying geometry (e.g., a + // polyline of the form AABBBBBCCCCCCDDDD). degenerateEdgesKeep is mainly + // useful for algorithms that require an output edge for every input edge. + degenerateEdgesKeep +) + +// duplicateEdges controls how duplicate edges (i.e., edges that are present +// multiple times) are handled. Such edges may be present in the input, or they +// can be created when vertices are snapped together. When several edges are +// merged, the result is a single edge labelled with all of the original input +// edge ids. +type duplicateEdges uint8 + +const ( + duplicateEdgesMerge duplicateEdges = iota + duplicateEdgesKeep +) + +// siblingPairs controls how sibling edge pairs (i.e., pairs consisting +// of an edge and its reverse edge) are handled. Layer types that +// define an interior (e.g., polygons) normally discard such edge pairs +// since they do not affect the result (i.e., they define a "loop" with +// no interior). +// +// If edgeType is edgeTypeUndirected, a sibling edge pair is considered +// to consist of four edges (two duplicate edges and their siblings), since +// only two of these four edges will be used in the final output. +// +// Furthermore, since the options REQUIRE and CREATE guarantee that all +// edges will have siblings, Builder implements these options for +// undirected edges by discarding half of the edges in each direction and +// changing the edgeType to edgeTypeDirected. For example, two +// undirected input edges between vertices A and B would first be converted +// into two directed edges in each direction, and then one edge of each pair +// would be discarded leaving only one edge in each direction. +// +// Degenerate edges are considered not to have siblings. If such edges are +// present, they are passed through unchanged by siblingPairs::DISCARD. For +// siblingPairs::REQUIRE or siblingPairs::CREATE with undirected edges, the +// number of copies of each degenerate edge is reduced by a factor of two. +// Any of the options that discard edges (DISCARD, DISCARDEXCESS, and +// REQUIRE/CREATE in the case of undirected edges) have the side effect that +// when duplicate edges are present, all of the corresponding edge labels +// are merged together and assigned to the remaining edges. (This avoids +// the problem of having to decide which edges are discarded.) Note that +// this merging takes place even when all copies of an edge are kept. For +// example, consider the graph {AB1, AB2, AB3, BA4, CD5, CD6} (where XYn +// denotes an edge from X to Y with label "n"). With siblingPairs::DISCARD, +// we need to discard one of the copies of AB. But which one? Rather than +// choosing arbitrarily, instead we merge the labels of all duplicate edges +// (even ones where no sibling pairs were discarded), yielding {AB123, +// AB123, CD45, CD45} (assuming that duplicate edges are being kept). +// Notice that the labels of duplicate edges are merged even if no siblings +// were discarded (such as CD5, CD6 in this example), and that this would +// happen even with duplicate degenerate edges (e.g. the edges EE7, EE8). +type siblingPairs uint8 + +const ( + // siblingPairsDiscard discards all sibling edge pairs. + siblingPairsDiscard siblingPairs = iota + // siblingPairsDiscardExcess is like siblingPairsDiscard, except that a + // single sibling pair is kept if the result would otherwise be empty. + // This is useful for polygons with degeneracies (LaxPolygon), and for + // simplifying polylines while ensuring that they are not split into + // multiple disconnected pieces. + siblingPairsDiscardExcess + // siblingPairsKeep keeps sibling pairs. This can be used to create + // polylines that double back on themselves, or degenerate loops (with + // a layer type such as LaxPolygon). + siblingPairsKeep + // siblingPairsRequire requires that all edges have a sibling (and returns + // an error otherwise). This is useful with layer types that create a + // collection of adjacent polygons (a polygon mesh). + siblingPairsRequire + // siblingPairsCreate ensures that all edges have a sibling edge by + // creating them if necessary. This is useful with polygon meshes where + // the input polygons do not cover the entire sphere. Such edges always + // have an empty set of labels and do not have an associated InputEdgeID. + siblingPairsCreate +) + +// graphOptions is only needed by Layer implementations. A layer is +// responsible for assembling an Graph of snapped edges into the +// desired output format (e.g., an Polygon). The graphOptions allows +// each Layer type to specify requirements on its input graph: for example, if +// degenerateEdgesDiscard is specified, then Builder will ensure that all +// degenerate edges are removed before passing the graph to Layer's Build +// method. +type graphOptions struct { + // Specifies whether the Builder input edges should be treated as + // undirected. If true, then all input edges are duplicated into pairs + // consisting of an edge and a sibling (reverse) edge. Note that the + // automatically created sibling edge has an empty set of labels and does + // not have an associated InputEdgeId. + // + // The layer implementation is responsible for ensuring that exactly one + // edge from each pair is used in the output, i.e. *only half* of the graph + // edges will be used. (Note that some values of the siblingPairs option + // automatically take care of this issue by removing half of the edges and + // changing edgeType to Directed.) + // + // DEFAULT: edgeTypeDirected + edgeType edgeType + + // DEFAULT: degenerateEdgesKeep + degenerateEdges degenerateEdges + + // DEFAULT: duplicateEdgesKeep + duplicateEdges duplicateEdges + + // DEFAULT: siblingPairsKeep + siblingPairs siblingPairs + + // This is a specialized option that is only needed by clients that want to + // work with the graphs for multiple layers at the same time (e.g., in order + // to check whether the same edge is present in two different graphs). [Note + // that if you need to do this, usually it is easier just to build a single + // graph with suitable edge labels.] + // + // When there are a large number of layers, then by default Builder builds + // a minimal subgraph for each layer containing only the vertices needed by + // the edges in that layer. This ensures that layer types that iterate over + // the vertices run in time proportional to the size of that layer rather + // than the size of all layers combined. (For example, if there are a + // million layers with one edge each, then each layer would be passed a + // graph with 2 vertices rather than 2 million vertices.) + // + // If this option is set to false, this optimization is disabled. Instead + // the graph passed to this layer will contain the full set of vertices. + // (This is not recommended when the number of layers could be large.) + // + // DEFAULT: true + allowVertexFiltering bool +} + +// defaultGraphOptions returns a graphOptions that specify that all edges should +// be kept, since this produces the least surprising output and makes it easier +// to diagnose the problem when an option is left unspecified. +func defaultGraphOptions() graphOptions { + return graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesKeep, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsKeep, + allowVertexFiltering: true, + } +} From 0b2af375ef213fd0f39f58c072ede3972349e562 Mon Sep 17 00:00:00 2001 From: Robert Snedegar Date: Sat, 10 May 2025 14:10:28 -0700 Subject: [PATCH 07/22] Add initial builder graph type and related enums. --- s2/builder_graph.go | 51 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 s2/builder_graph.go diff --git a/s2/builder_graph.go b/s2/builder_graph.go new file mode 100644 index 00000000..375d6c8f --- /dev/null +++ b/s2/builder_graph.go @@ -0,0 +1,51 @@ +// Copyright 2025 The S2 Geometry Project Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package s2 + +// PolylineType Indicates whether polylines should be "paths" (which don't +// allow duplicate vertices, except possibly the first and last vertex) or +// "walks" (which allow duplicate vertices and edges). +type PolylineType uint8 + +const ( + PolylineTypePath PolylineType = iota + PolylineTypeWalk +) + +// graphEdge is a tuple of edge IDs. +type graphEdge struct { + first, second int32 +} + +// A Graph represents a collection of snapped edges that is passed +// to a Layer for assembly. (Example layers include polygons, polylines, and +// polygon meshes.) The Graph object does not own any of its underlying data; +// it is simply a view of data that is stored elsewhere. You will only +// need this interface if you want to implement a new Layer subtype. +// +// The graph consists of vertices and directed edges. Vertices are numbered +// sequentially starting from zero. An edge is represented as a pair of +// vertex ids. The edges are sorted in lexicographic order, therefore all of +// the outgoing edges from a particular vertex form a contiguous range. +// +// TODO(rsned): Consider pulling out the methods that are helper functions for +// Layer implementations (such as getDirectedLoops) into a builder_util_graph.go. +type graph struct { + opts *graphOptions + numVertices int + vertices []Point + edges []graphEdge + + // TODO(rsned): Add remaining elements. +} From af0ce5088af0551a4b3f1b37c51d141ffd442e10 Mon Sep 17 00:00:00 2001 From: Robert Snedegar Date: Sat, 10 May 2025 14:12:02 -0700 Subject: [PATCH 08/22] Add more types to graph options. --- s2/builder_graph.go | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/s2/builder_graph.go b/s2/builder_graph.go index 375d6c8f..a26d1a88 100644 --- a/s2/builder_graph.go +++ b/s2/builder_graph.go @@ -42,10 +42,13 @@ type graphEdge struct { // TODO(rsned): Consider pulling out the methods that are helper functions for // Layer implementations (such as getDirectedLoops) into a builder_util_graph.go. type graph struct { - opts *graphOptions - numVertices int - vertices []Point - edges []graphEdge - - // TODO(rsned): Add remaining elements. + opts *graphOptions + numVertices int + vertices []Point + edges []graphEdge + inputEdgeIDSetIDs []int32 + inputEdgeIDSetLexicon *idSetLexicon + labelSetIDs []int32 + labelSetLexicon *idSetLexicon + isFullPolygonPredicate isFullPolygonPredicate } From 60846585f5114da04dc094ecc46ae3ec6a14997a Mon Sep 17 00:00:00 2001 From: Robert Snedegar Date: Sat, 10 May 2025 14:18:52 -0700 Subject: [PATCH 09/22] Add another batch of builder internal variables and update init. --- s2/builder.go | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/s2/builder.go b/s2/builder.go index 1d0080e1..08eef345 100644 --- a/s2/builder.go +++ b/s2/builder.go @@ -14,6 +14,12 @@ package s2 +import ( + "math" + + "github.com/golang/geo/s1" +) + const ( // maxEdgeDeviationRatio is set so that MaxEdgeDeviation will be large enough // compared to snapRadius such that edge splitting is rare. @@ -63,6 +69,29 @@ const ( edgeTypeUndirected ) +// isFullPolygonPredicate is an interface for determining if Polygons are +// full or not. For output layers that represent polygons, there is an ambiguity +// inherent in spherical geometry that does not exist in planar geometry. +// Namely, if a polygon has no edges, does it represent the empty polygon +// (containing no points) or the full polygon (containing all points)? This +// ambiguity also occurs for polygons that consist only of degeneracies, e.g. +// a degenerate loop with only two edges could be either a degenerate shell in +// the empty polygon or a degenerate hole in the full polygon. +// +// To resolve this ambiguity, an IsFullPolygonPredicate may be specified for +// each output layer (see AddIsFullPolygonPredicate below). If the output +// after snapping consists only of degenerate edges and/or sibling pairs +// (including the case where there are no edges at all), then the layer +// implementation calls the given predicate to determine whether the polygon +// is empty or full except for those degeneracies. The predicate is given +// an S2Builder::Graph containing the output edges, but note that in general +// the predicate must also have knowledge of the input geometry in order to +// determine the correct result. +// +// This predicate is only needed by layers that are assembled into polygons. +// It is not used by other layer types. +type isFullPolygonPredicate func(g *graph) (bool, error) + // builder is a tool for assembling polygonal geometry from edges. Here are // some of the things it is designed for: // @@ -155,9 +184,108 @@ const ( // TODO(rsned): Make the type public when Builder is ready. type builder struct { opts *builderOptions + + // The maximum distance (inclusive) that a vertex can move when snapped, + // equal to options.SnapFunction().SnapRadius()). + siteSnapRadiusCA s1.ChordAngle + + // The maximum distance (inclusive) that an edge can move when snapping to a + // snap site. It can be slightly larger than the site snap radius when + // edges are being split at crossings. + edgeSnapRadiusCA s1.ChordAngle + + // True if we need to check that snapping has not changed the input topology + // around any vertex (i.e. Voronoi site). Normally this is only necessary for + // forced vertices, but if the snap radius is very small (e.g., zero) and + // split_crossing_edges() is true then we need to do this for all vertices. + // In all other situations, any snapped edge that crosses a vertex will also + // be closer than min_edge_vertex_separation() to that vertex, which will + // cause us to add a separation site anyway. + checkAllSiteCrossings bool + + maxEdgeDeviation s1.Angle + edgeSiteQueryRadiusCA s1.ChordAngle + minEdgeLengthToSplitCA s1.ChordAngle + + minSiteSeparation s1.Angle + minSiteSeparationCA s1.ChordAngle + minEdgeSiteSeparationCA s1.ChordAngle + minEdgeSiteSeparationCALimit s1.ChordAngle + + maxAdjacentSiteSeparationCA s1.ChordAngle + + // The squared sine of the edge snap radius. This is equivalent to the snap + // radius (squared) for distances measured through the interior of the + // sphere to the plane containing an edge. This value is used only when + // interpolating new points along edges (see GetSeparationSite). + edgeSnapRadiusSin2 float64 + + // True if snapping was requested. This is true if either snapRadius() is + // positive, or splitCrossingEdges() is true (which implicitly requests + // snapping to ensure that both crossing edges are snapped to the + // intersection point). + snappingRequested bool + + // Initially false, and set to true when it is discovered that at least one + // input vertex or edge does not meet the output guarantees (e.g., that + // vertices are separated by at least snapFunction.minVertexSeparation). + snappingNeeded bool } // init initializes this instance with the given options. func (b *builder) init(opts *builderOptions) { b.opts = opts + + snapFunc := opts.snapFunction + sr := snapFunc.SnapRadius() + + // Cap the snap radius to the limit. + if sr > maxSnapRadius { + sr = maxSnapRadius + } + + // Convert the snap radius to an ChordAngle. This is the "true snap + // radius" used when evaluating exact predicates. + b.siteSnapRadiusCA = s1.ChordAngleFromAngle(sr) + + // When intersectionTolerance is non-zero we need to use a larger snap + // radius for edges than for vertices to ensure that both edges are snapped + // to the edge intersection location. This is because the computed + // intersection point is not exact; it may be up to intersectionTolerance + // away from its true position. The computed intersection point might then + // be snapped to some other vertex up to SnapRadius away. So to ensure + // that both edges are snapped to a common vertex, we need to increase the + // snap radius for edges to at least the sum of these two values (calculated + // conservatively). + edgeSnapRadius := opts.edgeSnapRadius() + b.edgeSnapRadiusCA = roundUp(edgeSnapRadius) + b.snappingRequested = (edgeSnapRadius > 0) + + // Compute the maximum distance that a vertex can be separated from an + // edge while still affecting how that edge is snapped. + b.maxEdgeDeviation = opts.maxEdgeDeviation() + b.edgeSiteQueryRadiusCA = s1.ChordAngleFromAngle(b.maxEdgeDeviation + + snapFunc.MinEdgeVertexSeparation()) + + // Compute the maximum edge length such that even if both endpoints move by + // the maximum distance allowed (i.e., edge_snap_radius), the center of the + // edge will still move by less than max_edge_deviation(). This saves us a + // lot of work since then we don't need to check the actual deviation. + if !b.snappingRequested { + b.minEdgeLengthToSplitCA = s1.InfChordAngle() + } else { + // This value varies between 30 and 50 degrees depending on + // the snap radius. + b.minEdgeLengthToSplitCA = s1.ChordAngleFromAngle(s1.Angle(2 * + math.Acos(math.Sin(edgeSnapRadius.Radians())/ + math.Sin(b.maxEdgeDeviation.Radians())))) + } + + // TODO(rsned): Continue adding to init +} + +// roundUp rounds the given angle up by the max error and returns it as a chord angle. +func roundUp(a s1.Angle) s1.ChordAngle { + ca := s1.ChordAngleFromAngle(a) + return ca.Expanded(ca.MaxAngleError()) } From 0b5e252871d46579ecdf9d7b7cea2a4af2bd9851 Mon Sep 17 00:00:00 2001 From: Robert Snedegar Date: Sat, 10 May 2025 18:08:53 -0700 Subject: [PATCH 10/22] change polylinetype to package private like the other builder enums. --- s2/builder_graph.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/s2/builder_graph.go b/s2/builder_graph.go index a26d1a88..c7bd963b 100644 --- a/s2/builder_graph.go +++ b/s2/builder_graph.go @@ -13,14 +13,14 @@ // limitations under the License. package s2 -// PolylineType Indicates whether polylines should be "paths" (which don't +// polylineType Indicates whether polylines should be "paths" (which don't // allow duplicate vertices, except possibly the first and last vertex) or // "walks" (which allow duplicate vertices and edges). -type PolylineType uint8 +type polylineType uint8 const ( - PolylineTypePath PolylineType = iota - PolylineTypeWalk + polylineTypePath polylineType = iota + polylineTypeWalk ) // graphEdge is a tuple of edge IDs. From 6435633c5680344b68fd869fc1af18d456b95397 Mon Sep 17 00:00:00 2001 From: Robert Snedegar Date: Sat, 10 May 2025 18:09:39 -0700 Subject: [PATCH 11/22] Add remaining builder datastructure elements and finish init(). --- s2/builder.go | 139 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 131 insertions(+), 8 deletions(-) diff --git a/s2/builder.go b/s2/builder.go index 08eef345..2f013782 100644 --- a/s2/builder.go +++ b/s2/builder.go @@ -152,7 +152,7 @@ type isFullPolygonPredicate func(g *graph) (bool, error) // passes nearby a vertex then it will be rerouted through that vertex. // Optionally, it can also detect nearly straight chains of short edges and // replace them with a single long edge, while maintaining the same -// accuracy, separation, and topology guarantees ("simplify_edge_chains"). +// accuracy, separation, and topology guarantees ("simplifyEdgeChains"). // // - It supports many different output types through the concept of "layers" // (polylines, polygons, polygon meshes, etc). You can build multiple @@ -168,11 +168,11 @@ type isFullPolygonPredicate func(g *graph) (bool, error) // // - Because Builder only works with edges, it cannot distinguish between // the empty and full polygons. If your application can generate both the -// empty and full polygons, you must implement logic outside of this class. +// empty and full polygons, you must implement logic outside of this type. // // Example showing how to snap a polygon to E7 coordinates: // -// builder := NewBuilder(BuilderOptions(IntLatLngSnapFunction(7))); +// builder := NewBuilder(newBuilderOptions(IntLatLngSnapFunction(7))); // var output *Polygon // builder.StartLayer(NewPolygonLayer(output)) // builder.AddPolygon(input); @@ -197,9 +197,9 @@ type builder struct { // True if we need to check that snapping has not changed the input topology // around any vertex (i.e. Voronoi site). Normally this is only necessary for // forced vertices, but if the snap radius is very small (e.g., zero) and - // split_crossing_edges() is true then we need to do this for all vertices. + // splitCrossingedges is true then we need to do this for all vertices. // In all other situations, any snapped edge that crosses a vertex will also - // be closer than min_edge_vertex_separation() to that vertex, which will + // be closer than minEdgeVertexSeparation() to that vertex, which will // cause us to add a separation site anyway. checkAllSiteCrossings bool @@ -230,6 +230,54 @@ type builder struct { // input vertex or edge does not meet the output guarantees (e.g., that // vertices are separated by at least snapFunction.minVertexSeparation). snappingNeeded bool + + // A flag indicating whether labelSet has been modified since the last + // time labelSetID was computed. + labelSetModified bool + + inputVertices []Point + // inputEdges []builderInputEdge + + layers []*builderLayer + layerOptions []*graphOptions + layerBegins []int32 + layerIsFullPolygonPredicates []isFullPolygonPredicate + + // Each input edge has "label set id" (an int32) representing the set of + // labels attached to that edge. This vector is populated only if at least + // one label is used. + labelSetIDs []int32 + labelSetLexicon *idSetLexicon + + // The current set of labels (represented as a stack). + labelSet []int32 + + // The labelSetID corresponding to the current label set, computed on demand + // (by adding it to labelSetLexicon()). + labelSetID int32 + + // The remaining fields are used for snapping and simplifying. + + // The number of sites specified using forceVertex(). These sites are + // always at the beginning of the sites vector. + numForcedSites int32 + + // The set of snapped vertex locations ("sites"). + sites []Point + + // A map from each input edge to the set of sites "nearby" that edge, + // defined as the set of sites that are candidates for snapping and/or + // avoidance. Note that compactarray will inline up to two sites, which + // usually takes care of the vast majority of edges. Sites are kept sorted + // by increasing distance from the origin of the input edge. + // + // Once snapping is finished, this field is discarded unless edge chain + // simplification was requested, in which case instead the sites are + // filtered by removing the ones that each edge was snapped to, leaving only + // the "sites to avoid" (needed for simplification). + edgeSites [][]int32 + + // TODO(rsned): Add memoryTracker if it becomes available. } // init initializes this instance with the given options. @@ -268,8 +316,8 @@ func (b *builder) init(opts *builderOptions) { snapFunc.MinEdgeVertexSeparation()) // Compute the maximum edge length such that even if both endpoints move by - // the maximum distance allowed (i.e., edge_snap_radius), the center of the - // edge will still move by less than max_edge_deviation(). This saves us a + // the maximum distance allowed (i.e., edgeSnapRadius), the center of the + // edge will still move by less than maxEdgeDeviation. This saves us a // lot of work since then we don't need to check the actual deviation. if !b.snappingRequested { b.minEdgeLengthToSplitCA = s1.InfChordAngle() @@ -281,7 +329,74 @@ func (b *builder) init(opts *builderOptions) { math.Sin(b.maxEdgeDeviation.Radians())))) } - // TODO(rsned): Continue adding to init + // In rare cases we may need to explicitly check that the input topology is + // preserved, i.e. that edges do not cross vertices when snapped. This is + // only necessary (1) for vertices added using forceVertex, and (2) when the + // snap radius is smaller than intersectionTolerance (which is typically + // either zero or intersectionError, about 9e-16 radians). This + // condition arises because when a geodesic edge is snapped, the edge center + // can move further than its endpoints. This can cause an edge to pass on the + // wrong side of an input vertex. (Note that this could not happen in a + // planar version of this algorithm.) Usually we don't need to consider this + // possibility explicitly, because if the snapped edge passes on the wrong + // side of a vertex then it is also closer than minEdgeVertexSeparation + // to that vertex, which will cause a separation site to be added. + // + // If the condition below is true then we need to check all sites (i.e., + // snapped input vertices) for topology changes. However this is almost never + // the case because + // + // maxEdgeDeviation() == 1.1 * edgeSnapRadius + // and minEdgeVertexSeparation() >= 0.219 * SnapRadius + // + // for all currently implemented snap functions. The condition below is + // only true when intersectionTolerance() is non-zero (which causes + // edgeSnapRadius() to exceed SnapRadius() by intersectionError) and + // SnapRadius() is very small (at most intersectionError / 1.19). + b.checkAllSiteCrossings = (opts.maxEdgeDeviation() > + opts.edgeSnapRadius()+snapFunc.MinEdgeVertexSeparation()) + if opts.intersectionTolerance <= 0 { + if b.checkAllSiteCrossings { + } + } + + // To implement idempotency, we check whether the input geometry could + // possibly be the output of a previous Builder invocation. This involves + // testing whether any site/site or edge/site pairs are too close together. + // This is done using exact predicates, which require converting the minimum + // separation values to a ChordAngle. + b.minSiteSeparation = snapFunc.MinVertexSeparation() + b.minSiteSeparationCA = s1.ChordAngleFromAngle(b.minSiteSeparation) + b.minEdgeSiteSeparationCA = s1.ChordAngleFromAngle(snapFunc.MinEdgeVertexSeparation()) + + // This is an upper bound on the distance computed by ClosestPointQuery + // where the true distance might be less than minEdgeSiteSeparationCA. + b.minEdgeSiteSeparationCALimit = addPointToEdgeError(b.minEdgeSiteSeparationCA) + + // Compute the maximum possible distance between two sites whose Voronoi + // regions touch. (The maximum radius of each Voronoi region is + // edgeSnapRadius.) Then increase this bound to account for errors. + b.maxAdjacentSiteSeparationCA = addPointToPointError(roundUp(2 * opts.edgeSnapRadius())) + + // Finally, we also precompute sin^2(edgeSnapRadius), which is simply the + // squared distance between a vertex and an edge measured perpendicular to + // the plane containing the edge, and increase this value by the maximum + // error in the calculation to compare this distance against the bound. + d := math.Sin(opts.edgeSnapRadius().Radians()) + b.edgeSnapRadiusSin2 = d * d + b.edgeSnapRadiusSin2 += ((9.5*d+2.5+2*sqrt3)*d + 9*dblEpsilon) * dblEpsilon + + // Initialize the current label set. + b.labelSetID = emptySetID + b.labelSetModified = false + + // If snapping was requested, we try to determine whether the input geometry + // already meets the output requirements. This is necessary for + // idempotency, and can also save work. If we discover any reason that the + // input geometry needs to be modified, snappingNeeded is set to true. + b.snappingNeeded = false + + // TODO(rsned): Memory tracker init. } // roundUp rounds the given angle up by the max error and returns it as a chord angle. @@ -289,3 +404,11 @@ func roundUp(a s1.Angle) s1.ChordAngle { ca := s1.ChordAngleFromAngle(a) return ca.Expanded(ca.MaxAngleError()) } + +func addPointToPointError(ca s1.ChordAngle) s1.ChordAngle { + return ca.Expanded(ca.MaxPointError()) +} + +func addPointToEdgeError(ca s1.ChordAngle) s1.ChordAngle { + return ca.Expanded(minUpdateDistanceMaxError(ca)) +} From 45220f6c4346de2b063a0d22e36beb99c0ad8ebe Mon Sep 17 00:00:00 2001 From: Robert Snedegar Date: Sun, 18 May 2025 11:48:38 -0700 Subject: [PATCH 12/22] update comments and move edge_type to graph_options next to the other enums. --- s2/builder.go | 29 --------------------------- s2/builder_graph.go | 8 +++++++- s2/builder_graph_options.go | 40 ++++++++++++++++++++++++++++++++----- s2/builder_layer.go | 1 + s2/builder_options.go | 1 + 5 files changed, 44 insertions(+), 35 deletions(-) diff --git a/s2/builder.go b/s2/builder.go index 2f013782..f0f81fa9 100644 --- a/s2/builder.go +++ b/s2/builder.go @@ -40,35 +40,6 @@ const ( maxEdgeDeviationRatio = 1.1 ) -// edgeType indicates whether the input edges are undirected. Typically this is -// specified for each output layer (e.g., PolygonBuilderLayer). -// -// Directed edges are preferred, since otherwise the output is ambiguous. -// For example, output polygons may be the *inverse* of the intended result -// (e.g., a polygon intended to represent the world's oceans may instead -// represent the world's land masses). Directed edges are also somewhat -// more efficient. -// -// However even with undirected edges, most Builder layer types try to -// preserve the input edge direction whenever possible. Generally, edges -// are reversed only when it would yield a simpler output. For example, -// PolygonLayer assumes that polygons created from undirected edges should -// cover at most half of the sphere. Similarly, PolylineVectorBuilderLayer -// assembles edges into as few polylines as possible, even if this means -// reversing some of the "undirected" input edges. -// -// For shapes with interiors, directed edges should be oriented so that the -// interior is to the left of all edges. This means that for a polygon with -// holes, the outer loops ("shells") should be directed counter-clockwise -// while the inner loops ("holes") should be directed clockwise. Note that -// AddPolygon() follows this convention automatically. -type edgeType uint8 - -const ( - edgeTypeDirected edgeType = iota - edgeTypeUndirected -) - // isFullPolygonPredicate is an interface for determining if Polygons are // full or not. For output layers that represent polygons, there is an ambiguity // inherent in spherical geometry that does not exist in planar geometry. diff --git a/s2/builder_graph.go b/s2/builder_graph.go index c7bd963b..130f45ef 100644 --- a/s2/builder_graph.go +++ b/s2/builder_graph.go @@ -11,6 +11,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. + package s2 // polylineType Indicates whether polylines should be "paths" (which don't @@ -28,6 +29,11 @@ type graphEdge struct { first, second int32 } +// reverse returns a new graphEdge with the vertices in reverse order. +func (g graphEdge) reverse() graphEdge { + return graphEdge{first: g.second, second: g.first} +} + // A Graph represents a collection of snapped edges that is passed // to a Layer for assembly. (Example layers include polygons, polylines, and // polygon meshes.) The Graph object does not own any of its underlying data; @@ -40,7 +46,7 @@ type graphEdge struct { // the outgoing edges from a particular vertex form a contiguous range. // // TODO(rsned): Consider pulling out the methods that are helper functions for -// Layer implementations (such as getDirectedLoops) into a builder_util_graph.go. +// Layer implementations (such as getDirectedLoops) into a builder_graph_util.go. type graph struct { opts *graphOptions numVertices int diff --git a/s2/builder_graph_options.go b/s2/builder_graph_options.go index 926cc173..dd84528c 100644 --- a/s2/builder_graph_options.go +++ b/s2/builder_graph_options.go @@ -11,8 +11,38 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. + package s2 +// edgeType indicates whether the input edges are undirected. Typically this is +// specified for each output layer (e.g., PolygonBuilderLayer). +// +// Directed edges are preferred, since otherwise the output is ambiguous. +// For example, output polygons may be the *inverse* of the intended result +// (e.g., a polygon intended to represent the world's oceans may instead +// represent the world's land masses). Directed edges are also somewhat +// more efficient. +// +// However even with undirected edges, most Builder layer types try to +// preserve the input edge direction whenever possible. Generally, edges +// are reversed only when it would yield a simpler output. For example, +// PolygonLayer assumes that polygons created from undirected edges should +// cover at most half of the sphere. Similarly, PolylineVectorBuilderLayer +// assembles edges into as few polylines as possible, even if this means +// reversing some of the "undirected" input edges. +// +// For shapes with interiors, directed edges should be oriented so that the +// interior is to the left of all edges. This means that for a polygon with +// holes, the outer loops ("shells") should be directed counter-clockwise +// while the inner loops ("holes") should be directed clockwise. Note that +// AddPolygon() follows this convention automatically. +type edgeType uint8 + +const ( + edgeTypeDirected edgeType = iota + edgeTypeUndirected +) + // degenerateEdges controls how degenerate edges (i.e., an edge from a vertex to // itself) are handled. Such edges may be present in the input, or they may be // created when both endpoints of an edge are snapped to the same output vertex. @@ -66,8 +96,8 @@ const ( // would be discarded leaving only one edge in each direction. // // Degenerate edges are considered not to have siblings. If such edges are -// present, they are passed through unchanged by siblingPairs::DISCARD. For -// siblingPairs::REQUIRE or siblingPairs::CREATE with undirected edges, the +// present, they are passed through unchanged by siblingPairsDiscard. For +// siblingPairsRequire or siblingPairsCreate with undirected edges, the // number of copies of each degenerate edge is reduced by a factor of two. // Any of the options that discard edges (DISCARD, DISCARDEXCESS, and // REQUIRE/CREATE in the case of undirected edges) have the side effect that @@ -76,7 +106,7 @@ const ( // the problem of having to decide which edges are discarded.) Note that // this merging takes place even when all copies of an edge are kept. For // example, consider the graph {AB1, AB2, AB3, BA4, CD5, CD6} (where XYn -// denotes an edge from X to Y with label "n"). With siblingPairs::DISCARD, +// denotes an edge from X to Y with label "n"). With siblingPairsDiscard, // we need to discard one of the copies of AB. But which one? Rather than // choosing arbitrarily, instead we merge the labels of all duplicate edges // (even ones where no sibling pairs were discarded), yielding {AB123, @@ -167,8 +197,8 @@ type graphOptions struct { // defaultGraphOptions returns a graphOptions that specify that all edges should // be kept, since this produces the least surprising output and makes it easier // to diagnose the problem when an option is left unspecified. -func defaultGraphOptions() graphOptions { - return graphOptions{ +func defaultGraphOptions() *graphOptions { + return &graphOptions{ edgeType: edgeTypeDirected, degenerateEdges: degenerateEdgesKeep, duplicateEdges: duplicateEdgesKeep, diff --git a/s2/builder_layer.go b/s2/builder_layer.go index 27bbf5d5..7e4ee772 100644 --- a/s2/builder_layer.go +++ b/s2/builder_layer.go @@ -11,6 +11,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. + package s2 // builderLayer defines the methods Layers must implement. diff --git a/s2/builder_options.go b/s2/builder_options.go index 9204d764..b1fad2da 100644 --- a/s2/builder_options.go +++ b/s2/builder_options.go @@ -11,6 +11,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. + package s2 import "github.com/golang/geo/s1" From 38bd88b8ca1384862be1d17a1d1e1379cae62605 Mon Sep 17 00:00:00 2001 From: Robert Snedegar Date: Sun, 18 May 2025 15:57:23 -0700 Subject: [PATCH 13/22] minor enum shuffling --- s2/builder_graph.go | 20 ------------------- s2/builder_graph_options.go | 29 --------------------------- s2/builder_options.go | 39 +++++++++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 49 deletions(-) diff --git a/s2/builder_graph.go b/s2/builder_graph.go index 130f45ef..149d35ce 100644 --- a/s2/builder_graph.go +++ b/s2/builder_graph.go @@ -14,26 +14,6 @@ package s2 -// polylineType Indicates whether polylines should be "paths" (which don't -// allow duplicate vertices, except possibly the first and last vertex) or -// "walks" (which allow duplicate vertices and edges). -type polylineType uint8 - -const ( - polylineTypePath polylineType = iota - polylineTypeWalk -) - -// graphEdge is a tuple of edge IDs. -type graphEdge struct { - first, second int32 -} - -// reverse returns a new graphEdge with the vertices in reverse order. -func (g graphEdge) reverse() graphEdge { - return graphEdge{first: g.second, second: g.first} -} - // A Graph represents a collection of snapped edges that is passed // to a Layer for assembly. (Example layers include polygons, polylines, and // polygon meshes.) The Graph object does not own any of its underlying data; diff --git a/s2/builder_graph_options.go b/s2/builder_graph_options.go index dd84528c..360c9fb7 100644 --- a/s2/builder_graph_options.go +++ b/s2/builder_graph_options.go @@ -14,35 +14,6 @@ package s2 -// edgeType indicates whether the input edges are undirected. Typically this is -// specified for each output layer (e.g., PolygonBuilderLayer). -// -// Directed edges are preferred, since otherwise the output is ambiguous. -// For example, output polygons may be the *inverse* of the intended result -// (e.g., a polygon intended to represent the world's oceans may instead -// represent the world's land masses). Directed edges are also somewhat -// more efficient. -// -// However even with undirected edges, most Builder layer types try to -// preserve the input edge direction whenever possible. Generally, edges -// are reversed only when it would yield a simpler output. For example, -// PolygonLayer assumes that polygons created from undirected edges should -// cover at most half of the sphere. Similarly, PolylineVectorBuilderLayer -// assembles edges into as few polylines as possible, even if this means -// reversing some of the "undirected" input edges. -// -// For shapes with interiors, directed edges should be oriented so that the -// interior is to the left of all edges. This means that for a polygon with -// holes, the outer loops ("shells") should be directed counter-clockwise -// while the inner loops ("holes") should be directed clockwise. Note that -// AddPolygon() follows this convention automatically. -type edgeType uint8 - -const ( - edgeTypeDirected edgeType = iota - edgeTypeUndirected -) - // degenerateEdges controls how degenerate edges (i.e., an edge from a vertex to // itself) are handled. Such edges may be present in the input, or they may be // created when both endpoints of an edge are snapped to the same output vertex. diff --git a/s2/builder_options.go b/s2/builder_options.go index b1fad2da..55e6c255 100644 --- a/s2/builder_options.go +++ b/s2/builder_options.go @@ -16,6 +16,45 @@ package s2 import "github.com/golang/geo/s1" +// polylineType Indicates whether polylines should be "paths" (which don't +// allow duplicate vertices, except possibly the first and last vertex) or +// "walks" (which allow duplicate vertices and edges). +type polylineType uint8 + +const ( + polylineTypePath polylineType = iota + polylineTypeWalk +) + +// edgeType indicates whether the input edges are undirected. Typically this is +// specified for each output layer (e.g., PolygonBuilderLayer). +// +// Directed edges are preferred, since otherwise the output is ambiguous. +// For example, output polygons may be the *inverse* of the intended result +// (e.g., a polygon intended to represent the world's oceans may instead +// represent the world's land masses). Directed edges are also somewhat +// more efficient. +// +// However even with undirected edges, most Builder layer types try to +// preserve the input edge direction whenever possible. Generally, edges +// are reversed only when it would yield a simpler output. For example, +// PolygonLayer assumes that polygons created from undirected edges should +// cover at most half of the sphere. Similarly, PolylineVectorBuilderLayer +// assembles edges into as few polylines as possible, even if this means +// reversing some of the "undirected" input edges. +// +// For shapes with interiors, directed edges should be oriented so that the +// interior is to the left of all edges. This means that for a polygon with +// holes, the outer loops ("shells") should be directed counter-clockwise +// while the inner loops ("holes") should be directed clockwise. Note that +// AddPolygon() follows this convention automatically. +type edgeType uint8 + +const ( + edgeTypeDirected edgeType = iota + edgeTypeUndirected +) + type builderOptions struct { // snapFunction holds the desired snap function. // From 03f216d77bc0bb68b9dde861e6947a4ddb04207a Mon Sep 17 00:00:00 2001 From: Robert Snedegar Date: Sun, 18 May 2025 15:58:44 -0700 Subject: [PATCH 14/22] Add S2Builder Graph's EdgeProcessor class to Go with unit tests. Convert the first of the nested types from S2Builer::Graph to Go adding some unit tests. --- s2/builder_graph_edge_processor.go | 337 +++++++++++++++++ s2/builder_graph_edge_processor_test.go | 477 ++++++++++++++++++++++++ 2 files changed, 814 insertions(+) create mode 100644 s2/builder_graph_edge_processor.go create mode 100644 s2/builder_graph_edge_processor_test.go diff --git a/s2/builder_graph_edge_processor.go b/s2/builder_graph_edge_processor.go new file mode 100644 index 00000000..5802ca8b --- /dev/null +++ b/s2/builder_graph_edge_processor.go @@ -0,0 +1,337 @@ +// Copyright 2025 The S2 Geometry Project Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package s2 + +import ( + "errors" + "sort" +) + +// maxVertexID is the maximum possible vertex ID, used as a sentinel value. +const maxVertexID = int32(^uint32(0) >> 1) + +// graphEdge is a tuple of edge IDs. +type graphEdge struct { + first, second int32 +} + +// reverse returns a new graphEdge with the vertices in reverse order. +func (g graphEdge) reverse() graphEdge { + return graphEdge{first: g.second, second: g.first} +} + +// minGraphEdge returns the minimum of two edges in lexicographic order. +func minGraphEdge(a, b graphEdge) graphEdge { + if a.first < b.first || (a.first == b.first && a.second <= b.second) { + return a + } + return b +} + +// stableLessThan compares two graphEdges for stable sorting. +// It uses the graphEdge IDs as a tiebreaker to ensure a stable sort. +func stableLessThan(a, b graphEdge, aID, bID int32) bool { + if a.first != b.first { + return a.first < b.first + } + if a.second != b.second { + return a.second < b.second + } + return aID < bID +} + +// edgeProcessor processes edges in a Graph to handle duplicates, siblings, +// and degenerate edges according to the specified GraphOptions. +type edgeProcessor struct { + options *graphOptions + edges []graphEdge + inputIDs []int32 + idSetLexicon *idSetLexicon + outEdges []int32 + inEdges []int32 + newEdges []graphEdge + newInputIDs []int32 +} + +// newedgeProcessor creates a new edgeProcessor with the given options and data. +func newEdgeProcessor(opts *graphOptions, edges []graphEdge, inputIDs []int32, + idSetLexicon *idSetLexicon) *edgeProcessor { + // opts should not be nil at this point, but just in case. + if opts == nil { + opts = defaultGraphOptions() + } + ep := &edgeProcessor{ + options: opts, + edges: edges, + inputIDs: inputIDs, + idSetLexicon: idSetLexicon, + inEdges: make([]int32, len(edges)), + outEdges: make([]int32, len(edges)), + } + + // Sort the outgoing and incoming edges in lexicographic order. + // We use a stable sort to ensure that each undirected edge becomes a sibling pair, + // even if there are multiple identical input edges. + + // Fill the slice with a number sequence. + for i := range ep.outEdges { + ep.outEdges[i] = int32(i) + } + stableSortEdges(ep.outEdges, func(a, b int32) bool { + return stableLessThan(ep.edges[a], ep.edges[b], a, b) + }) + + // Fill the slice with a number sequence. + for i := range ep.inEdges { + ep.inEdges[i] = int32(i) + } + stableSortEdges(ep.inEdges, func(a, b int32) bool { + return stableLessThan(ep.edges[a].reverse(), ep.edges[b].reverse(), a, b) + }) + + ep.newEdges = make([]graphEdge, 0, len(edges)) + ep.newInputIDs = make([]int32, 0, len(edges)) + + return ep +} + +// stableSortEdges performs a stable sort on the given slice of EdgeIDs using the provided less function. +func stableSortEdges(edges []int32, less func(a, b int32) bool) { + sort.SliceStable(edges, func(i, j int) bool { + return less(edges[i], edges[j]) + }) +} + +// addEdge adds a single edge with its input edge ID set to the new edges. +func (ep *edgeProcessor) addEdge(edge graphEdge, inputEdgeIDSetID int32) { + ep.newEdges = append(ep.newEdges, edge) + ep.newInputIDs = append(ep.newInputIDs, inputEdgeIDSetID) +} + +// addEdges adds multiple copies of the same edge with the same input edge ID set. +func (ep *edgeProcessor) addEdges(numEdges int, edge graphEdge, inputEdgeIDSetID int32) { + for i := 0; i < numEdges; i++ { + ep.addEdge(edge, inputEdgeIDSetID) + } +} + +// copyEdges copies a range of edges from the input edges to the new edges. +func (ep *edgeProcessor) copyEdges(outBegin, outEnd int) { + for i := outBegin; i < outEnd; i++ { + ep.addEdge(ep.edges[ep.outEdges[i]], ep.inputIDs[ep.outEdges[i]]) + } +} + +// mergeInputIDs merges the input edge ID sets for a range of edges. +func (ep *edgeProcessor) mergeInputIDs(outBegin, outEnd int) int32 { + if outEnd-outBegin == 1 { + return ep.inputIDs[ep.outEdges[outBegin]] + } + + var tmpIDs []int32 + + for i := outBegin; i < outEnd; i++ { + for _, id := range ep.idSetLexicon.idSet(ep.inputIDs[ep.outEdges[i]]) { + tmpIDs = append(tmpIDs, id) + } + } + return int32(ep.idSetLexicon.add(tmpIDs...)) +} + +// Run processes the edges according to the specified options. +func (ep *edgeProcessor) Run() error { + numEdges := len(ep.edges) + if numEdges == 0 { + return nil + } + + // Walk through the two sorted arrays performing a merge join. For each + // edge, gather all the duplicate copies of the edge in both directions + // (outgoing and incoming). Then decide what to do based on options and + // how many copies of the edge there are in each direction. + out, in := 0, 0 + outEdge := ep.edges[ep.outEdges[out]] + inEdge := ep.edges[ep.inEdges[in]] + sentinel := graphEdge{first: maxVertexID, second: maxVertexID} + + for { + edge := minGraphEdge(outEdge, inEdge.reverse()) + if edge == sentinel { + break + } + + outBegin := out + inBegin := in + for outEdge == edge { + out++ + if out == numEdges { + outEdge = sentinel + } else { + outEdge = ep.edges[ep.outEdges[out]] + } + } + for inEdge.reverse() == edge { + in++ + if in == numEdges { + inEdge = sentinel + } else { + inEdge = ep.edges[ep.inEdges[in]] + } + } + nOut := out - outBegin + nIn := in - inBegin + + if edge.first == edge.second { + // This is a degenerate edge. + if err := ep.handleDegenerateEdge(edge, outBegin, out, nOut, nIn, inBegin, in); err != nil { + return err + } + } else if err := ep.handleNormalEdge(edge, outBegin, out, nOut, nIn); err != nil { + return err + } + } + + // Replace the old edges with the new ones. + ep.edges = ep.newEdges + ep.inputIDs = ep.newInputIDs + return nil +} + +// handleDegenerateEdge handles a degenerate edge (an edge from a vertex to itself). +func (ep *edgeProcessor) handleDegenerateEdge(edge graphEdge, outBegin, outEnd int, nOut, nIn, inBegin, in int) error { + // This is a degenerate edge. + if nOut != nIn { + return errors.New("inconsistent number of degenerate edges") + } + if ep.options.degenerateEdges == degenerateEdgesDiscard { + return nil + } + if ep.options.degenerateEdges == degenerateEdgesDiscardExcess { + // Check if there are any non-degenerate incident edges. + if (outBegin > 0 && ep.edges[ep.outEdges[outBegin-1]].first == edge.first) || + (outEnd < len(ep.edges) && ep.edges[ep.outEdges[outEnd]].first == edge.first) || + (inBegin > 0 && ep.edges[ep.inEdges[inBegin-1]].second == edge.first) || + (in < len(ep.edges) && ep.edges[ep.inEdges[in]].second == edge.first) { + return nil // There were non-degenerate incident edges, so discard. + } + } + + // degenerateEdgesDiscardExcess also merges degenerate edges. + merge := ep.options.duplicateEdges == duplicateEdgesMerge || + ep.options.degenerateEdges == degenerateEdgesDiscardExcess + + if ep.options.edgeType == edgeTypeUndirected && + (ep.options.siblingPairs == siblingPairsRequire || + ep.options.siblingPairs == siblingPairsCreate) { + // When we have undirected edges and are guaranteed to have siblings, + // we cut the number of edges in half (see Builder). + if nOut&1 != 0 { + return errors.New("odd number of undirected degenerate edges") + } + if merge { + ep.addEdges(1, edge, ep.mergeInputIDs(outBegin, outEnd)) + } else { + ep.addEdges(nOut/2, edge, ep.mergeInputIDs(outBegin, outEnd)) + } + } else if merge { + if ep.options.edgeType == edgeTypeUndirected { + ep.addEdges(2, edge, ep.mergeInputIDs(outBegin, outEnd)) + } else { + ep.addEdges(1, edge, ep.mergeInputIDs(outBegin, outEnd)) + } + } else if ep.options.siblingPairs == siblingPairsDiscard || + ep.options.siblingPairs == siblingPairsDiscardExcess { + // Any SiblingPair option that discards edges causes the labels of all + // duplicate edges to be merged together (see Builder). + ep.addEdges(nOut, edge, ep.mergeInputIDs(outBegin, outEnd)) + } else { + ep.copyEdges(outBegin, outEnd) + } + return nil +} + +// handleNormalEdge handles a non-degenerate edge. +func (ep *edgeProcessor) handleNormalEdge(edge graphEdge, outBegin, outEnd int, nOut, nIn int) error { + if ep.options.siblingPairs == siblingPairsKeep { + if nOut > 1 && ep.options.duplicateEdges == duplicateEdgesMerge { + ep.addEdge(edge, ep.mergeInputIDs(outBegin, outEnd)) + } else { + ep.copyEdges(outBegin, outEnd) + } + } else if ep.options.siblingPairs == siblingPairsDiscard { + if ep.options.edgeType == edgeTypeDirected { + // If nOut == nIn: balanced sibling pairs + // If nOut < nIn: unbalanced siblings, in the form AB, BA, BA + // If nOut > nIn: unbalanced siblings, in the form AB, AB, BA + if nOut <= nIn { + return nil + } + // Any option that discards edges causes the labels of all duplicate + // edges to be merged together (see Builder). + if ep.options.duplicateEdges == duplicateEdgesMerge { + ep.addEdges(1, edge, ep.mergeInputIDs(outBegin, outEnd)) + } else { + ep.addEdges(nOut-nIn, edge, ep.mergeInputIDs(outBegin, outEnd)) + } + } else { + if nOut&1 == 0 { + return nil + } + ep.addEdge(edge, ep.mergeInputIDs(outBegin, outEnd)) + } + } else if ep.options.siblingPairs == siblingPairsDiscardExcess { + if ep.options.edgeType == edgeTypeDirected { + // See comments above. The only difference is that if there are + // balanced sibling pairs, we want to keep one such pair. + if nOut < nIn { + return nil + } + if ep.options.duplicateEdges == duplicateEdgesMerge { + ep.addEdges(1, edge, ep.mergeInputIDs(outBegin, outEnd)) + } else { + ep.addEdges(maxInt(1, nOut-nIn), edge, ep.mergeInputIDs(outBegin, outEnd)) + } + } else { + ep.addEdges((nOut&1)+1, edge, ep.mergeInputIDs(outBegin, outEnd)) + } + } else { + if ep.options.siblingPairs != siblingPairsRequire && + ep.options.siblingPairs != siblingPairsCreate { + return errors.New("invalid sibling pairs option") + } + // In C++, this check also checked the state of the S2Error passed in + // to make sure no previous errors had occured before now. + if ep.options.siblingPairs == siblingPairsRequire && + (ep.options.edgeType == edgeTypeDirected && nOut != nIn || + ep.options.edgeType == edgeTypeUndirected && nOut&1 != 0) { + return errors.New("expected all input edges to have siblingsa but some were missing") + } + + if ep.options.duplicateEdges == duplicateEdgesMerge { + ep.addEdge(edge, ep.mergeInputIDs(outBegin, outEnd)) + } else if ep.options.edgeType == edgeTypeUndirected { + // Convert graph to use directed edges instead (see documentation of + // REQUIRE/CREATE for undirected edges). + ep.addEdges((nOut+1)/2, edge, ep.mergeInputIDs(outBegin, outEnd)) + } else { + ep.copyEdges(outBegin, outEnd) + if nIn > nOut { + // Automatically created edges have no input edge ids or labels. + ep.addEdges(nIn-nOut, edge, emptySetID) + } + } + } + return nil +} diff --git a/s2/builder_graph_edge_processor_test.go b/s2/builder_graph_edge_processor_test.go new file mode 100644 index 00000000..5be3156b --- /dev/null +++ b/s2/builder_graph_edge_processor_test.go @@ -0,0 +1,477 @@ +// Copyright 2025 The S2 Geometry Project Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package s2 + +import ( + "slices" + "testing" +) + +func TestGraphEdgeProcessorStableLessThan(t *testing.T) { + tests := []struct { + name string + a graphEdge + b graphEdge + aInputID int32 + bInputID int32 + want bool + }{ + { + name: "a < b lexicographically", + a: graphEdge{first: 1, second: 2}, + b: graphEdge{first: 2, second: 3}, + aInputID: 1, + bInputID: 2, + want: true, + }, + { + name: "a > b lexicographically", + a: graphEdge{first: 3, second: 4}, + b: graphEdge{first: 1, second: 2}, + aInputID: 1, + bInputID: 2, + want: false, + }, + { + name: "a == b lexicographically, a.inputID < b.inputID", + a: graphEdge{first: 1, second: 2}, + b: graphEdge{first: 1, second: 2}, + aInputID: 1, + bInputID: 2, + want: true, + }, + { + name: "a == b lexicographically, a.inputID > b.inputID", + a: graphEdge{first: 1, second: 2}, + b: graphEdge{first: 1, second: 2}, + aInputID: 3, + bInputID: 2, + want: false, + }, + { + name: "a == b lexicographically, a.inputID == b.inputID", + a: graphEdge{first: 1, second: 2}, + b: graphEdge{first: 1, second: 2}, + aInputID: 5, + bInputID: 5, + want: false, + }, + { + name: "first vertices equal, second vertices different", + a: graphEdge{first: 1, second: 2}, + b: graphEdge{first: 1, second: 3}, + aInputID: 1, + bInputID: 2, + want: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := stableLessThan(test.a, test.b, test.aInputID, test.bInputID) + if got != test.want { + t.Errorf("stableLessThan() = %v, want %v", got, test.want) + } + }) + } +} + +func TestGraphEdgeProcessorAddEdge(t *testing.T) { + + tests := []struct { + name string + edge graphEdge + inputEdgeIDSetID int32 + wantEdges int + wantInputIDs int + }{ + { + name: "add single edge", + edge: graphEdge{first: 1, second: 2}, + inputEdgeIDSetID: 1, + wantEdges: 1, + wantInputIDs: 1, + }, + { + name: "add second edge", + edge: graphEdge{first: 2, second: 3}, + inputEdgeIDSetID: 2, + wantEdges: 2, + wantInputIDs: 2, + }, + } + + ep := &edgeProcessor{ + newEdges: make([]graphEdge, 0), + newInputIDs: make([]int32, 0), + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ep.addEdge(test.edge, test.inputEdgeIDSetID) + if len(ep.newEdges) != test.wantEdges { + t.Errorf("addEdge() edges = %v, want %v", len(ep.newEdges), test.wantEdges) + } + if len(ep.newInputIDs) != test.wantInputIDs { + t.Errorf("addEdge() inputIDs = %v, want %v", len(ep.newInputIDs), test.wantInputIDs) + } + }) + } +} + +func TestGraphEdgeProcessorAddEdges(t *testing.T) { + + tests := []struct { + name string + numEdges int + edge graphEdge + inputEdgeIDSetID int32 + wantEdges int + wantInputIDs int + }{ + { + name: "add single edge", + numEdges: 1, + edge: graphEdge{first: 1, second: 2}, + inputEdgeIDSetID: 1, + wantEdges: 1, + wantInputIDs: 1, + }, + { + name: "add multiple edges", + numEdges: 3, + edge: graphEdge{first: 1, second: 2}, + inputEdgeIDSetID: 7, + wantEdges: 3, + wantInputIDs: 3, + }, + { + name: "add zero edges", + numEdges: 0, + edge: graphEdge{first: 1, second: 2}, + inputEdgeIDSetID: 8, + wantEdges: 0, // Should remain unchanged + wantInputIDs: 0, // Should remain unchanged + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ep := &edgeProcessor{ + newEdges: make([]graphEdge, 0), + newInputIDs: make([]int32, 0), + } + + ep.addEdges(test.numEdges, test.edge, test.inputEdgeIDSetID) + if len(ep.newEdges) != test.wantEdges { + t.Errorf("addEdges() edges = %v, want %v", len(ep.newEdges), test.wantEdges) + } + + if len(ep.newInputIDs) != test.wantInputIDs { + t.Errorf("addEdges() inputIDs = %v, want %v", len(ep.newInputIDs), test.wantInputIDs) + } + + // addEdges uses the same inputEdgeIDSetID for each repeated edge. Ensure + // all the added ids match. + for k, v := range ep.newInputIDs { + if v != test.inputEdgeIDSetID { + t.Errorf("in addEdges, newInputIDs[%d] = %d, want %d", k, v, test.inputEdgeIDSetID) + } + } + }) + } +} + +func TestGraphEdgeProcessorHandleDegenerateEdge(t *testing.T) { + tests := []struct { + name string + edge graphEdge + options *graphOptions + outBegin int + outEnd int + nOut int + nIn int + inBegin int + in int + wantErr bool + wantErrMessage string + }{ + { + name: "discard degenerate edges", + edge: graphEdge{first: 1, second: 1}, + options: &graphOptions{ + degenerateEdges: degenerateEdgesDiscard, + }, + outBegin: 0, + outEnd: 1, + nOut: 1, + nIn: 1, + inBegin: 0, + in: 1, + wantErr: false, + }, + { + name: "inconsistent degenerate edges", + edge: graphEdge{first: 1, second: 1}, + options: &graphOptions{ + degenerateEdges: degenerateEdgesKeep, + }, + outBegin: 0, + outEnd: 1, + nOut: 1, + nIn: 2, // Mismatched counts + inBegin: 0, + in: 2, + wantErr: true, + wantErrMessage: "Inconsistent number of degenerate edges", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ep := &edgeProcessor{ + options: test.options, + edges: []graphEdge{test.edge}, + inputIDs: []int32{1}, + idSetLexicon: newIDSetLexicon(), + newEdges: make([]graphEdge, 0), + newInputIDs: make([]int32, 0), + outEdges: []int32{0}, // Initialize with index 0 + inEdges: []int32{0}, // Initialize with index 0 + } + gotErr := ep.handleDegenerateEdge(test.edge, test.outBegin, test.outEnd, + test.nOut, test.nIn, test.inBegin, test.in) + if (gotErr != nil) != test.wantErr { + t.Errorf("handleDegenerateEdge() error = %v, wantErr %v", + gotErr, test.wantErr) + } + }) + } +} + +func TestGraphEdgeProcessorHandleNormalEdge(t *testing.T) { + tests := []struct { + name string + edge graphEdge + options *graphOptions + outBegin int + outEnd int + nOut int + nIn int + wantErr bool + wantErrMessage string + }{ + { + name: "keep sibling pairs", + edge: graphEdge{first: 1, second: 2}, + options: &graphOptions{ + siblingPairs: siblingPairsKeep, + edgeType: edgeTypeDirected, + }, + outBegin: 0, + outEnd: 1, + nOut: 1, + nIn: 1, + wantErr: false, + }, + { + name: "invalid sibling pairs option", + edge: graphEdge{first: 1, second: 2}, + options: &graphOptions{ + siblingPairs: siblingPairs(255), // Use max uint8 value as invalid + edgeType: edgeTypeDirected, + }, + outBegin: 0, + outEnd: 1, + nOut: 1, + nIn: 1, + wantErr: true, + wantErrMessage: "Invalid sibling pairs option", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ep := &edgeProcessor{ + options: test.options, + edges: []graphEdge{test.edge}, + inputIDs: []int32{1}, + idSetLexicon: newIDSetLexicon(), + newEdges: make([]graphEdge, 0), + newInputIDs: make([]int32, 0), + outEdges: []int32{0}, // Initialize with index 0 + inEdges: []int32{0}, // Initialize with index 0 + } + + gotErr := ep.handleNormalEdge(test.edge, test.outBegin, test.outEnd, test.nOut, test.nIn) + if (gotErr != nil) != test.wantErr { + t.Errorf("handleNormalEdge() error = %v, wantErr %v", gotErr, test.wantErr) + } + }) + } +} + +func TestGraphEdgeProcessorMergeInputIDs(t *testing.T) { + tests := []struct { + name string + edges []graphEdge + inputIDs []int32 + outBegin int + outEnd int + wantInputIDSet []int32 + }{ + { + name: "single edge", + edges: []graphEdge{ + {first: 1, second: 2}, + }, + inputIDs: []int32{1}, + outBegin: 0, + outEnd: 1, + wantInputIDSet: []int32{1}, + }, + { + name: "multiple edges with same input ID should reduce to 1 output", + edges: []graphEdge{ + {first: 1, second: 2}, + {first: 1, second: 2}, + }, + inputIDs: []int32{1, 1}, + outBegin: 0, + outEnd: 2, + wantInputIDSet: []int32{1}, + }, + { + name: "multiple edges with different input IDs should keep distinct ids", + edges: []graphEdge{ + {first: 1, second: 2}, + {first: 1, second: 2}, + {first: 1, second: 2}, + }, + inputIDs: []int32{1, 2, 3}, + outBegin: 0, + outEnd: 3, + wantInputIDSet: []int32{1, 2, 3}, + }, + { + name: "subset of edges should return the smaller portion", + edges: []graphEdge{ + {first: 1, second: 2}, + {first: 1, second: 2}, + {first: 1, second: 2}, + }, + inputIDs: []int32{1, 2, 3}, + outBegin: 1, + outEnd: 3, + wantInputIDSet: []int32{2, 3}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ep := &edgeProcessor{ + edges: test.edges, + inputIDs: test.inputIDs, + idSetLexicon: newIDSetLexicon(), + outEdges: make([]int32, len(test.edges)), + } + + // Initialize outEdges with sequential indices + for i := range ep.outEdges { + ep.outEdges[i] = int32(i) + } + + // Add the input IDs to the lexicon first + for _, id := range test.inputIDs { + ep.idSetLexicon.add(id) + } + + merged := ep.mergeInputIDs(test.outBegin, test.outEnd) + + // Get the actual set of IDs from the lexicon + got := ep.idSetLexicon.idSet(merged) + + // Sort both slices for comparison + slices.Sort(got) + slices.Sort(test.wantInputIDSet) + + if !slices.Equal(got, test.wantInputIDSet) { + t.Errorf("mergeInputIDs() = %v, want %v", got, test.wantInputIDSet) + } + }) + } +} + +func TestGraphEdgeProcessorRun(t *testing.T) { + tests := []struct { + name string + edges []graphEdge + inputIDs []int32 + options *graphOptions + wantErr bool + }{ + { + name: "empty graph", + edges: []graphEdge{}, + inputIDs: []int32{}, + options: &graphOptions{ + edgeType: edgeTypeDirected, + duplicateEdges: duplicateEdgesKeep, + degenerateEdges: degenerateEdgesKeep, + siblingPairs: siblingPairsKeep, + }, + wantErr: false, + }, + { + name: "single edge", + edges: []graphEdge{ + {first: 1, second: 2}, + }, + inputIDs: []int32{1}, + options: &graphOptions{ + edgeType: edgeTypeDirected, + duplicateEdges: duplicateEdgesKeep, + degenerateEdges: degenerateEdgesKeep, + siblingPairs: siblingPairsKeep, + }, + wantErr: false, + }, + { + name: "degenerate edge with discard", + edges: []graphEdge{ + {first: 1, second: 1}, + }, + inputIDs: []int32{1}, + options: &graphOptions{ + edgeType: edgeTypeDirected, + duplicateEdges: duplicateEdgesKeep, + degenerateEdges: degenerateEdgesDiscard, + siblingPairs: siblingPairsKeep, + }, + wantErr: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + lexicon := newIDSetLexicon() + ep := newEdgeProcessor(test.options, test.edges, test.inputIDs, lexicon) + err := ep.Run() + if (err != nil) != test.wantErr { + t.Errorf("Run() error = %v, wantErr %v", err, test.wantErr) + } + }) + } +} From 4269fecb7ad63f8a9c6547d097301205c4d13034 Mon Sep 17 00:00:00 2001 From: Robert Snedegar Date: Sun, 18 May 2025 16:33:22 -0700 Subject: [PATCH 15/22] fix some staticcheck lint errors. --- s2/builder.go | 4 +--- s2/builder_graph_edge_processor.go | 5 +---- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/s2/builder.go b/s2/builder.go index f0f81fa9..59bb36d8 100644 --- a/s2/builder.go +++ b/s2/builder.go @@ -259,9 +259,7 @@ func (b *builder) init(opts *builderOptions) { sr := snapFunc.SnapRadius() // Cap the snap radius to the limit. - if sr > maxSnapRadius { - sr = maxSnapRadius - } + sr = min(sr, maxSnapRadius) // Convert the snap radius to an ChordAngle. This is the "true snap // radius" used when evaluating exact predicates. diff --git a/s2/builder_graph_edge_processor.go b/s2/builder_graph_edge_processor.go index 5802ca8b..a075c3bd 100644 --- a/s2/builder_graph_edge_processor.go +++ b/s2/builder_graph_edge_processor.go @@ -141,11 +141,8 @@ func (ep *edgeProcessor) mergeInputIDs(outBegin, outEnd int) int32 { } var tmpIDs []int32 - for i := outBegin; i < outEnd; i++ { - for _, id := range ep.idSetLexicon.idSet(ep.inputIDs[ep.outEdges[i]]) { - tmpIDs = append(tmpIDs, id) - } + tmpIDs = append(tmpIDs, ep.idSetLexicon.idSet(ep.inputIDs[ep.outEdges[i]])...) } return int32(ep.idSetLexicon.add(tmpIDs...)) } From d5f81d30a1bbf4c67100a1f9505014392be3e883 Mon Sep 17 00:00:00 2001 From: Robert Snedegar Date: Sun, 18 May 2025 23:58:53 -0700 Subject: [PATCH 16/22] Update edgeProcessor and add a few more test cases to cover more branches. --- s2/builder.go | 8 +- s2/builder_graph_edge_processor.go | 21 +- s2/builder_graph_edge_processor_test.go | 528 ++++++++++++++++++++---- 3 files changed, 457 insertions(+), 100 deletions(-) diff --git a/s2/builder.go b/s2/builder.go index 59bb36d8..33318634 100644 --- a/s2/builder.go +++ b/s2/builder.go @@ -324,10 +324,10 @@ func (b *builder) init(opts *builderOptions) { // SnapRadius() is very small (at most intersectionError / 1.19). b.checkAllSiteCrossings = (opts.maxEdgeDeviation() > opts.edgeSnapRadius()+snapFunc.MinEdgeVertexSeparation()) - if opts.intersectionTolerance <= 0 { - if b.checkAllSiteCrossings { - } - } + + // TODO(rsned): need to add check that b.checkAllSiteCrossings is false when tolerance is <= 0. + // if opts.intersectionTolerance <= 0 { + // } // To implement idempotency, we check whether the input geometry could // possibly be the output of a previous Builder invocation. This involves diff --git a/s2/builder_graph_edge_processor.go b/s2/builder_graph_edge_processor.go index a075c3bd..30bbb195 100644 --- a/s2/builder_graph_edge_processor.go +++ b/s2/builder_graph_edge_processor.go @@ -144,7 +144,7 @@ func (ep *edgeProcessor) mergeInputIDs(outBegin, outEnd int) int32 { for i := outBegin; i < outEnd; i++ { tmpIDs = append(tmpIDs, ep.idSetLexicon.idSet(ep.inputIDs[ep.outEdges[i]])...) } - return int32(ep.idSetLexicon.add(tmpIDs...)) + return ep.idSetLexicon.add(tmpIDs...) } // Run processes the edges according to the specified options. @@ -261,13 +261,14 @@ func (ep *edgeProcessor) handleDegenerateEdge(edge graphEdge, outBegin, outEnd i // handleNormalEdge handles a non-degenerate edge. func (ep *edgeProcessor) handleNormalEdge(edge graphEdge, outBegin, outEnd int, nOut, nIn int) error { - if ep.options.siblingPairs == siblingPairsKeep { + switch ep.options.siblingPairs { + case siblingPairsKeep: if nOut > 1 && ep.options.duplicateEdges == duplicateEdgesMerge { ep.addEdge(edge, ep.mergeInputIDs(outBegin, outEnd)) } else { ep.copyEdges(outBegin, outEnd) } - } else if ep.options.siblingPairs == siblingPairsDiscard { + case siblingPairsDiscard: if ep.options.edgeType == edgeTypeDirected { // If nOut == nIn: balanced sibling pairs // If nOut < nIn: unbalanced siblings, in the form AB, BA, BA @@ -288,7 +289,7 @@ func (ep *edgeProcessor) handleNormalEdge(edge graphEdge, outBegin, outEnd int, } ep.addEdge(edge, ep.mergeInputIDs(outBegin, outEnd)) } - } else if ep.options.siblingPairs == siblingPairsDiscardExcess { + case siblingPairsDiscardExcess: if ep.options.edgeType == edgeTypeDirected { // See comments above. The only difference is that if there are // balanced sibling pairs, we want to keep one such pair. @@ -303,24 +304,20 @@ func (ep *edgeProcessor) handleNormalEdge(edge graphEdge, outBegin, outEnd int, } else { ep.addEdges((nOut&1)+1, edge, ep.mergeInputIDs(outBegin, outEnd)) } - } else { - if ep.options.siblingPairs != siblingPairsRequire && - ep.options.siblingPairs != siblingPairsCreate { - return errors.New("invalid sibling pairs option") - } + case siblingPairsCreate, siblingPairsRequire: // In C++, this check also checked the state of the S2Error passed in // to make sure no previous errors had occured before now. if ep.options.siblingPairs == siblingPairsRequire && (ep.options.edgeType == edgeTypeDirected && nOut != nIn || ep.options.edgeType == edgeTypeUndirected && nOut&1 != 0) { - return errors.New("expected all input edges to have siblingsa but some were missing") + return errors.New("expected all input edges to have siblings but some were missing") } if ep.options.duplicateEdges == duplicateEdgesMerge { ep.addEdge(edge, ep.mergeInputIDs(outBegin, outEnd)) } else if ep.options.edgeType == edgeTypeUndirected { // Convert graph to use directed edges instead (see documentation of - // REQUIRE/CREATE for undirected edges). + // siblingPairsCreate/siblingPairsRequire for undirected edges). ep.addEdges((nOut+1)/2, edge, ep.mergeInputIDs(outBegin, outEnd)) } else { ep.copyEdges(outBegin, outEnd) @@ -329,6 +326,8 @@ func (ep *edgeProcessor) handleNormalEdge(edge graphEdge, outBegin, outEnd int, ep.addEdges(nIn-nOut, edge, emptySetID) } } + default: + return errors.New("invalid sibling pairs option") } return nil } diff --git a/s2/builder_graph_edge_processor_test.go b/s2/builder_graph_edge_processor_test.go index 5be3156b..be00025c 100644 --- a/s2/builder_graph_edge_processor_test.go +++ b/s2/builder_graph_edge_processor_test.go @@ -89,7 +89,6 @@ func TestGraphEdgeProcessorStableLessThan(t *testing.T) { } func TestGraphEdgeProcessorAddEdge(t *testing.T) { - tests := []struct { name string edge graphEdge @@ -132,7 +131,6 @@ func TestGraphEdgeProcessorAddEdge(t *testing.T) { } func TestGraphEdgeProcessorAddEdges(t *testing.T) { - tests := []struct { name string numEdges int @@ -196,17 +194,17 @@ func TestGraphEdgeProcessorAddEdges(t *testing.T) { func TestGraphEdgeProcessorHandleDegenerateEdge(t *testing.T) { tests := []struct { - name string - edge graphEdge - options *graphOptions - outBegin int - outEnd int - nOut int - nIn int - inBegin int - in int - wantErr bool - wantErrMessage string + name string + edge graphEdge + options *graphOptions + outBegin int + outEnd int + nOut int + nIn int + inBegin int + in int + wantErr bool + wantEdges int }{ { name: "discard degenerate edges", @@ -214,13 +212,117 @@ func TestGraphEdgeProcessorHandleDegenerateEdge(t *testing.T) { options: &graphOptions{ degenerateEdges: degenerateEdgesDiscard, }, - outBegin: 0, - outEnd: 1, - nOut: 1, - nIn: 1, - inBegin: 0, - in: 1, - wantErr: false, + outBegin: 0, + outEnd: 1, + nOut: 1, + nIn: 1, + inBegin: 0, + in: 1, + wantErr: false, + wantEdges: 0, + }, + { + name: "keep degenerate edges with merge", + edge: graphEdge{first: 1, second: 1}, + options: &graphOptions{ + degenerateEdges: degenerateEdgesKeep, + duplicateEdges: duplicateEdgesMerge, + edgeType: edgeTypeDirected, + }, + outBegin: 0, + outEnd: 2, + nOut: 2, + nIn: 2, + inBegin: 0, + in: 2, + wantErr: false, + wantEdges: 1, + }, + { + name: "keep degenerate edges without merge", + edge: graphEdge{first: 1, second: 1}, + options: &graphOptions{ + degenerateEdges: degenerateEdgesKeep, + duplicateEdges: duplicateEdgesKeep, + edgeType: edgeTypeDirected, + }, + outBegin: 0, + outEnd: 2, + nOut: 2, + nIn: 2, + inBegin: 0, + in: 2, + wantErr: false, + wantEdges: 2, + }, + { + name: "discard excess degenerate edges with incident edges", + edge: graphEdge{first: 1, second: 1}, + options: &graphOptions{ + degenerateEdges: degenerateEdgesDiscardExcess, + edgeType: edgeTypeDirected, + }, + outBegin: 1, + outEnd: 2, + nOut: 1, + nIn: 1, + inBegin: 1, + in: 2, + wantErr: false, + wantEdges: 0, + }, + { + name: "discard excess degenerate edges without incident edges", + edge: graphEdge{first: 1, second: 1}, + options: &graphOptions{ + degenerateEdges: degenerateEdgesDiscardExcess, + edgeType: edgeTypeDirected, + duplicateEdges: duplicateEdgesMerge, + }, + outBegin: 0, + outEnd: 2, + nOut: 2, + nIn: 2, + inBegin: 0, + in: 2, + wantErr: false, + wantEdges: 1, + }, + { + name: "undirected degenerate edges with require siblings", + edge: graphEdge{first: 1, second: 1}, + options: &graphOptions{ + degenerateEdges: degenerateEdgesKeep, + edgeType: edgeTypeUndirected, + siblingPairs: siblingPairsRequire, + duplicateEdges: duplicateEdgesMerge, + }, + outBegin: 0, + outEnd: 2, + nOut: 2, + nIn: 2, + inBegin: 0, + in: 2, + wantErr: false, + wantEdges: 1, + }, + { + name: "undirected degenerate edges with create siblings", + edge: graphEdge{first: 1, second: 1}, + options: &graphOptions{ + degenerateEdges: degenerateEdgesKeep, + edgeType: edgeTypeUndirected, + siblingPairs: siblingPairsCreate, + duplicateEdges: duplicateEdgesKeep, + }, + outBegin: 0, + outEnd: 4, + nOut: 4, + nIn: 4, + inBegin: 0, + in: 4, + wantErr: false, + wantEdges: 2, }, { name: "inconsistent degenerate edges", @@ -228,63 +330,220 @@ func TestGraphEdgeProcessorHandleDegenerateEdge(t *testing.T) { options: &graphOptions{ degenerateEdges: degenerateEdgesKeep, }, - outBegin: 0, - outEnd: 1, - nOut: 1, - nIn: 2, // Mismatched counts - inBegin: 0, - in: 2, - wantErr: true, - wantErrMessage: "Inconsistent number of degenerate edges", + outBegin: 0, + outEnd: 1, + nOut: 1, + nIn: 2, // Mismatched counts + inBegin: 0, + in: 2, + wantErr: true, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - ep := &edgeProcessor{ - options: test.options, - edges: []graphEdge{test.edge}, - inputIDs: []int32{1}, - idSetLexicon: newIDSetLexicon(), - newEdges: make([]graphEdge, 0), - newInputIDs: make([]int32, 0), - outEdges: []int32{0}, // Initialize with index 0 - inEdges: []int32{0}, // Initialize with index 0 + // Create edges and inputIDs arrays with the correct size + edges := make([]graphEdge, test.outEnd) + inputIDs := make([]int32, test.outEnd) + for i := range edges { + edges[i] = test.edge + inputIDs[i] = int32(i + 1) + } + + ep := newEdgeProcessor(test.options, edges, inputIDs, newIDSetLexicon()) + // Add the input IDs to the lexicon. + for _, id := range inputIDs { + ep.idSetLexicon.add(id) } + gotErr := ep.handleDegenerateEdge(test.edge, test.outBegin, test.outEnd, test.nOut, test.nIn, test.inBegin, test.in) if (gotErr != nil) != test.wantErr { t.Errorf("handleDegenerateEdge() error = %v, wantErr %v", gotErr, test.wantErr) } + if !test.wantErr && len(ep.newEdges) != test.wantEdges { + t.Errorf("handleDegenerateEdge() added %d edges, want %d", len(ep.newEdges), test.wantEdges) + } }) } } func TestGraphEdgeProcessorHandleNormalEdge(t *testing.T) { tests := []struct { - name string - edge graphEdge - options *graphOptions - outBegin int - outEnd int - nOut int - nIn int - wantErr bool - wantErrMessage string + name string + edge graphEdge + options *graphOptions + outBegin int + outEnd int + nOut int + nIn int + wantErr bool + wantEdges int }{ { - name: "keep sibling pairs", + name: "keep sibling pairs with merge", edge: graphEdge{first: 1, second: 2}, options: &graphOptions{ - siblingPairs: siblingPairsKeep, - edgeType: edgeTypeDirected, + siblingPairs: siblingPairsKeep, + edgeType: edgeTypeDirected, + duplicateEdges: duplicateEdgesMerge, + }, + outBegin: 0, + outEnd: 2, + nOut: 2, + nIn: 1, + wantErr: false, + wantEdges: 1, + }, + { + name: "keep sibling pairs without merge", + edge: graphEdge{first: 1, second: 2}, + options: &graphOptions{ + siblingPairs: siblingPairsKeep, + edgeType: edgeTypeDirected, + duplicateEdges: duplicateEdgesKeep, + }, + outBegin: 0, + outEnd: 2, + nOut: 2, + nIn: 1, + wantErr: false, + wantEdges: 2, + }, + { + name: "discard sibling pairs directed balanced", + edge: graphEdge{first: 1, second: 2}, + options: &graphOptions{ + siblingPairs: siblingPairsDiscard, + edgeType: edgeTypeDirected, + duplicateEdges: duplicateEdgesMerge, + }, + outBegin: 0, + outEnd: 2, + nOut: 2, + nIn: 2, + wantErr: false, + wantEdges: 0, + }, + { + name: "discard sibling pairs directed unbalanced", + edge: graphEdge{first: 1, second: 2}, + options: &graphOptions{ + siblingPairs: siblingPairsDiscard, + edgeType: edgeTypeDirected, + duplicateEdges: duplicateEdgesMerge, + }, + outBegin: 0, + outEnd: 3, + nOut: 3, + nIn: 1, + wantErr: false, + wantEdges: 1, + }, + { + name: "discard sibling pairs undirected even", + edge: graphEdge{first: 1, second: 2}, + options: &graphOptions{ + siblingPairs: siblingPairsDiscard, + edgeType: edgeTypeUndirected, + duplicateEdges: duplicateEdgesMerge, + }, + outBegin: 0, + outEnd: 2, + nOut: 2, + nIn: 2, + wantErr: false, + wantEdges: 0, + }, + { + name: "discard sibling pairs undirected odd", + edge: graphEdge{first: 1, second: 2}, + options: &graphOptions{ + siblingPairs: siblingPairsDiscard, + edgeType: edgeTypeUndirected, + duplicateEdges: duplicateEdgesMerge, + }, + outBegin: 0, + outEnd: 3, + nOut: 3, + nIn: 3, + wantErr: false, + wantEdges: 1, + }, + { + name: "discard excess sibling pairs directed balanced", + edge: graphEdge{first: 1, second: 2}, + options: &graphOptions{ + siblingPairs: siblingPairsDiscardExcess, + edgeType: edgeTypeDirected, + duplicateEdges: duplicateEdgesMerge, + }, + outBegin: 0, + outEnd: 2, + nOut: 2, + nIn: 2, + wantErr: false, + wantEdges: 1, + }, + { + name: "discard excess sibling pairs directed unbalanced", + edge: graphEdge{first: 1, second: 2}, + options: &graphOptions{ + siblingPairs: siblingPairsDiscardExcess, + edgeType: edgeTypeDirected, + duplicateEdges: duplicateEdgesMerge, + }, + outBegin: 0, + outEnd: 3, + nOut: 3, + nIn: 1, + wantErr: false, + wantEdges: 1, + }, + { + name: "require sibling pairs directed balanced", + edge: graphEdge{first: 1, second: 2}, + options: &graphOptions{ + siblingPairs: siblingPairsRequire, + edgeType: edgeTypeDirected, + duplicateEdges: duplicateEdgesMerge, + }, + outBegin: 0, + outEnd: 2, + nOut: 2, + nIn: 2, + wantErr: false, + wantEdges: 1, + }, + { + name: "require sibling pairs directed unbalanced", + edge: graphEdge{first: 1, second: 2}, + options: &graphOptions{ + siblingPairs: siblingPairsRequire, + edgeType: edgeTypeDirected, + duplicateEdges: duplicateEdgesMerge, }, outBegin: 0, - outEnd: 1, - nOut: 1, + outEnd: 2, + nOut: 2, nIn: 1, - wantErr: false, + wantErr: true, + }, + { + name: "create sibling pairs undirected", + edge: graphEdge{first: 1, second: 2}, + options: &graphOptions{ + siblingPairs: siblingPairsCreate, + edgeType: edgeTypeUndirected, + duplicateEdges: duplicateEdgesKeep, + }, + outBegin: 0, + outEnd: 4, + nOut: 4, + nIn: 2, + wantErr: false, + wantEdges: 2, }, { name: "invalid sibling pairs option", @@ -293,32 +552,37 @@ func TestGraphEdgeProcessorHandleNormalEdge(t *testing.T) { siblingPairs: siblingPairs(255), // Use max uint8 value as invalid edgeType: edgeTypeDirected, }, - outBegin: 0, - outEnd: 1, - nOut: 1, - nIn: 1, - wantErr: true, - wantErrMessage: "Invalid sibling pairs option", + outBegin: 0, + outEnd: 1, + nOut: 1, + nIn: 1, + wantErr: true, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - ep := &edgeProcessor{ - options: test.options, - edges: []graphEdge{test.edge}, - inputIDs: []int32{1}, - idSetLexicon: newIDSetLexicon(), - newEdges: make([]graphEdge, 0), - newInputIDs: make([]int32, 0), - outEdges: []int32{0}, // Initialize with index 0 - inEdges: []int32{0}, // Initialize with index 0 + // Create edges and inputIDs arrays with the correct size + edges := make([]graphEdge, test.outEnd) + inputIDs := make([]int32, test.outEnd) + for i := range edges { + edges[i] = test.edge + inputIDs[i] = int32(i + 1) + } + + ep := newEdgeProcessor(test.options, edges, inputIDs, newIDSetLexicon()) + // Add the input IDs to the lexicon. + for _, id := range inputIDs { + ep.idSetLexicon.add(id) } gotErr := ep.handleNormalEdge(test.edge, test.outBegin, test.outEnd, test.nOut, test.nIn) if (gotErr != nil) != test.wantErr { t.Errorf("handleNormalEdge() error = %v, wantErr %v", gotErr, test.wantErr) } + if !test.wantErr && len(ep.newEdges) != test.wantEdges { + t.Errorf("handleNormalEdge() added %d edges, want %d", len(ep.newEdges), test.wantEdges) + } }) } } @@ -416,51 +680,142 @@ func TestGraphEdgeProcessorMergeInputIDs(t *testing.T) { func TestGraphEdgeProcessorRun(t *testing.T) { tests := []struct { - name string - edges []graphEdge - inputIDs []int32 - options *graphOptions - wantErr bool + name string + edges []graphEdge + inputIDs []int32 + options *graphOptions + wantErr bool + wantEdges int }{ { - name: "empty graph", - edges: []graphEdge{}, - inputIDs: []int32{}, + name: "empty graph", + edges: []graphEdge{}, + inputIDs: []int32{}, + options: defaultGraphOptions(), + wantErr: false, + wantEdges: 0, + }, + { + name: "single edge", + edges: []graphEdge{ + {first: 1, second: 2}, + }, + inputIDs: []int32{1}, + options: defaultGraphOptions(), + wantErr: false, + wantEdges: 1, + }, + { + name: "degenerate edge with discard", + edges: []graphEdge{ + {first: 1, second: 1}, + }, + inputIDs: []int32{1}, options: &graphOptions{ edgeType: edgeTypeDirected, duplicateEdges: duplicateEdgesKeep, + degenerateEdges: degenerateEdgesDiscard, + siblingPairs: siblingPairsKeep, + }, + wantErr: false, + wantEdges: 0, + }, + { + name: "duplicate edges with merge", + edges: []graphEdge{ + {first: 1, second: 2}, + {first: 1, second: 2}, + }, + inputIDs: []int32{1, 2}, + options: &graphOptions{ + edgeType: edgeTypeDirected, + duplicateEdges: duplicateEdgesMerge, degenerateEdges: degenerateEdgesKeep, siblingPairs: siblingPairsKeep, }, - wantErr: false, + wantErr: false, + wantEdges: 1, }, { - name: "single edge", + name: "sibling pairs with discard", edges: []graphEdge{ {first: 1, second: 2}, + {first: 2, second: 1}, }, - inputIDs: []int32{1}, + inputIDs: []int32{1, 2}, options: &graphOptions{ edgeType: edgeTypeDirected, duplicateEdges: duplicateEdgesKeep, degenerateEdges: degenerateEdgesKeep, - siblingPairs: siblingPairsKeep, + siblingPairs: siblingPairsDiscard, }, - wantErr: false, + wantErr: false, + wantEdges: 0, }, { - name: "degenerate edge with discard", + name: "undirected edges with require siblings", edges: []graphEdge{ - {first: 1, second: 1}, + {first: 1, second: 2}, + {first: 2, second: 1}, + }, + inputIDs: []int32{1, 2}, + options: &graphOptions{ + edgeType: edgeTypeUndirected, + duplicateEdges: duplicateEdgesKeep, + degenerateEdges: degenerateEdgesKeep, + siblingPairs: siblingPairsRequire, + }, + wantErr: true, + wantEdges: 0, + }, + { + name: "undirected edges with create siblings", + edges: []graphEdge{ + {first: 1, second: 2}, + {first: 2, second: 1}, + }, + inputIDs: []int32{1, 2}, + options: &graphOptions{ + edgeType: edgeTypeUndirected, + duplicateEdges: duplicateEdgesKeep, + degenerateEdges: degenerateEdgesKeep, + siblingPairs: siblingPairsCreate, + }, + wantErr: false, + wantEdges: 2, + }, + { + name: "require siblings with missing sibling", + edges: []graphEdge{ + {first: 1, second: 2}, }, inputIDs: []int32{1}, options: &graphOptions{ - edgeType: edgeTypeDirected, + edgeType: edgeTypeUndirected, duplicateEdges: duplicateEdgesKeep, + degenerateEdges: degenerateEdgesKeep, + siblingPairs: siblingPairsRequire, + }, + wantErr: true, + wantEdges: 0, + }, + { + name: "multiple edges with various options", + edges: []graphEdge{ + {first: 1, second: 2}, + {first: 2, second: 1}, + {first: 1, second: 2}, + {first: 3, second: 3}, + }, + inputIDs: []int32{1, 2, 3, 4}, + options: &graphOptions{ + edgeType: edgeTypeDirected, + duplicateEdges: duplicateEdgesMerge, degenerateEdges: degenerateEdgesDiscard, - siblingPairs: siblingPairsKeep, + siblingPairs: siblingPairsDiscardExcess, }, - wantErr: false, + wantErr: false, + wantEdges: 1, }, } @@ -472,6 +827,9 @@ func TestGraphEdgeProcessorRun(t *testing.T) { if (err != nil) != test.wantErr { t.Errorf("Run() error = %v, wantErr %v", err, test.wantErr) } + if !test.wantErr && len(ep.edges) != test.wantEdges { + t.Errorf("Run() produced %d edges, want %d", len(ep.edges), test.wantEdges) + } }) } } From 2ce78adca4006e651f901b03bf24a651dff7e35d Mon Sep 17 00:00:00 2001 From: Robert Snedegar Date: Mon, 19 May 2025 00:04:08 -0700 Subject: [PATCH 17/22] simplify test merge input ids setup. --- s2/builder_graph_edge_processor_test.go | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/s2/builder_graph_edge_processor_test.go b/s2/builder_graph_edge_processor_test.go index be00025c..603c4afc 100644 --- a/s2/builder_graph_edge_processor_test.go +++ b/s2/builder_graph_edge_processor_test.go @@ -645,19 +645,8 @@ func TestGraphEdgeProcessorMergeInputIDs(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - ep := &edgeProcessor{ - edges: test.edges, - inputIDs: test.inputIDs, - idSetLexicon: newIDSetLexicon(), - outEdges: make([]int32, len(test.edges)), - } - - // Initialize outEdges with sequential indices - for i := range ep.outEdges { - ep.outEdges[i] = int32(i) - } - - // Add the input IDs to the lexicon first + ep := newEdgeProcessor(defaultGraphOptions(), test.edges, test.inputIDs, newIDSetLexicon()) + // Add the input IDs to the lexicon for _, id := range test.inputIDs { ep.idSetLexicon.add(id) } From c2952239811c56ae145616b9b87f890d0dab244a Mon Sep 17 00:00:00 2001 From: Robert Snedegar Date: Wed, 28 May 2025 16:57:47 -0700 Subject: [PATCH 18/22] Rename edge->graphEdge throughout, fix error exit case. Rename edge {int, int} to graphEdge to reduce confusablilty with Shape's Edge{point, point} In Run, fix a couple of logic errors with (x&1)+1. Don't fail on the first error encountered in run, keep processing edges and return any/the last error encountered to follow C++'s Run(). --- s2/builder_graph_edge_processor.go | 80 +++++++++++++++++++----------- 1 file changed, 52 insertions(+), 28 deletions(-) diff --git a/s2/builder_graph_edge_processor.go b/s2/builder_graph_edge_processor.go index 30bbb195..1f0bf6db 100644 --- a/s2/builder_graph_edge_processor.go +++ b/s2/builder_graph_edge_processor.go @@ -15,6 +15,7 @@ package s2 import ( + "cmp" "errors" "sort" ) @@ -52,9 +53,21 @@ func stableLessThan(a, b graphEdge, aID, bID int32) bool { return aID < bID } -// edgeProcessor processes edges in a Graph to handle duplicates, siblings, +func stableGraphEdgeCmp(a, b graphEdge, aID, bID int32) int { + if a.first != b.first { + return cmp.Compare(a.first, b.first) + } + + if a.second != b.second { + return cmp.Compare(a.second, b.second) + } + return cmp.Compare(aID, bID) + +} + +// graphEdgeProccessor processes edges in a Graph to handle duplicates, siblings, // and degenerate edges according to the specified GraphOptions. -type edgeProcessor struct { +type graphEdgeProccessor struct { options *graphOptions edges []graphEdge inputIDs []int32 @@ -65,14 +78,14 @@ type edgeProcessor struct { newInputIDs []int32 } -// newedgeProcessor creates a new edgeProcessor with the given options and data. -func newEdgeProcessor(opts *graphOptions, edges []graphEdge, inputIDs []int32, - idSetLexicon *idSetLexicon) *edgeProcessor { +// newgraphEdgeProccessor creates a new graphEdgeProccessor with the given options and data. +func newGraphEdgeProcessor(opts *graphOptions, edges []graphEdge, inputIDs []int32, + idSetLexicon *idSetLexicon) *graphEdgeProccessor { // opts should not be nil at this point, but just in case. if opts == nil { - opts = defaultGraphOptions() + opts = &graphOptions{} } - ep := &edgeProcessor{ + ep := &graphEdgeProccessor{ options: opts, edges: edges, inputIDs: inputIDs, @@ -80,6 +93,9 @@ func newEdgeProcessor(opts *graphOptions, edges []graphEdge, inputIDs []int32, inEdges: make([]int32, len(edges)), outEdges: make([]int32, len(edges)), } + if ep.idSetLexicon == nil { + ep.idSetLexicon = newIDSetLexicon() + } // Sort the outgoing and incoming edges in lexicographic order. // We use a stable sort to ensure that each undirected edge becomes a sibling pair, @@ -89,7 +105,7 @@ func newEdgeProcessor(opts *graphOptions, edges []graphEdge, inputIDs []int32, for i := range ep.outEdges { ep.outEdges[i] = int32(i) } - stableSortEdges(ep.outEdges, func(a, b int32) bool { + stableSortEdgeIDs(ep.outEdges, func(a, b int32) bool { return stableLessThan(ep.edges[a], ep.edges[b], a, b) }) @@ -97,7 +113,7 @@ func newEdgeProcessor(opts *graphOptions, edges []graphEdge, inputIDs []int32, for i := range ep.inEdges { ep.inEdges[i] = int32(i) } - stableSortEdges(ep.inEdges, func(a, b int32) bool { + stableSortEdgeIDs(ep.inEdges, func(a, b int32) bool { return stableLessThan(ep.edges[a].reverse(), ep.edges[b].reverse(), a, b) }) @@ -107,35 +123,36 @@ func newEdgeProcessor(opts *graphOptions, edges []graphEdge, inputIDs []int32, return ep } -// stableSortEdges performs a stable sort on the given slice of EdgeIDs using the provided less function. -func stableSortEdges(edges []int32, less func(a, b int32) bool) { +// stableSortEdgeIDs performs a stable sort on the given slice of EdgeIDs +// using the provided less function. +func stableSortEdgeIDs(edges []int32, less func(a, b int32) bool) { sort.SliceStable(edges, func(i, j int) bool { return less(edges[i], edges[j]) }) } // addEdge adds a single edge with its input edge ID set to the new edges. -func (ep *edgeProcessor) addEdge(edge graphEdge, inputEdgeIDSetID int32) { +func (ep *graphEdgeProccessor) addEdge(edge graphEdge, inputEdgeIDSetID int32) { ep.newEdges = append(ep.newEdges, edge) ep.newInputIDs = append(ep.newInputIDs, inputEdgeIDSetID) } // addEdges adds multiple copies of the same edge with the same input edge ID set. -func (ep *edgeProcessor) addEdges(numEdges int, edge graphEdge, inputEdgeIDSetID int32) { +func (ep *graphEdgeProccessor) addEdges(numEdges int, edge graphEdge, inputEdgeIDSetID int32) { for i := 0; i < numEdges; i++ { ep.addEdge(edge, inputEdgeIDSetID) } } // copyEdges copies a range of edges from the input edges to the new edges. -func (ep *edgeProcessor) copyEdges(outBegin, outEnd int) { +func (ep *graphEdgeProccessor) copyEdges(outBegin, outEnd int) { for i := outBegin; i < outEnd; i++ { ep.addEdge(ep.edges[ep.outEdges[i]], ep.inputIDs[ep.outEdges[i]]) } } // mergeInputIDs merges the input edge ID sets for a range of edges. -func (ep *edgeProcessor) mergeInputIDs(outBegin, outEnd int) int32 { +func (ep *graphEdgeProccessor) mergeInputIDs(outBegin, outEnd int) int32 { if outEnd-outBegin == 1 { return ep.inputIDs[ep.outEdges[outBegin]] } @@ -144,16 +161,19 @@ func (ep *edgeProcessor) mergeInputIDs(outBegin, outEnd int) int32 { for i := outBegin; i < outEnd; i++ { tmpIDs = append(tmpIDs, ep.idSetLexicon.idSet(ep.inputIDs[ep.outEdges[i]])...) } + return ep.idSetLexicon.add(tmpIDs...) } // Run processes the edges according to the specified options. -func (ep *edgeProcessor) Run() error { +func (ep *graphEdgeProccessor) Run() error { numEdges := len(ep.edges) if numEdges == 0 { return nil } + var err error + // Walk through the two sorted arrays performing a merge join. For each // edge, gather all the duplicate copies of the edge in both directions // (outgoing and incoming). Then decide what to do based on options and @@ -171,6 +191,7 @@ func (ep *edgeProcessor) Run() error { outBegin := out inBegin := in + for outEdge == edge { out++ if out == numEdges { @@ -192,22 +213,20 @@ func (ep *edgeProcessor) Run() error { if edge.first == edge.second { // This is a degenerate edge. - if err := ep.handleDegenerateEdge(edge, outBegin, out, nOut, nIn, inBegin, in); err != nil { - return err - } - } else if err := ep.handleNormalEdge(edge, outBegin, out, nOut, nIn); err != nil { - return err + err = ep.handleDegenerateEdge(edge, outBegin, out, nOut, nIn, inBegin, in) + } else { + err = ep.handleNormalEdge(edge, outBegin, out, nOut, nIn) } } // Replace the old edges with the new ones. ep.edges = ep.newEdges ep.inputIDs = ep.newInputIDs - return nil + return err } // handleDegenerateEdge handles a degenerate edge (an edge from a vertex to itself). -func (ep *edgeProcessor) handleDegenerateEdge(edge graphEdge, outBegin, outEnd int, nOut, nIn, inBegin, in int) error { +func (ep *graphEdgeProccessor) handleDegenerateEdge(edge graphEdge, outBegin, outEnd int, nOut, nIn, inBegin, in int) error { // This is a degenerate edge. if nOut != nIn { return errors.New("inconsistent number of degenerate edges") @@ -221,7 +240,7 @@ func (ep *edgeProcessor) handleDegenerateEdge(edge graphEdge, outBegin, outEnd i (outEnd < len(ep.edges) && ep.edges[ep.outEdges[outEnd]].first == edge.first) || (inBegin > 0 && ep.edges[ep.inEdges[inBegin-1]].second == edge.first) || (in < len(ep.edges) && ep.edges[ep.inEdges[in]].second == edge.first) { - return nil // There were non-degenerate incident edges, so discard. + return nil // There were some, so discard. } } @@ -260,7 +279,8 @@ func (ep *edgeProcessor) handleDegenerateEdge(edge graphEdge, outBegin, outEnd i } // handleNormalEdge handles a non-degenerate edge. -func (ep *edgeProcessor) handleNormalEdge(edge graphEdge, outBegin, outEnd int, nOut, nIn int) error { +func (ep *graphEdgeProccessor) handleNormalEdge(edge graphEdge, outBegin, outEnd int, nOut, nIn int) error { + var err error switch ep.options.siblingPairs { case siblingPairsKeep: if nOut > 1 && ep.options.duplicateEdges == duplicateEdgesMerge { @@ -302,7 +322,11 @@ func (ep *edgeProcessor) handleNormalEdge(edge graphEdge, outBegin, outEnd int, ep.addEdges(maxInt(1, nOut-nIn), edge, ep.mergeInputIDs(outBegin, outEnd)) } } else { - ep.addEdges((nOut&1)+1, edge, ep.mergeInputIDs(outBegin, outEnd)) + if (nOut & 1) != 0 { + ep.addEdges(1, edge, ep.mergeInputIDs(outBegin, outEnd)) + } else { + ep.addEdges(2, edge, ep.mergeInputIDs(outBegin, outEnd)) + } } case siblingPairsCreate, siblingPairsRequire: // In C++, this check also checked the state of the S2Error passed in @@ -310,7 +334,7 @@ func (ep *edgeProcessor) handleNormalEdge(edge graphEdge, outBegin, outEnd int, if ep.options.siblingPairs == siblingPairsRequire && (ep.options.edgeType == edgeTypeDirected && nOut != nIn || ep.options.edgeType == edgeTypeUndirected && nOut&1 != 0) { - return errors.New("expected all input edges to have siblings but some were missing") + err = errors.New("expected all input edges to have siblings but some were missing") } if ep.options.duplicateEdges == duplicateEdgesMerge { @@ -329,5 +353,5 @@ func (ep *edgeProcessor) handleNormalEdge(edge graphEdge, outBegin, outEnd int, default: return errors.New("invalid sibling pairs option") } - return nil + return err } From 7b462a46c00c899c144c54fa560dcfdf5c743584 Mon Sep 17 00:00:00 2001 From: Robert Snedegar Date: Wed, 28 May 2025 17:03:14 -0700 Subject: [PATCH 19/22] Add all of the test cases for graphEdgeProcessor's Run from C++ --- s2/builder_graph_test.go | 1053 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 1053 insertions(+) create mode 100644 s2/builder_graph_test.go diff --git a/s2/builder_graph_test.go b/s2/builder_graph_test.go new file mode 100644 index 00000000..2b0f7937 --- /dev/null +++ b/s2/builder_graph_test.go @@ -0,0 +1,1053 @@ +package s2 + +import ( + "slices" + "testing" +) + +type testGraphEdge struct { + edge graphEdge + inputIDs []int32 +} + +func TestGraphProcessGraphEdges(t *testing.T) { + tests := []struct { + name string + opts *graphOptions + have []testGraphEdge + want []testGraphEdge + wantErr bool + wantChangedEdgeType bool + wantEdgeType edgeType + }{ + { + name: "Discards Degenerate Edges", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsKeep, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 0}}, + {edge: graphEdge{first: 0, second: 0}}, + }, + want: []testGraphEdge{}, + wantErr: false, + }, + { + name: "Keep Duplicate Degenerate Edges", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesKeep, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsKeep, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 0}}, + {edge: graphEdge{first: 0, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 0}}, + {edge: graphEdge{first: 0, second: 0}}, + }, + wantErr: false, + }, + { + name: "MergeDuplicateDegenerateEdges", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesKeep, + duplicateEdges: duplicateEdgesMerge, + siblingPairs: siblingPairsKeep, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{1}}, + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{2}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{1, 2}}, + }, + wantErr: false, + }, + { + name: "Merge Undirected Duplicate Degenerate Edges", + // Edge count should be reduced to 2 (i.e., one undirected edge), and all + // labels should be merged. + opts: &graphOptions{ + edgeType: edgeTypeUndirected, + degenerateEdges: degenerateEdgesKeep, + duplicateEdges: duplicateEdgesMerge, + siblingPairs: siblingPairsKeep, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{1}}, + {edge: graphEdge{first: 0, second: 0}}, + {edge: graphEdge{first: 0, second: 0}}, + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{2}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{1, 2}}, + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{1, 2}}, + }, + wantErr: false, + }, + { + name: "Converted Undirected Degenerate Edges", + // Converting from edgeTypeUndirected to edgeTypeDirected cuts the edge + // count in half and merges any edge labels. + opts: &graphOptions{ + edgeType: edgeTypeUndirected, + degenerateEdges: degenerateEdgesKeep, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsRequire, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{1}}, + {edge: graphEdge{first: 0, second: 0}}, + {edge: graphEdge{first: 0, second: 0}}, + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{2}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{1, 2}}, + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{1, 2}}, + }, + wantErr: false, + wantChangedEdgeType: true, + wantEdgeType: edgeTypeDirected, + }, + { + // Like the previous test case, except that we also merge duplicates. + name: "Merge Converted Undirected Duplicate Degenerate Edges", + opts: &graphOptions{ + edgeType: edgeTypeUndirected, + degenerateEdges: degenerateEdgesKeep, + duplicateEdges: duplicateEdgesMerge, + siblingPairs: siblingPairsRequire, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{1}}, + {edge: graphEdge{first: 0, second: 0}}, + {edge: graphEdge{first: 0, second: 0}}, + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{2}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{1, 2}}, + }, + wantErr: false, + wantChangedEdgeType: true, + wantEdgeType: edgeTypeDirected, + }, + + // Test that degenerate edges are discarded if they are connected to any + // non-degenerate edges (whether they are incoming or outgoing, and whether + // they are lexicographically before or after the degenerate edge). + { + name: "Discard Excess Connected Degenerate Edges_1", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscardExcess, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsKeep, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 0}}, + {edge: graphEdge{first: 0, second: 1}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + { + name: "Discard Excess Connected Degenerate Edges_2", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscardExcess, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsKeep, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + { + name: "Discard Excess Connected Degenerate Edges_3", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscardExcess, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsKeep, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 1}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + { + name: "Discard Excess Connected Degenerate Edges_4", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscardExcess, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsKeep, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 1}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + // Test that degenerateEdgesDiscardExcess merges any duplicate undirected + // degenerate edges together. + { + name: "Discard Excess Undirected Isolated Degenerate Edges", + opts: &graphOptions{ + edgeType: edgeTypeUndirected, + degenerateEdges: degenerateEdgesDiscardExcess, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsKeep, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{1}}, + {edge: graphEdge{first: 0, second: 0}}, + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{2}}, + {edge: graphEdge{first: 0, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{1, 2}}, + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{1, 2}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + // Test that degenerateEdgesDiscardExcess with SiblingPairsRequire merges any duplicate + // edges together and converts the edges from edgeTypeUndirected to edgeTypeDirected. + { + name: "DiscardExcessConvertedUndirectedIsolatedDegenerateEdges", + opts: &graphOptions{ + edgeType: edgeTypeUndirected, + degenerateEdges: degenerateEdgesDiscardExcess, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsRequire, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{1}}, + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{2}}, + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{3}}, + {edge: graphEdge{first: 0, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{1, 2, 3}}, + }, + wantChangedEdgeType: true, + wantEdgeType: edgeTypeDirected, + }, + // Test that when either SiblingPairsDiscard or SiblingPairsDiscardExcess + // are specified, the edge labels of degenerate edges are merged together + // (for consistency, since these options merge the labels of all + // non-degenerate edges as well). + { + name: "SiblingPairsDiscardMergesDegenerateEdgeLabels_1", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesKeep, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsDiscard, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{1}}, + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{2}}, + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{3}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{1, 2, 3}}, + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{1, 2, 3}}, + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{1, 2, 3}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + { + name: "SiblingPairsDiscardMergesDegenerateEdgeLabels_2", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesKeep, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsDiscardExcess, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{1}}, + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{2}}, + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{3}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{1, 2, 3}}, + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{1, 2, 3}}, + {edge: graphEdge{first: 0, second: 0}, inputIDs: []int32{1, 2, 3}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + { + name: "KeepSiblingPairs", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsKeep, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + { + name: "MergeDuplicateSiblingPairs", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesMerge, + siblingPairs: siblingPairsKeep, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + // Check that matched pairs are discarded, leaving behind any excess edges. + { + name: "DiscardSiblingPairs_1", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsDiscard, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{}, + wantErr: false, + wantChangedEdgeType: false, + }, + { + name: "DiscardSiblingPairs_2", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsDiscard, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{}, + wantErr: false, + wantChangedEdgeType: false, + }, + { + name: "DiscardSiblingPairs_3", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsDiscard, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + { + name: "DiscardSiblingPairs_4", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsDiscard, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + // Check that matched pairs are discarded, and then any remaining edges + // are merged. + { + name: "DiscardSiblingPairsMergeDuplicates_1", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesMerge, + siblingPairs: siblingPairsDiscard, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{}, + wantErr: false, + wantChangedEdgeType: false, + }, + { + name: "DiscardSiblingPairsMergeDuplicates_2", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesMerge, + siblingPairs: siblingPairsDiscard, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + { + name: "DiscardSiblingPairsMergeDuplicates_3", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesMerge, + siblingPairs: siblingPairsDiscard, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + // An undirected sibling pair consists of four edges, two in each direction + // (see Builder). Since undirected edges always come in pairs, this + // means that the result always consists of either 0 or 2 edges. + { + name: "DiscardUndirectedSiblingPairs_1", + opts: &graphOptions{ + edgeType: edgeTypeUndirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsDiscard, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + { + name: "DiscardUndirectedSiblingPairs_2", + opts: &graphOptions{ + edgeType: edgeTypeUndirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsDiscard, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{}, + wantErr: false, + wantChangedEdgeType: false, + }, + { + name: "DiscardUndirectedSiblingPairs_3", + opts: &graphOptions{ + edgeType: edgeTypeUndirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsDiscard, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + // Like SiblingPairsDiscard, except that one sibling pair is kept if the + // result would otherwise be empty. + { + name: "DiscardExcessSiblingPairs_1", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsDiscardExcess, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + { + name: "DiscardExcessSiblingPairs_2", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsDiscardExcess, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + { + name: "DiscardExcessSiblingPairs_3", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsDiscardExcess, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + { + name: "DiscardExcessSiblingPairs_4", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsDiscardExcess, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + // Like SiblingPairsDiscard, except that one sibling pair is kept if the + // result would otherwise be empty. + { + name: "DiscardExcessSiblingPairsMergeDuplicates_1", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesMerge, + siblingPairs: siblingPairsDiscardExcess, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + { + name: "DiscardExcessSiblingPairsMergeDuplicates_2", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesMerge, + siblingPairs: siblingPairsDiscardExcess, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + { + name: "DiscardExcessSiblingPairsMergeDuplicates_3", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesMerge, + siblingPairs: siblingPairsDiscardExcess, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + // Like SiblingPairsDiscard, except that one undirected sibling pair + // (4 edges) is kept if the result would otherwise be empty. + { + name: "DiscardExcessUndirectedSiblingPairs_1", + opts: &graphOptions{ + edgeType: edgeTypeUndirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsDiscardExcess, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + { + name: "DiscardExcessUndirectedSiblingPairs_2", + opts: &graphOptions{ + edgeType: edgeTypeUndirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsDiscardExcess, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + { + name: "DiscardExcessUndirectedSiblingPairs_3", + opts: &graphOptions{ + edgeType: edgeTypeUndirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsDiscardExcess, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + { + name: "CreateSiblingPairs_1", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsCreate, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + { + name: "CreateSiblingPairs_2", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsCreate, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + { + name: "CreateSiblingPairs_3", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsCreate, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + // Like the test case "Create Sibling Pairs", but should generate an error. + { + name: "RequireSiblingPairs_1", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsRequire, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + { + // Requires sibling pairs but only has one edge, so should generate an error. + name: "RequireSiblingPairs_2", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsRequire, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: true, + wantChangedEdgeType: false, + }, + // An undirected sibling pair consists of 4 edges, but SiblingPairsCreate + // also converts the graph to EdgeTypeDirected and cuts the number of + // edges in half. + { + name: "CreateUndirectedSiblingPairs_1", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsCreate, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: true, + wantEdgeType: edgeTypeDirected, + }, + { + name: "CreateUndirectedSiblingPairs_2", + opts: &graphOptions{ + edgeType: edgeTypeUndirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsCreate, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: true, + wantEdgeType: edgeTypeDirected, + }, + { + name: "CreateUndirectedSiblingPairs_3", + opts: &graphOptions{ + edgeType: edgeTypeUndirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesKeep, + siblingPairs: siblingPairsCreate, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: true, + wantEdgeType: edgeTypeDirected, + }, + { + name: "CreateSiblingPairsMergeDuplicates_1", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesMerge, + siblingPairs: siblingPairsCreate, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + { + name: "CreateSiblingPairsMergeDuplicates_2", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesMerge, + siblingPairs: siblingPairsCreate, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: false, + }, + { + name: "CreateUndirectedSiblingPairsMergeDuplicates_1", + opts: &graphOptions{ + edgeType: edgeTypeDirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesMerge, + siblingPairs: siblingPairsCreate, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: true, + wantEdgeType: edgeTypeDirected, + }, + { + name: "CreateUndirectedSiblingPairsMergeDuplicates_2", + opts: &graphOptions{ + edgeType: edgeTypeUndirected, + degenerateEdges: degenerateEdgesDiscard, + duplicateEdges: duplicateEdgesMerge, + siblingPairs: siblingPairsCreate, + }, + have: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + want: []testGraphEdge{ + {edge: graphEdge{first: 0, second: 1}}, + {edge: graphEdge{first: 1, second: 0}}, + }, + wantErr: false, + wantChangedEdgeType: true, + wantEdgeType: edgeTypeDirected, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + edges := make([]graphEdge, len(test.have)) + idSetLexicon := newIDSetLexicon() + inputIDSetIDs := []int32{} + for i, e := range test.have { + edges[i] = e.edge + inputIDSetIDs = append(inputIDSetIDs, idSetLexicon.add(e.inputIDs...)) + } + gotEdges, gotIDs, err := processGraphEdges(test.opts, edges, inputIDSetIDs, idSetLexicon) + + if (err != nil) != test.wantErr { + t.Errorf("err != nil = %v, wanted %v", err != nil, test.wantErr) + } + if len(gotEdges) != len(gotIDs) { + t.Errorf("Num edges (%d) != num IDs (%d)", len(edges), len(inputIDSetIDs)) + } + + for i, want := range test.want { + if i > len(gotEdges) { + t.Errorf("Not enough output edges") + } + if want.edge != gotEdges[i] { + t.Errorf("got[%d] = %+v, want %+v", i, gotEdges[i], want.edge) + } + + actualIDs := idSetLexicon.idSet(gotIDs[i]) + if !slices.Equal(want.inputIDs, actualIDs) { + t.Errorf("edge %d: got: %+v, want: %+v", i, actualIDs, gotIDs) + } + } + if len(test.want) != len(gotEdges) { + t.Errorf("Too many output edges %d", len(gotEdges)) + } + + if test.wantChangedEdgeType { + if test.wantEdgeType != test.opts.edgeType { + t.Errorf("tested option and input combination should have changed edgeType but didn't") + } + } + }) + } +} From a2bb0fec72f39a7b18ef2882a4c17ad7b648c97b Mon Sep 17 00:00:00 2001 From: Robert Snedegar Date: Wed, 28 May 2025 17:04:29 -0700 Subject: [PATCH 20/22] Roll options back into the file of the same name. Move the enums and option types back into their main file. (graph_options-> graph) Re-order enums to ensure the default value is first in the iota list. Rename builder variables to get rid of the CA suffix. Clean up some comments --- s2/builder.go | 397 +++++++++++++++++++++++++++++------- s2/builder_graph.go | 214 ++++++++++++++++++- s2/builder_graph_options.go | 179 ---------------- s2/builder_options.go | 251 ----------------------- 4 files changed, 529 insertions(+), 512 deletions(-) delete mode 100644 s2/builder_graph_options.go delete mode 100644 s2/builder_options.go diff --git a/s2/builder.go b/s2/builder.go index 33318634..a06040d3 100644 --- a/s2/builder.go +++ b/s2/builder.go @@ -20,49 +20,6 @@ import ( "github.com/golang/geo/s1" ) -const ( - // maxEdgeDeviationRatio is set so that MaxEdgeDeviation will be large enough - // compared to snapRadius such that edge splitting is rare. - // - // Using spherical trigonometry, if the endpoints of an edge of length L - // move by at most a distance R, the center of the edge moves by at most - // asin(sin(R) / cos(L / 2)). Thus the (MaxEdgeDeviation / SnapRadius) - // ratio increases with both the snap radius R and the edge length L. - // - // We arbitrarily limit the edge deviation to be at most 10% more than the - // snap radius. With the maximum allowed snap radius of 70 degrees, this - // means that edges up to 30.6 degrees long are never split. For smaller - // snap radii, edges up to 49 degrees long are never split. (Edges of any - // length are not split unless their endpoints move far enough so that the - // actual edge deviation exceeds the limit; in practice, splitting is rare - // even with long edges.) Note that it is always possible to split edges - // when MaxEdgeDeviation is exceeded. - maxEdgeDeviationRatio = 1.1 -) - -// isFullPolygonPredicate is an interface for determining if Polygons are -// full or not. For output layers that represent polygons, there is an ambiguity -// inherent in spherical geometry that does not exist in planar geometry. -// Namely, if a polygon has no edges, does it represent the empty polygon -// (containing no points) or the full polygon (containing all points)? This -// ambiguity also occurs for polygons that consist only of degeneracies, e.g. -// a degenerate loop with only two edges could be either a degenerate shell in -// the empty polygon or a degenerate hole in the full polygon. -// -// To resolve this ambiguity, an IsFullPolygonPredicate may be specified for -// each output layer (see AddIsFullPolygonPredicate below). If the output -// after snapping consists only of degenerate edges and/or sibling pairs -// (including the case where there are no edges at all), then the layer -// implementation calls the given predicate to determine whether the polygon -// is empty or full except for those degeneracies. The predicate is given -// an S2Builder::Graph containing the output edges, but note that in general -// the predicate must also have knowledge of the input geometry in order to -// determine the correct result. -// -// This predicate is only needed by layers that are assembled into polygons. -// It is not used by other layer types. -type isFullPolygonPredicate func(g *graph) (bool, error) - // builder is a tool for assembling polygonal geometry from edges. Here are // some of the things it is designed for: // @@ -73,7 +30,7 @@ type isFullPolygonPredicate func(g *graph) (bool, error) // or E7 lat/lng coordinates) while preserving the input topology and with // guaranteed error bounds. // -// 3. Simplifying geometry (e.g. for indexing, display, or storage). +// 3. Simplifying geometry (e.g. for indexing, display, or storage). // // 4. Importing geometry from other formats, including repairing geometry // that has errors. @@ -81,7 +38,7 @@ type isFullPolygonPredicate func(g *graph) (bool, error) // 5. As a tool for implementing more complex operations such as polygon // intersections and unions. // -// The implementation is based on the framework of "snap rounding". Unlike +// The implementation is based on the framework of "snap rounding". Unlike // most snap rounding implementations, Builder defines edges as geodesics on // the sphere (straight lines) and uses the topology of the sphere (i.e., // there are no "seams" at the poles or 180th meridian). The algorithm is @@ -148,22 +105,22 @@ type isFullPolygonPredicate func(g *graph) (bool, error) // builder.StartLayer(NewPolygonLayer(output)) // builder.AddPolygon(input); // if err := builder.Build(); err != nil { -// fmt.Printf("error building: %v\n"), err -// ... +// fmt.Printf("error building: %v\n"), err) +// // ... // } // -// TODO(rsned): Make the type public when Builder is ready. +// TODO(rsned): Make this type public when Builder is ready. type builder struct { - opts *builderOptions + opts builderOptions // The maximum distance (inclusive) that a vertex can move when snapped, // equal to options.SnapFunction().SnapRadius()). - siteSnapRadiusCA s1.ChordAngle + siteSnapRadiusChordAngle s1.ChordAngle // The maximum distance (inclusive) that an edge can move when snapping to a // snap site. It can be slightly larger than the site snap radius when // edges are being split at crossings. - edgeSnapRadiusCA s1.ChordAngle + edgeSnapRadiusChordAngle s1.ChordAngle // True if we need to check that snapping has not changed the input topology // around any vertex (i.e. Voronoi site). Normally this is only necessary for @@ -174,16 +131,16 @@ type builder struct { // cause us to add a separation site anyway. checkAllSiteCrossings bool - maxEdgeDeviation s1.Angle - edgeSiteQueryRadiusCA s1.ChordAngle - minEdgeLengthToSplitCA s1.ChordAngle + maxEdgeDeviation s1.Angle + edgeSiteQueryRadiusChordAngle s1.ChordAngle + minEdgeLengthToSplitChordAngle s1.ChordAngle - minSiteSeparation s1.Angle - minSiteSeparationCA s1.ChordAngle - minEdgeSiteSeparationCA s1.ChordAngle - minEdgeSiteSeparationCALimit s1.ChordAngle + minSiteSeparation s1.Angle + minSiteSeparationChordAngle s1.ChordAngle + minEdgeSiteSeparationChordAngle s1.ChordAngle + minEdgeSiteSeparationChordAngleLimit s1.ChordAngle - maxAdjacentSiteSeparationCA s1.ChordAngle + maxAdjacentSiteSeparationChordAngle s1.ChordAngle // The squared sine of the edge snap radius. This is equivalent to the snap // radius (squared) for distances measured through the interior of the @@ -199,7 +156,7 @@ type builder struct { // Initially false, and set to true when it is discovered that at least one // input vertex or edge does not meet the output guarantees (e.g., that - // vertices are separated by at least snapFunction.minVertexSeparation). + // vertices are separated by at least snapper.minVertexSeparation). snappingNeeded bool // A flag indicating whether labelSet has been modified since the last @@ -252,18 +209,18 @@ type builder struct { } // init initializes this instance with the given options. -func (b *builder) init(opts *builderOptions) { +func (b *builder) init(opts builderOptions) { b.opts = opts - snapFunc := opts.snapFunction - sr := snapFunc.SnapRadius() + snapper := opts.snapper + sr := snapper.SnapRadius() // Cap the snap radius to the limit. sr = min(sr, maxSnapRadius) // Convert the snap radius to an ChordAngle. This is the "true snap // radius" used when evaluating exact predicates. - b.siteSnapRadiusCA = s1.ChordAngleFromAngle(sr) + b.siteSnapRadiusChordAngle = s1.ChordAngleFromAngle(sr) // When intersectionTolerance is non-zero we need to use a larger snap // radius for edges than for vertices to ensure that both edges are snapped @@ -275,25 +232,25 @@ func (b *builder) init(opts *builderOptions) { // snap radius for edges to at least the sum of these two values (calculated // conservatively). edgeSnapRadius := opts.edgeSnapRadius() - b.edgeSnapRadiusCA = roundUp(edgeSnapRadius) + b.edgeSnapRadiusChordAngle = roundChordAngleUp(edgeSnapRadius) b.snappingRequested = (edgeSnapRadius > 0) // Compute the maximum distance that a vertex can be separated from an // edge while still affecting how that edge is snapped. b.maxEdgeDeviation = opts.maxEdgeDeviation() - b.edgeSiteQueryRadiusCA = s1.ChordAngleFromAngle(b.maxEdgeDeviation + - snapFunc.MinEdgeVertexSeparation()) + b.edgeSiteQueryRadiusChordAngle = s1.ChordAngleFromAngle(b.maxEdgeDeviation + + snapper.MinEdgeVertexSeparation()) // Compute the maximum edge length such that even if both endpoints move by // the maximum distance allowed (i.e., edgeSnapRadius), the center of the // edge will still move by less than maxEdgeDeviation. This saves us a // lot of work since then we don't need to check the actual deviation. if !b.snappingRequested { - b.minEdgeLengthToSplitCA = s1.InfChordAngle() + b.minEdgeLengthToSplitChordAngle = s1.InfChordAngle() } else { // This value varies between 30 and 50 degrees depending on // the snap radius. - b.minEdgeLengthToSplitCA = s1.ChordAngleFromAngle(s1.Angle(2 * + b.minEdgeLengthToSplitChordAngle = s1.ChordAngleFromAngle(s1.Angle(2 * math.Acos(math.Sin(edgeSnapRadius.Radians())/ math.Sin(b.maxEdgeDeviation.Radians())))) } @@ -323,7 +280,7 @@ func (b *builder) init(opts *builderOptions) { // edgeSnapRadius() to exceed SnapRadius() by intersectionError) and // SnapRadius() is very small (at most intersectionError / 1.19). b.checkAllSiteCrossings = (opts.maxEdgeDeviation() > - opts.edgeSnapRadius()+snapFunc.MinEdgeVertexSeparation()) + opts.edgeSnapRadius()+snapper.MinEdgeVertexSeparation()) // TODO(rsned): need to add check that b.checkAllSiteCrossings is false when tolerance is <= 0. // if opts.intersectionTolerance <= 0 { @@ -334,18 +291,18 @@ func (b *builder) init(opts *builderOptions) { // testing whether any site/site or edge/site pairs are too close together. // This is done using exact predicates, which require converting the minimum // separation values to a ChordAngle. - b.minSiteSeparation = snapFunc.MinVertexSeparation() - b.minSiteSeparationCA = s1.ChordAngleFromAngle(b.minSiteSeparation) - b.minEdgeSiteSeparationCA = s1.ChordAngleFromAngle(snapFunc.MinEdgeVertexSeparation()) + b.minSiteSeparation = snapper.MinVertexSeparation() + b.minSiteSeparationChordAngle = s1.ChordAngleFromAngle(b.minSiteSeparation) + b.minEdgeSiteSeparationChordAngle = s1.ChordAngleFromAngle(snapper.MinEdgeVertexSeparation()) // This is an upper bound on the distance computed by ClosestPointQuery - // where the true distance might be less than minEdgeSiteSeparationCA. - b.minEdgeSiteSeparationCALimit = addPointToEdgeError(b.minEdgeSiteSeparationCA) + // where the true distance might be less than minEdgeSiteSeparationChordAngle. + b.minEdgeSiteSeparationChordAngleLimit = addPointToEdgeError(b.minEdgeSiteSeparationChordAngle) // Compute the maximum possible distance between two sites whose Voronoi // regions touch. (The maximum radius of each Voronoi region is // edgeSnapRadius.) Then increase this bound to account for errors. - b.maxAdjacentSiteSeparationCA = addPointToPointError(roundUp(2 * opts.edgeSnapRadius())) + b.maxAdjacentSiteSeparationChordAngle = addPointToPointError(roundChordAngleUp(2 * opts.edgeSnapRadius())) // Finally, we also precompute sin^2(edgeSnapRadius), which is simply the // squared distance between a vertex and an edge measured perpendicular to @@ -369,11 +326,16 @@ func (b *builder) init(opts *builderOptions) { } // roundUp rounds the given angle up by the max error and returns it as a chord angle. -func roundUp(a s1.Angle) s1.ChordAngle { +func roundChordAngleUp(a s1.Angle) s1.ChordAngle { ca := s1.ChordAngleFromAngle(a) return ca.Expanded(ca.MaxAngleError()) } +// roundAngleUp rounds the given angle up by the max error and returns it as an angle. +//func roundAngleUp(a s1.Angle) s1.Angle { +// return a.Expanded(a.MaxAngleError()) +//} + func addPointToPointError(ca s1.ChordAngle) s1.ChordAngle { return ca.Expanded(ca.MaxPointError()) } @@ -381,3 +343,284 @@ func addPointToPointError(ca s1.ChordAngle) s1.ChordAngle { func addPointToEdgeError(ca s1.ChordAngle) s1.ChordAngle { return ca.Expanded(minUpdateDistanceMaxError(ca)) } + +const ( + // maxEdgeDeviationRatio is set so that MaxEdgeDeviation will be large enough + // compared to snapRadius such that edge splitting is rare. + // + // Using spherical trigonometry, if the endpoints of an edge of length L + // move by at most a distance R, the center of the edge moves by at most + // asin(sin(R) / cos(L / 2)). Thus the (MaxEdgeDeviation / SnapRadius) + // ratio increases with both the snap radius R and the edge length L. + // + // We arbitrarily limit the edge deviation to be at most 10% more than the + // snap radius. With the maximum allowed snap radius of 70 degrees, this + // means that edges up to 30.6 degrees long are never split. For smaller + // snap radii, edges up to 49 degrees long are never split. (Edges of any + // length are not split unless their endpoints move far enough so that the + // actual edge deviation exceeds the limit; in practice, splitting is rare + // even with long edges.) Note that it is always possible to split edges + // when MaxEdgeDeviation is exceeded. + maxEdgeDeviationRatio = 1.1 +) + +// isFullPolygonPredicate is an interface for determining if Polygons are +// full or not. For output layers that represent polygons, there is an ambiguity +// inherent in spherical geometry that does not exist in planar geometry. +// Namely, if a polygon has no edges, does it represent the empty polygon +// (containing no points) or the full polygon (containing all points)? This +// ambiguity also occurs for polygons that consist only of degeneracies, e.g. +// a degenerate loop with only two edges could be either a degenerate shell in +// the empty polygon or a degenerate hole in the full polygon. +// +// To resolve this ambiguity, an IsFullPolygonPredicate may be specified for +// each output layer (see AddIsFullPolygonPredicate below). If the output +// after snapping consists only of degenerate edges and/or sibling pairs +// (including the case where there are no edges at all), then the layer +// implementation calls the given predicate to determine whether the polygon +// is empty or full except for those degeneracies. The predicate is given +// a Graph containing the output edges, but note that in general +// the predicate must also have knowledge of the input geometry in order to +// determine the correct result. +// +// This predicate is only needed by layers that are assembled into polygons. +// It is not used by other layer types. +type isFullPolygonPredicate func(g *graph) (bool, error) + +// polylineType indicates whether polylines should be "paths" (which don't +// allow duplicate vertices, except possibly the first and last vertex) or +// "walks" (which allow duplicate vertices and edges). +type polylineType uint8 + +const ( + polylineTypePath polylineType = iota + polylineTypeWalk +) + +// edgeType indicates whether the input edges are undirected. Typically this is +// specified for each output layer (e.g., PolygonBuilderLayer). +// +// Directed edges are preferred, since otherwise the output is ambiguous. +// For example, output polygons may be the *inverse* of the intended result +// (e.g., a polygon intended to represent the world's oceans may instead +// represent the world's land masses). Directed edges are also somewhat +// more efficient. +// +// However even with undirected edges, most Builder layer types try to +// preserve the input edge direction whenever possible. Generally, edges +// are reversed only when it would yield a simpler output. For example, +// PolygonLayer assumes that polygons created from undirected edges should +// cover at most half of the sphere. Similarly, PolylineVectorBuilderLayer +// assembles edges into as few polylines as possible, even if this means +// reversing some of the "undirected" input edges. +// +// For shapes with interiors, directed edges should be oriented so that the +// interior is to the left of all edges. This means that for a polygon with +// holes, the outer loops ("shells") should be directed counter-clockwise +// while the inner loops ("holes") should be directed clockwise. Note that +// AddPolygon() follows this convention automatically. +type edgeType uint8 + +const ( + edgeTypeDirected edgeType = iota + edgeTypeUndirected +) + +// builderOptions holds the options for the Builder. +type builderOptions struct { + // snapFunction holds the desired snap function. + // + // Note that if your input data includes vertices that were created using + // Intersection(), then you should use a "snapRadius" of + // at least intersectionMergeRadius, e.g. by calling + // + // options.setSnapFunction(IdentitySnapFunction(intersectionMergeRadius)); + // + // DEFAULT: IdentitySnapFunction(s1.Angle(0)) + // [This does no snapping and preserves all input vertices exactly.] + snapper Snapper + + // splitCrossingEdges determines how crossing edges are handled by Builder. + // If true, then detect all pairs of crossing edges and eliminate them by + // adding a new vertex at their intersection point. See also the + // AddIntersection() method which allows intersection points to be added + // selectively. + // + // When this option if true, intersectionTolerance is automatically set + // to a minimum of intersectionError (see intersectionTolerance + // for why this is necessary). Note that this means that edges can move + // by up to intersectionError even when the specified snap radius is + // zero. The exact distance that edges can move is always given by + // MaxEdgeDeviation(). + // + // Undirected edges should always be used when the output is a polygon, + // since splitting a directed loop at a self-intersection converts it into + // two loops that don't define a consistent interior according to the + // "interior is on the left" rule. (On the other hand, it is fine to use + // directed edges when defining a polygon *mesh* because in that case the + // input consists of sibling edge pairs.) + // + // Self-intersections can also arise when importing data from a 2D + // projection. You can minimize this problem by subdividing the input + // edges so that the S2 edges (which are geodesics) stay close to the + // original projected edges (which are curves on the sphere). This can + // be done using EdgeTessellator, for example. + // + // DEFAULT: false + splitCrossingEdges bool + + // intersectionTolerance specifies the maximum allowable distance between + // a vertex added by AddIntersection() and the edge(s) that it is intended + // to snap to. This method must be called before AddIntersection() can be + // used. It has the effect of increasing the snap radius for edges (but not + // vertices) by the given distance. + // + // The intersection tolerance should be set to the maximum error in the + // intersection calculation used. For example, if Intersection() + // is used then the error should be set to intersectionError. If + // PointOnLine is used then the error should be set to PointOnLineError. + // If Project is used then the error should be set to + // projectPerpendicularError. If more than one method is used then the + // intersection tolerance should be set to the maximum such error. + // + // The reason this option is necessary is that computed intersection + // points are not exact. For example, Intersection(a, b, c, d) + // returns a point up to intersectionError away from the true + // mathematical intersection of the edges AB and CD. Furthermore such + // intersection points are subject to further snapping in order to ensure + // that no pair of vertices is closer than the specified snap radius. For + // example, suppose the computed intersection point X of edges AB and CD + // is 1 nanonmeter away from both edges, and the snap radius is 1 meter. + // In that case X might snap to another vertex Y exactly 1 meter away, + // which would leave us with a vertex Y that could be up to 1.000000001 + // meters from the edges AB and/or CD. This means that AB and/or CD might + // not snap to Y leaving us with two edges that still cross each other. + // + // However if the intersection tolerance is set to 1 nanometer then the + // snap radius for edges is increased to 1.000000001 meters ensuring that + // both edges snap to a common vertex even in this worst case. (Tthis + // technique does not work if the vertex snap radius is increased as well; + // it requires edges and vertices to be handled differently.) + // + // Note that this option allows edges to move by up to the given + // intersection tolerance even when the snap radius is zero. The exact + // distance that edges can move is always given by maxEdgeDeviation() + // defined above. + // + // When splitCrossingEdges is true, the intersection tolerance is + // automatically set to a minimum of intersectionError. A larger + // value can be specified by calling this method explicitly. + // + // DEFAULT: s1.Angle(0) + intersectionTolerance s1.Angle + + // simplifyEdgeChains determines if the output geometry should be simplified + // by replacing nearly straight chains of short edges with a single long edge. + // + // The combined effect of snapping and simplifying will not change the + // input by more than the guaranteed tolerances (see the list documented + // with the SnapFunction class). For example, simplified edges are + // guaranteed to pass within snapRadius() of the *original* positions of + // all vertices that were removed from that edge. This is a much tighter + // guarantee than can be achieved by snapping and simplifying separately. + // + // However, note that this option does not guarantee idempotency. In + // other words, simplifying geometry that has already been simplified once + // may simplify it further. (This is unavoidable, since tolerances are + // measured with respect to the original geometry, which is no longer + // available when the geometry is simplified a second time.) + // + // When the output consists of multiple layers, simplification is + // guaranteed to be consistent: for example, edge chains are simplified in + // the same way across layers, and simplification preserves topological + // relationships between layers (e.g., no crossing edges will be created). + // Note that edge chains in different layers do not need to be identical + // (or even have the same number of vertices, etc) in order to be + // simplified together. All that is required is that they are close + // enough together so that the same simplified edge can meet all of their + // individual snapping guarantees. + // + // Note that edge chains are approximated as parametric curves rather than + // point sets. This means that if an edge chain backtracks on itself (for + // example, ABCDEFEDCDEFGH) then such backtracking will be preserved to + // within snapRadius() (for example, if the preceding point were all in a + // straight line then the edge chain would be simplified to ACFCFH, noting + // that C and F have degree > 2 and therefore can't be simplified away). + // + // Simplified edges are assigned all labels associated with the edges of + // the simplified chain. + // + // For this option to have any effect, a SnapFunction with a non-zero + // snapRadius() must be specified. Also note that vertices specified + // using ForceVertex are never simplified away. + // + // DEFAULT: false + simplifyEdgeChains bool + + // idempotent determines if snapping occurs only when the input geometry + // does not already meet the Builder output guarantees (see the Snapper + // type description for details). This means that if all input vertices + // are at snapped locations, all vertex pairs are separated by at least + // MinVertexSeparation(), and all edge-vertex pairs are separated by at + // least MinEdgeVertexSeparation(), then no snapping is done. + // + // If false, then all vertex pairs and edge-vertex pairs closer than + // "SnapRadius" will be considered for snapping. This can be useful, for + // example, if you know that your geometry contains errors and you want to + // make sure that features closer together than "SnapRadius" are merged. + // + // This option is automatically turned off when simplifyEdgeChains is true + // since simplifying edge chains is never guaranteed to be idempotent. + // + // DEFAULT: true + idempotent bool +} + +// defaultBuilderOptions returns a new instance with the proper defaults. +func defaultBuilderOptions() *builderOptions { + return &builderOptions{ + snapper: NewIdentitySnapper(0), + splitCrossingEdges: false, + intersectionTolerance: s1.Angle(0), + simplifyEdgeChains: false, + idempotent: true, + } +} + +// edgeSnapRadius reports the maximum distance from snapped edge vertices to +// the original edge. This is the same as SnapFunction().SnapRadius() except +// when splitCrossingEdges is true (see below), in which case the edge snap +// radius is increased by intersectionError. +func (o builderOptions) edgeSnapRadius() s1.Angle { + return o.snapper.SnapRadius() + o.intersectionTolerance +} + +// maxEdgeDeviation returns maximum distance that any point along an edge can +// move when snapped. It is slightly larger than edgeSnapRadius() because when +// a geodesic edge is snapped, the edge center moves further than its endpoints. +// Builder ensures that this distance is at most 10% larger than +// edgeSnapRadius(). +func (o builderOptions) maxEdgeDeviation() s1.Angle { + // We want maxEdgeDeviation to be large enough compared to SnapRadius() + // such that edge splitting is rare. + // + // Using spherical trigonometry, if the endpoints of an edge of length L + // move by at most a distance R, the center of the edge moves by at most + // asin(sin(R) / cos(L / 2)). Thus the (maxEdgeDeviation / SnapRadius) + // ratio increases with both the snap radius R and the edge length L. + // + // We arbitrarily limit the edge deviation to be at most 10% more than the + // snap radius. With the maximum allowed snap radius of 70 degrees, this + // means that edges up to 30.6 degrees long are never split. For smaller + // snap radii, edges up to 49 degrees long are never split. (Edges of any + // length are not split unless their endpoints move far enough so that the + // actual edge deviation exceeds the limit; in practice, splitting is rare + // even with long edges.) Note that it is always possible to split edges + // when maxEdgeDeviation() is exceeded; see maybeAddExtraSites(). + // + // TODO(rsned): What should we do when snapFunction.SnapRadius() > maxSnapRadius); + return maxEdgeDeviationRatio * o.edgeSnapRadius() +} + +// TODO(rsned): Differences from C++ +// all of builders body. diff --git a/s2/builder_graph.go b/s2/builder_graph.go index 149d35ce..f33e3505 100644 --- a/s2/builder_graph.go +++ b/s2/builder_graph.go @@ -16,20 +16,18 @@ package s2 // A Graph represents a collection of snapped edges that is passed // to a Layer for assembly. (Example layers include polygons, polylines, and -// polygon meshes.) The Graph object does not own any of its underlying data; -// it is simply a view of data that is stored elsewhere. You will only -// need this interface if you want to implement a new Layer subtype. +// polygon meshes.) // // The graph consists of vertices and directed edges. Vertices are numbered // sequentially starting from zero. An edge is represented as a pair of -// vertex ids. The edges are sorted in lexicographic order, therefore all of +// vertex IDs. The edges are sorted in lexicographic order, therefore all of // the outgoing edges from a particular vertex form a contiguous range. // // TODO(rsned): Consider pulling out the methods that are helper functions for // Layer implementations (such as getDirectedLoops) into a builder_graph_util.go. type graph struct { opts *graphOptions - numVertices int + numVertices int32 vertices []Point edges []graphEdge inputEdgeIDSetIDs []int32 @@ -38,3 +36,209 @@ type graph struct { labelSetLexicon *idSetLexicon isFullPolygonPredicate isFullPolygonPredicate } + +// newGraph returns a new graph instance initialized with the given data. +func newGraph(opts *graphOptions, + vertices []Point, + edges []graphEdge, + inputEdgeIDSetIDs []int32, + inputEdgeIDSetLexicon *idSetLexicon, + labelSetIDs []int32, + labelSetLexicon *idSetLexicon, + isFullPolygonPredicate isFullPolygonPredicate) *graph { + g := &graph{ + opts: opts, + vertices: vertices, + edges: edges, + inputEdgeIDSetIDs: inputEdgeIDSetIDs, + inputEdgeIDSetLexicon: inputEdgeIDSetLexicon, + labelSetIDs: labelSetIDs, + labelSetLexicon: labelSetLexicon, + isFullPolygonPredicate: isFullPolygonPredicate, + } + + return g +} + +// processGraphEdges transform an unsorted collection of graphEdges according +// to the given set of GraphOptions. This includes actions such as discarding +// degenerate edges; merging duplicate edges; and canonicalizing sibling +// edge pairs in several possible ways (e.g. discarding or creating them). +// The output is suitable for passing to the newGraph method. +// +// If options.edgeType == EdgeTypeUndirected, then all input edges +// should already have been transformed into a pair of directed edges. +// +// "inputIDs" is a slice of the same length as "edges" that indicates +// which input edges were snapped to each edge, by mapping each edge ID to a +// set of input edge IDs in idSetLexicon. This slice and the lexicon are +// also updated appropriately as edges are discarded, merged, etc. +// +// Note that the options may be modified by this method: in particular, if +// edgeType is edgeTypeUndirected and siblingPairs is siblingPairsCreate or +// siblingPairsRequire, then half of the edges in each direction will be +// discarded and edgeType will be changed to edgeTypeDirected the comments +// on siblingPairs for more details). +func processGraphEdges(opts *graphOptions, edges []graphEdge, inputIds []int32, + idSetLexicon *idSetLexicon) (newEdges []graphEdge, newInputIDs []int32, err error) { + // graphEdgeProcessor discards the edges and inputIDs slices passed in and + // replaces them with new slices, so we need to return whatever it ends + // up with. + ep := newGraphEdgeProcessor(opts, edges, inputIds, idSetLexicon) + err = ep.Run() + + // Certain values of siblingPairs discard half of the edges and change + // the edgeType to edgeTypeDirected (see the description of GraphOptions). + if opts.siblingPairs == siblingPairsRequire || + opts.siblingPairs == siblingPairsCreate { + opts.edgeType = edgeTypeDirected + } + return ep.edges, ep.inputIDs, err +} + +// degenerateEdges controls how degenerate edges (i.e., an edge from a vertex to +// itself) are handled. Such edges may be present in the input, or they may be +// created when both endpoints of an edge are snapped to the same output vertex. +// The options available are: +type degenerateEdges uint8 + +const ( + // degenerateEdgesKeep: Keeps all degenerate edges. Be aware that this + // may create many redundant edges when simplifying geometry (e.g., a + // polyline of the form AABBBBBCCCCCCDDDD). degenerateEdgesKeep is mainly + // useful for algorithms that require an output edge for every input edge. + degenerateEdgesKeep degenerateEdges = iota + // degenerateEdgesDiscard discards all degenerate edges. This is useful for + // layers that/do not support degeneracies, such as PolygonLayer. + degenerateEdgesDiscard + // degenerateEdgesDiscardExcess discards all degenerate edges that are + // connected to/non-degenerate edges and merges any remaining + // duplicate/degenerate edges. This is useful for simplifying/polygons + // while ensuring that loops that collapse to a/single point do not disappear. + degenerateEdgesDiscardExcess +) + +// duplicateEdges controls how duplicate edges (i.e., edges that are present +// multiple times) are handled. Such edges may be present in the input, or they +// can be created when vertices are snapped together. When several edges are +// merged, the result is a single edge labelled with all of the original input +// edge IDs. +type duplicateEdges uint8 + +const ( + duplicateEdgesKeep duplicateEdges = iota + duplicateEdgesMerge +) + +// siblingPairs controls how sibling edge pairs (i.e., pairs consisting +// of an edge and its reverse edge) are handled. Layer types that +// define an interior (e.g., polygons) normally discard such edge pairs +// since they do not affect the result (i.e., they define a "loop" with +// no interior). +// +// If edgeType is edgeTypeUndirected, a sibling edge pair is considered +// to consist of four edges (two duplicate edges and their siblings), since +// only two of these four edges will be used in the final output. +// +// Furthermore, since the options REQUIRE and CREATE guarantee that all +// edges will have siblings, Builder implements these options for +// undirected edges by discarding half of the edges in each direction and +// changing the edgeType to edgeTypeDirected. For example, two +// undirected input edges between vertices A and B would first be converted +// into two directed edges in each direction, and then one edge of each pair +// would be discarded leaving only one edge in each direction. +// +// Degenerate edges are considered not to have siblings. If such edges are +// present, they are passed through unchanged by siblingPairsDiscard. For +// siblingPairsRequire or siblingPairsCreate with undirected edges, the +// number of copies of each degenerate edge is reduced by a factor of two. +// Any of the options that discard edges (DISCARD, DISCARDEXCESS, and +// REQUIRE/CREATE in the case of undirected edges) have the side effect that +// when duplicate edges are present, all of the corresponding edge labels +// are merged together and assigned to the remaining edges. (This avoids +// the problem of having to decide which edges are discarded.) Note that +// this merging takes place even when all copies of an edge are kept. For +// example, consider the graph {AB1, AB2, AB3, BA4, CD5, CD6} (where XYn +// denotes an edge from X to Y with label "n"). With siblingPairsDiscard, +// we need to discard one of the copies of AB. But which one? Rather than +// choosing arbitrarily, instead we merge the labels of all duplicate edges +// (even ones where no sibling pairs were discarded), yielding {AB123, +// AB123, CD45, CD45} (assuming that duplicate edges are being kept). +// Notice that the labels of duplicate edges are merged even if no siblings +// were discarded (such as CD5, CD6 in this example), and that this would +// happen even with duplicate degenerate edges (e.g. the edges EE7, EE8). +type siblingPairs uint8 + +const ( + // siblingPairsKeep keeps sibling pairs. This can be used to create + // polylines that double back on themselves, or degenerate loops (with + // a layer type such as LaxPolygon). + siblingPairsKeep siblingPairs = iota + // siblingPairsDiscard discards all sibling edge pairs. + siblingPairsDiscard + // siblingPairsDiscardExcess is like siblingPairsDiscard, except that a + // single sibling pair is kept if the result would otherwise be empty. + // This is useful for polygons with degeneracies (LaxPolygon), and for + // simplifying polylines while ensuring that they are not split into + // multiple disconnected pieces. + siblingPairsDiscardExcess + // siblingPairsRequire requires that all edges have a sibling (and returns + // an error otherwise). This is useful with layer types that create a + // collection of adjacent polygons (a polygon mesh). + siblingPairsRequire + // siblingPairsCreate ensures that all edges have a sibling edge by + // creating them if necessary. This is useful with polygon meshes where + // the input polygons do not cover the entire sphere. Such edges always + // have an empty set of labels and do not have an associated InputEdgeID. + siblingPairsCreate +) + +// graphOptions is only needed by Layer implementations. A layer is +// responsible for assembling a Graph of snapped edges into the +// desired output format (e.g., an Polygon). The graphOptions allows +// each Layer type to specify requirements on its input graph: for example, if +// degenerateEdgesDiscard is specified, then Builder will ensure that all +// degenerate edges are removed before passing the graph to Layer's Build +// method. +// +// A default graphOptions value specifies that all edges should be kept, +// since this produces the least surprising output and makes it easier +// to diagnose the problem when an option is left unspecified. +type graphOptions struct { + // edgeType specifies whether the Builder input edges should be treated as + // undirected. If true, then all input edges are duplicated into pairs + // consisting of an edge and a sibling (reverse) edge. Note that the + // automatically created sibling edge has an empty set of labels and does + // not have an associated inputEdgeID. + // + // The layer implementation is responsible for ensuring that exactly one + // edge from each pair is used in the output, i.e. *only half* of the graph + // edges will be used. (Note that some values of the siblingPairs option + // automatically take care of this issue by removing half of the edges and + // changing edgeType to Directed.) + edgeType edgeType + degenerateEdges degenerateEdges + duplicateEdges duplicateEdges + siblingPairs siblingPairs + + // This is a specialized option that is only needed by clients that want to + // work with the graphs for multiple layers at the same time (e.g., in order + // to check whether the same edge is present in two different graphs). [Note + // that if you need to do this, usually it is easier just to build a single + // graph with suitable edge labels.] + // + // When there are a large number of layers, then by default Builder builds + // a minimal subgraph for each layer containing only the vertices needed by + // the edges in that layer. This ensures that layer types that iterate over + // the vertices run in time proportional to the size of that layer rather + // than the size of all layers combined. (For example, if there are a + // million layers with one edge each, then each layer would be passed a + // graph with 2 vertices rather than 2 million vertices.) + // + // If this option is set to false, this optimization is disabled. Instead + // the graph passed to this layer will contain the full set of vertices. + // (This is not recommended when the number of layers could be large.) + // + // Default is false. + disableVertexFiltering bool +} diff --git a/s2/builder_graph_options.go b/s2/builder_graph_options.go deleted file mode 100644 index 360c9fb7..00000000 --- a/s2/builder_graph_options.go +++ /dev/null @@ -1,179 +0,0 @@ -// Copyright 2025 The S2 Geometry Project Authors. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package s2 - -// degenerateEdges controls how degenerate edges (i.e., an edge from a vertex to -// itself) are handled. Such edges may be present in the input, or they may be -// created when both endpoints of an edge are snapped to the same output vertex. -// The options available are: -type degenerateEdges uint8 - -const ( - // degenerateEdgesDiscard discards all degenerate edges. This is useful for - // layers that/do not support degeneracies, such as PolygonLayer. - degenerateEdgesDiscard degenerateEdges = iota - // degenerateEdgesDiscardExcess discards all degenerate edges that are - // connected to/non-degenerate edges and merges any remaining - // duplicate/degenerate edges. This is useful for simplifying/polygons - // while ensuring that loops that collapse to a/single point do not disappear. - degenerateEdgesDiscardExcess - // degenerateEdgesKeep: Keeps all degenerate edges. Be aware that this - // may create many redundant edges when simplifying geometry (e.g., a - // polyline of the form AABBBBBCCCCCCDDDD). degenerateEdgesKeep is mainly - // useful for algorithms that require an output edge for every input edge. - degenerateEdgesKeep -) - -// duplicateEdges controls how duplicate edges (i.e., edges that are present -// multiple times) are handled. Such edges may be present in the input, or they -// can be created when vertices are snapped together. When several edges are -// merged, the result is a single edge labelled with all of the original input -// edge ids. -type duplicateEdges uint8 - -const ( - duplicateEdgesMerge duplicateEdges = iota - duplicateEdgesKeep -) - -// siblingPairs controls how sibling edge pairs (i.e., pairs consisting -// of an edge and its reverse edge) are handled. Layer types that -// define an interior (e.g., polygons) normally discard such edge pairs -// since they do not affect the result (i.e., they define a "loop" with -// no interior). -// -// If edgeType is edgeTypeUndirected, a sibling edge pair is considered -// to consist of four edges (two duplicate edges and their siblings), since -// only two of these four edges will be used in the final output. -// -// Furthermore, since the options REQUIRE and CREATE guarantee that all -// edges will have siblings, Builder implements these options for -// undirected edges by discarding half of the edges in each direction and -// changing the edgeType to edgeTypeDirected. For example, two -// undirected input edges between vertices A and B would first be converted -// into two directed edges in each direction, and then one edge of each pair -// would be discarded leaving only one edge in each direction. -// -// Degenerate edges are considered not to have siblings. If such edges are -// present, they are passed through unchanged by siblingPairsDiscard. For -// siblingPairsRequire or siblingPairsCreate with undirected edges, the -// number of copies of each degenerate edge is reduced by a factor of two. -// Any of the options that discard edges (DISCARD, DISCARDEXCESS, and -// REQUIRE/CREATE in the case of undirected edges) have the side effect that -// when duplicate edges are present, all of the corresponding edge labels -// are merged together and assigned to the remaining edges. (This avoids -// the problem of having to decide which edges are discarded.) Note that -// this merging takes place even when all copies of an edge are kept. For -// example, consider the graph {AB1, AB2, AB3, BA4, CD5, CD6} (where XYn -// denotes an edge from X to Y with label "n"). With siblingPairsDiscard, -// we need to discard one of the copies of AB. But which one? Rather than -// choosing arbitrarily, instead we merge the labels of all duplicate edges -// (even ones where no sibling pairs were discarded), yielding {AB123, -// AB123, CD45, CD45} (assuming that duplicate edges are being kept). -// Notice that the labels of duplicate edges are merged even if no siblings -// were discarded (such as CD5, CD6 in this example), and that this would -// happen even with duplicate degenerate edges (e.g. the edges EE7, EE8). -type siblingPairs uint8 - -const ( - // siblingPairsDiscard discards all sibling edge pairs. - siblingPairsDiscard siblingPairs = iota - // siblingPairsDiscardExcess is like siblingPairsDiscard, except that a - // single sibling pair is kept if the result would otherwise be empty. - // This is useful for polygons with degeneracies (LaxPolygon), and for - // simplifying polylines while ensuring that they are not split into - // multiple disconnected pieces. - siblingPairsDiscardExcess - // siblingPairsKeep keeps sibling pairs. This can be used to create - // polylines that double back on themselves, or degenerate loops (with - // a layer type such as LaxPolygon). - siblingPairsKeep - // siblingPairsRequire requires that all edges have a sibling (and returns - // an error otherwise). This is useful with layer types that create a - // collection of adjacent polygons (a polygon mesh). - siblingPairsRequire - // siblingPairsCreate ensures that all edges have a sibling edge by - // creating them if necessary. This is useful with polygon meshes where - // the input polygons do not cover the entire sphere. Such edges always - // have an empty set of labels and do not have an associated InputEdgeID. - siblingPairsCreate -) - -// graphOptions is only needed by Layer implementations. A layer is -// responsible for assembling an Graph of snapped edges into the -// desired output format (e.g., an Polygon). The graphOptions allows -// each Layer type to specify requirements on its input graph: for example, if -// degenerateEdgesDiscard is specified, then Builder will ensure that all -// degenerate edges are removed before passing the graph to Layer's Build -// method. -type graphOptions struct { - // Specifies whether the Builder input edges should be treated as - // undirected. If true, then all input edges are duplicated into pairs - // consisting of an edge and a sibling (reverse) edge. Note that the - // automatically created sibling edge has an empty set of labels and does - // not have an associated InputEdgeId. - // - // The layer implementation is responsible for ensuring that exactly one - // edge from each pair is used in the output, i.e. *only half* of the graph - // edges will be used. (Note that some values of the siblingPairs option - // automatically take care of this issue by removing half of the edges and - // changing edgeType to Directed.) - // - // DEFAULT: edgeTypeDirected - edgeType edgeType - - // DEFAULT: degenerateEdgesKeep - degenerateEdges degenerateEdges - - // DEFAULT: duplicateEdgesKeep - duplicateEdges duplicateEdges - - // DEFAULT: siblingPairsKeep - siblingPairs siblingPairs - - // This is a specialized option that is only needed by clients that want to - // work with the graphs for multiple layers at the same time (e.g., in order - // to check whether the same edge is present in two different graphs). [Note - // that if you need to do this, usually it is easier just to build a single - // graph with suitable edge labels.] - // - // When there are a large number of layers, then by default Builder builds - // a minimal subgraph for each layer containing only the vertices needed by - // the edges in that layer. This ensures that layer types that iterate over - // the vertices run in time proportional to the size of that layer rather - // than the size of all layers combined. (For example, if there are a - // million layers with one edge each, then each layer would be passed a - // graph with 2 vertices rather than 2 million vertices.) - // - // If this option is set to false, this optimization is disabled. Instead - // the graph passed to this layer will contain the full set of vertices. - // (This is not recommended when the number of layers could be large.) - // - // DEFAULT: true - allowVertexFiltering bool -} - -// defaultGraphOptions returns a graphOptions that specify that all edges should -// be kept, since this produces the least surprising output and makes it easier -// to diagnose the problem when an option is left unspecified. -func defaultGraphOptions() *graphOptions { - return &graphOptions{ - edgeType: edgeTypeDirected, - degenerateEdges: degenerateEdgesKeep, - duplicateEdges: duplicateEdgesKeep, - siblingPairs: siblingPairsKeep, - allowVertexFiltering: true, - } -} diff --git a/s2/builder_options.go b/s2/builder_options.go deleted file mode 100644 index 55e6c255..00000000 --- a/s2/builder_options.go +++ /dev/null @@ -1,251 +0,0 @@ -// Copyright 2025 The S2 Geometry Project Authors. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package s2 - -import "github.com/golang/geo/s1" - -// polylineType Indicates whether polylines should be "paths" (which don't -// allow duplicate vertices, except possibly the first and last vertex) or -// "walks" (which allow duplicate vertices and edges). -type polylineType uint8 - -const ( - polylineTypePath polylineType = iota - polylineTypeWalk -) - -// edgeType indicates whether the input edges are undirected. Typically this is -// specified for each output layer (e.g., PolygonBuilderLayer). -// -// Directed edges are preferred, since otherwise the output is ambiguous. -// For example, output polygons may be the *inverse* of the intended result -// (e.g., a polygon intended to represent the world's oceans may instead -// represent the world's land masses). Directed edges are also somewhat -// more efficient. -// -// However even with undirected edges, most Builder layer types try to -// preserve the input edge direction whenever possible. Generally, edges -// are reversed only when it would yield a simpler output. For example, -// PolygonLayer assumes that polygons created from undirected edges should -// cover at most half of the sphere. Similarly, PolylineVectorBuilderLayer -// assembles edges into as few polylines as possible, even if this means -// reversing some of the "undirected" input edges. -// -// For shapes with interiors, directed edges should be oriented so that the -// interior is to the left of all edges. This means that for a polygon with -// holes, the outer loops ("shells") should be directed counter-clockwise -// while the inner loops ("holes") should be directed clockwise. Note that -// AddPolygon() follows this convention automatically. -type edgeType uint8 - -const ( - edgeTypeDirected edgeType = iota - edgeTypeUndirected -) - -type builderOptions struct { - // snapFunction holds the desired snap function. - // - // Note that if your input data includes vertices that were created using - // Intersection(), then you should use a "snapRadius" of - // at least intersectionMergeRadius, e.g. by calling - // - // options.setSnapFunction(IdentitySnapFunction(intersectionMergeRadius)); - // - // DEFAULT: IdentitySnapFunction(s1.Angle(0)) - // [This does no snapping and preserves all input vertices exactly.] - snapFunction Snapper - - // splitCrossingEdges determines how crossing edges are handled by Builder. - // If true, then detect all pairs of crossing edges and eliminate them by - // adding a new vertex at their intersection point. See also the - // AddIntersection() method which allows intersection points to be added - // selectively. - // - // When this option if true, intersectionTolerance is automatically set - // to a minimum of intersectionError (see intersectionTolerance - // for why this is necessary). Note that this means that edges can move - // by up to intersectionError even when the specified snap radius is - // zero. The exact distance that edges can move is always given by - // MaxEdgeDeviation(). - // - // Undirected edges should always be used when the output is a polygon, - // since splitting a directed loop at a self-intersection converts it into - // two loops that don't define a consistent interior according to the - // "interior is on the left" rule. (On the other hand, it is fine to use - // directed edges when defining a polygon *mesh* because in that case the - // input consists of sibling edge pairs.) - // - // Self-intersections can also arise when importing data from a 2D - // projection. You can minimize this problem by subdividing the input - // edges so that the S2 edges (which are geodesics) stay close to the - // original projected edges (which are curves on the sphere). This can - // be done using EdgeTessellator, for example. - // - // DEFAULT: false - splitCrossingEdges bool - - // intersectionTolerance specifies the maximum allowable distance between - // a vertex added by AddIntersection() and the edge(s) that it is intended - // to snap to. This method must be called before AddIntersection() can be - // used. It has the effect of increasing the snap radius for edges (but not - // vertices) by the given distance. - // - // The intersection tolerance should be set to the maximum error in the - // intersection calculation used. For example, if Intersection() - // is used then the error should be set to intersectionError. If - // PointOnLine is used then the error should be set to PointOnLineError. - // If Project is used then the error should be set to - // projectPerpendicularError. If more than one method is used then the - // intersection tolerance should be set to the maximum such error. - // - // The reason this option is necessary is that computed intersection - // points are not exact. For example, Intersection(a, b, c, d) - // returns a point up to intersectionError away from the true - // mathematical intersection of the edges AB and CD. Furthermore such - // intersection points are subject to further snapping in order to ensure - // that no pair of vertices is closer than the specified snap radius. For - // example, suppose the computed intersection point X of edges AB and CD - // is 1 nanonmeter away from both edges, and the snap radius is 1 meter. - // In that case X might snap to another vertex Y exactly 1 meter away, - // which would leave us with a vertex Y that could be up to 1.000000001 - // meters from the edges AB and/or CD. This means that AB and/or CD might - // not snap to Y leaving us with two edges that still cross each other. - // - // However if the intersection tolerance is set to 1 nanometer then the - // snap radius for edges is increased to 1.000000001 meters ensuring that - // both edges snap to a common vertex even in this worst case. (Tthis - // technique does not work if the vertex snap radius is increased as well; - // it requires edges and vertices to be handled differently.) - // - // Note that this option allows edges to move by up to the given - // intersection tolerance even when the snap radius is zero. The exact - // distance that edges can move is always given by maxEdgeDeviation() - // defined above. - // - // When splitCrossingEdges is true, the intersection tolerance is - // automatically set to a minimum of intersectionError. A larger - // value can be specified by calling this method explicitly. - // - // DEFAULT: s1.Angle(0) - intersectionTolerance s1.Angle - - // simplifyEdgeChains determines if the output geometry should be simplified - // by replacing nearly straight chains of short edges with a single long edge. - // - // The combined effect of snapping and simplifying will not change the - // input by more than the guaranteed tolerances (see the list documented - // with the SnapFunction class). For example, simplified edges are - // guaranteed to pass within snapRadius() of the *original* positions of - // all vertices that were removed from that edge. This is a much tighter - // guarantee than can be achieved by snapping and simplifying separately. - // - // However, note that this option does not guarantee idempotency. In - // other words, simplifying geometry that has already been simplified once - // may simplify it further. (This is unavoidable, since tolerances are - // measured with respect to the original geometry, which is no longer - // available when the geometry is simplified a second time.) - // - // When the output consists of multiple layers, simplification is - // guaranteed to be consistent: for example, edge chains are simplified in - // the same way across layers, and simplification preserves topological - // relationships between layers (e.g., no crossing edges will be created). - // Note that edge chains in different layers do not need to be identical - // (or even have the same number of vertices, etc) in order to be - // simplified together. All that is required is that they are close - // enough together so that the same simplified edge can meet all of their - // individual snapping guarantees. - // - // Note that edge chains are approximated as parametric curves rather than - // point sets. This means that if an edge chain backtracks on itself (for - // example, ABCDEFEDCDEFGH) then such backtracking will be preserved to - // within snapRadius() (for example, if the preceding point were all in a - // straight line then the edge chain would be simplified to ACFCFH, noting - // that C and F have degree > 2 and therefore can't be simplified away). - // - // Simplified edges are assigned all labels associated with the edges of - // the simplified chain. - // - // For this option to have any effect, a SnapFunction with a non-zero - // snapRadius() must be specified. Also note that vertices specified - // using ForceVertex are never simplified away. - // - // DEFAULT: false - simplifyEdgeChains bool - - // idempotent determines if snapping occurs only when the input geometry - // does not already meet the Builder output guarantees (see the Snapper - // type description for details). This means that if all input vertices - // are at snapped locations, all vertex pairs are separated by at least - // MinVertexSeparation(), and all edge-vertex pairs are separated by at - // least MinEdgeVertexSeparation(), then no snapping is done. - // - // If false, then all vertex pairs and edge-vertex pairs closer than - // "SnapRadius" will be considered for snapping. This can be useful, for - // example, if you know that your geometry contains errors and you want to - // make sure that features closer together than "SnapRadius" are merged. - // - // This option is automatically turned off when simplifyEdgeChains is true - // since simplifying edge chains is never guaranteed to be idempotent. - // - // DEFAULT: true - idempotent bool -} - -// defaultBuilderOptions returns a new instance with the proper defaults. -func defaultBuilderOptions() *builderOptions { - return &builderOptions{ - snapFunction: NewIdentitySnapper(0), - splitCrossingEdges: false, - intersectionTolerance: s1.Angle(0), - simplifyEdgeChains: false, - idempotent: true, - } -} - -// edgeSnapRadius reports the maximum distance from snapped edge vertices to -// the original edge. This is the same as SnapFunction().SnapRadius() except -// when splitCrossingEdges is true (see below), in which case the edge snap -// radius is increased by intersectionError. -func (o builderOptions) edgeSnapRadius() s1.Angle { - return o.snapFunction.SnapRadius() + o.intersectionTolerance -} - -// maxEdgeDeviation returns maximum distance that any point along an edge can -// move when snapped. It is slightly larger than edgeSnapRadius() because when -// a geodesic edge is snapped, the edge center moves further than its endpoints. -// Builder ensures that this distance is at most 10% larger than -// edgeSnapRadius(). -func (o builderOptions) maxEdgeDeviation() s1.Angle { - // We want maxEdgeDeviation to be large enough compared to SnapRadius() - // such that edge splitting is rare. - // - // Using spherical trigonometry, if the endpoints of an edge of length L - // move by at most a distance R, the center of the edge moves by at most - // asin(sin(R) / cos(L / 2)). Thus the (maxEdgeDeviation / SnapRadius) - // ratio increases with both the snap radius R and the edge length L. - // - // We arbitrarily limit the edge deviation to be at most 10% more than the - // snap radius. With the maximum allowed snap radius of 70 degrees, this - // means that edges up to 30.6 degrees long are never split. For smaller - // snap radii, edges up to 49 degrees long are never split. (Edges of any - // length are not split unless their endpoints move far enough so that the - // actual edge deviation exceeds the limit; in practice, splitting is rare - // even with long edges.) Note that it is always possible to split edges - // when maxEdgeDeviation() is exceeded; see maybeAddExtraSites(). - // - // TODO(rsned): What should we do when snapFunction.SnapRadius() > maxSnapRadius); - return maxEdgeDeviationRatio * o.edgeSnapRadius() -} From d817c66bec027269fafac20a6eadd2becdcdd3ac Mon Sep 17 00:00:00 2001 From: Robert Snedegar Date: Wed, 28 May 2025 17:27:49 -0700 Subject: [PATCH 21/22] Move graphEdgeProcessor and tests into builder_graph.go and tests. --- s2/builder_graph.go | 342 ++++++++++ s2/builder_graph_edge_processor.go | 357 ---------- s2/builder_graph_edge_processor_test.go | 824 ------------------------ s2/builder_graph_test.go | 648 +++++++++++++++++++ 4 files changed, 990 insertions(+), 1181 deletions(-) delete mode 100644 s2/builder_graph_edge_processor.go delete mode 100644 s2/builder_graph_edge_processor_test.go diff --git a/s2/builder_graph.go b/s2/builder_graph.go index f33e3505..6189eb8a 100644 --- a/s2/builder_graph.go +++ b/s2/builder_graph.go @@ -14,6 +14,12 @@ package s2 +import ( + "cmp" + "errors" + "sort" +) + // A Graph represents a collection of snapped edges that is passed // to a Layer for assembly. (Example layers include polygons, polylines, and // polygon meshes.) @@ -242,3 +248,339 @@ type graphOptions struct { // Default is false. disableVertexFiltering bool } + +// maxVertexID is the maximum possible vertex ID, used as a sentinel value. +const maxVertexID = int32(^uint32(0) >> 1) + +// graphEdge is a tuple of edge IDs. +type graphEdge struct { + first, second int32 +} + +// reverse returns a new graphEdge with the vertices in reverse order. +func (g graphEdge) reverse() graphEdge { + return graphEdge{first: g.second, second: g.first} +} + +// minGraphEdge returns the minimum of two edges in lexicographic order. +func minGraphEdge(a, b graphEdge) graphEdge { + if a.first < b.first || (a.first == b.first && a.second <= b.second) { + return a + } + return b +} + +// stableLessThan compares two graphEdges for stable sorting. +// It uses the graphEdge IDs as a tiebreaker to ensure a stable sort. +func stableLessThan(a, b graphEdge, aID, bID int32) bool { + if a.first != b.first { + return a.first < b.first + } + if a.second != b.second { + return a.second < b.second + } + return aID < bID +} + +func stableGraphEdgeCmp(a, b graphEdge, aID, bID int32) int { + if a.first != b.first { + return cmp.Compare(a.first, b.first) + } + + if a.second != b.second { + return cmp.Compare(a.second, b.second) + } + return cmp.Compare(aID, bID) + +} + +// graphEdgeProccessor processes edges in a Graph to handle duplicates, siblings, +// and degenerate edges according to the specified GraphOptions. +type graphEdgeProccessor struct { + options *graphOptions + edges []graphEdge + inputIDs []int32 + idSetLexicon *idSetLexicon + outEdges []int32 + inEdges []int32 + newEdges []graphEdge + newInputIDs []int32 +} + +// newgraphEdgeProccessor creates a new graphEdgeProccessor with the given options and data. +func newGraphEdgeProcessor(opts *graphOptions, edges []graphEdge, inputIDs []int32, + idSetLexicon *idSetLexicon) *graphEdgeProccessor { + // opts should not be nil at this point, but just in case. + if opts == nil { + opts = &graphOptions{} + } + ep := &graphEdgeProccessor{ + options: opts, + edges: edges, + inputIDs: inputIDs, + idSetLexicon: idSetLexicon, + inEdges: make([]int32, len(edges)), + outEdges: make([]int32, len(edges)), + } + if ep.idSetLexicon == nil { + ep.idSetLexicon = newIDSetLexicon() + } + + // Sort the outgoing and incoming edges in lexicographic order. + // We use a stable sort to ensure that each undirected edge becomes a sibling pair, + // even if there are multiple identical input edges. + + // Fill the slice with a number sequence. + for i := range ep.outEdges { + ep.outEdges[i] = int32(i) + } + stableSortEdgeIDs(ep.outEdges, func(a, b int32) bool { + return stableLessThan(ep.edges[a], ep.edges[b], a, b) + }) + + // Fill the slice with a number sequence. + for i := range ep.inEdges { + ep.inEdges[i] = int32(i) + } + stableSortEdgeIDs(ep.inEdges, func(a, b int32) bool { + return stableLessThan(ep.edges[a].reverse(), ep.edges[b].reverse(), a, b) + }) + + ep.newEdges = make([]graphEdge, 0, len(edges)) + ep.newInputIDs = make([]int32, 0, len(edges)) + + return ep +} + +// stableSortEdgeIDs performs a stable sort on the given slice of EdgeIDs +// using the provided less function. +func stableSortEdgeIDs(edges []int32, less func(a, b int32) bool) { + sort.SliceStable(edges, func(i, j int) bool { + return less(edges[i], edges[j]) + }) +} + +// addEdge adds a single edge with its input edge ID set to the new edges. +func (ep *graphEdgeProccessor) addEdge(edge graphEdge, inputEdgeIDSetID int32) { + ep.newEdges = append(ep.newEdges, edge) + ep.newInputIDs = append(ep.newInputIDs, inputEdgeIDSetID) +} + +// addEdges adds multiple copies of the same edge with the same input edge ID set. +func (ep *graphEdgeProccessor) addEdges(numEdges int, edge graphEdge, inputEdgeIDSetID int32) { + for i := 0; i < numEdges; i++ { + ep.addEdge(edge, inputEdgeIDSetID) + } +} + +// copyEdges copies a range of edges from the input edges to the new edges. +func (ep *graphEdgeProccessor) copyEdges(outBegin, outEnd int) { + for i := outBegin; i < outEnd; i++ { + ep.addEdge(ep.edges[ep.outEdges[i]], ep.inputIDs[ep.outEdges[i]]) + } +} + +// mergeInputIDs merges the input edge ID sets for a range of edges. +func (ep *graphEdgeProccessor) mergeInputIDs(outBegin, outEnd int) int32 { + if outEnd-outBegin == 1 { + return ep.inputIDs[ep.outEdges[outBegin]] + } + + var tmpIDs []int32 + for i := outBegin; i < outEnd; i++ { + tmpIDs = append(tmpIDs, ep.idSetLexicon.idSet(ep.inputIDs[ep.outEdges[i]])...) + } + + return ep.idSetLexicon.add(tmpIDs...) +} + +// Run processes the edges according to the specified options. +func (ep *graphEdgeProccessor) Run() error { + numEdges := len(ep.edges) + if numEdges == 0 { + return nil + } + + var err error + + // Walk through the two sorted arrays performing a merge join. For each + // edge, gather all the duplicate copies of the edge in both directions + // (outgoing and incoming). Then decide what to do based on options and + // how many copies of the edge there are in each direction. + out, in := 0, 0 + outEdge := ep.edges[ep.outEdges[out]] + inEdge := ep.edges[ep.inEdges[in]] + sentinel := graphEdge{first: maxVertexID, second: maxVertexID} + + for { + edge := minGraphEdge(outEdge, inEdge.reverse()) + if edge == sentinel { + break + } + + outBegin := out + inBegin := in + + for outEdge == edge { + out++ + if out == numEdges { + outEdge = sentinel + } else { + outEdge = ep.edges[ep.outEdges[out]] + } + } + for inEdge.reverse() == edge { + in++ + if in == numEdges { + inEdge = sentinel + } else { + inEdge = ep.edges[ep.inEdges[in]] + } + } + nOut := out - outBegin + nIn := in - inBegin + + if edge.first == edge.second { + // This is a degenerate edge. + err = ep.handleDegenerateEdge(edge, outBegin, out, nOut, nIn, inBegin, in) + } else { + err = ep.handleNormalEdge(edge, outBegin, out, nOut, nIn) + } + } + + // Replace the old edges with the new ones. + ep.edges = ep.newEdges + ep.inputIDs = ep.newInputIDs + return err +} + +// handleDegenerateEdge handles a degenerate edge (an edge from a vertex to itself). +func (ep *graphEdgeProccessor) handleDegenerateEdge(edge graphEdge, outBegin, outEnd int, nOut, nIn, inBegin, in int) error { + // This is a degenerate edge. + if nOut != nIn { + return errors.New("inconsistent number of degenerate edges") + } + if ep.options.degenerateEdges == degenerateEdgesDiscard { + return nil + } + if ep.options.degenerateEdges == degenerateEdgesDiscardExcess { + // Check if there are any non-degenerate incident edges. + if (outBegin > 0 && ep.edges[ep.outEdges[outBegin-1]].first == edge.first) || + (outEnd < len(ep.edges) && ep.edges[ep.outEdges[outEnd]].first == edge.first) || + (inBegin > 0 && ep.edges[ep.inEdges[inBegin-1]].second == edge.first) || + (in < len(ep.edges) && ep.edges[ep.inEdges[in]].second == edge.first) { + return nil // There were some, so discard. + } + } + + // degenerateEdgesDiscardExcess also merges degenerate edges. + merge := ep.options.duplicateEdges == duplicateEdgesMerge || + ep.options.degenerateEdges == degenerateEdgesDiscardExcess + + if ep.options.edgeType == edgeTypeUndirected && + (ep.options.siblingPairs == siblingPairsRequire || + ep.options.siblingPairs == siblingPairsCreate) { + // When we have undirected edges and are guaranteed to have siblings, + // we cut the number of edges in half (see Builder). + if nOut&1 != 0 { + return errors.New("odd number of undirected degenerate edges") + } + if merge { + ep.addEdges(1, edge, ep.mergeInputIDs(outBegin, outEnd)) + } else { + ep.addEdges(nOut/2, edge, ep.mergeInputIDs(outBegin, outEnd)) + } + } else if merge { + if ep.options.edgeType == edgeTypeUndirected { + ep.addEdges(2, edge, ep.mergeInputIDs(outBegin, outEnd)) + } else { + ep.addEdges(1, edge, ep.mergeInputIDs(outBegin, outEnd)) + } + } else if ep.options.siblingPairs == siblingPairsDiscard || + ep.options.siblingPairs == siblingPairsDiscardExcess { + // Any SiblingPair option that discards edges causes the labels of all + // duplicate edges to be merged together (see Builder). + ep.addEdges(nOut, edge, ep.mergeInputIDs(outBegin, outEnd)) + } else { + ep.copyEdges(outBegin, outEnd) + } + return nil +} + +// handleNormalEdge handles a non-degenerate edge. +func (ep *graphEdgeProccessor) handleNormalEdge(edge graphEdge, outBegin, outEnd int, nOut, nIn int) error { + var err error + switch ep.options.siblingPairs { + case siblingPairsKeep: + if nOut > 1 && ep.options.duplicateEdges == duplicateEdgesMerge { + ep.addEdge(edge, ep.mergeInputIDs(outBegin, outEnd)) + } else { + ep.copyEdges(outBegin, outEnd) + } + case siblingPairsDiscard: + if ep.options.edgeType == edgeTypeDirected { + // If nOut == nIn: balanced sibling pairs + // If nOut < nIn: unbalanced siblings, in the form AB, BA, BA + // If nOut > nIn: unbalanced siblings, in the form AB, AB, BA + if nOut <= nIn { + return nil + } + // Any option that discards edges causes the labels of all duplicate + // edges to be merged together (see Builder). + if ep.options.duplicateEdges == duplicateEdgesMerge { + ep.addEdges(1, edge, ep.mergeInputIDs(outBegin, outEnd)) + } else { + ep.addEdges(nOut-nIn, edge, ep.mergeInputIDs(outBegin, outEnd)) + } + } else { + if nOut&1 == 0 { + return nil + } + ep.addEdge(edge, ep.mergeInputIDs(outBegin, outEnd)) + } + case siblingPairsDiscardExcess: + if ep.options.edgeType == edgeTypeDirected { + // See comments above. The only difference is that if there are + // balanced sibling pairs, we want to keep one such pair. + if nOut < nIn { + return nil + } + if ep.options.duplicateEdges == duplicateEdgesMerge { + ep.addEdges(1, edge, ep.mergeInputIDs(outBegin, outEnd)) + } else { + ep.addEdges(maxInt(1, nOut-nIn), edge, ep.mergeInputIDs(outBegin, outEnd)) + } + } else { + if (nOut & 1) != 0 { + ep.addEdges(1, edge, ep.mergeInputIDs(outBegin, outEnd)) + } else { + ep.addEdges(2, edge, ep.mergeInputIDs(outBegin, outEnd)) + } + } + case siblingPairsCreate, siblingPairsRequire: + // In C++, this check also checked the state of the S2Error passed in + // to make sure no previous errors had occured before now. + if ep.options.siblingPairs == siblingPairsRequire && + (ep.options.edgeType == edgeTypeDirected && nOut != nIn || + ep.options.edgeType == edgeTypeUndirected && nOut&1 != 0) { + err = errors.New("expected all input edges to have siblings but some were missing") + } + + if ep.options.duplicateEdges == duplicateEdgesMerge { + ep.addEdge(edge, ep.mergeInputIDs(outBegin, outEnd)) + } else if ep.options.edgeType == edgeTypeUndirected { + // Convert graph to use directed edges instead (see documentation of + // siblingPairsCreate/siblingPairsRequire for undirected edges). + ep.addEdges((nOut+1)/2, edge, ep.mergeInputIDs(outBegin, outEnd)) + } else { + ep.copyEdges(outBegin, outEnd) + if nIn > nOut { + // Automatically created edges have no input edge ids or labels. + ep.addEdges(nIn-nOut, edge, emptySetID) + } + } + default: + return errors.New("invalid sibling pairs option") + } + return err +} diff --git a/s2/builder_graph_edge_processor.go b/s2/builder_graph_edge_processor.go deleted file mode 100644 index 1f0bf6db..00000000 --- a/s2/builder_graph_edge_processor.go +++ /dev/null @@ -1,357 +0,0 @@ -// Copyright 2025 The S2 Geometry Project Authors. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package s2 - -import ( - "cmp" - "errors" - "sort" -) - -// maxVertexID is the maximum possible vertex ID, used as a sentinel value. -const maxVertexID = int32(^uint32(0) >> 1) - -// graphEdge is a tuple of edge IDs. -type graphEdge struct { - first, second int32 -} - -// reverse returns a new graphEdge with the vertices in reverse order. -func (g graphEdge) reverse() graphEdge { - return graphEdge{first: g.second, second: g.first} -} - -// minGraphEdge returns the minimum of two edges in lexicographic order. -func minGraphEdge(a, b graphEdge) graphEdge { - if a.first < b.first || (a.first == b.first && a.second <= b.second) { - return a - } - return b -} - -// stableLessThan compares two graphEdges for stable sorting. -// It uses the graphEdge IDs as a tiebreaker to ensure a stable sort. -func stableLessThan(a, b graphEdge, aID, bID int32) bool { - if a.first != b.first { - return a.first < b.first - } - if a.second != b.second { - return a.second < b.second - } - return aID < bID -} - -func stableGraphEdgeCmp(a, b graphEdge, aID, bID int32) int { - if a.first != b.first { - return cmp.Compare(a.first, b.first) - } - - if a.second != b.second { - return cmp.Compare(a.second, b.second) - } - return cmp.Compare(aID, bID) - -} - -// graphEdgeProccessor processes edges in a Graph to handle duplicates, siblings, -// and degenerate edges according to the specified GraphOptions. -type graphEdgeProccessor struct { - options *graphOptions - edges []graphEdge - inputIDs []int32 - idSetLexicon *idSetLexicon - outEdges []int32 - inEdges []int32 - newEdges []graphEdge - newInputIDs []int32 -} - -// newgraphEdgeProccessor creates a new graphEdgeProccessor with the given options and data. -func newGraphEdgeProcessor(opts *graphOptions, edges []graphEdge, inputIDs []int32, - idSetLexicon *idSetLexicon) *graphEdgeProccessor { - // opts should not be nil at this point, but just in case. - if opts == nil { - opts = &graphOptions{} - } - ep := &graphEdgeProccessor{ - options: opts, - edges: edges, - inputIDs: inputIDs, - idSetLexicon: idSetLexicon, - inEdges: make([]int32, len(edges)), - outEdges: make([]int32, len(edges)), - } - if ep.idSetLexicon == nil { - ep.idSetLexicon = newIDSetLexicon() - } - - // Sort the outgoing and incoming edges in lexicographic order. - // We use a stable sort to ensure that each undirected edge becomes a sibling pair, - // even if there are multiple identical input edges. - - // Fill the slice with a number sequence. - for i := range ep.outEdges { - ep.outEdges[i] = int32(i) - } - stableSortEdgeIDs(ep.outEdges, func(a, b int32) bool { - return stableLessThan(ep.edges[a], ep.edges[b], a, b) - }) - - // Fill the slice with a number sequence. - for i := range ep.inEdges { - ep.inEdges[i] = int32(i) - } - stableSortEdgeIDs(ep.inEdges, func(a, b int32) bool { - return stableLessThan(ep.edges[a].reverse(), ep.edges[b].reverse(), a, b) - }) - - ep.newEdges = make([]graphEdge, 0, len(edges)) - ep.newInputIDs = make([]int32, 0, len(edges)) - - return ep -} - -// stableSortEdgeIDs performs a stable sort on the given slice of EdgeIDs -// using the provided less function. -func stableSortEdgeIDs(edges []int32, less func(a, b int32) bool) { - sort.SliceStable(edges, func(i, j int) bool { - return less(edges[i], edges[j]) - }) -} - -// addEdge adds a single edge with its input edge ID set to the new edges. -func (ep *graphEdgeProccessor) addEdge(edge graphEdge, inputEdgeIDSetID int32) { - ep.newEdges = append(ep.newEdges, edge) - ep.newInputIDs = append(ep.newInputIDs, inputEdgeIDSetID) -} - -// addEdges adds multiple copies of the same edge with the same input edge ID set. -func (ep *graphEdgeProccessor) addEdges(numEdges int, edge graphEdge, inputEdgeIDSetID int32) { - for i := 0; i < numEdges; i++ { - ep.addEdge(edge, inputEdgeIDSetID) - } -} - -// copyEdges copies a range of edges from the input edges to the new edges. -func (ep *graphEdgeProccessor) copyEdges(outBegin, outEnd int) { - for i := outBegin; i < outEnd; i++ { - ep.addEdge(ep.edges[ep.outEdges[i]], ep.inputIDs[ep.outEdges[i]]) - } -} - -// mergeInputIDs merges the input edge ID sets for a range of edges. -func (ep *graphEdgeProccessor) mergeInputIDs(outBegin, outEnd int) int32 { - if outEnd-outBegin == 1 { - return ep.inputIDs[ep.outEdges[outBegin]] - } - - var tmpIDs []int32 - for i := outBegin; i < outEnd; i++ { - tmpIDs = append(tmpIDs, ep.idSetLexicon.idSet(ep.inputIDs[ep.outEdges[i]])...) - } - - return ep.idSetLexicon.add(tmpIDs...) -} - -// Run processes the edges according to the specified options. -func (ep *graphEdgeProccessor) Run() error { - numEdges := len(ep.edges) - if numEdges == 0 { - return nil - } - - var err error - - // Walk through the two sorted arrays performing a merge join. For each - // edge, gather all the duplicate copies of the edge in both directions - // (outgoing and incoming). Then decide what to do based on options and - // how many copies of the edge there are in each direction. - out, in := 0, 0 - outEdge := ep.edges[ep.outEdges[out]] - inEdge := ep.edges[ep.inEdges[in]] - sentinel := graphEdge{first: maxVertexID, second: maxVertexID} - - for { - edge := minGraphEdge(outEdge, inEdge.reverse()) - if edge == sentinel { - break - } - - outBegin := out - inBegin := in - - for outEdge == edge { - out++ - if out == numEdges { - outEdge = sentinel - } else { - outEdge = ep.edges[ep.outEdges[out]] - } - } - for inEdge.reverse() == edge { - in++ - if in == numEdges { - inEdge = sentinel - } else { - inEdge = ep.edges[ep.inEdges[in]] - } - } - nOut := out - outBegin - nIn := in - inBegin - - if edge.first == edge.second { - // This is a degenerate edge. - err = ep.handleDegenerateEdge(edge, outBegin, out, nOut, nIn, inBegin, in) - } else { - err = ep.handleNormalEdge(edge, outBegin, out, nOut, nIn) - } - } - - // Replace the old edges with the new ones. - ep.edges = ep.newEdges - ep.inputIDs = ep.newInputIDs - return err -} - -// handleDegenerateEdge handles a degenerate edge (an edge from a vertex to itself). -func (ep *graphEdgeProccessor) handleDegenerateEdge(edge graphEdge, outBegin, outEnd int, nOut, nIn, inBegin, in int) error { - // This is a degenerate edge. - if nOut != nIn { - return errors.New("inconsistent number of degenerate edges") - } - if ep.options.degenerateEdges == degenerateEdgesDiscard { - return nil - } - if ep.options.degenerateEdges == degenerateEdgesDiscardExcess { - // Check if there are any non-degenerate incident edges. - if (outBegin > 0 && ep.edges[ep.outEdges[outBegin-1]].first == edge.first) || - (outEnd < len(ep.edges) && ep.edges[ep.outEdges[outEnd]].first == edge.first) || - (inBegin > 0 && ep.edges[ep.inEdges[inBegin-1]].second == edge.first) || - (in < len(ep.edges) && ep.edges[ep.inEdges[in]].second == edge.first) { - return nil // There were some, so discard. - } - } - - // degenerateEdgesDiscardExcess also merges degenerate edges. - merge := ep.options.duplicateEdges == duplicateEdgesMerge || - ep.options.degenerateEdges == degenerateEdgesDiscardExcess - - if ep.options.edgeType == edgeTypeUndirected && - (ep.options.siblingPairs == siblingPairsRequire || - ep.options.siblingPairs == siblingPairsCreate) { - // When we have undirected edges and are guaranteed to have siblings, - // we cut the number of edges in half (see Builder). - if nOut&1 != 0 { - return errors.New("odd number of undirected degenerate edges") - } - if merge { - ep.addEdges(1, edge, ep.mergeInputIDs(outBegin, outEnd)) - } else { - ep.addEdges(nOut/2, edge, ep.mergeInputIDs(outBegin, outEnd)) - } - } else if merge { - if ep.options.edgeType == edgeTypeUndirected { - ep.addEdges(2, edge, ep.mergeInputIDs(outBegin, outEnd)) - } else { - ep.addEdges(1, edge, ep.mergeInputIDs(outBegin, outEnd)) - } - } else if ep.options.siblingPairs == siblingPairsDiscard || - ep.options.siblingPairs == siblingPairsDiscardExcess { - // Any SiblingPair option that discards edges causes the labels of all - // duplicate edges to be merged together (see Builder). - ep.addEdges(nOut, edge, ep.mergeInputIDs(outBegin, outEnd)) - } else { - ep.copyEdges(outBegin, outEnd) - } - return nil -} - -// handleNormalEdge handles a non-degenerate edge. -func (ep *graphEdgeProccessor) handleNormalEdge(edge graphEdge, outBegin, outEnd int, nOut, nIn int) error { - var err error - switch ep.options.siblingPairs { - case siblingPairsKeep: - if nOut > 1 && ep.options.duplicateEdges == duplicateEdgesMerge { - ep.addEdge(edge, ep.mergeInputIDs(outBegin, outEnd)) - } else { - ep.copyEdges(outBegin, outEnd) - } - case siblingPairsDiscard: - if ep.options.edgeType == edgeTypeDirected { - // If nOut == nIn: balanced sibling pairs - // If nOut < nIn: unbalanced siblings, in the form AB, BA, BA - // If nOut > nIn: unbalanced siblings, in the form AB, AB, BA - if nOut <= nIn { - return nil - } - // Any option that discards edges causes the labels of all duplicate - // edges to be merged together (see Builder). - if ep.options.duplicateEdges == duplicateEdgesMerge { - ep.addEdges(1, edge, ep.mergeInputIDs(outBegin, outEnd)) - } else { - ep.addEdges(nOut-nIn, edge, ep.mergeInputIDs(outBegin, outEnd)) - } - } else { - if nOut&1 == 0 { - return nil - } - ep.addEdge(edge, ep.mergeInputIDs(outBegin, outEnd)) - } - case siblingPairsDiscardExcess: - if ep.options.edgeType == edgeTypeDirected { - // See comments above. The only difference is that if there are - // balanced sibling pairs, we want to keep one such pair. - if nOut < nIn { - return nil - } - if ep.options.duplicateEdges == duplicateEdgesMerge { - ep.addEdges(1, edge, ep.mergeInputIDs(outBegin, outEnd)) - } else { - ep.addEdges(maxInt(1, nOut-nIn), edge, ep.mergeInputIDs(outBegin, outEnd)) - } - } else { - if (nOut & 1) != 0 { - ep.addEdges(1, edge, ep.mergeInputIDs(outBegin, outEnd)) - } else { - ep.addEdges(2, edge, ep.mergeInputIDs(outBegin, outEnd)) - } - } - case siblingPairsCreate, siblingPairsRequire: - // In C++, this check also checked the state of the S2Error passed in - // to make sure no previous errors had occured before now. - if ep.options.siblingPairs == siblingPairsRequire && - (ep.options.edgeType == edgeTypeDirected && nOut != nIn || - ep.options.edgeType == edgeTypeUndirected && nOut&1 != 0) { - err = errors.New("expected all input edges to have siblings but some were missing") - } - - if ep.options.duplicateEdges == duplicateEdgesMerge { - ep.addEdge(edge, ep.mergeInputIDs(outBegin, outEnd)) - } else if ep.options.edgeType == edgeTypeUndirected { - // Convert graph to use directed edges instead (see documentation of - // siblingPairsCreate/siblingPairsRequire for undirected edges). - ep.addEdges((nOut+1)/2, edge, ep.mergeInputIDs(outBegin, outEnd)) - } else { - ep.copyEdges(outBegin, outEnd) - if nIn > nOut { - // Automatically created edges have no input edge ids or labels. - ep.addEdges(nIn-nOut, edge, emptySetID) - } - } - default: - return errors.New("invalid sibling pairs option") - } - return err -} diff --git a/s2/builder_graph_edge_processor_test.go b/s2/builder_graph_edge_processor_test.go deleted file mode 100644 index 603c4afc..00000000 --- a/s2/builder_graph_edge_processor_test.go +++ /dev/null @@ -1,824 +0,0 @@ -// Copyright 2025 The S2 Geometry Project Authors. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package s2 - -import ( - "slices" - "testing" -) - -func TestGraphEdgeProcessorStableLessThan(t *testing.T) { - tests := []struct { - name string - a graphEdge - b graphEdge - aInputID int32 - bInputID int32 - want bool - }{ - { - name: "a < b lexicographically", - a: graphEdge{first: 1, second: 2}, - b: graphEdge{first: 2, second: 3}, - aInputID: 1, - bInputID: 2, - want: true, - }, - { - name: "a > b lexicographically", - a: graphEdge{first: 3, second: 4}, - b: graphEdge{first: 1, second: 2}, - aInputID: 1, - bInputID: 2, - want: false, - }, - { - name: "a == b lexicographically, a.inputID < b.inputID", - a: graphEdge{first: 1, second: 2}, - b: graphEdge{first: 1, second: 2}, - aInputID: 1, - bInputID: 2, - want: true, - }, - { - name: "a == b lexicographically, a.inputID > b.inputID", - a: graphEdge{first: 1, second: 2}, - b: graphEdge{first: 1, second: 2}, - aInputID: 3, - bInputID: 2, - want: false, - }, - { - name: "a == b lexicographically, a.inputID == b.inputID", - a: graphEdge{first: 1, second: 2}, - b: graphEdge{first: 1, second: 2}, - aInputID: 5, - bInputID: 5, - want: false, - }, - { - name: "first vertices equal, second vertices different", - a: graphEdge{first: 1, second: 2}, - b: graphEdge{first: 1, second: 3}, - aInputID: 1, - bInputID: 2, - want: true, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - got := stableLessThan(test.a, test.b, test.aInputID, test.bInputID) - if got != test.want { - t.Errorf("stableLessThan() = %v, want %v", got, test.want) - } - }) - } -} - -func TestGraphEdgeProcessorAddEdge(t *testing.T) { - tests := []struct { - name string - edge graphEdge - inputEdgeIDSetID int32 - wantEdges int - wantInputIDs int - }{ - { - name: "add single edge", - edge: graphEdge{first: 1, second: 2}, - inputEdgeIDSetID: 1, - wantEdges: 1, - wantInputIDs: 1, - }, - { - name: "add second edge", - edge: graphEdge{first: 2, second: 3}, - inputEdgeIDSetID: 2, - wantEdges: 2, - wantInputIDs: 2, - }, - } - - ep := &edgeProcessor{ - newEdges: make([]graphEdge, 0), - newInputIDs: make([]int32, 0), - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - ep.addEdge(test.edge, test.inputEdgeIDSetID) - if len(ep.newEdges) != test.wantEdges { - t.Errorf("addEdge() edges = %v, want %v", len(ep.newEdges), test.wantEdges) - } - if len(ep.newInputIDs) != test.wantInputIDs { - t.Errorf("addEdge() inputIDs = %v, want %v", len(ep.newInputIDs), test.wantInputIDs) - } - }) - } -} - -func TestGraphEdgeProcessorAddEdges(t *testing.T) { - tests := []struct { - name string - numEdges int - edge graphEdge - inputEdgeIDSetID int32 - wantEdges int - wantInputIDs int - }{ - { - name: "add single edge", - numEdges: 1, - edge: graphEdge{first: 1, second: 2}, - inputEdgeIDSetID: 1, - wantEdges: 1, - wantInputIDs: 1, - }, - { - name: "add multiple edges", - numEdges: 3, - edge: graphEdge{first: 1, second: 2}, - inputEdgeIDSetID: 7, - wantEdges: 3, - wantInputIDs: 3, - }, - { - name: "add zero edges", - numEdges: 0, - edge: graphEdge{first: 1, second: 2}, - inputEdgeIDSetID: 8, - wantEdges: 0, // Should remain unchanged - wantInputIDs: 0, // Should remain unchanged - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - ep := &edgeProcessor{ - newEdges: make([]graphEdge, 0), - newInputIDs: make([]int32, 0), - } - - ep.addEdges(test.numEdges, test.edge, test.inputEdgeIDSetID) - if len(ep.newEdges) != test.wantEdges { - t.Errorf("addEdges() edges = %v, want %v", len(ep.newEdges), test.wantEdges) - } - - if len(ep.newInputIDs) != test.wantInputIDs { - t.Errorf("addEdges() inputIDs = %v, want %v", len(ep.newInputIDs), test.wantInputIDs) - } - - // addEdges uses the same inputEdgeIDSetID for each repeated edge. Ensure - // all the added ids match. - for k, v := range ep.newInputIDs { - if v != test.inputEdgeIDSetID { - t.Errorf("in addEdges, newInputIDs[%d] = %d, want %d", k, v, test.inputEdgeIDSetID) - } - } - }) - } -} - -func TestGraphEdgeProcessorHandleDegenerateEdge(t *testing.T) { - tests := []struct { - name string - edge graphEdge - options *graphOptions - outBegin int - outEnd int - nOut int - nIn int - inBegin int - in int - wantErr bool - wantEdges int - }{ - { - name: "discard degenerate edges", - edge: graphEdge{first: 1, second: 1}, - options: &graphOptions{ - degenerateEdges: degenerateEdgesDiscard, - }, - outBegin: 0, - outEnd: 1, - nOut: 1, - nIn: 1, - inBegin: 0, - in: 1, - wantErr: false, - wantEdges: 0, - }, - { - name: "keep degenerate edges with merge", - edge: graphEdge{first: 1, second: 1}, - options: &graphOptions{ - degenerateEdges: degenerateEdgesKeep, - duplicateEdges: duplicateEdgesMerge, - edgeType: edgeTypeDirected, - }, - outBegin: 0, - outEnd: 2, - nOut: 2, - nIn: 2, - inBegin: 0, - in: 2, - wantErr: false, - wantEdges: 1, - }, - { - name: "keep degenerate edges without merge", - edge: graphEdge{first: 1, second: 1}, - options: &graphOptions{ - degenerateEdges: degenerateEdgesKeep, - duplicateEdges: duplicateEdgesKeep, - edgeType: edgeTypeDirected, - }, - outBegin: 0, - outEnd: 2, - nOut: 2, - nIn: 2, - inBegin: 0, - in: 2, - wantErr: false, - wantEdges: 2, - }, - { - name: "discard excess degenerate edges with incident edges", - edge: graphEdge{first: 1, second: 1}, - options: &graphOptions{ - degenerateEdges: degenerateEdgesDiscardExcess, - edgeType: edgeTypeDirected, - }, - outBegin: 1, - outEnd: 2, - nOut: 1, - nIn: 1, - inBegin: 1, - in: 2, - wantErr: false, - wantEdges: 0, - }, - { - name: "discard excess degenerate edges without incident edges", - edge: graphEdge{first: 1, second: 1}, - options: &graphOptions{ - degenerateEdges: degenerateEdgesDiscardExcess, - edgeType: edgeTypeDirected, - duplicateEdges: duplicateEdgesMerge, - }, - outBegin: 0, - outEnd: 2, - nOut: 2, - nIn: 2, - inBegin: 0, - in: 2, - wantErr: false, - wantEdges: 1, - }, - { - name: "undirected degenerate edges with require siblings", - edge: graphEdge{first: 1, second: 1}, - options: &graphOptions{ - degenerateEdges: degenerateEdgesKeep, - edgeType: edgeTypeUndirected, - siblingPairs: siblingPairsRequire, - duplicateEdges: duplicateEdgesMerge, - }, - outBegin: 0, - outEnd: 2, - nOut: 2, - nIn: 2, - inBegin: 0, - in: 2, - wantErr: false, - wantEdges: 1, - }, - { - name: "undirected degenerate edges with create siblings", - edge: graphEdge{first: 1, second: 1}, - options: &graphOptions{ - degenerateEdges: degenerateEdgesKeep, - edgeType: edgeTypeUndirected, - siblingPairs: siblingPairsCreate, - duplicateEdges: duplicateEdgesKeep, - }, - outBegin: 0, - outEnd: 4, - nOut: 4, - nIn: 4, - inBegin: 0, - in: 4, - wantErr: false, - wantEdges: 2, - }, - { - name: "inconsistent degenerate edges", - edge: graphEdge{first: 1, second: 1}, - options: &graphOptions{ - degenerateEdges: degenerateEdgesKeep, - }, - outBegin: 0, - outEnd: 1, - nOut: 1, - nIn: 2, // Mismatched counts - inBegin: 0, - in: 2, - wantErr: true, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - // Create edges and inputIDs arrays with the correct size - edges := make([]graphEdge, test.outEnd) - inputIDs := make([]int32, test.outEnd) - for i := range edges { - edges[i] = test.edge - inputIDs[i] = int32(i + 1) - } - - ep := newEdgeProcessor(test.options, edges, inputIDs, newIDSetLexicon()) - // Add the input IDs to the lexicon. - for _, id := range inputIDs { - ep.idSetLexicon.add(id) - } - - gotErr := ep.handleDegenerateEdge(test.edge, test.outBegin, test.outEnd, - test.nOut, test.nIn, test.inBegin, test.in) - if (gotErr != nil) != test.wantErr { - t.Errorf("handleDegenerateEdge() error = %v, wantErr %v", - gotErr, test.wantErr) - } - if !test.wantErr && len(ep.newEdges) != test.wantEdges { - t.Errorf("handleDegenerateEdge() added %d edges, want %d", len(ep.newEdges), test.wantEdges) - } - }) - } -} - -func TestGraphEdgeProcessorHandleNormalEdge(t *testing.T) { - tests := []struct { - name string - edge graphEdge - options *graphOptions - outBegin int - outEnd int - nOut int - nIn int - wantErr bool - wantEdges int - }{ - { - name: "keep sibling pairs with merge", - edge: graphEdge{first: 1, second: 2}, - options: &graphOptions{ - siblingPairs: siblingPairsKeep, - edgeType: edgeTypeDirected, - duplicateEdges: duplicateEdgesMerge, - }, - outBegin: 0, - outEnd: 2, - nOut: 2, - nIn: 1, - wantErr: false, - wantEdges: 1, - }, - { - name: "keep sibling pairs without merge", - edge: graphEdge{first: 1, second: 2}, - options: &graphOptions{ - siblingPairs: siblingPairsKeep, - edgeType: edgeTypeDirected, - duplicateEdges: duplicateEdgesKeep, - }, - outBegin: 0, - outEnd: 2, - nOut: 2, - nIn: 1, - wantErr: false, - wantEdges: 2, - }, - { - name: "discard sibling pairs directed balanced", - edge: graphEdge{first: 1, second: 2}, - options: &graphOptions{ - siblingPairs: siblingPairsDiscard, - edgeType: edgeTypeDirected, - duplicateEdges: duplicateEdgesMerge, - }, - outBegin: 0, - outEnd: 2, - nOut: 2, - nIn: 2, - wantErr: false, - wantEdges: 0, - }, - { - name: "discard sibling pairs directed unbalanced", - edge: graphEdge{first: 1, second: 2}, - options: &graphOptions{ - siblingPairs: siblingPairsDiscard, - edgeType: edgeTypeDirected, - duplicateEdges: duplicateEdgesMerge, - }, - outBegin: 0, - outEnd: 3, - nOut: 3, - nIn: 1, - wantErr: false, - wantEdges: 1, - }, - { - name: "discard sibling pairs undirected even", - edge: graphEdge{first: 1, second: 2}, - options: &graphOptions{ - siblingPairs: siblingPairsDiscard, - edgeType: edgeTypeUndirected, - duplicateEdges: duplicateEdgesMerge, - }, - outBegin: 0, - outEnd: 2, - nOut: 2, - nIn: 2, - wantErr: false, - wantEdges: 0, - }, - { - name: "discard sibling pairs undirected odd", - edge: graphEdge{first: 1, second: 2}, - options: &graphOptions{ - siblingPairs: siblingPairsDiscard, - edgeType: edgeTypeUndirected, - duplicateEdges: duplicateEdgesMerge, - }, - outBegin: 0, - outEnd: 3, - nOut: 3, - nIn: 3, - wantErr: false, - wantEdges: 1, - }, - { - name: "discard excess sibling pairs directed balanced", - edge: graphEdge{first: 1, second: 2}, - options: &graphOptions{ - siblingPairs: siblingPairsDiscardExcess, - edgeType: edgeTypeDirected, - duplicateEdges: duplicateEdgesMerge, - }, - outBegin: 0, - outEnd: 2, - nOut: 2, - nIn: 2, - wantErr: false, - wantEdges: 1, - }, - { - name: "discard excess sibling pairs directed unbalanced", - edge: graphEdge{first: 1, second: 2}, - options: &graphOptions{ - siblingPairs: siblingPairsDiscardExcess, - edgeType: edgeTypeDirected, - duplicateEdges: duplicateEdgesMerge, - }, - outBegin: 0, - outEnd: 3, - nOut: 3, - nIn: 1, - wantErr: false, - wantEdges: 1, - }, - { - name: "require sibling pairs directed balanced", - edge: graphEdge{first: 1, second: 2}, - options: &graphOptions{ - siblingPairs: siblingPairsRequire, - edgeType: edgeTypeDirected, - duplicateEdges: duplicateEdgesMerge, - }, - outBegin: 0, - outEnd: 2, - nOut: 2, - nIn: 2, - wantErr: false, - wantEdges: 1, - }, - { - name: "require sibling pairs directed unbalanced", - edge: graphEdge{first: 1, second: 2}, - options: &graphOptions{ - siblingPairs: siblingPairsRequire, - edgeType: edgeTypeDirected, - duplicateEdges: duplicateEdgesMerge, - }, - outBegin: 0, - outEnd: 2, - nOut: 2, - nIn: 1, - wantErr: true, - }, - { - name: "create sibling pairs undirected", - edge: graphEdge{first: 1, second: 2}, - options: &graphOptions{ - siblingPairs: siblingPairsCreate, - edgeType: edgeTypeUndirected, - duplicateEdges: duplicateEdgesKeep, - }, - outBegin: 0, - outEnd: 4, - nOut: 4, - nIn: 2, - wantErr: false, - wantEdges: 2, - }, - { - name: "invalid sibling pairs option", - edge: graphEdge{first: 1, second: 2}, - options: &graphOptions{ - siblingPairs: siblingPairs(255), // Use max uint8 value as invalid - edgeType: edgeTypeDirected, - }, - outBegin: 0, - outEnd: 1, - nOut: 1, - nIn: 1, - wantErr: true, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - // Create edges and inputIDs arrays with the correct size - edges := make([]graphEdge, test.outEnd) - inputIDs := make([]int32, test.outEnd) - for i := range edges { - edges[i] = test.edge - inputIDs[i] = int32(i + 1) - } - - ep := newEdgeProcessor(test.options, edges, inputIDs, newIDSetLexicon()) - // Add the input IDs to the lexicon. - for _, id := range inputIDs { - ep.idSetLexicon.add(id) - } - - gotErr := ep.handleNormalEdge(test.edge, test.outBegin, test.outEnd, test.nOut, test.nIn) - if (gotErr != nil) != test.wantErr { - t.Errorf("handleNormalEdge() error = %v, wantErr %v", gotErr, test.wantErr) - } - if !test.wantErr && len(ep.newEdges) != test.wantEdges { - t.Errorf("handleNormalEdge() added %d edges, want %d", len(ep.newEdges), test.wantEdges) - } - }) - } -} - -func TestGraphEdgeProcessorMergeInputIDs(t *testing.T) { - tests := []struct { - name string - edges []graphEdge - inputIDs []int32 - outBegin int - outEnd int - wantInputIDSet []int32 - }{ - { - name: "single edge", - edges: []graphEdge{ - {first: 1, second: 2}, - }, - inputIDs: []int32{1}, - outBegin: 0, - outEnd: 1, - wantInputIDSet: []int32{1}, - }, - { - name: "multiple edges with same input ID should reduce to 1 output", - edges: []graphEdge{ - {first: 1, second: 2}, - {first: 1, second: 2}, - }, - inputIDs: []int32{1, 1}, - outBegin: 0, - outEnd: 2, - wantInputIDSet: []int32{1}, - }, - { - name: "multiple edges with different input IDs should keep distinct ids", - edges: []graphEdge{ - {first: 1, second: 2}, - {first: 1, second: 2}, - {first: 1, second: 2}, - }, - inputIDs: []int32{1, 2, 3}, - outBegin: 0, - outEnd: 3, - wantInputIDSet: []int32{1, 2, 3}, - }, - { - name: "subset of edges should return the smaller portion", - edges: []graphEdge{ - {first: 1, second: 2}, - {first: 1, second: 2}, - {first: 1, second: 2}, - }, - inputIDs: []int32{1, 2, 3}, - outBegin: 1, - outEnd: 3, - wantInputIDSet: []int32{2, 3}, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - ep := newEdgeProcessor(defaultGraphOptions(), test.edges, test.inputIDs, newIDSetLexicon()) - // Add the input IDs to the lexicon - for _, id := range test.inputIDs { - ep.idSetLexicon.add(id) - } - - merged := ep.mergeInputIDs(test.outBegin, test.outEnd) - - // Get the actual set of IDs from the lexicon - got := ep.idSetLexicon.idSet(merged) - - // Sort both slices for comparison - slices.Sort(got) - slices.Sort(test.wantInputIDSet) - - if !slices.Equal(got, test.wantInputIDSet) { - t.Errorf("mergeInputIDs() = %v, want %v", got, test.wantInputIDSet) - } - }) - } -} - -func TestGraphEdgeProcessorRun(t *testing.T) { - tests := []struct { - name string - edges []graphEdge - inputIDs []int32 - options *graphOptions - wantErr bool - wantEdges int - }{ - { - name: "empty graph", - edges: []graphEdge{}, - inputIDs: []int32{}, - options: defaultGraphOptions(), - wantErr: false, - wantEdges: 0, - }, - { - name: "single edge", - edges: []graphEdge{ - {first: 1, second: 2}, - }, - inputIDs: []int32{1}, - options: defaultGraphOptions(), - wantErr: false, - wantEdges: 1, - }, - { - name: "degenerate edge with discard", - edges: []graphEdge{ - {first: 1, second: 1}, - }, - inputIDs: []int32{1}, - options: &graphOptions{ - edgeType: edgeTypeDirected, - duplicateEdges: duplicateEdgesKeep, - degenerateEdges: degenerateEdgesDiscard, - siblingPairs: siblingPairsKeep, - }, - wantErr: false, - wantEdges: 0, - }, - { - name: "duplicate edges with merge", - edges: []graphEdge{ - {first: 1, second: 2}, - {first: 1, second: 2}, - }, - inputIDs: []int32{1, 2}, - options: &graphOptions{ - edgeType: edgeTypeDirected, - duplicateEdges: duplicateEdgesMerge, - degenerateEdges: degenerateEdgesKeep, - siblingPairs: siblingPairsKeep, - }, - wantErr: false, - wantEdges: 1, - }, - { - name: "sibling pairs with discard", - edges: []graphEdge{ - {first: 1, second: 2}, - {first: 2, second: 1}, - }, - inputIDs: []int32{1, 2}, - options: &graphOptions{ - edgeType: edgeTypeDirected, - duplicateEdges: duplicateEdgesKeep, - degenerateEdges: degenerateEdgesKeep, - siblingPairs: siblingPairsDiscard, - }, - wantErr: false, - wantEdges: 0, - }, - { - name: "undirected edges with require siblings", - edges: []graphEdge{ - {first: 1, second: 2}, - {first: 2, second: 1}, - }, - inputIDs: []int32{1, 2}, - options: &graphOptions{ - edgeType: edgeTypeUndirected, - duplicateEdges: duplicateEdgesKeep, - degenerateEdges: degenerateEdgesKeep, - siblingPairs: siblingPairsRequire, - }, - wantErr: true, - wantEdges: 0, - }, - { - name: "undirected edges with create siblings", - edges: []graphEdge{ - {first: 1, second: 2}, - {first: 2, second: 1}, - }, - inputIDs: []int32{1, 2}, - options: &graphOptions{ - edgeType: edgeTypeUndirected, - duplicateEdges: duplicateEdgesKeep, - degenerateEdges: degenerateEdgesKeep, - siblingPairs: siblingPairsCreate, - }, - wantErr: false, - wantEdges: 2, - }, - { - name: "require siblings with missing sibling", - edges: []graphEdge{ - {first: 1, second: 2}, - }, - inputIDs: []int32{1}, - options: &graphOptions{ - edgeType: edgeTypeUndirected, - duplicateEdges: duplicateEdgesKeep, - degenerateEdges: degenerateEdgesKeep, - siblingPairs: siblingPairsRequire, - }, - wantErr: true, - wantEdges: 0, - }, - { - name: "multiple edges with various options", - edges: []graphEdge{ - {first: 1, second: 2}, - {first: 2, second: 1}, - {first: 1, second: 2}, - {first: 3, second: 3}, - }, - inputIDs: []int32{1, 2, 3, 4}, - options: &graphOptions{ - edgeType: edgeTypeDirected, - duplicateEdges: duplicateEdgesMerge, - degenerateEdges: degenerateEdgesDiscard, - siblingPairs: siblingPairsDiscardExcess, - }, - wantErr: false, - wantEdges: 1, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - lexicon := newIDSetLexicon() - ep := newEdgeProcessor(test.options, test.edges, test.inputIDs, lexicon) - err := ep.Run() - if (err != nil) != test.wantErr { - t.Errorf("Run() error = %v, wantErr %v", err, test.wantErr) - } - if !test.wantErr && len(ep.edges) != test.wantEdges { - t.Errorf("Run() produced %d edges, want %d", len(ep.edges), test.wantEdges) - } - }) - } -} diff --git a/s2/builder_graph_test.go b/s2/builder_graph_test.go index 2b0f7937..3c116165 100644 --- a/s2/builder_graph_test.go +++ b/s2/builder_graph_test.go @@ -5,6 +5,654 @@ import ( "testing" ) +func TestGraphEdgeProcessorStableLessThan(t *testing.T) { + tests := []struct { + name string + a graphEdge + b graphEdge + aInputID int32 + bInputID int32 + want bool + }{ + { + name: "a < b lexicographically", + a: graphEdge{first: 1, second: 2}, + b: graphEdge{first: 2, second: 3}, + aInputID: 1, + bInputID: 2, + want: true, + }, + { + name: "a > b lexicographically", + a: graphEdge{first: 3, second: 4}, + b: graphEdge{first: 1, second: 2}, + aInputID: 1, + bInputID: 2, + want: false, + }, + { + name: "a == b lexicographically, a.inputID < b.inputID", + a: graphEdge{first: 1, second: 2}, + b: graphEdge{first: 1, second: 2}, + aInputID: 1, + bInputID: 2, + want: true, + }, + { + name: "a == b lexicographically, a.inputID > b.inputID", + a: graphEdge{first: 1, second: 2}, + b: graphEdge{first: 1, second: 2}, + aInputID: 3, + bInputID: 2, + want: false, + }, + { + name: "a == b lexicographically, a.inputID == b.inputID", + a: graphEdge{first: 1, second: 2}, + b: graphEdge{first: 1, second: 2}, + aInputID: 5, + bInputID: 5, + want: false, + }, + { + name: "first vertices equal, second vertices different", + a: graphEdge{first: 1, second: 2}, + b: graphEdge{first: 1, second: 3}, + aInputID: 1, + bInputID: 2, + want: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := stableLessThan(test.a, test.b, test.aInputID, test.bInputID) + if got != test.want { + t.Errorf("stableLessThan() = %v, want %v", got, test.want) + } + }) + } +} + +func TestGraphEdgeProcessorAddEdge(t *testing.T) { + tests := []struct { + name string + edge graphEdge + inputEdgeIDSetID int32 + wantEdges int + wantInputIDs int + }{ + { + name: "add single edge", + edge: graphEdge{first: 1, second: 2}, + inputEdgeIDSetID: 1, + wantEdges: 1, + wantInputIDs: 1, + }, + { + name: "add second edge", + edge: graphEdge{first: 2, second: 3}, + inputEdgeIDSetID: 2, + wantEdges: 2, + wantInputIDs: 2, + }, + } + + ep := &graphEdgeProccessor{ + newEdges: make([]graphEdge, 0), + newInputIDs: make([]int32, 0), + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ep.addEdge(test.edge, test.inputEdgeIDSetID) + if len(ep.newEdges) != test.wantEdges { + t.Errorf("addEdge() edges = %v, want %v", len(ep.newEdges), test.wantEdges) + } + if len(ep.newInputIDs) != test.wantInputIDs { + t.Errorf("addEdge() inputIDs = %v, want %v", len(ep.newInputIDs), test.wantInputIDs) + } + }) + } +} + +func TestGraphEdgeProcessorAddEdges(t *testing.T) { + tests := []struct { + name string + numEdges int + edge graphEdge + inputEdgeIDSetID int32 + wantEdges int + wantInputIDs int + }{ + { + name: "add single edge", + numEdges: 1, + edge: graphEdge{first: 1, second: 2}, + inputEdgeIDSetID: 1, + wantEdges: 1, + wantInputIDs: 1, + }, + { + name: "add multiple edges", + numEdges: 3, + edge: graphEdge{first: 1, second: 2}, + inputEdgeIDSetID: 7, + wantEdges: 3, + wantInputIDs: 3, + }, + { + name: "add zero edges", + numEdges: 0, + edge: graphEdge{first: 1, second: 2}, + inputEdgeIDSetID: 8, + wantEdges: 0, // Should remain unchanged + wantInputIDs: 0, // Should remain unchanged + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ep := &graphEdgeProccessor{ + newEdges: make([]graphEdge, 0), + newInputIDs: make([]int32, 0), + } + + ep.addEdges(test.numEdges, test.edge, test.inputEdgeIDSetID) + if len(ep.newEdges) != test.wantEdges { + t.Errorf("addEdges() edges = %v, want %v", len(ep.newEdges), test.wantEdges) + } + + if len(ep.newInputIDs) != test.wantInputIDs { + t.Errorf("addEdges() inputIDs = %v, want %v", len(ep.newInputIDs), test.wantInputIDs) + } + + // addEdges uses the same inputEdgeIDSetID for each repeated edge. Ensure + // all the added ids match. + for k, v := range ep.newInputIDs { + if v != test.inputEdgeIDSetID { + t.Errorf("in addEdges, newInputIDs[%d] = %d, want %d", k, v, test.inputEdgeIDSetID) + } + } + }) + } +} + +func TestGraphEdgeProcessorHandleDegenerateEdge(t *testing.T) { + tests := []struct { + name string + edge graphEdge + options *graphOptions + outBegin int + outEnd int + nOut int + nIn int + inBegin int + in int + wantErr bool + wantEdges int + }{ + { + name: "discard degenerate edges", + edge: graphEdge{first: 1, second: 1}, + options: &graphOptions{ + degenerateEdges: degenerateEdgesDiscard, + }, + outBegin: 0, + outEnd: 1, + nOut: 1, + nIn: 1, + inBegin: 0, + in: 1, + wantErr: false, + wantEdges: 0, + }, + { + name: "keep degenerate edges with merge", + edge: graphEdge{first: 1, second: 1}, + options: &graphOptions{ + degenerateEdges: degenerateEdgesKeep, + duplicateEdges: duplicateEdgesMerge, + edgeType: edgeTypeDirected, + }, + outBegin: 0, + outEnd: 2, + nOut: 2, + nIn: 2, + inBegin: 0, + in: 2, + wantErr: false, + wantEdges: 1, + }, + { + name: "keep degenerate edges without merge", + edge: graphEdge{first: 1, second: 1}, + options: &graphOptions{ + degenerateEdges: degenerateEdgesKeep, + duplicateEdges: duplicateEdgesKeep, + edgeType: edgeTypeDirected, + }, + outBegin: 0, + outEnd: 2, + nOut: 2, + nIn: 2, + inBegin: 0, + in: 2, + wantErr: false, + wantEdges: 2, + }, + { + name: "discard excess degenerate edges with incident edges", + edge: graphEdge{first: 1, second: 1}, + options: &graphOptions{ + degenerateEdges: degenerateEdgesDiscardExcess, + edgeType: edgeTypeDirected, + }, + outBegin: 1, + outEnd: 2, + nOut: 1, + nIn: 1, + inBegin: 1, + in: 2, + wantErr: false, + wantEdges: 0, + }, + { + name: "discard excess degenerate edges without incident edges", + edge: graphEdge{first: 1, second: 1}, + options: &graphOptions{ + degenerateEdges: degenerateEdgesDiscardExcess, + edgeType: edgeTypeDirected, + duplicateEdges: duplicateEdgesMerge, + }, + outBegin: 0, + outEnd: 2, + nOut: 2, + nIn: 2, + inBegin: 0, + in: 2, + wantErr: false, + wantEdges: 1, + }, + { + name: "undirected degenerate edges with require siblings", + edge: graphEdge{first: 1, second: 1}, + options: &graphOptions{ + degenerateEdges: degenerateEdgesKeep, + edgeType: edgeTypeUndirected, + siblingPairs: siblingPairsRequire, + duplicateEdges: duplicateEdgesMerge, + }, + outBegin: 0, + outEnd: 2, + nOut: 2, + nIn: 2, + inBegin: 0, + in: 2, + wantErr: false, + wantEdges: 1, + }, + { + name: "undirected degenerate edges with create siblings", + edge: graphEdge{first: 1, second: 1}, + options: &graphOptions{ + degenerateEdges: degenerateEdgesKeep, + edgeType: edgeTypeUndirected, + siblingPairs: siblingPairsCreate, + duplicateEdges: duplicateEdgesKeep, + }, + outBegin: 0, + outEnd: 4, + nOut: 4, + nIn: 4, + inBegin: 0, + in: 4, + wantErr: false, + wantEdges: 2, + }, + { + name: "inconsistent degenerate edges", + edge: graphEdge{first: 1, second: 1}, + options: &graphOptions{ + degenerateEdges: degenerateEdgesKeep, + }, + outBegin: 0, + outEnd: 1, + nOut: 1, + nIn: 2, // Mismatched counts + inBegin: 0, + in: 2, + wantErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Create edges and inputIDs arrays with the correct size + edges := make([]graphEdge, test.outEnd) + inputIDs := make([]int32, test.outEnd) + for i := range edges { + edges[i] = test.edge + inputIDs[i] = int32(i + 1) + } + + ep := newGraphEdgeProcessor(test.options, edges, inputIDs, newIDSetLexicon()) + // Add the input IDs to the lexicon. + for _, id := range inputIDs { + ep.idSetLexicon.add(id) + } + + gotErr := ep.handleDegenerateEdge(test.edge, test.outBegin, test.outEnd, + test.nOut, test.nIn, test.inBegin, test.in) + if (gotErr != nil) != test.wantErr { + t.Errorf("handleDegenerateEdge() error = %v, wantErr %v", + gotErr, test.wantErr) + } + if !test.wantErr && len(ep.newEdges) != test.wantEdges { + t.Errorf("handleDegenerateEdge() added %d edges, want %d", len(ep.newEdges), test.wantEdges) + } + }) + } +} + +func TestGraphEdgeProcessorHandleNormalEdge(t *testing.T) { + tests := []struct { + name string + edge graphEdge + options *graphOptions + outBegin int + outEnd int + nOut int + nIn int + wantErr bool + wantEdges int + }{ + { + name: "keep sibling pairs with merge", + edge: graphEdge{first: 1, second: 2}, + options: &graphOptions{ + siblingPairs: siblingPairsKeep, + edgeType: edgeTypeDirected, + duplicateEdges: duplicateEdgesMerge, + }, + outBegin: 0, + outEnd: 2, + nOut: 2, + nIn: 1, + wantErr: false, + wantEdges: 1, + }, + { + name: "keep sibling pairs without merge", + edge: graphEdge{first: 1, second: 2}, + options: &graphOptions{ + siblingPairs: siblingPairsKeep, + edgeType: edgeTypeDirected, + duplicateEdges: duplicateEdgesKeep, + }, + outBegin: 0, + outEnd: 2, + nOut: 2, + nIn: 1, + wantErr: false, + wantEdges: 2, + }, + { + name: "discard sibling pairs directed balanced", + edge: graphEdge{first: 1, second: 2}, + options: &graphOptions{ + siblingPairs: siblingPairsDiscard, + edgeType: edgeTypeDirected, + duplicateEdges: duplicateEdgesMerge, + }, + outBegin: 0, + outEnd: 2, + nOut: 2, + nIn: 2, + wantErr: false, + wantEdges: 0, + }, + { + name: "discard sibling pairs directed unbalanced", + edge: graphEdge{first: 1, second: 2}, + options: &graphOptions{ + siblingPairs: siblingPairsDiscard, + edgeType: edgeTypeDirected, + duplicateEdges: duplicateEdgesMerge, + }, + outBegin: 0, + outEnd: 3, + nOut: 3, + nIn: 1, + wantErr: false, + wantEdges: 1, + }, + { + name: "discard sibling pairs undirected even", + edge: graphEdge{first: 1, second: 2}, + options: &graphOptions{ + siblingPairs: siblingPairsDiscard, + edgeType: edgeTypeUndirected, + duplicateEdges: duplicateEdgesMerge, + }, + outBegin: 0, + outEnd: 2, + nOut: 2, + nIn: 2, + wantErr: false, + wantEdges: 0, + }, + { + name: "discard sibling pairs undirected odd", + edge: graphEdge{first: 1, second: 2}, + options: &graphOptions{ + siblingPairs: siblingPairsDiscard, + edgeType: edgeTypeUndirected, + duplicateEdges: duplicateEdgesMerge, + }, + outBegin: 0, + outEnd: 3, + nOut: 3, + nIn: 3, + wantErr: false, + wantEdges: 1, + }, + { + name: "discard excess sibling pairs directed balanced", + edge: graphEdge{first: 1, second: 2}, + options: &graphOptions{ + siblingPairs: siblingPairsDiscardExcess, + edgeType: edgeTypeDirected, + duplicateEdges: duplicateEdgesMerge, + }, + outBegin: 0, + outEnd: 2, + nOut: 2, + nIn: 2, + wantErr: false, + wantEdges: 1, + }, + { + name: "discard excess sibling pairs directed unbalanced", + edge: graphEdge{first: 1, second: 2}, + options: &graphOptions{ + siblingPairs: siblingPairsDiscardExcess, + edgeType: edgeTypeDirected, + duplicateEdges: duplicateEdgesMerge, + }, + outBegin: 0, + outEnd: 3, + nOut: 3, + nIn: 1, + wantErr: false, + wantEdges: 1, + }, + { + name: "require sibling pairs directed balanced", + edge: graphEdge{first: 1, second: 2}, + options: &graphOptions{ + siblingPairs: siblingPairsRequire, + edgeType: edgeTypeDirected, + duplicateEdges: duplicateEdgesMerge, + }, + outBegin: 0, + outEnd: 2, + nOut: 2, + nIn: 2, + wantErr: false, + wantEdges: 1, + }, + { + name: "require sibling pairs directed unbalanced", + edge: graphEdge{first: 1, second: 2}, + options: &graphOptions{ + siblingPairs: siblingPairsRequire, + edgeType: edgeTypeDirected, + duplicateEdges: duplicateEdgesMerge, + }, + outBegin: 0, + outEnd: 2, + nOut: 2, + nIn: 1, + wantErr: true, + }, + { + name: "create sibling pairs undirected", + edge: graphEdge{first: 1, second: 2}, + options: &graphOptions{ + siblingPairs: siblingPairsCreate, + edgeType: edgeTypeUndirected, + duplicateEdges: duplicateEdgesKeep, + }, + outBegin: 0, + outEnd: 4, + nOut: 4, + nIn: 2, + wantErr: false, + wantEdges: 2, + }, + { + name: "invalid sibling pairs option", + edge: graphEdge{first: 1, second: 2}, + options: &graphOptions{ + siblingPairs: siblingPairs(255), // Use max uint8 value as invalid + edgeType: edgeTypeDirected, + }, + outBegin: 0, + outEnd: 1, + nOut: 1, + nIn: 1, + wantErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Create edges and inputIDs arrays with the correct size + edges := make([]graphEdge, test.outEnd) + inputIDs := make([]int32, test.outEnd) + for i := range edges { + edges[i] = test.edge + inputIDs[i] = int32(i + 1) + } + + ep := newGraphEdgeProcessor(test.options, edges, inputIDs, newIDSetLexicon()) + // Add the input IDs to the lexicon. + for _, id := range inputIDs { + ep.idSetLexicon.add(id) + } + + gotErr := ep.handleNormalEdge(test.edge, test.outBegin, test.outEnd, test.nOut, test.nIn) + if (gotErr != nil) != test.wantErr { + t.Errorf("handleNormalEdge() error = %v, wantErr %v", gotErr, test.wantErr) + } + if !test.wantErr && len(ep.newEdges) != test.wantEdges { + t.Errorf("handleNormalEdge() added %d edges, want %d", len(ep.newEdges), test.wantEdges) + } + }) + } +} + +func TestGraphEdgeProcessorMergeInputIDs(t *testing.T) { + tests := []struct { + name string + edges []graphEdge + inputIDs []int32 + outBegin int + outEnd int + wantInputIDSet []int32 + }{ + { + name: "single edge", + edges: []graphEdge{ + {first: 1, second: 2}, + }, + inputIDs: []int32{1}, + outBegin: 0, + outEnd: 1, + wantInputIDSet: []int32{1}, + }, + { + name: "multiple edges with same input ID should reduce to 1 output", + edges: []graphEdge{ + {first: 1, second: 2}, + {first: 1, second: 2}, + }, + inputIDs: []int32{1, 1}, + outBegin: 0, + outEnd: 2, + wantInputIDSet: []int32{1}, + }, + { + name: "multiple edges with different input IDs should keep distinct ids", + edges: []graphEdge{ + {first: 1, second: 2}, + {first: 1, second: 2}, + {first: 1, second: 2}, + }, + inputIDs: []int32{1, 2, 3}, + outBegin: 0, + outEnd: 3, + wantInputIDSet: []int32{1, 2, 3}, + }, + { + name: "subset of edges should return the smaller portion", + edges: []graphEdge{ + {first: 1, second: 2}, + {first: 1, second: 2}, + {first: 1, second: 2}, + }, + inputIDs: []int32{1, 2, 3}, + outBegin: 1, + outEnd: 3, + wantInputIDSet: []int32{2, 3}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ep := newGraphEdgeProcessor(&graphOptions{}, test.edges, test.inputIDs, newIDSetLexicon()) + // Add the input IDs to the lexicon + for _, id := range test.inputIDs { + ep.idSetLexicon.add(id) + } + + merged := ep.mergeInputIDs(test.outBegin, test.outEnd) + + // Get the actual set of IDs from the lexicon + got := ep.idSetLexicon.idSet(merged) + + // Sort both slices for comparison + slices.Sort(got) + slices.Sort(test.wantInputIDSet) + + if !slices.Equal(got, test.wantInputIDSet) { + t.Errorf("mergeInputIDs() = %v, want %v", got, test.wantInputIDSet) + } + }) + } +} + type testGraphEdge struct { edge graphEdge inputIDs []int32 From b68663fb1d12dd244c5ff410086f796259cfc284 Mon Sep 17 00:00:00 2001 From: Robert Snedegar Date: Fri, 30 May 2025 00:10:16 -0700 Subject: [PATCH 22/22] Add comments to remaining member variables in Builder and Graph. Incorporate the comments missing on builder and graph from the Java classes. Spelling fixes. --- s2/builder.go | 170 ++++++++++++++++++++++++--------------- s2/builder_graph.go | 77 ++++++++++++------ s2/builder_graph_test.go | 10 +-- 3 files changed, 163 insertions(+), 94 deletions(-) diff --git a/s2/builder.go b/s2/builder.go index a06040d3..fc3b6b92 100644 --- a/s2/builder.go +++ b/s2/builder.go @@ -100,7 +100,7 @@ import ( // // Example showing how to snap a polygon to E7 coordinates: // -// builder := NewBuilder(newBuilderOptions(IntLatLngSnapFunction(7))); +// builder := NewBuilder(NewBuilderOptions(IntLatLngSnapFunction(7))); // var output *Polygon // builder.StartLayer(NewPolygonLayer(output)) // builder.AddPolygon(input); @@ -113,91 +113,133 @@ import ( type builder struct { opts builderOptions - // The maximum distance (inclusive) that a vertex can move when snapped, - // equal to options.SnapFunction().SnapRadius()). + // siteSnapRadiusChordAngle is the maximum distance (inclusive) that a + // vertex can move when snapped, equal to opts.snapper.SnapRadius(). siteSnapRadiusChordAngle s1.ChordAngle - // The maximum distance (inclusive) that an edge can move when snapping to a - // snap site. It can be slightly larger than the site snap radius when - // edges are being split at crossings. + // edgeSnapRadiusChordAngle is the maximum distance (inclusive) that an + // edge can move when snapping to a snap site. It can be slightly larger + // than the site snap radius when edges are being split at crossings. edgeSnapRadiusChordAngle s1.ChordAngle - // True if we need to check that snapping has not changed the input topology - // around any vertex (i.e. Voronoi site). Normally this is only necessary for - // forced vertices, but if the snap radius is very small (e.g., zero) and - // splitCrossingedges is true then we need to do this for all vertices. + // checkAllSiteCrossings reports if we need to check that snapping has not + // changed the input topology around any vertex (i.e. Voronoi site). + // Normally this is only necessary for forced vertices, but if the snap + // radius is very small (e.g., zero) and opts.splitCrossingEdges is true + // then we need to do this for all vertices. // In all other situations, any snapped edge that crosses a vertex will also - // be closer than minEdgeVertexSeparation() to that vertex, which will + // be closer than opts.minEdgeVertexSeparation() to that vertex, which will // cause us to add a separation site anyway. checkAllSiteCrossings bool - maxEdgeDeviation s1.Angle - edgeSiteQueryRadiusChordAngle s1.ChordAngle + // maxEdgeDeviation is the maximum distance that a vertex can be separated + // from an edge while still affecting how that edge is snapped. + maxEdgeDeviation s1.Angle + + // edgeSiteQueryRadiusChordAngle is the distance from an edge in which candidates for + // snapping and/or avoidance are considered. + edgeSiteQueryRadiusChordAngle s1.ChordAngle + + // minEdgeLengthToSplitChordAngle is the maximum edge length such that + // even if both endpoints move by the maximum distance allowed (i.e. + // edgeSnapRadius), the center of the edge will still move by less than + // maxEdgeDeviation. minEdgeLengthToSplitChordAngle s1.ChordAngle - minSiteSeparation s1.Angle - minSiteSeparationChordAngle s1.ChordAngle - minEdgeSiteSeparationChordAngle s1.ChordAngle + // minSiteSeparation comes from the snapper. + minSiteSeparation s1.Angle + + // minSiteSeparationChordAngle is the ChordAngle of the minSiteSeparation. + minSiteSeparationChordAngle s1.ChordAngle + + // minEdgeSiteSeparationChordAngle is the minimum separation between edges + // and sites as a ChordAngle. + minEdgeSiteSeparationChordAngle s1.ChordAngle + + // minEdgeSiteSeparationChordAngleLimit is the upper bound on the distance + // from ClosestEdgeQuery as a ChordAngle. minEdgeSiteSeparationChordAngleLimit s1.ChordAngle + // maxAdjacentSiteSeparationChordAngle is the maximum possible distance + // between two sites whose Voronoi regions touch, increased to account + // for errors. (The maximum radius of each Voronoi region is + // edgeSnapRadius.) maxAdjacentSiteSeparationChordAngle s1.ChordAngle - // The squared sine of the edge snap radius. This is equivalent to the snap - // radius (squared) for distances measured through the interior of the - // sphere to the plane containing an edge. This value is used only when - // interpolating new points along edges (see GetSeparationSite). + // edgeSnapRadiusSin2 is the squared sine of the edge snap radius. + // This is equivalent to the snap radius (squared) for distances + // measured through the interior of the sphere to the plane containing + // an edge. This value is used only when interpolating new points along + // edges (see getSeparationSite()). edgeSnapRadiusSin2 float64 - // True if snapping was requested. This is true if either snapRadius() is - // positive, or splitCrossingEdges() is true (which implicitly requests - // snapping to ensure that both crossing edges are snapped to the - // intersection point). + // snappingRequested reports if snapping was requested. + // This is true if either opts.snapper.SnapRadius() is positive, or + // opts.splitCrossingEdges is true (which implicitly requests snapping to + // ensure that both crossing edges are snapped to the intersection point). snappingRequested bool - // Initially false, and set to true when it is discovered that at least one - // input vertex or edge does not meet the output guarantees (e.g., that - // vertices are separated by at least snapper.minVertexSeparation). + // snappingNeeded will be set to to true when it is discovered that at + // least one input vertex or edge does not meet the output guarantees + // (e.g., that vertices are separated by at least snapper.minVertexSeparation). snappingNeeded bool - // A flag indicating whether labelSet has been modified since the last - // time labelSetID was computed. + // labelSetModified indicates whether labelSet has been modified since the + // last time labelSetId was computed. labelSetModified bool + // inputVertices is a slice of input vertices. inputVertices []Point - // inputEdges []builderInputEdge - layers []*builderLayer - layerOptions []*graphOptions - layerBegins []int32 + // inputEdges is a slice of all Edges for all Layers. + ///// inputEdges []builderEdge + + // layers is a slice of layers for this Builder. The last layer is the + // current layer. All edges are assigned to the current layer when the + // edge is added. + layers []*builderLayer + + // layerOptions is a slice of graphOptions corresponding to the layers slice. + // TODO(rsned): pull this struct out or replace with a generic. + layerOptions []struct { + first, second int32 + } + + // layerBegins is a slice of int32s, each indicating the index into + // inputEdges corresponding to each layer. + layerBegins []int32 + + // layerIsFullPolygonPredicates is a slice of isFullPolygonPredicate, + // each indicating the predicate for the corresponding layer. layerIsFullPolygonPredicates []isFullPolygonPredicate - // Each input edge has "label set id" (an int32) representing the set of - // labels attached to that edge. This vector is populated only if at least + // Each input edge has a labelSetID (an int32) representing the set of + // labels attached to that edge. This slice is populated only if at least // one label is used. - labelSetIDs []int32 + labelSetIDs []int32 + // labelSetLexicon stores labels assigned to each labelSet. labelSetLexicon *idSetLexicon - // The current set of labels (represented as a stack). + // labelSet is the current set of labels (represented as a stack). labelSet []int32 // The labelSetID corresponding to the current label set, computed on demand - // (by adding it to labelSetLexicon()). + // (by adding it to labelSetLexicon). labelSetID int32 // The remaining fields are used for snapping and simplifying. - // The number of sites specified using forceVertex(). These sites are - // always at the beginning of the sites vector. + // numForcedSites is the number of sites specified using forceVertex(). + // These sites are always at the beginning of the sites vector. numForcedSites int32 - // The set of snapped vertex locations ("sites"). + // sites is the set of snapped vertex locations ("sites"). sites []Point - // A map from each input edge to the set of sites "nearby" that edge, - // defined as the set of sites that are candidates for snapping and/or - // avoidance. Note that compactarray will inline up to two sites, which - // usually takes care of the vast majority of edges. Sites are kept sorted - // by increasing distance from the origin of the input edge. + // edgeSites is a map from each input edge to the set of sites "nearby" + // that edge, defined as the set of sites that are candidates for snapping + // and/or avoidance. Sites are kept by increasing distance from the origin + // of the input edge. // // Once snapping is finished, this field is discarded unless edge chain // simplification was requested, in which case instead the sites are @@ -331,11 +373,6 @@ func roundChordAngleUp(a s1.Angle) s1.ChordAngle { return ca.Expanded(ca.MaxAngleError()) } -// roundAngleUp rounds the given angle up by the max error and returns it as an angle. -//func roundAngleUp(a s1.Angle) s1.Angle { -// return a.Expanded(a.MaxAngleError()) -//} - func addPointToPointError(ca s1.ChordAngle) s1.ChordAngle { return ca.Expanded(ca.MaxPointError()) } @@ -427,16 +464,18 @@ const ( ) // builderOptions holds the options for the Builder. +// +// TODO(rsned): Add public setters. type builderOptions struct { - // snapFunction holds the desired snap function. + // snapper holds the desired snap function. // // Note that if your input data includes vertices that were created using // Intersection(), then you should use a "snapRadius" of // at least intersectionMergeRadius, e.g. by calling // - // options.setSnapFunction(IdentitySnapFunction(intersectionMergeRadius)); + // options.setSnapper(IdentitySnapFunction(intersectionMergeRadius)); // - // DEFAULT: IdentitySnapFunction(s1.Angle(0)) + // The default for this should be the IdentitySnapFunction(s1.Angle(0)) // [This does no snapping and preserves all input vertices exactly.] snapper Snapper @@ -466,7 +505,7 @@ type builderOptions struct { // original projected edges (which are curves on the sphere). This can // be done using EdgeTessellator, for example. // - // DEFAULT: false + // The default for this is false. splitCrossingEdges bool // intersectionTolerance specifies the maximum allowable distance between @@ -511,7 +550,7 @@ type builderOptions struct { // automatically set to a minimum of intersectionError. A larger // value can be specified by calling this method explicitly. // - // DEFAULT: s1.Angle(0) + // The default tolerance should be 0. intersectionTolerance s1.Angle // simplifyEdgeChains determines if the output geometry should be simplified @@ -554,7 +593,7 @@ type builderOptions struct { // snapRadius() must be specified. Also note that vertices specified // using ForceVertex are never simplified away. // - // DEFAULT: false + // The default for this is false. simplifyEdgeChains bool // idempotent determines if snapping occurs only when the input geometry @@ -565,25 +604,25 @@ type builderOptions struct { // least MinEdgeVertexSeparation(), then no snapping is done. // // If false, then all vertex pairs and edge-vertex pairs closer than - // "SnapRadius" will be considered for snapping. This can be useful, for + // "edgeSnapRadius" will be considered for snapping. This can be useful, for // example, if you know that your geometry contains errors and you want to - // make sure that features closer together than "SnapRadius" are merged. + // make sure that features closer together than "edgeSnapRadius" are merged. // // This option is automatically turned off when simplifyEdgeChains is true // since simplifying edge chains is never guaranteed to be idempotent. // - // DEFAULT: true - idempotent bool + // The default for this is false. (meaning it IS idempotent) + nonIdempotent bool } // defaultBuilderOptions returns a new instance with the proper defaults. -func defaultBuilderOptions() *builderOptions { - return &builderOptions{ +func defaultBuilderOptions() builderOptions { + return builderOptions{ snapper: NewIdentitySnapper(0), splitCrossingEdges: false, intersectionTolerance: s1.Angle(0), simplifyEdgeChains: false, - idempotent: true, + nonIdempotent: false, } } @@ -623,4 +662,5 @@ func (o builderOptions) maxEdgeDeviation() s1.Angle { } // TODO(rsned): Differences from C++ -// all of builders body. +// All of builder. +// edgeChainSimplifier diff --git a/s2/builder_graph.go b/s2/builder_graph.go index 6189eb8a..de3a5aed 100644 --- a/s2/builder_graph.go +++ b/s2/builder_graph.go @@ -32,14 +32,34 @@ import ( // TODO(rsned): Consider pulling out the methods that are helper functions for // Layer implementations (such as getDirectedLoops) into a builder_graph_util.go. type graph struct { - opts *graphOptions - numVertices int32 - vertices []Point - edges []graphEdge - inputEdgeIDSetIDs []int32 - inputEdgeIDSetLexicon *idSetLexicon - labelSetIDs []int32 - labelSetLexicon *idSetLexicon + // The options for this Graph. + opts *graphOptions + // The number of vertices in this Graph. + numVertices int32 + // The vertices in this Graph. The index of a Vertex in this list is its VertexID. + vertices []Point + // The graphEdges in this Graph. The index of a graphEdge in this list is the EdgeID. + edges []graphEdge + // A slice mapping edge id to IdSet ids. The index is the edge ID, and + // the value is the inputEdgeIdSetID, i.e. the id of the IDSet in the + // inputEdgeIDSetLexicon. Each of those IDSets is the set of input + // edge ids that were mapped to the edge. + inputEdgeIDSetIDs []int32 + // inputEdgeIDSetLexicon is a mapping from inputEdgeIDSetIDs to sets of + // input edge IDs. + inputEdgeIDSetLexicon *idSetLexicon + // labelSetIDs is a slice mapping input edge IDs to labelSet IDs. The keys + // are input edge IDs, and values are labelSet IDs, i.e. the IDs of + // IDSets in the labelSetLexicon. Each of those IDSets is the set of + // labels which were attached to the given input edge. This list may be + // empty to indicate that no labels are present. + labelSetIDs []int32 + // labelSetLexicon is a mapping from ints which are labelSetIDs to a sets + // of labels, which are also ints. This lexicon will exist even if no + // labels are present. + labelSetLexicon *idSetLexicon + // isFullPolygonPredicate is used to determine if it is a full or empty + // polygon if the geometry in this graph has no edges. isFullPolygonPredicate isFullPolygonPredicate } @@ -66,7 +86,7 @@ func newGraph(opts *graphOptions, return g } -// processGraphEdges transform an unsorted collection of graphEdges according +// processGraphEdges transforms an unsorted collection of graphEdges according // to the given set of GraphOptions. This includes actions such as discarding // degenerate edges; merging duplicate edges; and canonicalizing sibling // edge pairs in several possible ways (e.g. discarding or creating them). @@ -83,13 +103,10 @@ func newGraph(opts *graphOptions, // Note that the options may be modified by this method: in particular, if // edgeType is edgeTypeUndirected and siblingPairs is siblingPairsCreate or // siblingPairsRequire, then half of the edges in each direction will be -// discarded and edgeType will be changed to edgeTypeDirected the comments +// discarded and edgeType will be changed to edgeTypeDirected (see the comments // on siblingPairs for more details). func processGraphEdges(opts *graphOptions, edges []graphEdge, inputIds []int32, idSetLexicon *idSetLexicon) (newEdges []graphEdge, newInputIDs []int32, err error) { - // graphEdgeProcessor discards the edges and inputIDs slices passed in and - // replaces them with new slices, so we need to return whatever it ends - // up with. ep := newGraphEdgeProcessor(opts, edges, inputIds, idSetLexicon) err = ep.Run() @@ -99,6 +116,9 @@ func processGraphEdges(opts *graphOptions, edges []graphEdge, inputIds []int32, opts.siblingPairs == siblingPairsCreate { opts.edgeType = edgeTypeDirected } + // graphEdgeProcessor discards the edges and inputIDs slices passed in and + // replaces them with new slices, so we need to return whatever it ends + // up with. return ep.edges, ep.inputIDs, err } @@ -294,9 +314,9 @@ func stableGraphEdgeCmp(a, b graphEdge, aID, bID int32) int { } -// graphEdgeProccessor processes edges in a Graph to handle duplicates, siblings, +// graphEdgeProcessor processes edges in a Graph to handle duplicates, siblings, // and degenerate edges according to the specified GraphOptions. -type graphEdgeProccessor struct { +type graphEdgeProcessor struct { options *graphOptions edges []graphEdge inputIDs []int32 @@ -309,12 +329,12 @@ type graphEdgeProccessor struct { // newgraphEdgeProccessor creates a new graphEdgeProccessor with the given options and data. func newGraphEdgeProcessor(opts *graphOptions, edges []graphEdge, inputIDs []int32, - idSetLexicon *idSetLexicon) *graphEdgeProccessor { + idSetLexicon *idSetLexicon) *graphEdgeProcessor { // opts should not be nil at this point, but just in case. if opts == nil { opts = &graphOptions{} } - ep := &graphEdgeProccessor{ + ep := &graphEdgeProcessor{ options: opts, edges: edges, inputIDs: inputIDs, @@ -361,27 +381,27 @@ func stableSortEdgeIDs(edges []int32, less func(a, b int32) bool) { } // addEdge adds a single edge with its input edge ID set to the new edges. -func (ep *graphEdgeProccessor) addEdge(edge graphEdge, inputEdgeIDSetID int32) { +func (ep *graphEdgeProcessor) addEdge(edge graphEdge, inputEdgeIDSetID int32) { ep.newEdges = append(ep.newEdges, edge) ep.newInputIDs = append(ep.newInputIDs, inputEdgeIDSetID) } // addEdges adds multiple copies of the same edge with the same input edge ID set. -func (ep *graphEdgeProccessor) addEdges(numEdges int, edge graphEdge, inputEdgeIDSetID int32) { +func (ep *graphEdgeProcessor) addEdges(numEdges int, edge graphEdge, inputEdgeIDSetID int32) { for i := 0; i < numEdges; i++ { ep.addEdge(edge, inputEdgeIDSetID) } } // copyEdges copies a range of edges from the input edges to the new edges. -func (ep *graphEdgeProccessor) copyEdges(outBegin, outEnd int) { +func (ep *graphEdgeProcessor) copyEdges(outBegin, outEnd int) { for i := outBegin; i < outEnd; i++ { ep.addEdge(ep.edges[ep.outEdges[i]], ep.inputIDs[ep.outEdges[i]]) } } // mergeInputIDs merges the input edge ID sets for a range of edges. -func (ep *graphEdgeProccessor) mergeInputIDs(outBegin, outEnd int) int32 { +func (ep *graphEdgeProcessor) mergeInputIDs(outBegin, outEnd int) int32 { if outEnd-outBegin == 1 { return ep.inputIDs[ep.outEdges[outBegin]] } @@ -395,7 +415,7 @@ func (ep *graphEdgeProccessor) mergeInputIDs(outBegin, outEnd int) int32 { } // Run processes the edges according to the specified options. -func (ep *graphEdgeProccessor) Run() error { +func (ep *graphEdgeProcessor) Run() error { numEdges := len(ep.edges) if numEdges == 0 { return nil @@ -455,7 +475,7 @@ func (ep *graphEdgeProccessor) Run() error { } // handleDegenerateEdge handles a degenerate edge (an edge from a vertex to itself). -func (ep *graphEdgeProccessor) handleDegenerateEdge(edge graphEdge, outBegin, outEnd int, nOut, nIn, inBegin, in int) error { +func (ep *graphEdgeProcessor) handleDegenerateEdge(edge graphEdge, outBegin, outEnd int, nOut, nIn, inBegin, in int) error { // This is a degenerate edge. if nOut != nIn { return errors.New("inconsistent number of degenerate edges") @@ -508,7 +528,7 @@ func (ep *graphEdgeProccessor) handleDegenerateEdge(edge graphEdge, outBegin, ou } // handleNormalEdge handles a non-degenerate edge. -func (ep *graphEdgeProccessor) handleNormalEdge(edge graphEdge, outBegin, outEnd int, nOut, nIn int) error { +func (ep *graphEdgeProcessor) handleNormalEdge(edge graphEdge, outBegin, outEnd int, nOut, nIn int) error { var err error switch ep.options.siblingPairs { case siblingPairsKeep: @@ -584,3 +604,12 @@ func (ep *graphEdgeProccessor) handleNormalEdge(edge graphEdge, outBegin, outEnd } return err } + +// TODO(rsned): Differences from C++ +// polylineBuilder +// vertexInMap, vertexOutMap, vertexInEdgeIds, vertexOutEdgeIds, vertexOutEdges +// makeSubGraph +// getPolylines +// filterVertices +// getDirected/UndirectedComponents +// all of graphs helpers diff --git a/s2/builder_graph_test.go b/s2/builder_graph_test.go index 3c116165..16614338 100644 --- a/s2/builder_graph_test.go +++ b/s2/builder_graph_test.go @@ -98,7 +98,7 @@ func TestGraphEdgeProcessorAddEdge(t *testing.T) { }, } - ep := &graphEdgeProccessor{ + ep := &graphEdgeProcessor{ newEdges: make([]graphEdge, 0), newInputIDs: make([]int32, 0), } @@ -153,7 +153,7 @@ func TestGraphEdgeProcessorAddEdges(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - ep := &graphEdgeProccessor{ + ep := &graphEdgeProcessor{ newEdges: make([]graphEdge, 0), newInputIDs: make([]int32, 0), } @@ -1671,12 +1671,12 @@ func TestGraphProcessGraphEdges(t *testing.T) { t.Errorf("err != nil = %v, wanted %v", err != nil, test.wantErr) } if len(gotEdges) != len(gotIDs) { - t.Errorf("Num edges (%d) != num IDs (%d)", len(edges), len(inputIDSetIDs)) + t.Errorf("num edges (%d) != num IDs (%d)", len(edges), len(inputIDSetIDs)) } for i, want := range test.want { if i > len(gotEdges) { - t.Errorf("Not enough output edges") + t.Errorf("not enough output edges") } if want.edge != gotEdges[i] { t.Errorf("got[%d] = %+v, want %+v", i, gotEdges[i], want.edge) @@ -1688,7 +1688,7 @@ func TestGraphProcessGraphEdges(t *testing.T) { } } if len(test.want) != len(gotEdges) { - t.Errorf("Too many output edges %d", len(gotEdges)) + t.Errorf("too many output edges %d", len(gotEdges)) } if test.wantChangedEdgeType {