The Douglas-Peucker (RDP) algorithm is excellent for simplifying polylines by removing points within a tolerance epsilon. However, standard RDP only guarantees geometric proximity — it doesn’t prevent topological violations like self-intersections, collapsed holes, or accidental crossings between nearby features.
In this post, we’ll explore what topology-preserving simplification means and how to implement it using RhinoCommon.
Why Standard RDP Can Break Topology
While RDP with epsilon controls point deviation, it can still create invalid geometries:
Common topology violations:
Self-intersections: A simplified polyline crosses itself
Collapsed holes: Interior vertices of a polygon are removed, collapsing a hole
Feature crossings: Two close parallel lines collapse into crossing lines after simplification
Lost connectivity: Road intersections disappear or new false intersections appear
These issues arise because RDP makes local decisions (remove this point based on distance) without considering global constraints (does this create an intersection?).
What “Topology-Preserving” Actually Means
In spatial geometry, topology refers to relationships between features:
Relationship
Example
Adjacency
Two polygons sharing a border
Containment
A hole inside a polygon
Non-intersection
Polylines that never cross
Connectivity
Road segments meeting at intersections
A topology-preserving simplification ensures:
✔ Polygon rings stay valid (no self-intersections) ✔ Holes remain inside boundary shells ✔ Shared boundaries aren’t broken or duplicated ✔ Lines that didn’t intersect before still don’t intersect after ✔ Connectivity is not accidentally removed or added
This is stronger than just controlling point deviation with epsilon. Standard RDP only guarantees geometric proximity, not topological validity.
How Topology-Preserving RDP Works
Most topology-preserving algorithms extend RDP with additional checks:
1. Self-Intersection Checks
Before removing a point, test whether the new segment would intersect any existing segments in the polyline.
1 2 3 4
Original: A─B─C─D─E └───┘ Remove C: A───┬─D─E ← Does A-D intersect B-C? If yes, keep C. B
2. Constraint Propagation
When simplifying adjacent features (e.g., shared polygon borders), ensure both sides simplify consistently to maintain adjacency.
3. Global Validity Guarantees
After simplification, verify the output is a valid geometry (e.g., closed polygons without crossing boundaries).
Implementation in RhinoCommon
Let’s implement a topology-preserving RDP algorithm for polylines in C#/RhinoCommon.
using Rhino.Geometry; using System; using System.Collections.Generic; using System.Linq;
publicstaticclassPolylineSimplification { ///<summary> /// Standard Douglas-Peucker simplification ///</summary> publicstaticList<Point3d> SimplifyRDP(List<Point3d> points, double epsilon) { if (points.Count < 3) returnnew List<Point3d>(points);
// Find the point with maximum distance from line segment double maxDist = 0; int maxIndex = 0; Point3d start = points[0]; Point3d end = points[points.Count - 1]; Line baseLine = new Line(start, end);
for (int i = 1; i < points.Count - 1; i++) { double dist = baseLine.DistanceTo(points[i], false); if (dist > maxDist) { maxDist = dist; maxIndex = i; } }
// If max distance is greater than epsilon, recursively simplify if (maxDist > epsilon) { // Recursive call for left and right segments List<Point3d> left = SimplifyRDP(points.GetRange(0, maxIndex + 1), epsilon); List<Point3d> right = SimplifyRDP(points.GetRange(maxIndex, points.Count - maxIndex), epsilon);
// Combine results (remove duplicate middle point) left.RemoveAt(left.Count - 1); left.AddRange(right); return left; } else { // All points can be removed except endpoints returnnew List<Point3d> { start, end }; } } }
privatestatic List<Point3d> SimplifyRecursive(List<Point3d> points, double epsilon, int startIdx, int endIdx) { if (endIdx - startIdx < 2) returnnew List<Point3d> { points[startIdx], points[endIdx] };
// Find point with maximum distance double maxDist = 0; int maxIndex = startIdx; Point3d start = points[startIdx]; Point3d end = points[endIdx]; Line baseLine = new Line(start, end);
for (int i = startIdx + 1; i < endIdx; i++) { double dist = baseLine.DistanceTo(points[i], false); if (dist > maxDist) { maxDist = dist; maxIndex = i; } }
// Check if we can safely simplify (remove all intermediate points) if (maxDist <= epsilon) { // TOPOLOGY CHECK: Would creating segment start->end cause self-intersection? Line newSegment = new Line(start, end); if (WouldCauseSelfIntersection(points, startIdx, endIdx, newSegment)) { // Cannot simplify - keep the furthest point and recurse List<Point3d> left = SimplifyRecursive(points, epsilon, startIdx, maxIndex); List<Point3d> right = SimplifyRecursive(points, epsilon, maxIndex, endIdx); left.RemoveAt(left.Count - 1); left.AddRange(right); return left; } else { // Safe to simplify returnnew List<Point3d> { start, end }; } } else { // Recursively simplify both sides List<Point3d> left = SimplifyRecursive(points, epsilon, startIdx, maxIndex); List<Point3d> right = SimplifyRecursive(points, epsilon, maxIndex, endIdx); left.RemoveAt(left.Count - 1); left.AddRange(right); return left; } }
///<summary> /// Check if a new segment would cause self-intersection with the polyline ///</summary> privatestaticboolWouldCauseSelfIntersection(List<Point3d> points, int startIdx, int endIdx, Line newSegment) { // Check all existing segments in the polyline for (int i = 0; i < points.Count - 1; i++) { // Skip adjacent segments (they naturally share endpoints) if (i == startIdx - 1 || i == startIdx || i == endIdx - 1 || i == endIdx) continue;
// Skip if we're checking the segment being replaced if (i >= startIdx && i < endIdx) continue;
Line existingSegment = new Line(points[i], points[i + 1]); // Check for intersection double a, b; if (Rhino.Geometry.Intersect.Intersection.LineLine( newSegment, existingSegment, out a, out b, 0.001, false)) { // Check if intersection is at interior of both segments (not just endpoints) if (a > 0.001 && a < 0.999 && b > 0.001 && b < 0.999) { returntrue; // Self-intersection detected } } } returnfalse; // No self-intersection }
///<summary> /// Simplify a polyline curve with topology preservation ///</summary> publicstatic Polyline SimplifyPolylineCurve(Polyline polyline, double epsilon) { bool isClosed = polyline.IsClosed; List<Point3d> points = polyline.ToList(); // For closed polylines, temporarily open them if (isClosed && points.Count > 0) { points.RemoveAt(points.Count - 1); // Remove duplicate closing point } List<Point3d> simplified = SimplifyPreserveTopology(points, epsilon); // Restore closed state if needed if (isClosed && simplified.Count > 0) { simplified.Add(simplified[0]); // Re-close the polyline } returnnew Polyline(simplified); } }
Handling Closed Polylines
For closed polylines (polygons), we need additional care:
publicstaticclassPolygonSimplification { ///<summary> /// Simplify a closed polygon with topology preservation ///</summary> publicstatic Polyline SimplifyPolygon(Polyline polygon, double epsilon) { if (!polygon.IsClosed || polygon.Count < 4) return polygon;
List<Point3d> points = polygon.ToList(); points.RemoveAt(points.Count - 1); // Remove duplicate closing point // Find the point furthest from centroid as a stable anchor Point3d centroid = AveragePoint(points); int anchorIdx = FindFurthestPoint(points, centroid); // Rotate list so anchor is first List<Point3d> rotated = RotateList(points, anchorIdx); // Simplify as open polyline List<Point3d> simplified = TopologyPreservingSimplification .SimplifyPreserveTopology(rotated, epsilon); // Check for self-intersections in the final closed polygon simplified.Add(simplified[0]); // Close it if (HasSelfIntersection(simplified)) { // Fall back to original if simplification created invalid geometry return polygon; } returnnew Polyline(simplified); }
privatestatic Point3d AveragePoint(List<Point3d> points) { double x = 0, y = 0, z = 0; foreach (var pt in points) { x += pt.X; y += pt.Y; z += pt.Z; } int count = points.Count; returnnew Point3d(x / count, y / count, z / count); }
privatestaticintFindFurthestPoint(List<Point3d> points, Point3d reference) { double maxDist = 0; int maxIdx = 0; for (int i = 0; i < points.Count; i++) { double dist = reference.DistanceTo(points[i]); if (dist > maxDist) { maxDist = dist; maxIdx = i; } } return maxIdx; }
privatestatic List<Point3d> RotateList(List<Point3d> list, int startIdx) { var result = new List<Point3d>(); for (int i = 0; i < list.Count; i++) { result.Add(list[(startIdx + i) % list.Count]); } return result; }
privatestaticboolHasSelfIntersection(List<Point3d> points) { for (int i = 0; i < points.Count - 1; i++) { Line seg1 = new Line(points[i], points[i + 1]); for (int j = i + 2; j < points.Count - 1; j++) { // Skip adjacent segments if (j == i + 1 || (i == 0 && j == points.Count - 2)) continue; Line seg2 = new Line(points[j], points[j + 1]); double a, b; if (Rhino.Geometry.Intersect.Intersection.LineLine( seg1, seg2, out a, out b, 0.001, false)) { if (a > 0.001 && a < 0.999 && b > 0.001 && b < 0.999) returntrue; } } } returnfalse; } }
GEOS Library: C++ library used by PostGIS for geometric operations
Visvalingam-Whyatt: Alternative algorithm that can also be made topology-aware (see Visvalingam-Whyatt post)
Conclusion
While standard RDP is fast and simple, topology-preserving RDP is essential when geometric validity matters. By adding self-intersection checks, we ensure that simplified geometries remain valid and maintain their topological relationships.
The performance cost is worth it for applications where invalid geometry could cause downstream failures — such as spatial databases, GIS analysis, or manufacturing CAD models.
Key takeaway: Always consider whether your simplification needs to preserve topology. If yes, implement intersection checks. If performance is critical, invest in spatial indexing structures.