From ced5a26f2130e8740e7372a9befe41d32a8cb052 Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Fri, 27 Mar 2026 17:08:17 +1100 Subject: [PATCH 01/32] Add Sutherland-Hodgman algorithm documentation Detailed description of the algorithm for clipping polygons against axis-aligned rectangles, in preparation for a pure Go ClipByRect implementation. --- docs/sutherland_hodgman.md | 500 +++++++++++++++++++++++++++++++++++++ 1 file changed, 500 insertions(+) create mode 100644 docs/sutherland_hodgman.md diff --git a/docs/sutherland_hodgman.md b/docs/sutherland_hodgman.md new file mode 100644 index 00000000..4478420c --- /dev/null +++ b/docs/sutherland_hodgman.md @@ -0,0 +1,500 @@ +# Sutherland-Hodgman Polygon Clipping Algorithm + +## Motivation + +ClipByRect computes the intersection of a geometry with an axis-aligned +rectangle. While this can be computed using a general-purpose overlay algorithm, +a dedicated rectangle clipper is significantly faster because it exploits the +simple structure of the clipping region. + +The Sutherland-Hodgman algorithm is the classic approach. It clips a polygon +against a convex clipping region by decomposing the problem into a sequence of +simpler clips, one for each edge of the clipping region. For an axis-aligned +rectangle, this means four passes. + +## Core Idea + +A convex polygon can be described as the intersection of half-planes. An +axis-aligned rectangle is the intersection of four half-planes: + +``` +left: x >= xmin +right: x <= xmax +bottom: y >= ymin +top: y <= ymax +``` + +Clipping a polygon against the rectangle is equivalent to clipping it against +each of these four half-planes in sequence. The output of each pass becomes the +input to the next. + +``` +input polygon + | + v +[clip to left edge] + | + v +[clip to right edge] + | + v +[clip to bottom edge] + | + v +[clip to top edge] + | + v +output polygon +``` + +The order of the four passes does not matter. The result is the same regardless +of which edge is processed first. + +## Clipping Against a Single Half-Plane + +Each pass walks the edges of the input polygon. An edge is a segment from +vertex A to the next vertex B (wrapping from the last vertex back to the +first). + +For each edge A to B, there are exactly four cases depending on whether A and B +are inside or outside the half-plane: + +### Case 1: Both Inside + +``` + | + A--->B A is inside, B is inside. + | + | Emit: B +``` + +Both vertices are on the retained side. The entire edge survives. Emit B. (A +was already emitted by the previous edge, or will be handled as the first +vertex.) + +### Case 2: Inside to Outside + +``` + | + A--->+--B A is inside, B is outside. + | + | Emit: intersection point I +``` + +The edge crosses from the retained side to the clipped side. Emit the +intersection point where the segment crosses the clipping edge. B is discarded. + +### Case 3: Outside to Inside + +``` + | + B<---+--A A is outside, B is inside. + | + | Emit: intersection point I, then B +``` + +The edge crosses from the clipped side to the retained side. Emit the +intersection point, then emit B. Two vertices are produced for this edge +because the output polygon needs to include both the point where it re-enters +the clipping boundary and the destination vertex. + +### Case 4: Both Outside + +``` + | + | A-->B A is outside, B is outside. + | + | Emit: nothing +``` + +The entire edge is on the clipped side. It contributes nothing to the output. + +### Summary Table + +| A | B | Emitted vertices | +| --- | --- | --- | +| inside | inside | B | +| inside | outside | I | +| outside | inside | I, B | +| outside | outside | (none) | + +Where I is the intersection of segment AB with the clipping edge. + +## Inside Test + +For each of the four edges of the rectangle, the inside test is a single +comparison: + +| Clipping edge | Condition for "inside" | +| --- | --- | +| left (x = xmin) | point.x >= xmin | +| right (x = xmax) | point.x <= xmax | +| bottom (y = ymin) | point.y >= ymin | +| top (y = ymax) | point.y <= ymax | + +## Computing Intersections + +Because the clipping edges are axis-aligned, intersection computation reduces +to simple linear interpolation. + +### Intersection With a Vertical Edge (x = k) + +Given segment from A to B and a vertical clipping edge at x = k: + +``` +t = (k - A.x) / (B.x - A.x) +I = (k, A.y + t * (B.y - A.y)) +``` + +The parameter t is the fractional distance along the segment from A to B where +it crosses the edge. Since we only compute this when A and B are on opposite +sides of the edge, B.x - A.x is guaranteed to be non-zero. + +### Intersection With a Horizontal Edge (y = k) + +Given segment from A to B and a horizontal clipping edge at y = k: + +``` +t = (k - A.y) / (B.y - A.y) +I = (A.x + t * (B.x - A.x), k) +``` + +## Worked Example + +Clip the triangle with vertices P0(0, 1), P1(4, 3), P2(2, -1) against a +rectangle with xmin=1, xmax=3, ymin=0, ymax=2. + +### Pass 1: Clip to Left Edge (x >= 1) + +Process each edge of the triangle: + +**Edge P0(0,1) to P1(4,3):** P0 is outside (x=0 < 1), P1 is inside (x=4 >= 1). +Case 3: emit intersection, then P1. + +``` +t = (1 - 0) / (4 - 0) = 0.25 +I_a = (1, 1 + 0.25 * (3 - 1)) = (1, 1.5) +``` + +Emit: (1, 1.5), (4, 3). + +**Edge P1(4,3) to P2(2,-1):** Both inside (x=4 >= 1, x=2 >= 1). Case 1: emit +P2. + +Emit: (2, -1). + +**Edge P2(2,-1) to P0(0,1):** P2 is inside (x=2 >= 1), P0 is outside (x=0 < +1). Case 2: emit intersection. + +``` +t = (1 - 2) / (0 - 2) = 0.5 +I_b = (1, -1 + 0.5 * (1 - (-1))) = (1, 0) +``` + +Emit: (1, 0). + +**Result after pass 1:** (1, 1.5), (4, 3), (2, -1), (1, 0). + +### Pass 2: Clip to Right Edge (x <= 3) + +Input: (1, 1.5), (4, 3), (2, -1), (1, 0). + +**Edge (1, 1.5) to (4, 3):** Inside to outside. Emit intersection. + +``` +t = (3 - 1) / (4 - 1) = 2/3 +I = (3, 1.5 + 2/3 * (3 - 1.5)) = (3, 2.5) +``` + +Emit: (3, 2.5). + +**Edge (4, 3) to (2, -1):** Outside to inside. Emit intersection, then (2, -1). + +``` +t = (3 - 4) / (2 - 4) = 0.5 +I = (3, 3 + 0.5 * (-1 - 3)) = (3, 1) +``` + +Emit: (3, 1), (2, -1). + +**Edge (2, -1) to (1, 0):** Both inside. Emit (1, 0). + +Emit: (1, 0). + +**Edge (1, 0) to (1, 1.5):** Both inside. Emit (1, 1.5). + +Emit: (1, 1.5). + +**Result after pass 2:** (3, 2.5), (3, 1), (2, -1), (1, 0), (1, 1.5). + +### Pass 3: Clip to Bottom Edge (y >= 0) + +Input: (3, 2.5), (3, 1), (2, -1), (1, 0), (1, 1.5). + +**Edge (3, 2.5) to (3, 1):** Both inside. Emit (3, 1). + +**Edge (3, 1) to (2, -1):** Inside to outside. Emit intersection. + +``` +t = (0 - 1) / (-1 - 1) = 0.5 +I = (3 + 0.5 * (2 - 3), 0) = (2.5, 0) +``` + +Emit: (2.5, 0). + +**Edge (2, -1) to (1, 0):** Outside to inside. Emit intersection, then (1, 0). + +``` +t = (0 - (-1)) / (0 - (-1)) = 1 +I = (2 + 1 * (1 - 2), 0) = (1, 0) +``` + +Emit: (1, 0), (1, 0). + +Note: The intersection coincides with (1, 0). This produces a duplicate vertex, +which is harmless and can be cleaned up afterward. + +**Edge (1, 0) to (1, 1.5):** Both inside. Emit (1, 1.5). + +Emit: (1, 1.5). + +**Edge (1, 1.5) to (3, 2.5):** Both inside. Emit (3, 2.5). + +Emit: (3, 2.5). + +**Result after pass 3:** (3, 1), (2.5, 0), (1, 0), (1, 0), (1, 1.5), (3, 2.5). + +### Pass 4: Clip to Top Edge (y <= 2) + +Input: (3, 1), (2.5, 0), (1, 0), (1, 0), (1, 1.5), (3, 2.5). + +**Edge (3, 1) to (2.5, 0):** Both inside. Emit (2.5, 0). + +**Edge (2.5, 0) to (1, 0):** Both inside. Emit (1, 0). + +**Edge (1, 0) to (1, 0):** Both inside. Emit (1, 0). + +**Edge (1, 0) to (1, 1.5):** Both inside. Emit (1, 1.5). + +**Edge (1, 1.5) to (3, 2.5):** Inside to outside. Emit intersection. + +``` +t = (2 - 1.5) / (2.5 - 1.5) = 0.5 +I = (1 + 0.5 * (3 - 1), 2) = (2, 2) +``` + +Emit: (2, 2). + +**Edge (3, 2.5) to (3, 1):** Outside to inside. Emit intersection, then (3, 1). + +``` +t = (2 - 2.5) / (1 - 2.5) = 1/3 +I = (3 + 1/3 * (3 - 3), 2) = (3, 2) +``` + +Emit: (3, 2), (3, 1). + +**Final result:** (2.5, 0), (1, 0), (1, 0), (1, 1.5), (2, 2), (3, 2), (3, 1). + +After removing the duplicate vertex at (1, 0): + +**(2.5, 0), (1, 0), (1, 1.5), (2, 2), (3, 2), (3, 1).** + +This is the triangle clipped to the rectangle. + +## Pseudocode + +``` +function clipPolygonToRect(vertices, xmin, ymin, xmax, ymax): + output = vertices + output = clipToEdge(output, LEFT, xmin) + output = clipToEdge(output, RIGHT, xmax) + output = clipToEdge(output, BOTTOM, ymin) + output = clipToEdge(output, TOP, ymax) + return output + +function clipToEdge(vertices, edge, value): + if len(vertices) == 0: + return [] + + output = [] + A = vertices[last] + + for each B in vertices: + aInside = isInside(A, edge, value) + bInside = isInside(B, edge, value) + + if aInside and bInside: + append B to output + else if aInside and not bInside: + append intersection(A, B, edge, value) to output + else if not aInside and bInside: + append intersection(A, B, edge, value) to output + append B to output + // else: both outside, emit nothing + + A = B + + return output + +function isInside(point, edge, value): + switch edge: + LEFT: return point.x >= value + RIGHT: return point.x <= value + BOTTOM: return point.y >= value + TOP: return point.y <= value + +function intersection(A, B, edge, value): + switch edge: + LEFT, RIGHT: + t = (value - A.x) / (B.x - A.x) + return (value, A.y + t * (B.y - A.y)) + BOTTOM, TOP: + t = (value - A.y) / (B.y - A.y) + return (A.x + t * (B.x - A.x), value) +``` + +## Complexity + +- **Time:** O(N) per clipping edge, where N is the number of vertices. With 4 + edges, the total is O(4N) = O(N). In the worst case, each pass can at most + double the vertex count (every edge crosses the clipping line), so the + intermediate vertex lists remain bounded. + +- **Space:** O(N) for the output vertex list. The algorithm can be implemented + with two buffers that are swapped between passes. + +## Properties + +- **Preserves winding order.** If the input polygon has counter-clockwise + winding, the output will too. + +- **Works for concave polygons.** Unlike some clipping algorithms, + Sutherland-Hodgman handles concave (non-convex) input polygons. However, when + a concave polygon is clipped, the result may contain coincident (overlapping) + edges along the clipping boundary. These are topologically valid but may need + post-processing depending on the application. + +- **May produce degenerate edges.** When a polygon vertex lies exactly on the + clipping edge, the intersection point coincides with the vertex, producing + zero-length edges or duplicate vertices. A post-processing step to remove + duplicate consecutive vertices handles this. + +## Extension to LineStrings + +Sutherland-Hodgman is designed for closed polygonal rings. For open LineStrings, +a related but simpler approach works: clip each segment independently against +the rectangle (using Cohen-Sutherland or Liang-Barsky line clipping), then +merge consecutive surviving segments into output LineStrings. Segments that are +entirely outside produce gaps, potentially splitting one LineString into +multiple. + +## Extension to Other Geometry Types + +- **Point:** Test whether the point lies within the rectangle. Emit it or + discard it. + +- **MultiPoint:** Test each point individually. + +- **LineString:** Clip each segment against the rectangle. Consecutive surviving + segments form output LineStrings. A single input LineString may produce + multiple output LineStrings (yielding a MultiLineString). + +- **Polygon (no holes):** Clip the exterior ring using Sutherland-Hodgman. If + the result is empty, the polygon is entirely outside the rectangle. + +- **Polygon (with holes):** Clip the exterior ring and each hole ring + independently. Discard hole rings that become empty. Hole rings that are + entirely inside the rectangle survive unchanged. This is explained in + detail below. + +- **Multi-geometries:** Process each component independently and collect the + results. + +- **GeometryCollection:** Recurse into each element. + +## Handling Polygons With Holes + +Polygons with holes require care because holes can interact with the clipping +rectangle in ways that change the topology. + +### Case 1: Hole Entirely Inside the Rectangle + +The hole survives unchanged. No special handling needed. + +``` ++--rect-----------+ +| | +| +-exterior-+ | +| | | | +| | +-hole+ | | +| | | | | | +| | +-----+ | | +| | | | +| +----------+ | +| | ++------------------+ +``` + +### Case 2: Hole Entirely Outside the Rectangle + +The hole is irrelevant to the clipped result. Discard it. + +### Case 3: Hole Crosses the Rectangle Boundary + +This is the complex case. When a hole's ring crosses the clipping boundary, +parts of the clipping boundary become part of the polygon's boundary. + +``` ++-rect-----+ +| | +| +--exterior--+ +| | | | +| | +--hole---+--+ +| | | | | +| | +----+-------+ +| | | | +| +-------+----+ +| | ++-----------+ +``` + +In this situation, clipping the exterior ring and hole ring independently and +then naively combining them will not produce a valid polygon. The clipped hole +shares edges along the rectangle boundary with the clipped exterior ring, +effectively splitting the polygon. + +There are two strategies for handling this: + +**Strategy A: Clip rings then resolve topology.** Clip each ring +independently, then use a topology-aware algorithm to merge the clipped rings +into a valid polygon or multipolygon. This is essentially a simplified overlay +operation restricted to the rectangle boundary. + +**Strategy B: Fallback to general intersection.** Detect when holes cross the +rectangle boundary and fall back to a general-purpose intersection algorithm +for those cases. This avoids implementing the topology resolution but sacrifices +the performance advantage for these inputs. + +### Topology Resolution for Strategy A + +When holes cross the rectangle boundary, the clipped result may need to be a +MultiPolygon rather than a single Polygon. For example, a U-shaped polygon +clipped by a rectangle across the opening can produce two separate polygons. + +The resolution algorithm: + +1. Clip the exterior ring and all hole rings independently. +2. Identify clipped ring segments that lie along the rectangle boundary. +3. Pair up boundary segments: where the exterior ring enters the boundary, a + hole ring (or the same ring) must exit, and vice versa. +4. Walk the combined ring structure, tracing the outline of each resulting + polygon. +5. Determine which resulting rings are exteriors and which are holes based on + winding order (or signed area). +6. Group holes with their enclosing exterior rings. + +This is the most algorithmically complex part of implementing ClipByRect. It +resembles the edge-tracing phase of an overlay algorithm, but is constrained to +only four possible boundary edges, which simplifies the data structures +involved. From 6374c8c60aab2f8982be113d611fb9ba7bedd856 Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Tue, 31 Mar 2026 17:19:00 +1100 Subject: [PATCH 02/32] Add exhaustive ClipByRect unit test case inventory Document test cases for all geometry types (Point, MultiPoint, LineString, MultiLineString, Polygon, MultiPolygon, GeometryCollection) covering spatial relationships, boundary interactions, degenerate rectangles, numerical edge cases, and coordinate dimension preservation. --- docs/sutherland_hodgman.md | 310 +++++++++++++++++++++++++++++++++++-- 1 file changed, 298 insertions(+), 12 deletions(-) diff --git a/docs/sutherland_hodgman.md b/docs/sutherland_hodgman.md index 4478420c..b4bc8435 100644 --- a/docs/sutherland_hodgman.md +++ b/docs/sutherland_hodgman.md @@ -111,12 +111,12 @@ The entire edge is on the clipped side. It contributes nothing to the output. ### Summary Table -| A | B | Emitted vertices | -| --- | --- | --- | -| inside | inside | B | -| inside | outside | I | -| outside | inside | I, B | -| outside | outside | (none) | +| A | B | Emitted vertices | +| --- | --- | --- | +| inside | inside | B | +| inside | outside | I | +| outside | inside | I, B | +| outside | outside | (none) | Where I is the intersection of segment AB with the clipping edge. @@ -125,12 +125,12 @@ Where I is the intersection of segment AB with the clipping edge. For each of the four edges of the rectangle, the inside test is a single comparison: -| Clipping edge | Condition for "inside" | -| --- | --- | -| left (x = xmin) | point.x >= xmin | -| right (x = xmax) | point.x <= xmax | -| bottom (y = ymin) | point.y >= ymin | -| top (y = ymax) | point.y <= ymax | +| Clipping edge | Condition for "inside" | +| --- | --- | +| left (x = xmin) | point.x >= xmin | +| right (x = xmax) | point.x <= xmax | +| bottom (y = ymin) | point.y >= ymin | +| top (y = ymax) | point.y <= ymax | ## Computing Intersections @@ -498,3 +498,289 @@ This is the most algorithmically complex part of implementing ClipByRect. It resembles the edge-tracing phase of an overlay algorithm, but is constrained to only four possible boundary edges, which simplifies the data structures involved. + +## Unit Test Cases + +This section enumerates unit test cases for ClipByRect. The clipping rectangle +is denoted R. Each test specifies the input geometry, the rectangle, and the +expected output. + +### Rectangle Configurations + +Before testing individual geometry types, the rectangle itself can vary: + +| # | Case | Notes | +| --- | --- | --- | +| R1 | Empty envelope | Always return empty geometry of the input type | +| R2 | Point envelope (min = max) | Degenerate: output type matches input type, only same-dimension intersections survive | +| R3 | Line envelope (zero width or zero height) | Degenerate: output type matches input type, only same-dimension intersections survive | +| R4 | Normal rectangle (positive width and height) | Standard case | + +All geometry-type tests below assume a normal rectangle unless stated otherwise. +A small set of tests should also exercise R1, R2, and R3 with representative +geometries. + +### Point + +| # | Case | Expected output | +| --- | --- | --- | +| PT1 | Empty Point | Empty Point | +| PT2 | Point strictly inside R | Same Point | +| PT3 | Point strictly outside R | Empty Point | +| PT4 | Point on left edge of R | Same Point | +| PT5 | Point on right edge of R | Same Point | +| PT6 | Point on bottom edge of R | Same Point | +| PT7 | Point on top edge of R | Same Point | +| PT8 | Point on corner of R (e.g. bottom-left) | Same Point | +| PT9 | Point on corner of R (e.g. top-right) | Same Point | + +### MultiPoint + +| # | Case | Expected output | +| --- | --- | --- | +| MP1 | Empty MultiPoint | Empty MultiPoint | +| MP2 | All points inside R | Same MultiPoint | +| MP3 | All points outside R | Empty MultiPoint | +| MP4 | Some points inside, some outside | MultiPoint with only inside points | +| MP5 | Single point inside R | Point or MultiPoint with 1 point | +| MP6 | Points on edges and corners of R | All retained | +| MP7 | Mix of inside, on-boundary, and outside | Inside and boundary points retained | +| MP8 | MultiPoint containing empty points | Empty points excluded from output | + +### LineString + +#### Spatial relationship to R + +| # | Case | Expected output | +| --- | --- | --- | +| LS1 | Empty LineString | Empty LineString | +| LS2 | Entirely inside R | Same LineString | +| LS3 | Entirely outside R | Empty LineString | +| LS4 | Entirely outside R on one side (e.g. all left of R) | Empty LineString | +| LS5 | Crosses R, entering and exiting once | LineString clipped to entry and exit points | +| LS6 | Crosses R, entering and exiting multiple times | MultiLineString of surviving segments | +| LS7 | One endpoint inside, one outside | LineString from inside endpoint to intersection | +| LS8 | One endpoint outside, one inside | LineString from intersection to inside endpoint | +| LS9 | Both endpoints outside, segment passes through R | LineString clipped to two intersection points | + +#### Boundary interactions + +| # | Case | Expected output | +| --- | --- | --- | +| LS10 | Endpoint exactly on edge of R, other inside | LineString preserved | +| LS11 | Endpoint exactly on corner of R, other inside | LineString preserved | +| LS12 | Both endpoints on boundary of R | LineString preserved | +| LS13 | LineString lies entirely along one edge of R | LineString preserved (collinear with boundary) | +| LS14 | LineString lies entirely along two adjacent edges (L-shaped along boundary) | LineString preserved | +| LS15 | Segment touches corner of R but does not enter (V-shape touching corner) | Point or empty | +| LS16 | Segment touches edge of R tangentially (parallel approach, touches, leaves) | Point or empty | + +#### Direction and shape + +| # | Case | Expected output | +| --- | --- | --- | +| LS17 | Diagonal line crossing all four edges of R | LineString with 2 intersection points | +| LS18 | Horizontal line crossing left and right edges | LineString clipped to left and right edges | +| LS19 | Vertical line crossing bottom and top edges | LineString clipped to bottom and top edges | +| LS20 | Closed LineString (ring) inside R | Same closed LineString | +| LS21 | Closed LineString (ring) partially overlapping R | MultiLineString of surviving arcs | +| LS22 | Multi-segment LineString with some segments inside, some outside | MultiLineString of surviving segments | +| LS23 | Zigzag LineString entering and exiting R many times | MultiLineString with multiple components | + +#### Corner and edge-coincident cases + +| # | Case | Expected output | +| --- | --- | --- | +| LS24 | Vertex exactly on R boundary, adjacent edges inside | LineString preserved through boundary vertex | +| LS25 | Vertex exactly on R boundary, adjacent edges outside | Point or empty (vertex touches but segments don't enter) | +| LS26 | LineString passes through two opposite corners of R | LineString clipped between corners | + +### MultiLineString + +| # | Case | Expected output | +| --- | --- | --- | +| MLS1 | Empty MultiLineString | Empty MultiLineString | +| MLS2 | All component LineStrings inside R | Same MultiLineString | +| MLS3 | All component LineStrings outside R | Empty MultiLineString | +| MLS4 | Some components inside, some outside | MultiLineString with surviving components | +| MLS5 | Single component, clipped to a single segment | LineString or MultiLineString | +| MLS6 | Multiple components, each partially clipped | MultiLineString combining all surviving fragments | +| MLS7 | One component crosses R, another is inside R | MultiLineString with clipped and unclipped parts | +| MLS8 | Component that produces multiple fragments when clipped | Fragments included in output MultiLineString | +| MLS9 | MultiLineString containing empty LineStrings | Empty components excluded | + +### Polygon (No Holes) + +#### Spatial relationship to R + +| # | Case | Expected output | +| --- | --- | --- | +| PG1 | Empty Polygon | Empty Polygon | +| PG2 | Entirely inside R | Same Polygon | +| PG3 | Entirely outside R (no overlap) | Empty Polygon | +| PG4 | R entirely inside Polygon | Polygon equal to R | +| PG5 | Partially overlapping one edge of R | Polygon clipped to R boundary | +| PG6 | Partially overlapping two adjacent edges (corner clip) | Polygon clipped at corner | +| PG7 | Partially overlapping two opposite edges (strip through R) | Polygon clipped on two sides | +| PG8 | Partially overlapping three edges | Polygon clipped on three sides | +| PG9 | Partially overlapping all four edges (R fully inside polygon, polygon extends past all sides) | Polygon equal to R | + +#### Boundary interactions + +| # | Case | Expected output | +| --- | --- | --- | +| PG10 | Polygon shares an entire edge with R | Polygon preserved along shared edge | +| PG11 | Polygon vertex exactly on R edge | Polygon with vertex on boundary | +| PG12 | Polygon vertex exactly on R corner | Polygon with vertex on corner | +| PG13 | Polygon edge collinear with R edge, polygon inside R | Polygon preserved | +| PG14 | Polygon edge collinear with R edge, polygon outside R | Empty or degenerate | +| PG15 | Polygon touches R at a single point (vertex-to-edge) | Empty or degenerate (point contact) | +| PG16 | Polygon touches R at a single corner point | Empty or degenerate (point contact) | + +#### Shape variations + +| # | Case | Expected output | +| --- | --- | --- | +| PG17 | Convex polygon clipped by R | Convex clipped polygon | +| PG18 | Concave polygon, concavity inside R | Concave clipped polygon | +| PG19 | Concave polygon, concavity facing R boundary | Clipped polygon reflecting concavity | +| PG20 | U-shaped polygon clipped across the opening | MultiPolygon (two separate pieces) | +| PG21 | Very thin sliver polygon partially inside R | Thin clipped polygon | +| PG22 | Triangle clipped to produce various vertex counts | Polygon with 3-7 vertices depending on clip | + +#### Winding order + +| # | Case | Expected output | +| --- | --- | --- | +| PG23 | Counter-clockwise exterior ring | Output preserves CCW winding | +| PG24 | Clockwise exterior ring | Output preserves CW winding | + +### Polygon (With Holes) + +#### Hole entirely inside R + +| # | Case | Expected output | +| --- | --- | --- | +| PH1 | Exterior and hole both inside R | Same Polygon with hole | +| PH2 | Exterior clipped, hole entirely inside clipped region | Polygon with hole preserved | +| PH3 | Multiple holes, all inside R | Polygon with all holes preserved | + +#### Hole entirely outside R + +| # | Case | Expected output | +| --- | --- | --- | +| PH4 | Hole entirely outside R (but inside exterior ring outside R) | Polygon without hole (hole discarded) | +| PH5 | Hole in part of exterior ring that is clipped away | Polygon without hole (hole irrelevant) | + +#### Hole crossing R boundary + +| # | Case | Expected output | +| --- | --- | --- | +| PH6 | Hole crosses one edge of R | Polygon with hole clipped to boundary | +| PH7 | Hole crosses two adjacent edges of R (corner) | Polygon/MultiPolygon depending on topology | +| PH8 | Hole crosses two opposite edges of R (splits polygon) | MultiPolygon | +| PH9 | Hole crosses all four edges of R | Complex result, possibly empty interior | +| PH10 | Hole shares edge with R boundary | Polygon with hole on boundary | + +#### Multiple holes interacting with R + +| # | Case | Expected output | +| --- | --- | --- | +| PH11 | One hole inside R, another outside R | Polygon with only inside hole | +| PH12 | One hole inside R, another crossing R boundary | Polygon or MultiPolygon depending on topology | +| PH13 | Multiple holes crossing R boundary | MultiPolygon with complex topology | +| PH14 | Two holes that merge along R boundary | MultiPolygon | + +#### Topology-changing cases + +| # | Case | Expected output | +| --- | --- | --- | +| PH15 | Hole splits polygon into two when clipped (U-shape via hole) | MultiPolygon with two components | +| PH16 | Hole splits polygon into three or more pieces | MultiPolygon with multiple components | +| PH17 | Hole causes exterior ring to become trivial | Empty or degenerate | +| PH18 | Nested holes (hole within a hole is not valid, but exterior ring of polygon inside a hole of another polygon in a MultiPolygon) | Handle gracefully | + +### MultiPolygon + +| # | Case | Expected output | +| --- | --- | --- | +| MPG1 | Empty MultiPolygon | Empty MultiPolygon | +| MPG2 | All component Polygons inside R | Same MultiPolygon | +| MPG3 | All component Polygons outside R | Empty MultiPolygon | +| MPG4 | Some components inside, some outside | MultiPolygon with surviving components | +| MPG5 | One component partially clipped, another fully inside | MultiPolygon combining both | +| MPG6 | One component becomes MultiPolygon when clipped (e.g. U-shape) | All resulting polygons in output | +| MPG7 | Multiple components, each partially clipped | MultiPolygon with all fragments | +| MPG8 | Components with holes, some holes clipped | MultiPolygon preserving relevant holes | +| MPG9 | Single component fully inside R | Polygon or MultiPolygon | +| MPG10 | MultiPolygon containing empty Polygons | Empty components excluded | + +### GeometryCollection + +| # | Case | Expected output | +| --- | --- | --- | +| GC1 | Empty GeometryCollection | Empty GeometryCollection | +| GC2 | Contains only Points, all inside R | GeometryCollection with all Points | +| GC3 | Contains only Points, all outside R | Empty GeometryCollection | +| GC4 | Contains mixed types, all inside R | GeometryCollection with all components | +| GC5 | Contains mixed types, all outside R | Empty GeometryCollection | +| GC6 | Contains mixed types, some inside, some outside | GeometryCollection with surviving components | +| GC7 | Contains Point, LineString, and Polygon | Each component clipped independently | +| GC8 | Contains nested GeometryCollection | Recursive clipping into nested collection | +| GC9 | Contains nested GeometryCollection with mixed types | Recursive clipping at all levels | +| GC10 | All child geometries are empty | Empty GeometryCollection | +| GC11 | Contains MultiPoint, MultiLineString, MultiPolygon | Each multi-type component clipped independently | +| GC12 | Deeply nested GeometryCollections (3+ levels) | Recursive clipping at all levels | + +### Degenerate Rectangle Cases + +These cases test non-standard rectangles with representative geometries from +each type. + +| # | Case | Geometry | Expected output | +| --- | --- | --- | --- | +| DR1 | Empty envelope | Point inside would-be area | Empty Point | +| DR2 | Empty envelope | LineString | Empty LineString | +| DR3 | Empty envelope | Polygon | Empty Polygon | +| DR4 | Point envelope (0x0) | Point at same location | Same Point | +| DR5 | Point envelope (0x0) | Point at different location | Empty Point | +| DR6 | Point envelope (0x0) | MultiPoint with some points at location | Clipped MultiPoint | +| DR7 | Point envelope (0x0) | MultiPoint with no points at location | Empty MultiPoint | +| DR8 | Point envelope (0x0) | LineString through that point | Empty LineString | +| DR9 | Point envelope (0x0) | Polygon containing that point | Empty Polygon | +| DR10 | Line envelope (0 width) | Point on the line | Same Point | +| DR11 | Line envelope (0 width) | Point off the line | Empty Point | +| DR12 | Line envelope (0 width) | MultiPoint with some points on the line | Clipped MultiPoint | +| DR13 | Line envelope (0 width) | MultiPoint with no points on the line | Empty MultiPoint | +| DR14 | Line envelope (0 width) | LineString crossing the line | Empty LineString | +| DR15 | Line envelope (0 width) | LineString collinear with line | Clipped LineString | +| DR16 | Line envelope (0 width) | Polygon crossing the line | Empty Polygon | +| DR17 | Line envelope (0 height) | Point on the line | Same Point | +| DR18 | Line envelope (0 height) | Point off the line | Empty Point | +| DR19 | Line envelope (0 height) | MultiPoint with some points on the line | Clipped MultiPoint | +| DR20 | Line envelope (0 height) | MultiPoint with no points on the line | Empty MultiPoint | +| DR21 | Line envelope (0 height) | LineString crossing the line | Empty LineString | +| DR22 | Line envelope (0 height) | LineString collinear with line | Clipped LineString | +| DR23 | Line envelope (0 height) | Polygon crossing the line | Empty Polygon | + +### Numerical Edge Cases + +| # | Case | Notes | +| --- | --- | --- | +| NE1 | Very large coordinates (near float64 max) | Test numerical stability | +| NE2 | Very small coordinates (near float64 epsilon) | Test precision | +| NE3 | Negative coordinates | All quadrants should work | +| NE4 | Coordinates that produce intersection parameters t very close to 0 | Near-vertex intersection | +| NE5 | Coordinates that produce intersection parameters t very close to 1 | Near-vertex intersection | +| NE6 | Polygon vertex exactly at intersection point with R edge | Degenerate intersection (duplicate vertex) | +| NE7 | Segment nearly parallel to R edge (small angle) | Intersection computation precision | +| NE8 | Zero-length segments in input (duplicate consecutive vertices) | Should not cause division by zero | + +### Coordinate Dimension Preservation + +| # | Case | Notes | +| --- | --- | --- | +| CD1 | XY geometry clipped | Output has XY coordinates | +| CD2 | XYZ geometry clipped | Output has XYZ coordinates, Z interpolated at intersections | +| CD3 | XYM geometry clipped | Output has XYM coordinates, M interpolated at intersections | +| CD4 | XYZM geometry clipped | Output has XYZM coordinates, Z and M interpolated | From 182d374f253dc53e349ff7ca262ae17b8ca29bc1 Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Wed, 1 Apr 2026 16:49:18 +1100 Subject: [PATCH 03/32] sutherland_hodgman.md: tweak test cases --- docs/sutherland_hodgman.md | 126 ++++++++++++++++++------------------- 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/docs/sutherland_hodgman.md b/docs/sutherland_hodgman.md index b4bc8435..fc350457 100644 --- a/docs/sutherland_hodgman.md +++ b/docs/sutherland_hodgman.md @@ -112,7 +112,7 @@ The entire edge is on the clipped side. It contributes nothing to the output. ### Summary Table | A | B | Emitted vertices | -| --- | --- | --- | +| ------- | ------- | ---------------- | | inside | inside | B | | inside | outside | I | | outside | inside | I, B | @@ -126,7 +126,7 @@ For each of the four edges of the rectangle, the inside test is a single comparison: | Clipping edge | Condition for "inside" | -| --- | --- | +| ----------------- | ---------------------- | | left (x = xmin) | point.x >= xmin | | right (x = xmax) | point.x <= xmax | | bottom (y = ymin) | point.y >= ymin | @@ -510,7 +510,7 @@ expected output. Before testing individual geometry types, the rectangle itself can vary: | # | Case | Notes | -| --- | --- | --- | +| --- | -------------------------------------------- | ------------------------------------------------------------------------------------- | | R1 | Empty envelope | Always return empty geometry of the input type | | R2 | Point envelope (min = max) | Degenerate: output type matches input type, only same-dimension intersections survive | | R3 | Line envelope (zero width or zero height) | Degenerate: output type matches input type, only same-dimension intersections survive | @@ -523,7 +523,7 @@ geometries. ### Point | # | Case | Expected output | -| --- | --- | --- | +| --- | --------------------------------------- | --------------- | | PT1 | Empty Point | Empty Point | | PT2 | Point strictly inside R | Same Point | | PT3 | Point strictly outside R | Empty Point | @@ -537,12 +537,12 @@ geometries. ### MultiPoint | # | Case | Expected output | -| --- | --- | --- | +| --- | --------------------------------------- | ----------------------------------- | | MP1 | Empty MultiPoint | Empty MultiPoint | | MP2 | All points inside R | Same MultiPoint | | MP3 | All points outside R | Empty MultiPoint | | MP4 | Some points inside, some outside | MultiPoint with only inside points | -| MP5 | Single point inside R | Point or MultiPoint with 1 point | +| MP5 | Single point inside R | MultiPoint with 1 point | | MP6 | Points on edges and corners of R | All retained | | MP7 | Mix of inside, on-boundary, and outside | Inside and boundary points retained | | MP8 | MultiPoint containing empty points | Empty points excluded from output | @@ -552,7 +552,7 @@ geometries. #### Spatial relationship to R | # | Case | Expected output | -| --- | --- | --- | +| --- | --------------------------------------------------- | ----------------------------------------------- | | LS1 | Empty LineString | Empty LineString | | LS2 | Entirely inside R | Same LineString | | LS3 | Entirely outside R | Empty LineString | @@ -566,44 +566,44 @@ geometries. #### Boundary interactions | # | Case | Expected output | -| --- | --- | --- | +| ---- | --------------------------------------------------------------------------- | ---------------------------------------------- | | LS10 | Endpoint exactly on edge of R, other inside | LineString preserved | | LS11 | Endpoint exactly on corner of R, other inside | LineString preserved | | LS12 | Both endpoints on boundary of R | LineString preserved | | LS13 | LineString lies entirely along one edge of R | LineString preserved (collinear with boundary) | | LS14 | LineString lies entirely along two adjacent edges (L-shaped along boundary) | LineString preserved | -| LS15 | Segment touches corner of R but does not enter (V-shape touching corner) | Point or empty | -| LS16 | Segment touches edge of R tangentially (parallel approach, touches, leaves) | Point or empty | +| LS15 | Segment touches corner of R but does not enter (V-shape touching corner) | Empty LineString | +| LS16 | Segment touches edge of R tangentially (parallel approach, touches, leaves) | Empty LineString | #### Direction and shape | # | Case | Expected output | -| --- | --- | --- | +| ---- | ---------------------------------------------------------------- | ------------------------------------------ | | LS17 | Diagonal line crossing all four edges of R | LineString with 2 intersection points | | LS18 | Horizontal line crossing left and right edges | LineString clipped to left and right edges | | LS19 | Vertical line crossing bottom and top edges | LineString clipped to bottom and top edges | | LS20 | Closed LineString (ring) inside R | Same closed LineString | -| LS21 | Closed LineString (ring) partially overlapping R | MultiLineString of surviving arcs | +| LS21 | Closed LineString (ring) partially overlapping R | LineString of surviving arcs | | LS22 | Multi-segment LineString with some segments inside, some outside | MultiLineString of surviving segments | | LS23 | Zigzag LineString entering and exiting R many times | MultiLineString with multiple components | #### Corner and edge-coincident cases -| # | Case | Expected output | -| --- | --- | --- | -| LS24 | Vertex exactly on R boundary, adjacent edges inside | LineString preserved through boundary vertex | -| LS25 | Vertex exactly on R boundary, adjacent edges outside | Point or empty (vertex touches but segments don't enter) | -| LS26 | LineString passes through two opposite corners of R | LineString clipped between corners | +| # | Case | Expected output | +| ---- | ---------------------------------------------------- | -------------------------------------------- | +| LS24 | Vertex exactly on R boundary, adjacent edges inside | LineString preserved through boundary vertex | +| LS25 | Vertex exactly on R boundary, adjacent edges outside | Empty LineString | +| LS26 | LineString passes through two opposite corners of R | LineString clipped between corners | ### MultiLineString | # | Case | Expected output | -| --- | --- | --- | +| ---- | ------------------------------------------------------- | ------------------------------------------------- | | MLS1 | Empty MultiLineString | Empty MultiLineString | | MLS2 | All component LineStrings inside R | Same MultiLineString | | MLS3 | All component LineStrings outside R | Empty MultiLineString | | MLS4 | Some components inside, some outside | MultiLineString with surviving components | -| MLS5 | Single component, clipped to a single segment | LineString or MultiLineString | +| MLS5 | Single component, clipped to a single segment | MultiLineString with 1 component | | MLS6 | Multiple components, each partially clipped | MultiLineString combining all surviving fragments | | MLS7 | One component crosses R, another is inside R | MultiLineString with clipped and unclipped parts | | MLS8 | Component that produces multiple fragments when clipped | Fragments included in output MultiLineString | @@ -614,7 +614,7 @@ geometries. #### Spatial relationship to R | # | Case | Expected output | -| --- | --- | --- | +| --- | --------------------------------------------------------------------------------------------- | ------------------------------ | | PG1 | Empty Polygon | Empty Polygon | | PG2 | Entirely inside R | Same Polygon | | PG3 | Entirely outside R (no overlap) | Empty Polygon | @@ -628,19 +628,19 @@ geometries. #### Boundary interactions | # | Case | Expected output | -| --- | --- | --- | +| ---- | ----------------------------------------------------- | ----------------------------------- | | PG10 | Polygon shares an entire edge with R | Polygon preserved along shared edge | | PG11 | Polygon vertex exactly on R edge | Polygon with vertex on boundary | | PG12 | Polygon vertex exactly on R corner | Polygon with vertex on corner | | PG13 | Polygon edge collinear with R edge, polygon inside R | Polygon preserved | -| PG14 | Polygon edge collinear with R edge, polygon outside R | Empty or degenerate | -| PG15 | Polygon touches R at a single point (vertex-to-edge) | Empty or degenerate (point contact) | -| PG16 | Polygon touches R at a single corner point | Empty or degenerate (point contact) | +| PG14 | Polygon edge collinear with R edge, polygon outside R | Empty Polygon | +| PG15 | Polygon touches R at a single point (vertex-to-edge) | Empty Polygon | +| PG16 | Polygon touches R at a single corner point | Empty Polygon | #### Shape variations | # | Case | Expected output | -| --- | --- | --- | +| ---- | ------------------------------------------------- | ------------------------------------------- | | PG17 | Convex polygon clipped by R | Convex clipped polygon | | PG18 | Concave polygon, concavity inside R | Concave clipped polygon | | PG19 | Concave polygon, concavity facing R boundary | Clipped polygon reflecting concavity | @@ -651,7 +651,7 @@ geometries. #### Winding order | # | Case | Expected output | -| --- | --- | --- | +| ---- | ------------------------------- | ---------------------------- | | PG23 | Counter-clockwise exterior ring | Output preserves CCW winding | | PG24 | Clockwise exterior ring | Output preserves CW winding | @@ -660,7 +660,7 @@ geometries. #### Hole entirely inside R | # | Case | Expected output | -| --- | --- | --- | +| --- | ----------------------------------------------------- | -------------------------------- | | PH1 | Exterior and hole both inside R | Same Polygon with hole | | PH2 | Exterior clipped, hole entirely inside clipped region | Polygon with hole preserved | | PH3 | Multiple holes, all inside R | Polygon with all holes preserved | @@ -668,57 +668,57 @@ geometries. #### Hole entirely outside R | # | Case | Expected output | -| --- | --- | --- | +| --- | ------------------------------------------------------------ | -------------------------------------- | | PH4 | Hole entirely outside R (but inside exterior ring outside R) | Polygon without hole (hole discarded) | | PH5 | Hole in part of exterior ring that is clipped away | Polygon without hole (hole irrelevant) | #### Hole crossing R boundary -| # | Case | Expected output | -| --- | --- | --- | -| PH6 | Hole crosses one edge of R | Polygon with hole clipped to boundary | -| PH7 | Hole crosses two adjacent edges of R (corner) | Polygon/MultiPolygon depending on topology | -| PH8 | Hole crosses two opposite edges of R (splits polygon) | MultiPolygon | -| PH9 | Hole crosses all four edges of R | Complex result, possibly empty interior | -| PH10 | Hole shares edge with R boundary | Polygon with hole on boundary | +| # | Case | Expected output | +| ---- | ----------------------------------------------------- | ------------------------------------- | +| PH6 | Hole crosses one edge of R | Polygon with hole clipped to boundary | +| PH7 | Hole crosses two adjacent edges of R (corner) | Polygon or MultiPolygon | +| PH8 | Hole crosses two opposite edges of R (splits polygon) | MultiPolygon | +| PH9 | Hole crosses all four edges of R | MultiPolygon, possibly empty | +| PH10 | Hole shares edge with R boundary | Polygon with hole on boundary | #### Multiple holes interacting with R -| # | Case | Expected output | -| --- | --- | --- | -| PH11 | One hole inside R, another outside R | Polygon with only inside hole | -| PH12 | One hole inside R, another crossing R boundary | Polygon or MultiPolygon depending on topology | -| PH13 | Multiple holes crossing R boundary | MultiPolygon with complex topology | -| PH14 | Two holes that merge along R boundary | MultiPolygon | +| # | Case | Expected output | +| ---- | ---------------------------------------------- | ----------------------------- | +| PH11 | One hole inside R, another outside R | Polygon with only inside hole | +| PH12 | One hole inside R, another crossing R boundary | Polygon or MultiPolygon | +| PH13 | Multiple holes crossing R boundary | MultiPolygon | +| PH14 | Two holes that merge along R boundary | MultiPolygon | #### Topology-changing cases -| # | Case | Expected output | -| --- | --- | --- | -| PH15 | Hole splits polygon into two when clipped (U-shape via hole) | MultiPolygon with two components | -| PH16 | Hole splits polygon into three or more pieces | MultiPolygon with multiple components | -| PH17 | Hole causes exterior ring to become trivial | Empty or degenerate | -| PH18 | Nested holes (hole within a hole is not valid, but exterior ring of polygon inside a hole of another polygon in a MultiPolygon) | Handle gracefully | +| # | Case | Expected output | +| ---- | ------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | +| PH15 | Hole splits polygon into two when clipped (U-shape via hole) | MultiPolygon with two components | +| PH16 | Hole splits polygon into three or more pieces | MultiPolygon | +| PH17 | Hole causes exterior ring to become trivial | Empty Polygon | +| PH18 | Nested holes (hole within a hole is not valid, but exterior ring of polygon inside a hole of another polygon in a MultiPolygon) | Handle gracefully | ### MultiPolygon -| # | Case | Expected output | -| --- | --- | --- | -| MPG1 | Empty MultiPolygon | Empty MultiPolygon | -| MPG2 | All component Polygons inside R | Same MultiPolygon | -| MPG3 | All component Polygons outside R | Empty MultiPolygon | -| MPG4 | Some components inside, some outside | MultiPolygon with surviving components | -| MPG5 | One component partially clipped, another fully inside | MultiPolygon combining both | -| MPG6 | One component becomes MultiPolygon when clipped (e.g. U-shape) | All resulting polygons in output | -| MPG7 | Multiple components, each partially clipped | MultiPolygon with all fragments | -| MPG8 | Components with holes, some holes clipped | MultiPolygon preserving relevant holes | -| MPG9 | Single component fully inside R | Polygon or MultiPolygon | -| MPG10 | MultiPolygon containing empty Polygons | Empty components excluded | +| # | Case | Expected output | +| ----- | ------------------------------------------------------------------- | ---------------------------------------- | +| MPG1 | Empty MultiPolygon | Empty MultiPolygon | +| MPG2 | All component Polygons inside R | Same MultiPolygon | +| MPG3 | All component Polygons outside R | Empty MultiPolygon | +| MPG4 | Some components inside, some outside | MultiPolygon with surviving components | +| MPG5 | One component partially clipped, another fully inside | MultiPolygon combining both | +| MPG6 | One component becomes multiple polygons when clipped (e.g. U-shape) | MultiPolygon with all resulting polygons | +| MPG7 | Multiple components, each partially clipped | MultiPolygon with all fragments | +| MPG8 | Components with holes, some holes clipped | MultiPolygon preserving relevant holes | +| MPG9 | Single component fully inside R | MultiPolygon with 1 component | +| MPG10 | MultiPolygon containing empty Polygons | Empty components excluded | ### GeometryCollection | # | Case | Expected output | -| --- | --- | --- | +| ---- | --------------------------------------------------- | ----------------------------------------------- | | GC1 | Empty GeometryCollection | Empty GeometryCollection | | GC2 | Contains only Points, all inside R | GeometryCollection with all Points | | GC3 | Contains only Points, all outside R | Empty GeometryCollection | @@ -738,7 +738,7 @@ These cases test non-standard rectangles with representative geometries from each type. | # | Case | Geometry | Expected output | -| --- | --- | --- | --- | +| ---- | ------------------------ | --------------------------------------- | ------------------ | | DR1 | Empty envelope | Point inside would-be area | Empty Point | | DR2 | Empty envelope | LineString | Empty LineString | | DR3 | Empty envelope | Polygon | Empty Polygon | @@ -766,7 +766,7 @@ each type. ### Numerical Edge Cases | # | Case | Notes | -| --- | --- | --- | +| --- | ------------------------------------------------------------------ | ------------------------------------------ | | NE1 | Very large coordinates (near float64 max) | Test numerical stability | | NE2 | Very small coordinates (near float64 epsilon) | Test precision | | NE3 | Negative coordinates | All quadrants should work | @@ -779,7 +779,7 @@ each type. ### Coordinate Dimension Preservation | # | Case | Notes | -| --- | --- | --- | +| --- | --------------------- | ----------------------------------------------------------- | | CD1 | XY geometry clipped | Output has XY coordinates | | CD2 | XYZ geometry clipped | Output has XYZ coordinates, Z interpolated at intersections | | CD3 | XYM geometry clipped | Output has XYM coordinates, M interpolated at intersections | From 1a071563732a70aa8bfb2e2e76c65b8708734a81 Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Thu, 2 Apr 2026 16:52:46 +1100 Subject: [PATCH 04/32] sutherland_hodgman.md: tweak test cases and rules --- docs/sutherland_hodgman.md | 264 +++++++++++++++++++++---------------- 1 file changed, 148 insertions(+), 116 deletions(-) diff --git a/docs/sutherland_hodgman.md b/docs/sutherland_hodgman.md index fc350457..6601d467 100644 --- a/docs/sutherland_hodgman.md +++ b/docs/sutherland_hodgman.md @@ -505,34 +505,79 @@ This section enumerates unit test cases for ClipByRect. The clipping rectangle is denoted R. Each test specifies the input geometry, the rectangle, and the expected output. +Each test case is defined with concrete coordinates in a single canonical +orientation. The test implementation systematically applies all 8 symmetry +transformations of the dihedral group D4 (4 rotations × 2 reflections) to both +the input geometry and the clipping rectangle, generating 8 sub-tests per case. +This ensures every case is tested against all edges and corners of R without +needing to list each orientation explicitly. + +### Output Type Rules + +Two rules govern the output geometry type: + +1. **Dimension preservation.** The topological dimension of the output always + matches the input. A LineString input never produces a Point output; it + produces an empty LineString instead. + +2. **Multi-type promotion only.** A singular type may be promoted to its + multi-type when clipping produces multiple components (e.g. a LineString that + is split into multiple segments becomes a MultiLineString). However, a + multi-type input is never demoted: a MultiPoint input always produces a + MultiPoint output, even if only one point survives. + +The output type only ever stays the same or gets wider (singular to multi), never +narrower. For multi-type inputs, the output type is always the same as the input +type. For singular-type inputs, the output is the same singular type unless the +result has multiple components, in which case it is promoted to the corresponding +multi-type. + +When a child element of a multi-type is clipped away entirely, it is omitted from +the result (not included as an empty geometry). For example, a MultiPoint with +three points where one is outside R produces a MultiPoint with two points. In +contrast, a singular type that is clipped away entirely becomes an empty geometry +of the same type: a Point outside R produces an empty Point. + +GeometryCollections are processed recursively. Each non-GeometryCollection child +is clipped independently per the rules above. Children that clip to empty (or +were already empty) are omitted from the result. Nested GeometryCollections that +become empty after their children are omitted are themselves omitted. The +top-level GeometryCollection is never omitted: if all children are removed, the +result is an empty GeometryCollection. + ### Rectangle Configurations -Before testing individual geometry types, the rectangle itself can vary: +The clipping rectangle can take four forms: + +- **Normal rectangle** (positive width and height): The standard case. All + geometry-type tests below assume a normal rectangle. + +- **Empty envelope**: Always returns an empty geometry of the input type. + +- **Point envelope** (min = max): Degenerate. Lower-dimensional intersections + are discarded. A Point at the same location survives, but a LineString passing + through the point produces an empty LineString (the intersection is a point, + which is lower-dimensional than a LineString). -| # | Case | Notes | -| --- | -------------------------------------------- | ------------------------------------------------------------------------------------- | -| R1 | Empty envelope | Always return empty geometry of the input type | -| R2 | Point envelope (min = max) | Degenerate: output type matches input type, only same-dimension intersections survive | -| R3 | Line envelope (zero width or zero height) | Degenerate: output type matches input type, only same-dimension intersections survive | -| R4 | Normal rectangle (positive width and height) | Standard case | +- **Line envelope** (zero width or zero height): Degenerate. Lower-dimensional + intersections are discarded. A LineString collinear with the line survives, but + a LineString that merely crosses it produces an empty LineString (the + intersection is a point). Similarly, a Polygon crossing the line produces an + empty Polygon (the intersection is at most a line, which is lower-dimensional + than a Polygon). -All geometry-type tests below assume a normal rectangle unless stated otherwise. -A small set of tests should also exercise R1, R2, and R3 with representative -geometries. +The Degenerate Rectangle Cases section below exercises the empty, point, and line +envelope configurations with representative geometries. ### Point -| # | Case | Expected output | -| --- | --------------------------------------- | --------------- | -| PT1 | Empty Point | Empty Point | -| PT2 | Point strictly inside R | Same Point | -| PT3 | Point strictly outside R | Empty Point | -| PT4 | Point on left edge of R | Same Point | -| PT5 | Point on right edge of R | Same Point | -| PT6 | Point on bottom edge of R | Same Point | -| PT7 | Point on top edge of R | Same Point | -| PT8 | Point on corner of R (e.g. bottom-left) | Same Point | -| PT9 | Point on corner of R (e.g. top-right) | Same Point | +| # | Case | Expected output | +| --- | ------------------------ | --------------- | +| PT1 | Empty Point | Empty Point | +| PT2 | Point strictly inside R | Same Point | +| PT3 | Point strictly outside R | Empty Point | +| PT4 | Point on edge of R | Same Point | +| PT5 | Point on corner of R | Same Point | ### MultiPoint @@ -577,83 +622,79 @@ geometries. #### Direction and shape -| # | Case | Expected output | -| ---- | ---------------------------------------------------------------- | ------------------------------------------ | -| LS17 | Diagonal line crossing all four edges of R | LineString with 2 intersection points | -| LS18 | Horizontal line crossing left and right edges | LineString clipped to left and right edges | -| LS19 | Vertical line crossing bottom and top edges | LineString clipped to bottom and top edges | -| LS20 | Closed LineString (ring) inside R | Same closed LineString | -| LS21 | Closed LineString (ring) partially overlapping R | LineString of surviving arcs | -| LS22 | Multi-segment LineString with some segments inside, some outside | MultiLineString of surviving segments | -| LS23 | Zigzag LineString entering and exiting R many times | MultiLineString with multiple components | +| # | Case | Expected output | +| ---- | ---------------------------------------------------------------- | ------------------------------------------------- | +| LS17 | Diagonal line crossing two edges of R | LineString with 2 intersection points | +| LS18 | Axis-aligned line crossing two opposite edges of R | LineString clipped to opposite edge intersections | +| LS19 | Closed LineString (ring) inside R | Same closed LineString | +| LS20 | Closed LineString (ring) partially overlapping R | LineString of surviving arcs | +| LS21 | Multi-segment LineString with some segments inside, some outside | MultiLineString of surviving segments | +| LS22 | Zigzag LineString entering and exiting R many times | MultiLineString with multiple components | #### Corner and edge-coincident cases | # | Case | Expected output | | ---- | ---------------------------------------------------- | -------------------------------------------- | -| LS24 | Vertex exactly on R boundary, adjacent edges inside | LineString preserved through boundary vertex | -| LS25 | Vertex exactly on R boundary, adjacent edges outside | Empty LineString | -| LS26 | LineString passes through two opposite corners of R | LineString clipped between corners | +| LS23 | Vertex exactly on R boundary, adjacent edges inside | LineString preserved through boundary vertex | +| LS24 | Vertex exactly on R boundary, adjacent edges outside | Empty LineString | +| LS25 | LineString passes through two opposite corners of R | LineString clipped between corners | ### MultiLineString -| # | Case | Expected output | -| ---- | ------------------------------------------------------- | ------------------------------------------------- | -| MLS1 | Empty MultiLineString | Empty MultiLineString | -| MLS2 | All component LineStrings inside R | Same MultiLineString | -| MLS3 | All component LineStrings outside R | Empty MultiLineString | -| MLS4 | Some components inside, some outside | MultiLineString with surviving components | -| MLS5 | Single component, clipped to a single segment | MultiLineString with 1 component | -| MLS6 | Multiple components, each partially clipped | MultiLineString combining all surviving fragments | -| MLS7 | One component crosses R, another is inside R | MultiLineString with clipped and unclipped parts | -| MLS8 | Component that produces multiple fragments when clipped | Fragments included in output MultiLineString | -| MLS9 | MultiLineString containing empty LineStrings | Empty components excluded | +| # | Case | Expected output | +| ---- | ------------------------------------------------------- | ------------------------------------------------ | +| MLS1 | Empty MultiLineString | Empty MultiLineString | +| MLS2 | All component LineStrings inside R | Same MultiLineString | +| MLS3 | All component LineStrings outside R | Empty MultiLineString | +| MLS4 | Some components inside, some outside | MultiLineString with surviving components | +| MLS5 | Single component, clipped to a single segment | MultiLineString with 1 component | +| MLS6 | One component crosses R, another is inside R | MultiLineString with clipped and unclipped parts | +| MLS7 | Component that produces multiple fragments when clipped | Fragments included in output MultiLineString | +| MLS8 | MultiLineString containing empty LineStrings | Empty components excluded | ### Polygon (No Holes) #### Spatial relationship to R -| # | Case | Expected output | -| --- | --------------------------------------------------------------------------------------------- | ------------------------------ | -| PG1 | Empty Polygon | Empty Polygon | -| PG2 | Entirely inside R | Same Polygon | -| PG3 | Entirely outside R (no overlap) | Empty Polygon | -| PG4 | R entirely inside Polygon | Polygon equal to R | -| PG5 | Partially overlapping one edge of R | Polygon clipped to R boundary | -| PG6 | Partially overlapping two adjacent edges (corner clip) | Polygon clipped at corner | -| PG7 | Partially overlapping two opposite edges (strip through R) | Polygon clipped on two sides | -| PG8 | Partially overlapping three edges | Polygon clipped on three sides | -| PG9 | Partially overlapping all four edges (R fully inside polygon, polygon extends past all sides) | Polygon equal to R | +| # | Case | Expected output | +| --- | ---------------------------------------------------------- | ------------------------------ | +| PG1 | Empty Polygon | Empty Polygon | +| PG2 | Entirely inside R | Same Polygon | +| PG3 | Entirely outside R (no overlap) | Empty Polygon | +| PG4 | R entirely inside Polygon | Polygon equal to R | +| PG5 | Partially overlapping one edge of R | Polygon clipped to R boundary | +| PG6 | Partially overlapping two adjacent edges (corner clip) | Polygon clipped at corner | +| PG7 | Partially overlapping two opposite edges (strip through R) | Polygon clipped on two sides | +| PG8 | Partially overlapping three edges | Polygon clipped on three sides | #### Boundary interactions | # | Case | Expected output | | ---- | ----------------------------------------------------- | ----------------------------------- | -| PG10 | Polygon shares an entire edge with R | Polygon preserved along shared edge | -| PG11 | Polygon vertex exactly on R edge | Polygon with vertex on boundary | -| PG12 | Polygon vertex exactly on R corner | Polygon with vertex on corner | -| PG13 | Polygon edge collinear with R edge, polygon inside R | Polygon preserved | -| PG14 | Polygon edge collinear with R edge, polygon outside R | Empty Polygon | -| PG15 | Polygon touches R at a single point (vertex-to-edge) | Empty Polygon | -| PG16 | Polygon touches R at a single corner point | Empty Polygon | +| PG9 | Polygon shares an entire edge with R | Polygon preserved along shared edge | +| PG10 | Polygon vertex exactly on R edge | Polygon with vertex on boundary | +| PG11 | Polygon vertex exactly on R corner | Polygon with vertex on corner | +| PG12 | Polygon edge collinear with R edge, polygon inside R | Polygon preserved | +| PG13 | Polygon edge collinear with R edge, polygon outside R | Empty Polygon | +| PG14 | Polygon touches R at a single point (vertex-to-edge) | Empty Polygon | +| PG15 | Polygon touches R at a single corner point | Empty Polygon | #### Shape variations | # | Case | Expected output | | ---- | ------------------------------------------------- | ------------------------------------------- | -| PG17 | Convex polygon clipped by R | Convex clipped polygon | -| PG18 | Concave polygon, concavity inside R | Concave clipped polygon | -| PG19 | Concave polygon, concavity facing R boundary | Clipped polygon reflecting concavity | -| PG20 | U-shaped polygon clipped across the opening | MultiPolygon (two separate pieces) | -| PG21 | Very thin sliver polygon partially inside R | Thin clipped polygon | -| PG22 | Triangle clipped to produce various vertex counts | Polygon with 3-7 vertices depending on clip | +| PG16 | Convex polygon clipped by R | Convex clipped polygon | +| PG17 | Concave polygon, concavity inside R | Concave clipped polygon | +| PG18 | Concave polygon, concavity facing R boundary | Clipped polygon reflecting concavity | +| PG19 | U-shaped polygon clipped across the opening | MultiPolygon (two separate pieces) | +| PG20 | Very thin sliver polygon partially inside R | Thin clipped polygon | +| PG21 | Triangle clipped to produce various vertex counts | Polygon with 3-7 vertices depending on clip | #### Winding order -| # | Case | Expected output | -| ---- | ------------------------------- | ---------------------------- | -| PG23 | Counter-clockwise exterior ring | Output preserves CCW winding | -| PG24 | Clockwise exterior ring | Output preserves CW winding | +| # | Case | Expected output | +| ---- | ------------------------------- | ---------------------------------------------------- | +| PG22 | Counter-clockwise exterior ring | Output preserves winding order (reflections test CW) | ### Polygon (With Holes) @@ -674,31 +715,28 @@ geometries. #### Hole crossing R boundary -| # | Case | Expected output | -| ---- | ----------------------------------------------------- | ------------------------------------- | -| PH6 | Hole crosses one edge of R | Polygon with hole clipped to boundary | -| PH7 | Hole crosses two adjacent edges of R (corner) | Polygon or MultiPolygon | -| PH8 | Hole crosses two opposite edges of R (splits polygon) | MultiPolygon | -| PH9 | Hole crosses all four edges of R | MultiPolygon, possibly empty | -| PH10 | Hole shares edge with R boundary | Polygon with hole on boundary | +| # | Case | Expected output | +| ---- | -------------------------------------------------------------------- | ------------------------------------------ | +| PH6 | Hole crosses one edge of R | Polygon with concavity (no hole in output) | +| PH7 | Hole crosses two adjacent edges of R (corner) | Polygon with concavity (no hole in output) | +| PH8 | Hole crosses two opposite edges of R (splits polygon) | MultiPolygon | +| PH9 | Hole crosses all four edges of R, leaving corner fragments | MultiPolygon | +| PH10 | Hole inside R shares edge with R boundary, exterior extends beyond R | Polygon with concavity (no hole in output) | #### Multiple holes interacting with R -| # | Case | Expected output | -| ---- | ---------------------------------------------- | ----------------------------- | -| PH11 | One hole inside R, another outside R | Polygon with only inside hole | -| PH12 | One hole inside R, another crossing R boundary | Polygon or MultiPolygon | -| PH13 | Multiple holes crossing R boundary | MultiPolygon | -| PH14 | Two holes that merge along R boundary | MultiPolygon | +| # | Case | Expected output | +| ---- | ------------------------------------------ | --------------------------------------- | +| PH11 | One hole inside R, another outside R | Polygon with only inside hole | +| PH12 | One hole inside R, another splits polygon | MultiPolygon, hole in correct component | +| PH13 | Multiple holes each crossing one edge of R | Polygon with multiple concavities | #### Topology-changing cases -| # | Case | Expected output | -| ---- | ------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | -| PH15 | Hole splits polygon into two when clipped (U-shape via hole) | MultiPolygon with two components | -| PH16 | Hole splits polygon into three or more pieces | MultiPolygon | -| PH17 | Hole causes exterior ring to become trivial | Empty Polygon | -| PH18 | Nested holes (hole within a hole is not valid, but exterior ring of polygon inside a hole of another polygon in a MultiPolygon) | Handle gracefully | +| # | Case | Expected output | +| ---- | --------------------------------------------- | --------------- | +| PH14 | Hole splits polygon into three or more pieces | MultiPolygon | +| PH15 | Hole covers entire clipped area | Empty Polygon | ### MultiPolygon @@ -731,37 +769,31 @@ geometries. | GC10 | All child geometries are empty | Empty GeometryCollection | | GC11 | Contains MultiPoint, MultiLineString, MultiPolygon | Each multi-type component clipped independently | | GC12 | Deeply nested GeometryCollections (3+ levels) | Recursive clipping at all levels | +| GC13 | Nested GC whose children all clip to empty | Nested GC omitted from parent | ### Degenerate Rectangle Cases These cases test non-standard rectangles with representative geometries from each type. -| # | Case | Geometry | Expected output | -| ---- | ------------------------ | --------------------------------------- | ------------------ | -| DR1 | Empty envelope | Point inside would-be area | Empty Point | -| DR2 | Empty envelope | LineString | Empty LineString | -| DR3 | Empty envelope | Polygon | Empty Polygon | -| DR4 | Point envelope (0x0) | Point at same location | Same Point | -| DR5 | Point envelope (0x0) | Point at different location | Empty Point | -| DR6 | Point envelope (0x0) | MultiPoint with some points at location | Clipped MultiPoint | -| DR7 | Point envelope (0x0) | MultiPoint with no points at location | Empty MultiPoint | -| DR8 | Point envelope (0x0) | LineString through that point | Empty LineString | -| DR9 | Point envelope (0x0) | Polygon containing that point | Empty Polygon | -| DR10 | Line envelope (0 width) | Point on the line | Same Point | -| DR11 | Line envelope (0 width) | Point off the line | Empty Point | -| DR12 | Line envelope (0 width) | MultiPoint with some points on the line | Clipped MultiPoint | -| DR13 | Line envelope (0 width) | MultiPoint with no points on the line | Empty MultiPoint | -| DR14 | Line envelope (0 width) | LineString crossing the line | Empty LineString | -| DR15 | Line envelope (0 width) | LineString collinear with line | Clipped LineString | -| DR16 | Line envelope (0 width) | Polygon crossing the line | Empty Polygon | -| DR17 | Line envelope (0 height) | Point on the line | Same Point | -| DR18 | Line envelope (0 height) | Point off the line | Empty Point | -| DR19 | Line envelope (0 height) | MultiPoint with some points on the line | Clipped MultiPoint | -| DR20 | Line envelope (0 height) | MultiPoint with no points on the line | Empty MultiPoint | -| DR21 | Line envelope (0 height) | LineString crossing the line | Empty LineString | -| DR22 | Line envelope (0 height) | LineString collinear with line | Clipped LineString | -| DR23 | Line envelope (0 height) | Polygon crossing the line | Empty Polygon | +| # | Case | Geometry | Expected output | +| ---- | -------------------- | --------------------------------------- | ------------------ | +| DR1 | Empty envelope | Point inside would-be area | Empty Point | +| DR2 | Empty envelope | LineString | Empty LineString | +| DR3 | Empty envelope | Polygon | Empty Polygon | +| DR4 | Point envelope (0x0) | Point at same location | Same Point | +| DR5 | Point envelope (0x0) | Point at different location | Empty Point | +| DR6 | Point envelope (0x0) | MultiPoint with some points at location | Clipped MultiPoint | +| DR7 | Point envelope (0x0) | MultiPoint with no points at location | Empty MultiPoint | +| DR8 | Point envelope (0x0) | LineString through that point | Empty LineString | +| DR9 | Point envelope (0x0) | Polygon containing that point | Empty Polygon | +| DR10 | Line envelope | Point on the line | Same Point | +| DR11 | Line envelope | Point off the line | Empty Point | +| DR12 | Line envelope | MultiPoint with some points on the line | Clipped MultiPoint | +| DR13 | Line envelope | MultiPoint with no points on the line | Empty MultiPoint | +| DR14 | Line envelope | LineString crossing the line | Empty LineString | +| DR15 | Line envelope | LineString collinear with line | Clipped LineString | +| DR16 | Line envelope | Polygon crossing the line | Empty Polygon | ### Numerical Edge Cases From d2cecf9957799c553bf8ccb628c80638df494278 Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Thu, 2 Apr 2026 16:54:08 +1100 Subject: [PATCH 05/32] sutherland_hodgman.md: tweaks --- docs/sutherland_hodgman.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/sutherland_hodgman.md b/docs/sutherland_hodgman.md index 6601d467..b1d9492a 100644 --- a/docs/sutherland_hodgman.md +++ b/docs/sutherland_hodgman.md @@ -499,20 +499,7 @@ resembles the edge-tracing phase of an overlay algorithm, but is constrained to only four possible boundary edges, which simplifies the data structures involved. -## Unit Test Cases - -This section enumerates unit test cases for ClipByRect. The clipping rectangle -is denoted R. Each test specifies the input geometry, the rectangle, and the -expected output. - -Each test case is defined with concrete coordinates in a single canonical -orientation. The test implementation systematically applies all 8 symmetry -transformations of the dihedral group D4 (4 rotations × 2 reflections) to both -the input geometry and the clipping rectangle, generating 8 sub-tests per case. -This ensures every case is tested against all edges and corners of R without -needing to list each orientation explicitly. - -### Output Type Rules +## Output Type Rules Two rules govern the output geometry type: @@ -545,6 +532,19 @@ become empty after their children are omitted are themselves omitted. The top-level GeometryCollection is never omitted: if all children are removed, the result is an empty GeometryCollection. +## Unit Test Cases + +This section enumerates unit test cases for ClipByRect. The clipping rectangle +is denoted R. Each test specifies the input geometry, the rectangle, and the +expected output. + +Each test case is defined with concrete coordinates in a single canonical +orientation. The test implementation systematically applies all 8 symmetry +transformations of the dihedral group D4 (4 rotations × 2 reflections) to both +the input geometry and the clipping rectangle, generating 8 sub-tests per case. +This ensures every case is tested against all edges and corners of R without +needing to list each orientation explicitly. + ### Rectangle Configurations The clipping rectangle can take four forms: From 7bf04e64da842a06ac5005cc8401f8e8984c8802 Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Thu, 2 Apr 2026 20:22:51 +1100 Subject: [PATCH 06/32] alg_sutherland_hodgman.go: add stubs and dispatch for ClipByRect --- geom/alg_sutherland_hodgman.go | 54 ++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 geom/alg_sutherland_hodgman.go diff --git a/geom/alg_sutherland_hodgman.go b/geom/alg_sutherland_hodgman.go new file mode 100644 index 00000000..f6da679b --- /dev/null +++ b/geom/alg_sutherland_hodgman.go @@ -0,0 +1,54 @@ +package geom + +// ClipByRect clips a geometry to an axis-aligned rectangle defined by the +// given [Envelope], returning the portion of the geometry that lies within the +// rectangle. It uses the Sutherland-Hodgman algorithm for polygon clipping +// and related approaches for other geometry types. +func ClipByRect(g Geometry, rect Envelope) Geometry { + switch g.Type() { + case TypePoint: + return clipPointByRect(g.MustAsPoint(), rect).AsGeometry() + case TypeMultiPoint: + return clipMultiPointByRect(g.MustAsMultiPoint(), rect).AsGeometry() + case TypeLineString: + return clipLineStringByRect(g.MustAsLineString(), rect) + case TypeMultiLineString: + return clipMultiLineStringByRect(g.MustAsMultiLineString(), rect).AsGeometry() + case TypePolygon: + return clipPolygonByRect(g.MustAsPolygon(), rect) + case TypeMultiPolygon: + return clipMultiPolygonByRect(g.MustAsMultiPolygon(), rect).AsGeometry() + case TypeGeometryCollection: + return clipGeometryCollectionByRect(g.MustAsGeometryCollection(), rect).AsGeometry() + default: + panic("unknown geometry: " + g.Type().String()) + } +} + +func clipPointByRect(p Point, rect Envelope) Point { + panic("TODO") +} + +func clipMultiPointByRect(mp MultiPoint, rect Envelope) MultiPoint { + panic("TODO") +} + +func clipLineStringByRect(ls LineString, rect Envelope) Geometry { + panic("TODO") +} + +func clipMultiLineStringByRect(mls MultiLineString, rect Envelope) MultiLineString { + panic("TODO") +} + +func clipPolygonByRect(p Polygon, rect Envelope) Geometry { + panic("TODO") +} + +func clipMultiPolygonByRect(mp MultiPolygon, rect Envelope) MultiPolygon { + panic("TODO") +} + +func clipGeometryCollectionByRect(gc GeometryCollection, rect Envelope) GeometryCollection { + panic("TODO") +} From 1a32f86926bf6a51dd97ddbfb424a29652f481df Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Thu, 2 Apr 2026 20:46:07 +1100 Subject: [PATCH 07/32] Implement for Point --- geom/alg_sutherland_hodgman.go | 9 ++++- geom/alg_sutherland_hodgman_test.go | 56 +++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 geom/alg_sutherland_hodgman_test.go diff --git a/geom/alg_sutherland_hodgman.go b/geom/alg_sutherland_hodgman.go index f6da679b..b021e6f3 100644 --- a/geom/alg_sutherland_hodgman.go +++ b/geom/alg_sutherland_hodgman.go @@ -26,7 +26,14 @@ func ClipByRect(g Geometry, rect Envelope) Geometry { } func clipPointByRect(p Point, rect Envelope) Point { - panic("TODO") + xy, ok := p.XY() + if !ok { + return p + } + if rect.Contains(xy) { + return p + } + return NewEmptyPoint(p.CoordinatesType()) } func clipMultiPointByRect(mp MultiPoint, rect Envelope) MultiPoint { diff --git a/geom/alg_sutherland_hodgman_test.go b/geom/alg_sutherland_hodgman_test.go new file mode 100644 index 00000000..00ac7cb6 --- /dev/null +++ b/geom/alg_sutherland_hodgman_test.go @@ -0,0 +1,56 @@ +package geom_test + +import ( + "testing" + + "github.com/peterstace/simplefeatures/geom" +) + +func TestClipByRect(t *testing.T) { + type d4Transform struct { + name string + fn func(geom.XY) geom.XY + } + d4Transforms := []d4Transform{ + {"identity", func(xy geom.XY) geom.XY { return xy }}, + {"rot90", func(xy geom.XY) geom.XY { return geom.XY{X: -xy.Y, Y: xy.X} }}, + {"rot180", func(xy geom.XY) geom.XY { return geom.XY{X: -xy.X, Y: -xy.Y} }}, + {"rot270", func(xy geom.XY) geom.XY { return geom.XY{X: xy.Y, Y: -xy.X} }}, + {"reflect_x", func(xy geom.XY) geom.XY { return geom.XY{X: -xy.X, Y: xy.Y} }}, + {"reflect_y", func(xy geom.XY) geom.XY { return geom.XY{X: xy.X, Y: -xy.Y} }}, + {"reflect_diag", func(xy geom.XY) geom.XY { return geom.XY{X: xy.Y, Y: xy.X} }}, + {"reflect_anti", func(xy geom.XY) geom.XY { return geom.XY{X: -xy.Y, Y: -xy.X} }}, + } + // R is a non-square rectangle so that the D4 transforms produce distinct + // configurations. + rect := geom.NewEnvelope(geom.XY{X: 1, Y: 2}, geom.XY{X: 5, Y: 4}) + + for _, tt := range []struct { + name string + input string + want string + }{ + // PT1: Empty Point + {"PT1", "POINT EMPTY", "POINT EMPTY"}, + // PT2: Point strictly inside R + {"PT2", "POINT(3 3)", "POINT(3 3)"}, + // PT3: Point strictly outside R + {"PT3", "POINT(0 0)", "POINT EMPTY"}, + // PT4: Point on edge of R (on left edge, x=1) + {"PT4", "POINT(1 3)", "POINT(1 3)"}, + // PT5: Point on corner of R (bottom-left corner) + {"PT5", "POINT(1 2)", "POINT(1 2)"}, + } { + t.Run(tt.name, func(t *testing.T) { + for _, tr := range d4Transforms { + t.Run(tr.name, func(t *testing.T) { + input := geomFromWKT(t, tt.input).TransformXY(tr.fn) + want := geomFromWKT(t, tt.want).TransformXY(tr.fn) + r := rect.TransformXY(tr.fn) + got := geom.ClipByRect(input, r) + expectGeomEq(t, got, want) + }) + } + }) + } +} From e7e56f3486712a62e7b55fc8a704d77f16b91a9b Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Fri, 3 Apr 2026 09:49:01 +1100 Subject: [PATCH 08/32] MultiPoint --- docs/sutherland_hodgman.md | 4 ++++ geom/alg_sutherland_hodgman.go | 14 +++++++++++++- geom/alg_sutherland_hodgman_test.go | 19 +++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/docs/sutherland_hodgman.md b/docs/sutherland_hodgman.md index b1d9492a..505af209 100644 --- a/docs/sutherland_hodgman.md +++ b/docs/sutherland_hodgman.md @@ -591,6 +591,7 @@ envelope configurations with representative geometries. | MP6 | Points on edges and corners of R | All retained | | MP7 | Mix of inside, on-boundary, and outside | Inside and boundary points retained | | MP8 | MultiPoint containing empty points | Empty points excluded from output | +| MP9 | XYZ MultiPoint, all points outside R | Empty XYZ MultiPoint | ### LineString @@ -651,6 +652,7 @@ envelope configurations with representative geometries. | MLS6 | One component crosses R, another is inside R | MultiLineString with clipped and unclipped parts | | MLS7 | Component that produces multiple fragments when clipped | Fragments included in output MultiLineString | | MLS8 | MultiLineString containing empty LineStrings | Empty components excluded | +| MLS9 | XYZ MultiLineString, all components outside R | Empty XYZ MultiLineString | ### Polygon (No Holes) @@ -752,6 +754,7 @@ envelope configurations with representative geometries. | MPG8 | Components with holes, some holes clipped | MultiPolygon preserving relevant holes | | MPG9 | Single component fully inside R | MultiPolygon with 1 component | | MPG10 | MultiPolygon containing empty Polygons | Empty components excluded | +| MPG11 | XYZ MultiPolygon, all components outside R | Empty XYZ MultiPolygon | ### GeometryCollection @@ -770,6 +773,7 @@ envelope configurations with representative geometries. | GC11 | Contains MultiPoint, MultiLineString, MultiPolygon | Each multi-type component clipped independently | | GC12 | Deeply nested GeometryCollections (3+ levels) | Recursive clipping at all levels | | GC13 | Nested GC whose children all clip to empty | Nested GC omitted from parent | +| GC14 | XYZ GeometryCollection, all children outside R | Empty XYZ GeometryCollection | ### Degenerate Rectangle Cases diff --git a/geom/alg_sutherland_hodgman.go b/geom/alg_sutherland_hodgman.go index b021e6f3..632baaf9 100644 --- a/geom/alg_sutherland_hodgman.go +++ b/geom/alg_sutherland_hodgman.go @@ -37,7 +37,19 @@ func clipPointByRect(p Point, rect Envelope) Point { } func clipMultiPointByRect(mp MultiPoint, rect Envelope) MultiPoint { - panic("TODO") + n := mp.NumPoints() + var pts []Point + for i := 0; i < n; i++ { + p := mp.PointN(i) + clipped := clipPointByRect(p, rect) + if !clipped.IsEmpty() { + pts = append(pts, clipped) + } + } + if len(pts) == 0 { + return NewMultiPoint(nil).ForceCoordinatesType(mp.CoordinatesType()) + } + return NewMultiPoint(pts) } func clipLineStringByRect(ls LineString, rect Envelope) Geometry { diff --git a/geom/alg_sutherland_hodgman_test.go b/geom/alg_sutherland_hodgman_test.go index 00ac7cb6..8fdd8326 100644 --- a/geom/alg_sutherland_hodgman_test.go +++ b/geom/alg_sutherland_hodgman_test.go @@ -40,6 +40,25 @@ func TestClipByRect(t *testing.T) { {"PT4", "POINT(1 3)", "POINT(1 3)"}, // PT5: Point on corner of R (bottom-left corner) {"PT5", "POINT(1 2)", "POINT(1 2)"}, + + // MP1: Empty MultiPoint + {"MP1", "MULTIPOINT EMPTY", "MULTIPOINT EMPTY"}, + // MP2: All points inside R + {"MP2", "MULTIPOINT(2 3,3 3)", "MULTIPOINT(2 3,3 3)"}, + // MP3: All points outside R + {"MP3", "MULTIPOINT(0 0,6 6)", "MULTIPOINT EMPTY"}, + // MP4: Some points inside, some outside + {"MP4", "MULTIPOINT(3 3,0 0)", "MULTIPOINT(3 3)"}, + // MP5: Single point inside R + {"MP5", "MULTIPOINT(3 3)", "MULTIPOINT(3 3)"}, + // MP6: Points on edges and corners of R + {"MP6", "MULTIPOINT(1 3,1 2)", "MULTIPOINT(1 3,1 2)"}, + // MP7: Mix of inside, on-boundary, and outside + {"MP7", "MULTIPOINT(3 3,1 3,0 0)", "MULTIPOINT(3 3,1 3)"}, + // MP8: MultiPoint containing empty points + {"MP8", "MULTIPOINT(3 3,EMPTY)", "MULTIPOINT(3 3)"}, + // MP9: XYZ MultiPoint, all points outside R + {"MP9", "MULTIPOINT Z(0 0 7,6 6 8)", "MULTIPOINT Z EMPTY"}, } { t.Run(tt.name, func(t *testing.T) { for _, tr := range d4Transforms { From 22cb4c5fdc5ba6b5199fc45356990310f9bff13d Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Fri, 3 Apr 2026 11:14:34 +1100 Subject: [PATCH 09/32] LineString --- geom/alg_sutherland_hodgman.go | 113 +++++++++++++++++++++++++++- geom/alg_sutherland_hodgman_test.go | 51 +++++++++++++ 2 files changed, 163 insertions(+), 1 deletion(-) diff --git a/geom/alg_sutherland_hodgman.go b/geom/alg_sutherland_hodgman.go index 632baaf9..f7d55bb5 100644 --- a/geom/alg_sutherland_hodgman.go +++ b/geom/alg_sutherland_hodgman.go @@ -53,7 +53,118 @@ func clipMultiPointByRect(mp MultiPoint, rect Envelope) MultiPoint { } func clipLineStringByRect(ls LineString, rect Envelope) Geometry { - panic("TODO") + seq := ls.Coordinates() + n := seq.Length() + if n == 0 { + return ls.AsGeometry() + } + + min, max, ok := rect.MinMaxXYs() + if !ok { + return NewLineString(NewSequence(nil, seq.CoordinatesType())).AsGeometry() + } + + ctype := seq.CoordinatesType() + dim := ctype.Dimension() + + var chains [][]float64 + var cur []float64 + + for i := 0; i < n-1; i++ { + a := seq.GetXY(i) + b := seq.GetXY(i + 1) + + tMin, tMax, ok := clipSegmentParams(a, b, min, max) + if !ok { + if len(cur) > 0 { + chains = append(chains, cur) + cur = nil + } + continue + } + + ca := interpolateSeqCoord(seq, i, i+1, tMin) + cb := interpolateSeqCoord(seq, i, i+1, tMax) + + if len(cur) > 0 && cur[len(cur)-dim] == ca.X && cur[len(cur)-dim+1] == ca.Y { + cur = cb.appendFloat64s(cur) + } else { + if len(cur) > 0 { + chains = append(chains, cur) + } + cur = ca.appendFloat64s(nil) + cur = cb.appendFloat64s(cur) + } + } + if len(cur) > 0 { + chains = append(chains, cur) + } + + if len(chains) == 0 { + return NewLineString(NewSequence(nil, ctype)).AsGeometry() + } + + lines := make([]LineString, len(chains)) + for i, c := range chains { + lines[i] = NewLineString(NewSequence(c, ctype)) + } + + if len(lines) == 1 { + return lines[0].AsGeometry() + } + return NewMultiLineString(lines).AsGeometry() +} + +// clipSegmentParams uses the Liang-Barsky algorithm to compute the parametric +// range [tMin, tMax] of segment a->b that lies inside the axis-aligned +// rectangle from min to max. It returns false if no segment of positive length +// survives. +func clipSegmentParams(a, b, min, max XY) (float64, float64, bool) { + tMin := 0.0 + tMax := 1.0 + dx := b.X - a.X + dy := b.Y - a.Y + for _, pq := range [4][2]float64{ + {-dx, a.X - min.X}, // left + {dx, max.X - a.X}, // right + {-dy, a.Y - min.Y}, // bottom + {dy, max.Y - a.Y}, // top + } { + p, q := pq[0], pq[1] + if p == 0 { + if q < 0 { + return 0, 0, false + } + continue + } + t := q / p + if p < 0 { + if t > tMin { + tMin = t + } + } else { + if t < tMax { + tMax = t + } + } + if tMin >= tMax { + return 0, 0, false + } + } + return tMin, tMax, true +} + +// interpolateSeqCoord linearly interpolates between coordinates at index i and +// j in seq at parameter t (0=coord i, 1=coord j). Delegates to +// [interpolateCoords] which uses a numerically robust lerp. +func interpolateSeqCoord(seq Sequence, i, j int, t float64) Coordinates { + if t == 0 { + return seq.Get(i) + } + if t == 1 { + return seq.Get(j) + } + return interpolateCoords(seq.Get(i), seq.Get(j), t) } func clipMultiLineStringByRect(mls MultiLineString, rect Envelope) MultiLineString { diff --git a/geom/alg_sutherland_hodgman_test.go b/geom/alg_sutherland_hodgman_test.go index 8fdd8326..f56648b2 100644 --- a/geom/alg_sutherland_hodgman_test.go +++ b/geom/alg_sutherland_hodgman_test.go @@ -59,6 +59,57 @@ func TestClipByRect(t *testing.T) { {"MP8", "MULTIPOINT(3 3,EMPTY)", "MULTIPOINT(3 3)"}, // MP9: XYZ MultiPoint, all points outside R {"MP9", "MULTIPOINT Z(0 0 7,6 6 8)", "MULTIPOINT Z EMPTY"}, + + // LS1: Empty LineString + {"LS1", "LINESTRING EMPTY", "LINESTRING EMPTY"}, + // LS2: Entirely inside R + {"LS2", "LINESTRING(2 3,4 3)", "LINESTRING(2 3,4 3)"}, + // LS3: Entirely outside R (no overlap) + {"LS3", "LINESTRING(6 5,7 6)", "LINESTRING EMPTY"}, + // LS4: Entirely outside R on one side (all left of R) + {"LS4", "LINESTRING(0 3,0 3.5)", "LINESTRING EMPTY"}, + // LS5: Crosses R, entering and exiting once + {"LS5", "LINESTRING(0 3,2 3,6 5)", "LINESTRING(1 3,2 3,4 4)"}, + // LS6: Crosses R, entering and exiting multiple times + {"LS6", "LINESTRING(0 3,3 3,3 5,4 5,4 3,6 3)", "MULTILINESTRING((1 3,3 3,3 4),(4 4,4 3,5 3))"}, + // LS7: One endpoint inside, one outside + {"LS7", "LINESTRING(3 3,6 3)", "LINESTRING(3 3,5 3)"}, + // LS8: One endpoint outside, one inside + {"LS8", "LINESTRING(0 3,3 3)", "LINESTRING(1 3,3 3)"}, + // LS9: Both endpoints outside, segment passes through R + {"LS9", "LINESTRING(0 3,6 3)", "LINESTRING(1 3,5 3)"}, + // LS10: Endpoint exactly on edge of R, other inside + {"LS10", "LINESTRING(1 3,3 3)", "LINESTRING(1 3,3 3)"}, + // LS11: Endpoint exactly on corner of R, other inside + {"LS11", "LINESTRING(1 2,3 3)", "LINESTRING(1 2,3 3)"}, + // LS12: Both endpoints on boundary of R + {"LS12", "LINESTRING(1 3,5 3)", "LINESTRING(1 3,5 3)"}, + // LS13: LineString lies entirely along one edge of R + {"LS13", "LINESTRING(2 2,4 2)", "LINESTRING(2 2,4 2)"}, + // LS14: LineString lies entirely along two adjacent edges (L-shaped) + {"LS14", "LINESTRING(1 3,1 2,3 2)", "LINESTRING(1 3,1 2,3 2)"}, + // LS15: Segment touches corner of R but does not enter (V-shape) + {"LS15", "LINESTRING(0 1,1 2,0 3)", "LINESTRING EMPTY"}, + // LS16: Segment touches edge of R tangentially + {"LS16", "LINESTRING(2 5,3 4,4 5)", "LINESTRING EMPTY"}, + // LS17: Diagonal line crossing two edges of R + {"LS17", "LINESTRING(0 0,6 6)", "LINESTRING(2 2,4 4)"}, + // LS18: Axis-aligned line crossing two opposite edges of R + {"LS18", "LINESTRING(3 0,3 6)", "LINESTRING(3 2,3 4)"}, + // LS19: Closed LineString (ring) inside R + {"LS19", "LINESTRING(2 2.5,4 2.5,3 3.5,2 2.5)", "LINESTRING(2 2.5,4 2.5,3 3.5,2 2.5)"}, + // LS20: Closed LineString (ring) partially overlapping R + {"LS20", "LINESTRING(2 1,4 1,4 5,2 5,2 1)", "MULTILINESTRING((4 2,4 4),(2 4,2 2))"}, + // LS21: Multi-segment LineString with some segments inside, some outside + {"LS21", "LINESTRING(3 3,4 3,6 3)", "LINESTRING(3 3,4 3,5 3)"}, + // LS22: Zigzag LineString entering and exiting R many times + {"LS22", "LINESTRING(0 3,2 3,2 5,3 5,3 3,4 3,4 5,5 5,5 3,7 3)", "MULTILINESTRING((1 3,2 3,2 4),(3 4,3 3,4 3,4 4),(5 4,5 3))"}, + // LS23: Vertex exactly on R boundary, adjacent edges inside + {"LS23", "LINESTRING(2 3,3 2,4 3)", "LINESTRING(2 3,3 2,4 3)"}, + // LS24: Vertex exactly on R boundary, adjacent edges outside + {"LS24", "LINESTRING(0 1,1 3,0 5)", "LINESTRING EMPTY"}, + // LS25: LineString passes through two opposite corners of R + {"LS25", "LINESTRING(0 1.5,6 4.5)", "LINESTRING(1 2,5 4)"}, } { t.Run(tt.name, func(t *testing.T) { for _, tr := range d4Transforms { From e1c59570f2cd09010a254e7a87372cf69c1b8718 Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Fri, 3 Apr 2026 11:23:03 +1100 Subject: [PATCH 10/32] MultiLineString --- geom/alg_sutherland_hodgman.go | 21 ++++++++++++++++++++- geom/alg_sutherland_hodgman_test.go | 19 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/geom/alg_sutherland_hodgman.go b/geom/alg_sutherland_hodgman.go index f7d55bb5..ac8782cd 100644 --- a/geom/alg_sutherland_hodgman.go +++ b/geom/alg_sutherland_hodgman.go @@ -168,7 +168,26 @@ func interpolateSeqCoord(seq Sequence, i, j int, t float64) Coordinates { } func clipMultiLineStringByRect(mls MultiLineString, rect Envelope) MultiLineString { - panic("TODO") + n := mls.NumLineStrings() + var lines []LineString + for i := 0; i < n; i++ { + clipped := clipLineStringByRect(mls.LineStringN(i), rect) + switch clipped.Type() { + case TypeLineString: + ls := clipped.MustAsLineString() + if !ls.IsEmpty() { + lines = append(lines, ls) + } + case TypeMultiLineString: + lines = append(lines, clipped.MustAsMultiLineString().Dump()...) + default: + panic("unexpected type from clipLineStringByRect: " + clipped.Type().String()) + } + } + if len(lines) == 0 { + return NewMultiLineString(nil).ForceCoordinatesType(mls.CoordinatesType()) + } + return NewMultiLineString(lines) } func clipPolygonByRect(p Polygon, rect Envelope) Geometry { diff --git a/geom/alg_sutherland_hodgman_test.go b/geom/alg_sutherland_hodgman_test.go index f56648b2..7024155d 100644 --- a/geom/alg_sutherland_hodgman_test.go +++ b/geom/alg_sutherland_hodgman_test.go @@ -110,6 +110,25 @@ func TestClipByRect(t *testing.T) { {"LS24", "LINESTRING(0 1,1 3,0 5)", "LINESTRING EMPTY"}, // LS25: LineString passes through two opposite corners of R {"LS25", "LINESTRING(0 1.5,6 4.5)", "LINESTRING(1 2,5 4)"}, + + // MLS1: Empty MultiLineString + {"MLS1", "MULTILINESTRING EMPTY", "MULTILINESTRING EMPTY"}, + // MLS2: All component LineStrings inside R + {"MLS2", "MULTILINESTRING((2 3,4 3),(3 2.5,3 3.5))", "MULTILINESTRING((2 3,4 3),(3 2.5,3 3.5))"}, + // MLS3: All component LineStrings outside R + {"MLS3", "MULTILINESTRING((6 5,7 6),(0 0,0 1))", "MULTILINESTRING EMPTY"}, + // MLS4: Some components inside, some outside + {"MLS4", "MULTILINESTRING((2 3,4 3),(6 5,7 6))", "MULTILINESTRING((2 3,4 3))"}, + // MLS5: Single component, clipped to a single segment + {"MLS5", "MULTILINESTRING((0 3,6 3))", "MULTILINESTRING((1 3,5 3))"}, + // MLS6: One component crosses R, another is inside R + {"MLS6", "MULTILINESTRING((0 3,6 3),(3 2.5,3 3.5))", "MULTILINESTRING((1 3,5 3),(3 2.5,3 3.5))"}, + // MLS7: Component that produces multiple fragments when clipped + {"MLS7", "MULTILINESTRING((0 3,3 3,3 5,4 5,4 3,6 3))", "MULTILINESTRING((1 3,3 3,3 4),(4 4,4 3,5 3))"}, + // MLS8: MultiLineString containing empty LineStrings + {"MLS8", "MULTILINESTRING((2 3,4 3),EMPTY)", "MULTILINESTRING((2 3,4 3))"}, + // MLS9: XYZ MultiLineString, all components outside R + {"MLS9", "MULTILINESTRING Z((6 5 1,7 6 2),(0 0 3,0 1 4))", "MULTILINESTRING Z EMPTY"}, } { t.Run(tt.name, func(t *testing.T) { for _, tr := range d4Transforms { From 2f014f666428df7be266d9a238db5cc3c9812261 Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Sat, 4 Apr 2026 20:02:27 +1100 Subject: [PATCH 11/32] Implement polygon clipping --- docs/clip_polygon_algorithm.md | 428 +++++++++++++ geom/alg_clip_by_rect.go | 84 +++ geom/alg_clip_by_rect_liang_barsky.go | 116 ++++ geom/alg_clip_by_rect_sutherland_hodgman.go | 630 ++++++++++++++++++++ geom/alg_sutherland_hodgman.go | 203 ------- geom/alg_sutherland_hodgman_test.go | 223 +++++-- 6 files changed, 1432 insertions(+), 252 deletions(-) create mode 100644 docs/clip_polygon_algorithm.md create mode 100644 geom/alg_clip_by_rect.go create mode 100644 geom/alg_clip_by_rect_liang_barsky.go create mode 100644 geom/alg_clip_by_rect_sutherland_hodgman.go delete mode 100644 geom/alg_sutherland_hodgman.go diff --git a/docs/clip_polygon_algorithm.md b/docs/clip_polygon_algorithm.md new file mode 100644 index 00000000..02265828 --- /dev/null +++ b/docs/clip_polygon_algorithm.md @@ -0,0 +1,428 @@ +# Polygon Clipping Algorithm + +This document describes the algorithm used by `clipPolygonByRect` to clip a +polygon against an axis-aligned rectangle. It covers simple polygons (no +holes), concave polygons that split into multiple pieces, and polygons with +holes that cross the rectangle boundary. + +## Overview + +The algorithm has four phases: + +1. **Sutherland-Hodgman clipping** — clip each ring independently against the + rectangle's four edges. +2. **Arc extraction** — decompose each clipped ring into "interior arcs" + (portions that pass through the rectangle's interior) and "boundary arcs" + (portions that lie along the rectangle's edges). +3. **Topology resolution** — reconnect interior arcs using correct boundary + paths to produce output rings. +4. **Assembly** — classify output rings, assign free holes, and build the + output geometry. + +## Phase 1: Sutherland-Hodgman Clipping + +Each ring (exterior and holes) is clipped independently. The Sutherland-Hodgman +algorithm clips a closed polygon ring against a single half-plane. Four +sequential passes clip against the four rectangle edges: + +1. Left: x ≥ min.X +2. Right: x ≤ max.X +3. Bottom: y ≥ min.Y +4. Top: y ≤ max.Y + +### Single-edge pass + +The input is an open ring (a list of vertices without a repeated closing +vertex). The algorithm walks the edges of the ring. For each edge from vertex A +to vertex B, it applies one of four rules: + +| A | B | Action | +| ------- | ------- | ------------------------------ | +| inside | inside | emit B | +| inside | outside | emit intersection(A,B) | +| outside | inside | emit intersection(A,B), emit B | +| outside | outside | emit nothing | + +"Inside" means the vertex satisfies the half-plane condition (e.g. x ≥ min.X +for the left edge). + +The walk starts from the last vertex in the list (treating the ring as +implicitly closed), so the first edge processed is from the last vertex to the +first vertex. + +### Intersection computation + +For a vertical clipping edge x = k, the intersection of segment A→B is +computed as: + +``` +t = (k - A.x) / (B.x - A.x) +result = interpolateCoords(A, B, t) +result.x = k // set exactly to avoid float imprecision +``` + +Horizontal edges (y = k) are analogous. The `interpolateCoords` function +handles Z and M dimensions using a numerically robust lerp. The explicit +assignment of the boundary coordinate ensures that later boundary detection +(which uses `==`) is reliable. + +### After all four passes + +Duplicate consecutive vertices (by XY) are removed, including wrap-around +(first == last). If fewer than 3 vertices remain, the ring was entirely outside +the rectangle and is discarded. + +### Properties of the S-H output + +The output ring is a mixture of two kinds of edges: + +- **Interior edges** — edges where the path passes through the rectangle's + interior (at least one endpoint is not on the rectangle boundary). +- **Boundary edges** — edges where both endpoints lie on the same rectangle + edge. + +The S-H algorithm preserves the winding direction of the input ring. + +For convex polygons, the output is always a valid simple ring. For concave +polygons, the output ring may **self-touch** along the rectangle boundary. For +example, a C-shaped polygon clipped across its opening produces a ring where +two boundary arcs overlap on the same rectangle edge. The topology resolution +phase (Phase 3) handles this. + +## Phase 2: Arc Extraction + +Each clipped ring is decomposed into **interior arcs**. An interior arc is a +maximal sequence of consecutive interior edges. Each arc starts and ends at a +vertex on the rectangle boundary. + +### Identifying boundary edges + +An edge between vertices P and Q is a boundary edge if both P and Q lie on the +same rectangle edge: + +- both P.x == min.X and Q.x == min.X (left edge), or +- both P.x == max.X and Q.x == max.X (right edge), or +- both P.y == min.Y and Q.y == min.Y (bottom edge), or +- both P.y == max.Y and Q.y == max.Y (top edge). + +### Extracting arcs + +Walk the ring starting from a boundary edge. Skip consecutive boundary edges. +When a non-boundary edge is encountered, begin an interior arc at the current +vertex. Continue collecting vertices until the next boundary edge is +encountered. The arc ends at the vertex where the boundary edge begins. + +Each arc records: + +- Its coordinate sequence +- Its **start parameter** and **end parameter** on the rectangle boundary (see + parameterisation below) + +### Special cases + +- **No boundary edges at all**: the entire ring is interior (no contact with + the rectangle boundary). This happens when a hole is entirely inside the + rectangle and has no vertices on the rectangle boundary. Such a ring is + classified as a "free hole" and is not decomposed into arcs. It will be + assigned to an output polygon in Phase 4. A hole whose envelope is covered + by the rectangle but that has vertices on the rectangle boundary is **not** + free — it must be clipped to avoid producing invalid polygons with shared + edges between the exterior and hole rings. + +- **No interior edges at all**: the entire ring is boundary. For the exterior + ring, this means the original polygon completely contains the rectangle. No + arcs are extracted and in Phase 3 the output is the rectangle itself. For a + hole ring, this means the hole covers the entire rectangle. No polygon area + survives — the result is an empty polygon. + +## Phase 3: Topology Resolution + +### Rectangle boundary parameterisation + +The rectangle boundary is parameterised counter-clockwise starting from the +bottom-left corner: + +``` +Bottom edge (left to right): parameter 0 to W +Right edge (bottom to top): parameter W to W+H +Top edge (right to left): parameter W+H to 2W+H +Left edge (top to bottom): parameter 2W+H to 2W+2H (= perimeter) +``` + +Where W = max.X - min.X and H = max.Y - min.Y. The parameter wraps: the +bottom-left corner has parameter 0 and also parameter 2W+2H. + +A point on the boundary maps to a unique parameter based on which edge it lies +on. Corner points are assigned to the edge they appear on first in the CCW +traversal (e.g. the bottom-right corner has parameter W, assigned to the start +of the right edge). + +### Winding normalisation + +Before topology resolution, the clipped exterior ring is normalised to CCW. If +it has negative signed area (CW), it is reversed. Hole rings are **not** +reversed during this step — each hole's winding is handled independently. After +topology resolution, if the exterior was reversed, all output rings are +reversed back to restore the original winding convention. + +This means the topology resolution algorithm only needs to handle the CCW +exterior case. + +### Preparing arcs + +Interior arcs from the exterior ring are used as-is. + +Interior arcs from hole rings may or may not need reversal, depending on the +hole's own winding direction. In the simplefeatures data model, interior rings +are not required to have any particular orientation relative to the exterior +ring — a hole may be CW or CCW independently. + +The rule: after the exterior is normalised to CCW, a hole arc must be reversed +if and only if the hole ring is CCW (positive signed area). This is because a +CCW ring has its enclosed area to the left — for a hole, that enclosed area is +the empty space, so the polygon interior is to the right. Reversal puts the +polygon interior to the left, matching the CCW output convention. A CW hole +ring already has the polygon interior to the left and its arcs are used as-is. +This is determined by computing the signed area of each clipped hole ring +independently. + +Reversed arcs have their start and end parameters **swapped** (so that the +"output start" is the arc's original end, and vice versa) and are marked with a +`reverse` flag so the walking algorithm traverses their coordinates backwards. + +### The walking algorithm + +All arcs (from the exterior and from holes) are collected. Each arc has an +"output start" parameter and an "output end" parameter on the rectangle +boundary. + +The algorithm produces output rings by walking: + +1. Pick any unused arc. Call it the "first arc" of this ring. +2. Traverse the arc's coordinates (reversed if flagged). The ring is now at the + arc's output end parameter on the boundary. +3. Find the **next arc**: the arc whose output start parameter is the next one + going CCW from the current position on the boundary. This is the arc with + the smallest positive CCW distance from the current end parameter. +4. Build a **boundary path** from the current end parameter to the next arc's + start parameter. This path follows the rectangle boundary CCW and includes + any rectangle corners in between. +5. If the next arc is the first arc, the ring is complete. Otherwise, go to + step 2 with the next arc. +6. Repeat from step 1 for any remaining unused arcs. + +### Building boundary paths + +Given a start parameter p₁ and end parameter p₂, the boundary path includes +any rectangle corners whose parameters lie strictly between p₁ and p₂ going +CCW. + +For example, if p₁ = 3 (on the bottom edge) and p₂ = 10 (on the top edge) for +a rectangle with W=4, H=2 (corners at 0, 4, 6, 10): + +- Corners at parameters 4 and 6 are between 3 and 10. +- The path is: corner at param 4 (bottom-right), corner at param 6 + (top-right). + +The boundary path does not include the start and end points themselves (those +are already the last and first coordinates of the adjacent arcs). + +For Z/M coordinates, corner values are linearly interpolated between the Z/M +values at the start and end of the boundary path, proportional to the boundary +distance. + +### Why this produces correct output + +The key invariant is: **along the rectangle boundary going CCW, arc endpoints +alternate between "ends" and "starts"**. This is because each arc enters the +interior from the boundary and returns to the boundary. Between consecutive +end/start pairs, the boundary segment is part of the output polygon's boundary. + +For exterior arcs, the boundary between an arc's end and the next arc's start +represents a portion of the rectangle boundary that replaces the part of the +original polygon that extended outside the rectangle. + +For hole arcs (reversed), the boundary between the reversed arc's end and the +next arc's start represents a portion of the rectangle boundary that was +"inside the polygon but outside the hole" — i.e., it's still part of the +polygon. + +When a concave polygon self-touches after S-H clipping (e.g., a C-shape +clipped across its opening), the overlapping boundary arcs are replaced by +correct pairings that produce two separate output rings. + +## Phase 4: Assembly + +### Ring classification + +All output rings from topology resolution are exterior rings (CCW after +normalisation). Boundary-crossing holes create concavities or split the +exterior into multiple pieces, but never produce new holes. This is a property +of rectangle clipping: a hole that crosses the boundary "opens up" onto the +boundary, becoming part of the exterior's boundary rather than enclosing a +separate hole. + +Holes in the output come only from "free holes" — holes that are entirely +inside the rectangle and have no contact with the boundary. + +### Free hole assignment + +Each free hole is assigned to the output exterior ring that contains it. A +point from the hole (its first vertex) is tested against each output ring using +a ray-casting point-in-polygon test. + +### Output construction + +- If there are no output rings: return an empty polygon. +- If there is one output ring (with any free holes): return a Polygon. +- If there are multiple output rings: return a MultiPolygon. + +## Fast Paths + +Before running the full algorithm, several fast paths are checked: + +1. **Empty polygon**: return empty polygon. +2. **Degenerate rectangle** (point or line envelope): return empty polygon. A + polygon intersected with a lower-dimensional shape produces at most a + lower-dimensional result, which is discarded. +3. **Disjoint envelopes**: return empty polygon. +4. **Polygon entirely inside rectangle**: return the polygon unchanged. + +## Worked Example: C-shaped Polygon + +Input polygon (CCW): + +``` +(0, 2.5) → (3, 2.5) → (3, 2.8) → (0.5, 2.8) → +(0.5, 3.2) → (3, 3.2) → (3, 3.5) → (0, 3.5) +``` + +Rectangle: (1, 2) to (5, 4). + +### Phase 1: S-H clipping + +Only the left edge (x ≥ 1) produces changes. The other three edges leave all +vertices unchanged. + +After clipping: + +``` +(1, 2.5) → (3, 2.5) → (3, 2.8) → (1, 2.8) → +(1, 3.2) → (3, 3.2) → (3, 3.5) → (1, 3.5) +``` + +### Phase 2: Arc extraction + +Edge classification (boundary = both endpoints on same rect edge): + +| Edge | Type | +| ----------------- | -------- | +| (1,2.5) → (3,2.5) | interior | +| (3,2.5) → (3,2.8) | interior | +| (3,2.8) → (1,2.8) | interior | +| (1,2.8) → (1,3.2) | boundary | +| (1,3.2) → (3,3.2) | interior | +| (3,3.2) → (3,3.5) | interior | +| (3,3.5) → (1,3.5) | interior | +| (1,3.5) → (1,2.5) | boundary | + +Interior arcs: + +- **Arc 1**: (1,2.5) → (3,2.5) → (3,2.8) → (1,2.8). Start param = 11.5, end + param = 11.2. +- **Arc 2**: (1,3.2) → (3,3.2) → (3,3.5) → (1,3.5). Start param = 10.8, end + param = 10.5. + +### Phase 3: Topology resolution + +No hole arcs. Signed area is positive (CCW), so no reversal needed. + +Sorted arc endpoints on the boundary (going CCW from param 0): + +- 10.5 — Arc 2 end +- 10.8 — Arc 2 start +- 11.2 — Arc 1 end +- 11.5 — Arc 1 start + +Walking: + +**Ring A**: Start with Arc 2. Traverse (1,3.2) → (3,3.2) → (3,3.5) → (1,3.5). +End at param 10.5. Next start going CCW: param 10.8 (Arc 2's own start). Build +boundary path from 10.5 to 10.8: no corners between them (both on left edge). +Arc 2 is the first arc, so the ring is complete: (1,3.2), (3,3.2), (3,3.5), +(1,3.5). + +**Ring B**: Start with Arc 1. Traverse (1,2.5) → (3,2.5) → (3,2.8) → (1,2.8). +End at param 11.2. Next start going CCW: param 11.5 (Arc 1's own start). Build +boundary path from 11.2 to 11.5: no corners. Ring complete: (1,2.5), (3,2.5), +(3,2.8), (1,2.8). + +### Phase 4: Assembly + +Two output rings → MultiPolygon: + +``` +MULTIPOLYGON( + ((1 2.5, 3 2.5, 3 2.8, 1 2.8, 1 2.5)), + ((1 3.2, 3 3.2, 3 3.5, 1 3.5, 1 3.2)) +) +``` + +## Worked Example: Polygon Containing Rectangle (with hole) + +Input polygon: + +- Exterior (CCW): (-2, -2) → (6, -2) → (6, 6) → (-2, 6) — contains the + entire rectangle. +- Hole (CW): (-1, 1) → (2, 1) → (2, 3) → (-1, 3) — crosses left edge of + rectangle. + +Rectangle: (0, 0) to (4, 4). + +### Phase 1: S-H clipping + +Exterior clips to the rectangle itself (all boundary): (0,0), (4,0), (4,4), +(0,4). + +Hole clips to: (0,1), (2,1), (2,3), (0,3). + +### Phase 2: Arc extraction + +Exterior: no interior arcs (all boundary). + +Hole edges: + +| Edge | Type | +| ------------- | -------- | +| (0,1) → (2,1) | interior | +| (2,1) → (2,3) | interior | +| (2,3) → (0,3) | interior | +| (0,3) → (0,1) | boundary | + +Hole interior arc: (0,1) → (2,1) → (2,3) → (0,3). Start param = 15 (on left +edge), end param = 13 (on left edge). + +### Phase 3: Topology resolution + +Exterior signed area is positive (CCW), no reversal. The hole arc is CW. Its +start/end are swapped for the CCW output: output start = 13 (original end), +output end = 15 (original start). + +Only one arc. Walking: + +Start with the hole arc. Since `fromHole = true`, traverse in reverse: (0,3) → +(2,3) → (2,1) → (0,1). End at output end param = 15. Next start going CCW: +param 13 (the same arc). Build boundary path from param 15 to param 13: this +wraps around the entire rectangle. Corners at params 0, 4, 8, 12 are all +between 15 and 13 (going CCW past 16/0). Path: (0,0), (4,0), (4,4), (0,4). +Ring complete. + +Output ring: (0,3), (2,3), (2,1), (0,1), (0,0), (4,0), (4,4), (0,4). + +### Phase 4: Assembly + +Single output ring. The rectangle with a concavity where the hole was: + +``` +POLYGON((0 3, 2 3, 2 1, 0 1, 0 0, 4 0, 4 4, 0 4, 0 3)) +``` diff --git a/geom/alg_clip_by_rect.go b/geom/alg_clip_by_rect.go new file mode 100644 index 00000000..e491499d --- /dev/null +++ b/geom/alg_clip_by_rect.go @@ -0,0 +1,84 @@ +package geom + +// ClipByRect clips a geometry to an axis-aligned rectangle defined by the +// given [Envelope], returning the portion of the geometry that lies within the +// rectangle. It uses the Sutherland-Hodgman algorithm for polygon clipping +// and related approaches for other geometry types. +func ClipByRect(g Geometry, rect Envelope) Geometry { + switch g.Type() { + case TypePoint: + return clipPointByRect(g.MustAsPoint(), rect).AsGeometry() + case TypeMultiPoint: + return clipMultiPointByRect(g.MustAsMultiPoint(), rect).AsGeometry() + case TypeLineString: + return clipLineStringByRect(g.MustAsLineString(), rect) + case TypeMultiLineString: + return clipMultiLineStringByRect(g.MustAsMultiLineString(), rect).AsGeometry() + case TypePolygon: + return clipPolygonByRect(g.MustAsPolygon(), rect) + case TypeMultiPolygon: + return clipMultiPolygonByRect(g.MustAsMultiPolygon(), rect).AsGeometry() + case TypeGeometryCollection: + return clipGeometryCollectionByRect(g.MustAsGeometryCollection(), rect).AsGeometry() + default: + panic("unknown geometry: " + g.Type().String()) + } +} + +func clipPointByRect(p Point, rect Envelope) Point { + xy, ok := p.XY() + if !ok { + return p + } + if rect.Contains(xy) { + return p + } + return NewEmptyPoint(p.CoordinatesType()) +} + +func clipMultiPointByRect(mp MultiPoint, rect Envelope) MultiPoint { + n := mp.NumPoints() + var pts []Point + for i := 0; i < n; i++ { + p := mp.PointN(i) + clipped := clipPointByRect(p, rect) + if !clipped.IsEmpty() { + pts = append(pts, clipped) + } + } + if len(pts) == 0 { + return NewMultiPoint(nil).ForceCoordinatesType(mp.CoordinatesType()) + } + return NewMultiPoint(pts) +} + +func clipMultiLineStringByRect(mls MultiLineString, rect Envelope) MultiLineString { + n := mls.NumLineStrings() + var lines []LineString + for i := 0; i < n; i++ { + clipped := clipLineStringByRect(mls.LineStringN(i), rect) + switch clipped.Type() { + case TypeLineString: + ls := clipped.MustAsLineString() + if !ls.IsEmpty() { + lines = append(lines, ls) + } + case TypeMultiLineString: + lines = append(lines, clipped.MustAsMultiLineString().Dump()...) + default: + panic("unexpected type from clipLineStringByRect: " + clipped.Type().String()) + } + } + if len(lines) == 0 { + return NewMultiLineString(nil).ForceCoordinatesType(mls.CoordinatesType()) + } + return NewMultiLineString(lines) +} + +func clipMultiPolygonByRect(mp MultiPolygon, rect Envelope) MultiPolygon { + panic("TODO") +} + +func clipGeometryCollectionByRect(gc GeometryCollection, rect Envelope) GeometryCollection { + panic("TODO") +} diff --git a/geom/alg_clip_by_rect_liang_barsky.go b/geom/alg_clip_by_rect_liang_barsky.go new file mode 100644 index 00000000..9dcca008 --- /dev/null +++ b/geom/alg_clip_by_rect_liang_barsky.go @@ -0,0 +1,116 @@ +package geom + +func clipLineStringByRect(ls LineString, rect Envelope) Geometry { + seq := ls.Coordinates() + n := seq.Length() + if n == 0 { + return ls.AsGeometry() + } + + min, max, ok := rect.MinMaxXYs() + if !ok { + return NewLineString(NewSequence(nil, seq.CoordinatesType())).AsGeometry() + } + + ctype := seq.CoordinatesType() + dim := ctype.Dimension() + + var chains [][]float64 + var cur []float64 + + for i := 0; i < n-1; i++ { + a := seq.GetXY(i) + b := seq.GetXY(i + 1) + + tMin, tMax, ok := clipSegmentParams(a, b, min, max) + if !ok { + if len(cur) > 0 { + chains = append(chains, cur) + cur = nil + } + continue + } + + ca := interpolateSeqCoord(seq, i, i+1, tMin) + cb := interpolateSeqCoord(seq, i, i+1, tMax) + + if len(cur) > 0 && cur[len(cur)-dim] == ca.X && cur[len(cur)-dim+1] == ca.Y { + cur = cb.appendFloat64s(cur) + } else { + if len(cur) > 0 { + chains = append(chains, cur) + } + cur = ca.appendFloat64s(nil) + cur = cb.appendFloat64s(cur) + } + } + if len(cur) > 0 { + chains = append(chains, cur) + } + + if len(chains) == 0 { + return NewLineString(NewSequence(nil, ctype)).AsGeometry() + } + + lines := make([]LineString, len(chains)) + for i, c := range chains { + lines[i] = NewLineString(NewSequence(c, ctype)) + } + + if len(lines) == 1 { + return lines[0].AsGeometry() + } + return NewMultiLineString(lines).AsGeometry() +} + +// clipSegmentParams uses the Liang-Barsky algorithm to compute the parametric +// range [tMin, tMax] of segment a->b that lies inside the axis-aligned +// rectangle from min to max. It returns false if no segment of positive length +// survives. +func clipSegmentParams(a, b, min, max XY) (float64, float64, bool) { + tMin := 0.0 + tMax := 1.0 + dx := b.X - a.X + dy := b.Y - a.Y + for _, pq := range [4][2]float64{ + {-dx, a.X - min.X}, // left + {dx, max.X - a.X}, // right + {-dy, a.Y - min.Y}, // bottom + {dy, max.Y - a.Y}, // top + } { + p, q := pq[0], pq[1] + if p == 0 { + if q < 0 { + return 0, 0, false + } + continue + } + t := q / p + if p < 0 { + if t > tMin { + tMin = t + } + } else { + if t < tMax { + tMax = t + } + } + if tMin >= tMax { + return 0, 0, false + } + } + return tMin, tMax, true +} + +// interpolateSeqCoord linearly interpolates between coordinates at index i and +// j in seq at parameter t (0=coord i, 1=coord j). Delegates to +// [interpolateCoords] which uses a numerically robust lerp. +func interpolateSeqCoord(seq Sequence, i, j int, t float64) Coordinates { + if t == 0 { + return seq.Get(i) + } + if t == 1 { + return seq.Get(j) + } + return interpolateCoords(seq.Get(i), seq.Get(j), t) +} diff --git a/geom/alg_clip_by_rect_sutherland_hodgman.go b/geom/alg_clip_by_rect_sutherland_hodgman.go new file mode 100644 index 00000000..b351ddb4 --- /dev/null +++ b/geom/alg_clip_by_rect_sutherland_hodgman.go @@ -0,0 +1,630 @@ +package geom + +import ( + "cmp" + "fmt" + "slices" +) + +func clipPolygonByRect(p Polygon, rect Envelope) Geometry { + ctype := p.CoordinatesType() + emptyPoly := NewPolygon(nil).ForceCoordinatesType(ctype).AsGeometry() + + if p.IsEmpty() { + return emptyPoly + } + + // Normalise to CCW exterior / CW holes. This ensures all clipped rings + // have known winding, which the topology resolution depends on. + p = p.ForceCCW() + + // Degenerate rect: point or line envelope → empty polygon. + if !rect.IsRectangle() { + return emptyPoly + } + + min, max, ok := rect.MinMaxXYs() + if !ok { + return emptyPoly + } + + pEnv := p.Envelope() + + // Fast path: envelopes disjoint. + if !rect.Intersects(pEnv) { + return emptyPoly + } + + // Fast path: polygon entirely inside rect. + if rect.Covers(pEnv) { + return p.AsGeometry() + } + + // Clip exterior ring. + clippedExt := clipRingSH(p.ExteriorRing().Coordinates(), min, max) + if len(clippedExt) == 0 { + return emptyPoly + } + + // Classify holes. + var freeHoles []LineString + var clippedHoles [][]Coordinates + for i := 0; i < p.NumInteriorRings(); i++ { + hole := p.InteriorRingN(i) + holeEnv := hole.Envelope() + + if !rect.Intersects(holeEnv) { + // Hole entirely outside rect → discard. + continue + } + + // A hole is "free" (entirely inside the rect with no boundary + // contact) only if its envelope is strictly inside the rect. If any + // vertex lies on the rect boundary, it must be clipped to avoid + // producing invalid polygons with shared edges. + if rect.Covers(holeEnv) && !holeOnRectBoundary(hole.Coordinates(), min, max) { + freeHoles = append(freeHoles, hole) + continue + } + + // Hole touches or crosses rect boundary — clip it. + clippedHole := clipRingSH(hole.Coordinates(), min, max) + if len(clippedHole) > 0 { + clippedHoles = append(clippedHoles, clippedHole) + } + } + + return resolveClippedPolygon(clippedExt, freeHoles, clippedHoles, min, max, ctype) +} + +// holeOnRectBoundary reports whether any vertex of the sequence lies on the +// rect boundary. +func holeOnRectBoundary(seq Sequence, min, max XY) bool { + for i := 0; i < seq.Length(); i++ { + xy := seq.GetXY(i) + if xy.X == min.X || xy.X == max.X || xy.Y == min.Y || xy.Y == max.Y { + return true + } + } + return false +} + +// resolveClippedPolygon takes the clipped exterior ring, classified holes, and +// the rect bounds, and produces the output Geometry (Polygon or MultiPolygon). +// The input polygon must have been normalised to CCW (exterior CCW, holes CW) +// before clipping, so that all clipped rings have known winding. +func resolveClippedPolygon( + clippedExterior []Coordinates, + freeHoles []LineString, + clippedHoles [][]Coordinates, + min, max XY, + ctype CoordinatesType, +) Geometry { + extArcs := extractInteriorArcs(clippedExterior, min, max) + + // Collect all arcs. + var allArcs []interiorArc + allArcs = append(allArcs, extArcs...) + + emptyPoly := NewPolygon(nil).ForceCoordinatesType(ctype).AsGeometry() + for _, hole := range clippedHoles { + arcs := extractInteriorArcs(hole, min, max) + if len(arcs) == 0 { + // Clipped hole is entirely on the rect boundary — it covers + // the entire rect. No polygon area survives. + return emptyPoly + } + allArcs = append(allArcs, arcs...) + } + + var outputRings [][]Coordinates + if len(allArcs) == 0 { + // No interior arcs: the polygon contains the entire rect. + // Output is the rect itself (closed ring). + w := max.X - min.X + h := max.Y - min.Y + corners := [4]float64{0, w, w + h, 2*w + h} + ring := make([]Coordinates, 5) + for i, cp := range corners { + ring[i] = Coordinates{XY: paramToXY(cp, min, max), Type: ctype} + } + ring[4] = ring[0] // close the ring + outputRings = append(outputRings, ring) + } else { + outputRings = walkArcs(allArcs, min, max) + } + + // Build output polygons. Each output ring is an exterior (already closed). + var polys []Polygon + for _, ring := range outputRings { + seq := coordsToSeq(ring, ctype) + exterior := NewLineString(seq) + polys = append(polys, NewPolygon([]LineString{exterior})) + } + + // Assign free holes to the correct exterior ring. + for _, hole := range freeHoles { + holeXY, ok := hole.StartPoint().XY() + if !ok { + continue + } + // TODO: Use R-Tree to speed up assignment to the correct exterior ring. + for i, ring := range outputRings { + if pointInRingXY(holeXY, ring) { + existingRings := polys[i].DumpRings() + existingRings = append(existingRings, hole) + polys[i] = NewPolygon(existingRings) + break + } + } + } + + // Return Polygon or MultiPolygon. + if len(polys) == 0 { + return NewPolygon(nil).ForceCoordinatesType(ctype).AsGeometry() + } + if len(polys) == 1 { + return polys[0].AsGeometry() + } + return NewMultiPolygon(polys).AsGeometry() +} + +// walkArcs performs the topology resolution walk, producing output rings from +// the collected interior arcs. It pairs arc endpoints along the rect boundary +// (CCW) and traces complete rings. +func walkArcs(arcs []interiorArc, min, max XY) [][]Coordinates { + w := max.X - min.X + h := max.Y - min.Y + perim := 2*w + 2*h + + // Build a sorted list of arc "end" events and a map from "end param" to + // the index of the arc that ends there. Also build a map from + // "start param" to arc index. + type endpoint struct { + param float64 + idx int + } + var endPoints []endpoint // sorted by param + startByParam := make(map[float64]int) + for i, a := range arcs { + endPoints = append(endPoints, endpoint{a.endParam, i}) + startByParam[a.startParam] = i + } + + // Sort endpoints by param. + slices.SortFunc(endPoints, func(a, b endpoint) int { + return cmp.Compare(a.param, b.param) + }) + + // Collect all start params sorted. + var startParams []float64 + for p := range startByParam { + startParams = append(startParams, p) + } + slices.Sort(startParams) + + // For a given end param, find the next start param going CCW. + findNextStart := func(endParam float64) (float64, int) { + // Find the first start param that is strictly after endParam (CCW). + best := -1.0 + bestDist := perim + 1 + for _, sp := range startParams { + d := ccwDist(endParam, sp, perim) + if d > 0 && d < bestDist { + bestDist = d + best = sp + } + } + if best < 0 { + // TODO: Investigate whether this fallback is reachable and + // whether it produces correct results. + // + // This branch is reached when every start param has ccwDist + // == 0 from endParam — i.e., every arc's start is at the + // exact same boundary parameter as the current arc's end. + // The d > 0 filter above rejected them all. + // + // For valid clipped polygons this shouldn't happen: an + // arc's end and the next arc's start should be at distinct + // boundary positions (separated by at least one boundary + // edge). Two arcs sharing the same boundary parameter would + // mean two ring transitions at the exact same point, which + // would imply a degenerate or self-touching input. + // + // If this is truly unreachable, it should be replaced with + // a panic. If it is reachable, the behaviour of picking an + // arbitrary arc at the same parameter needs validation — + // it's unclear whether the walking algorithm produces + // correct output in this case. + for _, sp := range startParams { + if sp == endParam { + return sp, startByParam[sp] + } + } + } + return best, startByParam[best] + } + + used := make([]bool, len(arcs)) + var rings [][]Coordinates + + for { + // Find first unused arc. + firstIdx := -1 + for i := range arcs { + if !used[i] { + firstIdx = i + break + } + } + if firstIdx < 0 { + break + } + + var ring []Coordinates + curIdx := firstIdx + + for { + used[curIdx] = true + a := arcs[curIdx] + + // Append arc coordinates. + ring = append(ring, a.coords...) + + // Find next arc via boundary. + nextStartParam, nextIdx := findNextStart(a.endParam) + + // Build boundary path from this arc's end to the next arc's start. + endCoord := ring[len(ring)-1] + startCoord := arcs[nextIdx].coords[0] + + bpath := buildBoundaryPath(a.endParam, nextStartParam, endCoord, startCoord, min, max) + ring = append(ring, bpath...) + + if nextIdx == firstIdx { + break // Ring complete. + } + curIdx = nextIdx + } + + // Close the ring. + ring = append(ring, ring[0]) + ring = removeDupConsecutiveCoords(ring) + if len(ring) >= 4 { + rings = append(rings, ring) + } + } + + return rings +} + +// buildBoundaryPath returns the coordinates along the rect boundary going CCW +// from startParam to endParam. It includes rect corners between the two +// parameters but does NOT include the start or end points themselves (those are +// the last/first coordinates of the adjacent arcs). Z and M values at corners +// are linearly interpolated between startCoord and endCoord based on boundary +// distance. +func buildBoundaryPath(startParam, endParam float64, startCoord, endCoord Coordinates, min, max XY) []Coordinates { + w := max.X - min.X + h := max.Y - min.Y + perim := 2*w + 2*h + + // Corner parameters in CCW order. + corners := [4]float64{0, w, w + h, 2*w + h} + + // Find the first corner after startParam going CCW. The corners are in + // ascending parameter order, so this is the first with cp > startParam. + // If startParam is past the last corner (on the left edge), no corner + // qualifies and firstIdx stays at 0 — the bottom-left corner is the + // next one going CCW. + firstIdx := 0 + for i, cp := range corners { + if cp > startParam { + firstIdx = i + break + } + } + + // Iterate corners in CCW order from firstIdx, collecting those strictly + // between startParam and endParam. + totalDist := ccwDist(startParam, endParam, perim) + var path []Coordinates + for k := 0; k < 4; k++ { + cp := corners[(firstIdx+k)%4] + d := ccwDist(startParam, cp, perim) + if d >= totalDist { + break + } + frac := d / totalDist + c := interpolateCoords(startCoord, endCoord, frac) + c.XY = paramToXY(cp, min, max) + path = append(path, c) + } + return path +} + +// extractInteriorArcs decomposes a clipped ring into interior arcs. The ring +// must be explicitly closed (first == last). Each arc starts and ends at a +// point on the rect boundary. Returns an empty slice if the ring has no +// interior arcs (entirely on the boundary or entirely interior with no +// boundary contact). +func extractInteriorArcs(ring []Coordinates, min, max XY) []interiorArc { + n := len(ring) + if n < 4 { + return nil + } + + // There are n-1 edges in a closed ring (the last vertex == first vertex, + // so edge n-1 from ring[n-1] to ring[0] is degenerate and skipped). + numEdges := n - 1 + + // Classify each edge as boundary (both endpoints on same rect edge) or interior. + isBdry := make([]bool, numEdges) + for i := 0; i < numEdges; i++ { + isBdry[i] = isSameRectEdge(ring[i].XY, ring[i+1].XY, min, max) + } + + // Find a starting boundary edge so we can walk from there. If there are + // no boundary edges, the entire ring is interior (no boundary contact). + start := -1 + for i := 0; i < numEdges; i++ { + if isBdry[i] { + start = i + break + } + } + if start < 0 { + return nil + } + + // Walk around the ring starting from a boundary edge. + var arcs []interiorArc + i := start + for steps := 0; steps < numEdges; { + // Skip boundary edges. + for steps < numEdges && isBdry[i%numEdges] { + i++ + steps++ + } + if steps >= numEdges { + break + } + // Start of an interior arc at ring[i%numEdges]. + var arcCoords []Coordinates + arcCoords = append(arcCoords, ring[i%numEdges]) + i++ + steps++ + for steps < numEdges && !isBdry[i%numEdges] { + arcCoords = append(arcCoords, ring[i%numEdges]) + i++ + steps++ + } + if steps < numEdges { + // The arc ends at ring[i%numEdges] (the start of the next boundary edge). + arcCoords = append(arcCoords, ring[i%numEdges]) + } else { + // Wrapped around; the arc ends at ring[start] (where we began). + arcCoords = append(arcCoords, ring[start]) + } + + sp := rectBoundaryParam(arcCoords[0].XY, min, max) + ep := rectBoundaryParam(arcCoords[len(arcCoords)-1].XY, min, max) + arcs = append(arcs, interiorArc{ + coords: arcCoords, + startParam: sp, + endParam: ep, + }) + } + return arcs +} + +// interiorArc represents a portion of a clipped ring that passes through the +// interior of the clipping rectangle (not along its boundary). The first and +// last elements of coords are on the rectangle boundary; everything in between +// is in the interior. +type interiorArc struct { + coords []Coordinates + startParam float64 // boundary parameter of coords[0] + endParam float64 // boundary parameter of coords[len(coords)-1] +} + +// clipRingSH clips a closed ring against an axis-aligned rectangle using the +// Sutherland-Hodgman algorithm. It returns the clipped ring as a closed slice +// of [Coordinates] (first == last), or nil if the ring is entirely outside the +// rectangle. The input ring must be explicitly closed. +func clipRingSH(seq Sequence, min, max XY) []Coordinates { + coords := seqToCoords(seq) + if len(coords) < 4 { + return nil + } + + // Clip against each of the 4 edges. + coords = clipToEdge(coords, + func(c Coordinates) bool { return c.X >= min.X }, + func(a, b Coordinates) Coordinates { return interpX(a, b, min.X) }) + coords = clipToEdge(coords, + func(c Coordinates) bool { return c.X <= max.X }, + func(a, b Coordinates) Coordinates { return interpX(a, b, max.X) }) + coords = clipToEdge(coords, + func(c Coordinates) bool { return c.Y >= min.Y }, + func(a, b Coordinates) Coordinates { return interpY(a, b, min.Y) }) + coords = clipToEdge(coords, + func(c Coordinates) bool { return c.Y <= max.Y }, + func(a, b Coordinates) Coordinates { return interpY(a, b, max.Y) }) + + coords = removeDupConsecutiveCoords(coords) + if len(coords) < 4 { + return nil + } + return coords +} + +// clipToEdge performs one pass of the Sutherland-Hodgman algorithm, clipping a +// closed ring against a single half-plane defined by isInside. The intersect +// function computes the intersection of segment a→b with the clipping edge. +// The output is also an explicitly closed ring. +func clipToEdge( + coords []Coordinates, + isInside func(Coordinates) bool, + intersect func(Coordinates, Coordinates) Coordinates, +) []Coordinates { + if len(coords) == 0 { + return nil + } + var output []Coordinates + a := coords[len(coords)-1] + for _, b := range coords { + aIn := isInside(a) + bIn := isInside(b) + switch { + case aIn && bIn: + output = append(output, b) + case aIn && !bIn: + output = append(output, intersect(a, b)) + case !aIn && bIn: + output = append(output, intersect(a, b)) + output = append(output, b) + } + a = b + } + // Ensure the output is explicitly closed. When the input's closing vertex + // is outside the clip region, the degenerate closing edge emits nothing, + // leaving the output open. + if len(output) > 0 && output[0].XY != output[len(output)-1].XY { + output = append(output, output[0]) + } + return output +} + +// interpX returns the intersection of segment a→b with the vertical line x=k. +// The result's X coordinate is set to x exactly to ensure reliable boundary +// detection. Z and M are interpolated via [interpolateCoords]. +func interpX(a, b Coordinates, x float64) Coordinates { + t := (x - a.X) / (b.X - a.X) + c := interpolateCoords(a, b, t) + c.XY.X = x + return c +} + +// interpY returns the intersection of segment a→b with the horizontal line +// y=k. The result's Y coordinate is set to y exactly to ensure reliable +// boundary detection. Z and M are interpolated via [interpolateCoords]. +func interpY(a, b Coordinates, y float64) Coordinates { + t := (y - a.Y) / (b.Y - a.Y) + c := interpolateCoords(a, b, t) + c.XY.Y = y + return c +} + +// rectBoundaryParam returns the CCW boundary parameter for a point on the rect +// boundary. The parameterisation starts at the bottom-left corner and goes +// counter-clockwise: bottom→right→top→left. +func rectBoundaryParam(xy, min, max XY) float64 { + w := max.X - min.X + h := max.Y - min.Y + switch { + case xy.Y == min.Y: // bottom edge + return xy.X - min.X + case xy.X == max.X: // right edge + return w + (xy.Y - min.Y) + case xy.Y == max.Y: // top edge + return w + h + (max.X - xy.X) + case xy.X == min.X: // left edge + return 2*w + h + (max.Y - xy.Y) + default: + panic(fmt.Sprintf("point %v not on rect boundary [%v, %v]", xy, min, max)) + } +} + +// paramToXY converts a CCW boundary parameter back to an XY coordinate. +func paramToXY(param float64, min, max XY) XY { + w := max.X - min.X + h := max.Y - min.Y + switch { + case param < w: // bottom edge + return XY{X: min.X + param, Y: min.Y} + case param < w+h: // right edge + return XY{X: max.X, Y: min.Y + param - w} + case param < 2*w+h: // top edge + return XY{X: max.X - (param - w - h), Y: max.Y} + case param < 2*w+2*h: // left edge + return XY{X: min.X, Y: max.Y - (param - 2*w - h)} + default: + panic(fmt.Sprintf("boundary parameter %v out of range [0, %v)", param, 2*w+2*h)) + } +} + +// ccwDist returns the CCW distance from param a to param b on a boundary with +// total perimeter perim. +func ccwDist(a, b, perim float64) float64 { + d := b - a + if d < 0 { + d += perim + } + return d +} + +// pointInRingXY returns true if xy is inside the ring defined by the given +// closed coordinates (first == last), using the ray-casting algorithm. +func pointInRingXY(xy XY, ring []Coordinates) bool { + inside := false + n := len(ring) + for i := range ring { + j := (i + 1) % n + yi, yj := ring[i].Y, ring[j].Y + xi, xj := ring[i].X, ring[j].X + if (yi > xy.Y) != (yj > xy.Y) { + slope := (xy.Y - yi) / (yj - yi) + xIntersect := xi + slope*(xj-xi) + if xy.X < xIntersect { + inside = !inside + } + } + } + return inside +} + +// seqToCoords extracts all Coordinates from a Sequence into a slice. +func seqToCoords(seq Sequence) []Coordinates { + n := seq.Length() + coords := make([]Coordinates, n) + for i := range coords { + coords[i] = seq.Get(i) + } + return coords +} + +// coordsToSeq converts a slice of Coordinates into a Sequence. +func coordsToSeq(coords []Coordinates, ctype CoordinatesType) Sequence { + dim := ctype.Dimension() + floats := make([]float64, 0, len(coords)*dim) + for _, c := range coords { + c.Type = ctype + floats = c.appendFloat64s(floats) + } + return NewSequence(floats, ctype) +} + +// removeDupConsecutiveCoords removes consecutive vertices with identical XY. +// For closed rings (first == last), the closing vertex is preserved. +func removeDupConsecutiveCoords(coords []Coordinates) []Coordinates { + if len(coords) == 0 { + return nil + } + out := coords[:1] + for _, c := range coords[1:] { + if c.XY != out[len(out)-1].XY { + out = append(out, c) + } + } + return out +} + +// isSameRectEdge returns true if both points lie on the same edge of the +// rectangle. +func isSameRectEdge(a, b, min, max XY) bool { + return (a.X == min.X && b.X == min.X) || + (a.X == max.X && b.X == max.X) || + (a.Y == min.Y && b.Y == min.Y) || + (a.Y == max.Y && b.Y == max.Y) +} diff --git a/geom/alg_sutherland_hodgman.go b/geom/alg_sutherland_hodgman.go deleted file mode 100644 index ac8782cd..00000000 --- a/geom/alg_sutherland_hodgman.go +++ /dev/null @@ -1,203 +0,0 @@ -package geom - -// ClipByRect clips a geometry to an axis-aligned rectangle defined by the -// given [Envelope], returning the portion of the geometry that lies within the -// rectangle. It uses the Sutherland-Hodgman algorithm for polygon clipping -// and related approaches for other geometry types. -func ClipByRect(g Geometry, rect Envelope) Geometry { - switch g.Type() { - case TypePoint: - return clipPointByRect(g.MustAsPoint(), rect).AsGeometry() - case TypeMultiPoint: - return clipMultiPointByRect(g.MustAsMultiPoint(), rect).AsGeometry() - case TypeLineString: - return clipLineStringByRect(g.MustAsLineString(), rect) - case TypeMultiLineString: - return clipMultiLineStringByRect(g.MustAsMultiLineString(), rect).AsGeometry() - case TypePolygon: - return clipPolygonByRect(g.MustAsPolygon(), rect) - case TypeMultiPolygon: - return clipMultiPolygonByRect(g.MustAsMultiPolygon(), rect).AsGeometry() - case TypeGeometryCollection: - return clipGeometryCollectionByRect(g.MustAsGeometryCollection(), rect).AsGeometry() - default: - panic("unknown geometry: " + g.Type().String()) - } -} - -func clipPointByRect(p Point, rect Envelope) Point { - xy, ok := p.XY() - if !ok { - return p - } - if rect.Contains(xy) { - return p - } - return NewEmptyPoint(p.CoordinatesType()) -} - -func clipMultiPointByRect(mp MultiPoint, rect Envelope) MultiPoint { - n := mp.NumPoints() - var pts []Point - for i := 0; i < n; i++ { - p := mp.PointN(i) - clipped := clipPointByRect(p, rect) - if !clipped.IsEmpty() { - pts = append(pts, clipped) - } - } - if len(pts) == 0 { - return NewMultiPoint(nil).ForceCoordinatesType(mp.CoordinatesType()) - } - return NewMultiPoint(pts) -} - -func clipLineStringByRect(ls LineString, rect Envelope) Geometry { - seq := ls.Coordinates() - n := seq.Length() - if n == 0 { - return ls.AsGeometry() - } - - min, max, ok := rect.MinMaxXYs() - if !ok { - return NewLineString(NewSequence(nil, seq.CoordinatesType())).AsGeometry() - } - - ctype := seq.CoordinatesType() - dim := ctype.Dimension() - - var chains [][]float64 - var cur []float64 - - for i := 0; i < n-1; i++ { - a := seq.GetXY(i) - b := seq.GetXY(i + 1) - - tMin, tMax, ok := clipSegmentParams(a, b, min, max) - if !ok { - if len(cur) > 0 { - chains = append(chains, cur) - cur = nil - } - continue - } - - ca := interpolateSeqCoord(seq, i, i+1, tMin) - cb := interpolateSeqCoord(seq, i, i+1, tMax) - - if len(cur) > 0 && cur[len(cur)-dim] == ca.X && cur[len(cur)-dim+1] == ca.Y { - cur = cb.appendFloat64s(cur) - } else { - if len(cur) > 0 { - chains = append(chains, cur) - } - cur = ca.appendFloat64s(nil) - cur = cb.appendFloat64s(cur) - } - } - if len(cur) > 0 { - chains = append(chains, cur) - } - - if len(chains) == 0 { - return NewLineString(NewSequence(nil, ctype)).AsGeometry() - } - - lines := make([]LineString, len(chains)) - for i, c := range chains { - lines[i] = NewLineString(NewSequence(c, ctype)) - } - - if len(lines) == 1 { - return lines[0].AsGeometry() - } - return NewMultiLineString(lines).AsGeometry() -} - -// clipSegmentParams uses the Liang-Barsky algorithm to compute the parametric -// range [tMin, tMax] of segment a->b that lies inside the axis-aligned -// rectangle from min to max. It returns false if no segment of positive length -// survives. -func clipSegmentParams(a, b, min, max XY) (float64, float64, bool) { - tMin := 0.0 - tMax := 1.0 - dx := b.X - a.X - dy := b.Y - a.Y - for _, pq := range [4][2]float64{ - {-dx, a.X - min.X}, // left - {dx, max.X - a.X}, // right - {-dy, a.Y - min.Y}, // bottom - {dy, max.Y - a.Y}, // top - } { - p, q := pq[0], pq[1] - if p == 0 { - if q < 0 { - return 0, 0, false - } - continue - } - t := q / p - if p < 0 { - if t > tMin { - tMin = t - } - } else { - if t < tMax { - tMax = t - } - } - if tMin >= tMax { - return 0, 0, false - } - } - return tMin, tMax, true -} - -// interpolateSeqCoord linearly interpolates between coordinates at index i and -// j in seq at parameter t (0=coord i, 1=coord j). Delegates to -// [interpolateCoords] which uses a numerically robust lerp. -func interpolateSeqCoord(seq Sequence, i, j int, t float64) Coordinates { - if t == 0 { - return seq.Get(i) - } - if t == 1 { - return seq.Get(j) - } - return interpolateCoords(seq.Get(i), seq.Get(j), t) -} - -func clipMultiLineStringByRect(mls MultiLineString, rect Envelope) MultiLineString { - n := mls.NumLineStrings() - var lines []LineString - for i := 0; i < n; i++ { - clipped := clipLineStringByRect(mls.LineStringN(i), rect) - switch clipped.Type() { - case TypeLineString: - ls := clipped.MustAsLineString() - if !ls.IsEmpty() { - lines = append(lines, ls) - } - case TypeMultiLineString: - lines = append(lines, clipped.MustAsMultiLineString().Dump()...) - default: - panic("unexpected type from clipLineStringByRect: " + clipped.Type().String()) - } - } - if len(lines) == 0 { - return NewMultiLineString(nil).ForceCoordinatesType(mls.CoordinatesType()) - } - return NewMultiLineString(lines) -} - -func clipPolygonByRect(p Polygon, rect Envelope) Geometry { - panic("TODO") -} - -func clipMultiPolygonByRect(mp MultiPolygon, rect Envelope) MultiPolygon { - panic("TODO") -} - -func clipGeometryCollectionByRect(gc GeometryCollection, rect Envelope) GeometryCollection { - panic("TODO") -} diff --git a/geom/alg_sutherland_hodgman_test.go b/geom/alg_sutherland_hodgman_test.go index 7024155d..68163c9f 100644 --- a/geom/alg_sutherland_hodgman_test.go +++ b/geom/alg_sutherland_hodgman_test.go @@ -29,106 +29,231 @@ func TestClipByRect(t *testing.T) { name string input string want string + opts []geom.ExactEqualsOption }{ // PT1: Empty Point - {"PT1", "POINT EMPTY", "POINT EMPTY"}, + {"PT1", "POINT EMPTY", "POINT EMPTY", nil}, // PT2: Point strictly inside R - {"PT2", "POINT(3 3)", "POINT(3 3)"}, + {"PT2", "POINT(3 3)", "POINT(3 3)", nil}, // PT3: Point strictly outside R - {"PT3", "POINT(0 0)", "POINT EMPTY"}, + {"PT3", "POINT(0 0)", "POINT EMPTY", nil}, // PT4: Point on edge of R (on left edge, x=1) - {"PT4", "POINT(1 3)", "POINT(1 3)"}, + {"PT4", "POINT(1 3)", "POINT(1 3)", nil}, // PT5: Point on corner of R (bottom-left corner) - {"PT5", "POINT(1 2)", "POINT(1 2)"}, + {"PT5", "POINT(1 2)", "POINT(1 2)", nil}, // MP1: Empty MultiPoint - {"MP1", "MULTIPOINT EMPTY", "MULTIPOINT EMPTY"}, + {"MP1", "MULTIPOINT EMPTY", "MULTIPOINT EMPTY", nil}, // MP2: All points inside R - {"MP2", "MULTIPOINT(2 3,3 3)", "MULTIPOINT(2 3,3 3)"}, + {"MP2", "MULTIPOINT(2 3,3 3)", "MULTIPOINT(2 3,3 3)", nil}, // MP3: All points outside R - {"MP3", "MULTIPOINT(0 0,6 6)", "MULTIPOINT EMPTY"}, + {"MP3", "MULTIPOINT(0 0,6 6)", "MULTIPOINT EMPTY", nil}, // MP4: Some points inside, some outside - {"MP4", "MULTIPOINT(3 3,0 0)", "MULTIPOINT(3 3)"}, + {"MP4", "MULTIPOINT(3 3,0 0)", "MULTIPOINT(3 3)", nil}, // MP5: Single point inside R - {"MP5", "MULTIPOINT(3 3)", "MULTIPOINT(3 3)"}, + {"MP5", "MULTIPOINT(3 3)", "MULTIPOINT(3 3)", nil}, // MP6: Points on edges and corners of R - {"MP6", "MULTIPOINT(1 3,1 2)", "MULTIPOINT(1 3,1 2)"}, + {"MP6", "MULTIPOINT(1 3,1 2)", "MULTIPOINT(1 3,1 2)", nil}, // MP7: Mix of inside, on-boundary, and outside - {"MP7", "MULTIPOINT(3 3,1 3,0 0)", "MULTIPOINT(3 3,1 3)"}, + {"MP7", "MULTIPOINT(3 3,1 3,0 0)", "MULTIPOINT(3 3,1 3)", nil}, // MP8: MultiPoint containing empty points - {"MP8", "MULTIPOINT(3 3,EMPTY)", "MULTIPOINT(3 3)"}, + {"MP8", "MULTIPOINT(3 3,EMPTY)", "MULTIPOINT(3 3)", nil}, // MP9: XYZ MultiPoint, all points outside R - {"MP9", "MULTIPOINT Z(0 0 7,6 6 8)", "MULTIPOINT Z EMPTY"}, + {"MP9", "MULTIPOINT Z(0 0 7,6 6 8)", "MULTIPOINT Z EMPTY", nil}, // LS1: Empty LineString - {"LS1", "LINESTRING EMPTY", "LINESTRING EMPTY"}, + {"LS1", "LINESTRING EMPTY", "LINESTRING EMPTY", nil}, // LS2: Entirely inside R - {"LS2", "LINESTRING(2 3,4 3)", "LINESTRING(2 3,4 3)"}, + {"LS2", "LINESTRING(2 3,4 3)", "LINESTRING(2 3,4 3)", nil}, // LS3: Entirely outside R (no overlap) - {"LS3", "LINESTRING(6 5,7 6)", "LINESTRING EMPTY"}, + {"LS3", "LINESTRING(6 5,7 6)", "LINESTRING EMPTY", nil}, // LS4: Entirely outside R on one side (all left of R) - {"LS4", "LINESTRING(0 3,0 3.5)", "LINESTRING EMPTY"}, + {"LS4", "LINESTRING(0 3,0 3.5)", "LINESTRING EMPTY", nil}, // LS5: Crosses R, entering and exiting once - {"LS5", "LINESTRING(0 3,2 3,6 5)", "LINESTRING(1 3,2 3,4 4)"}, + {"LS5", "LINESTRING(0 3,2 3,6 5)", "LINESTRING(1 3,2 3,4 4)", nil}, // LS6: Crosses R, entering and exiting multiple times - {"LS6", "LINESTRING(0 3,3 3,3 5,4 5,4 3,6 3)", "MULTILINESTRING((1 3,3 3,3 4),(4 4,4 3,5 3))"}, + {"LS6", "LINESTRING(0 3,3 3,3 5,4 5,4 3,6 3)", "MULTILINESTRING((1 3,3 3,3 4),(4 4,4 3,5 3))", nil}, // LS7: One endpoint inside, one outside - {"LS7", "LINESTRING(3 3,6 3)", "LINESTRING(3 3,5 3)"}, + {"LS7", "LINESTRING(3 3,6 3)", "LINESTRING(3 3,5 3)", nil}, // LS8: One endpoint outside, one inside - {"LS8", "LINESTRING(0 3,3 3)", "LINESTRING(1 3,3 3)"}, + {"LS8", "LINESTRING(0 3,3 3)", "LINESTRING(1 3,3 3)", nil}, // LS9: Both endpoints outside, segment passes through R - {"LS9", "LINESTRING(0 3,6 3)", "LINESTRING(1 3,5 3)"}, + {"LS9", "LINESTRING(0 3,6 3)", "LINESTRING(1 3,5 3)", nil}, // LS10: Endpoint exactly on edge of R, other inside - {"LS10", "LINESTRING(1 3,3 3)", "LINESTRING(1 3,3 3)"}, + {"LS10", "LINESTRING(1 3,3 3)", "LINESTRING(1 3,3 3)", nil}, // LS11: Endpoint exactly on corner of R, other inside - {"LS11", "LINESTRING(1 2,3 3)", "LINESTRING(1 2,3 3)"}, + {"LS11", "LINESTRING(1 2,3 3)", "LINESTRING(1 2,3 3)", nil}, // LS12: Both endpoints on boundary of R - {"LS12", "LINESTRING(1 3,5 3)", "LINESTRING(1 3,5 3)"}, + {"LS12", "LINESTRING(1 3,5 3)", "LINESTRING(1 3,5 3)", nil}, // LS13: LineString lies entirely along one edge of R - {"LS13", "LINESTRING(2 2,4 2)", "LINESTRING(2 2,4 2)"}, + {"LS13", "LINESTRING(2 2,4 2)", "LINESTRING(2 2,4 2)", nil}, // LS14: LineString lies entirely along two adjacent edges (L-shaped) - {"LS14", "LINESTRING(1 3,1 2,3 2)", "LINESTRING(1 3,1 2,3 2)"}, + {"LS14", "LINESTRING(1 3,1 2,3 2)", "LINESTRING(1 3,1 2,3 2)", nil}, // LS15: Segment touches corner of R but does not enter (V-shape) - {"LS15", "LINESTRING(0 1,1 2,0 3)", "LINESTRING EMPTY"}, + {"LS15", "LINESTRING(0 1,1 2,0 3)", "LINESTRING EMPTY", nil}, // LS16: Segment touches edge of R tangentially - {"LS16", "LINESTRING(2 5,3 4,4 5)", "LINESTRING EMPTY"}, + {"LS16", "LINESTRING(2 5,3 4,4 5)", "LINESTRING EMPTY", nil}, // LS17: Diagonal line crossing two edges of R - {"LS17", "LINESTRING(0 0,6 6)", "LINESTRING(2 2,4 4)"}, + {"LS17", "LINESTRING(0 0,6 6)", "LINESTRING(2 2,4 4)", nil}, // LS18: Axis-aligned line crossing two opposite edges of R - {"LS18", "LINESTRING(3 0,3 6)", "LINESTRING(3 2,3 4)"}, + {"LS18", "LINESTRING(3 0,3 6)", "LINESTRING(3 2,3 4)", nil}, // LS19: Closed LineString (ring) inside R - {"LS19", "LINESTRING(2 2.5,4 2.5,3 3.5,2 2.5)", "LINESTRING(2 2.5,4 2.5,3 3.5,2 2.5)"}, + {"LS19", "LINESTRING(2 2.5,4 2.5,3 3.5,2 2.5)", "LINESTRING(2 2.5,4 2.5,3 3.5,2 2.5)", nil}, // LS20: Closed LineString (ring) partially overlapping R - {"LS20", "LINESTRING(2 1,4 1,4 5,2 5,2 1)", "MULTILINESTRING((4 2,4 4),(2 4,2 2))"}, + {"LS20", "LINESTRING(2 1,4 1,4 5,2 5,2 1)", "MULTILINESTRING((4 2,4 4),(2 4,2 2))", nil}, // LS21: Multi-segment LineString with some segments inside, some outside - {"LS21", "LINESTRING(3 3,4 3,6 3)", "LINESTRING(3 3,4 3,5 3)"}, + {"LS21", "LINESTRING(3 3,4 3,6 3)", "LINESTRING(3 3,4 3,5 3)", nil}, // LS22: Zigzag LineString entering and exiting R many times - {"LS22", "LINESTRING(0 3,2 3,2 5,3 5,3 3,4 3,4 5,5 5,5 3,7 3)", "MULTILINESTRING((1 3,2 3,2 4),(3 4,3 3,4 3,4 4),(5 4,5 3))"}, + {"LS22", "LINESTRING(0 3,2 3,2 5,3 5,3 3,4 3,4 5,5 5,5 3,7 3)", "MULTILINESTRING((1 3,2 3,2 4),(3 4,3 3,4 3,4 4),(5 4,5 3))", nil}, // LS23: Vertex exactly on R boundary, adjacent edges inside - {"LS23", "LINESTRING(2 3,3 2,4 3)", "LINESTRING(2 3,3 2,4 3)"}, + {"LS23", "LINESTRING(2 3,3 2,4 3)", "LINESTRING(2 3,3 2,4 3)", nil}, // LS24: Vertex exactly on R boundary, adjacent edges outside - {"LS24", "LINESTRING(0 1,1 3,0 5)", "LINESTRING EMPTY"}, + {"LS24", "LINESTRING(0 1,1 3,0 5)", "LINESTRING EMPTY", nil}, // LS25: LineString passes through two opposite corners of R - {"LS25", "LINESTRING(0 1.5,6 4.5)", "LINESTRING(1 2,5 4)"}, + {"LS25", "LINESTRING(0 1.5,6 4.5)", "LINESTRING(1 2,5 4)", nil}, // MLS1: Empty MultiLineString - {"MLS1", "MULTILINESTRING EMPTY", "MULTILINESTRING EMPTY"}, + {"MLS1", "MULTILINESTRING EMPTY", "MULTILINESTRING EMPTY", nil}, // MLS2: All component LineStrings inside R - {"MLS2", "MULTILINESTRING((2 3,4 3),(3 2.5,3 3.5))", "MULTILINESTRING((2 3,4 3),(3 2.5,3 3.5))"}, + {"MLS2", "MULTILINESTRING((2 3,4 3),(3 2.5,3 3.5))", "MULTILINESTRING((2 3,4 3),(3 2.5,3 3.5))", nil}, // MLS3: All component LineStrings outside R - {"MLS3", "MULTILINESTRING((6 5,7 6),(0 0,0 1))", "MULTILINESTRING EMPTY"}, + {"MLS3", "MULTILINESTRING((6 5,7 6),(0 0,0 1))", "MULTILINESTRING EMPTY", nil}, // MLS4: Some components inside, some outside - {"MLS4", "MULTILINESTRING((2 3,4 3),(6 5,7 6))", "MULTILINESTRING((2 3,4 3))"}, + {"MLS4", "MULTILINESTRING((2 3,4 3),(6 5,7 6))", "MULTILINESTRING((2 3,4 3))", nil}, // MLS5: Single component, clipped to a single segment - {"MLS5", "MULTILINESTRING((0 3,6 3))", "MULTILINESTRING((1 3,5 3))"}, + {"MLS5", "MULTILINESTRING((0 3,6 3))", "MULTILINESTRING((1 3,5 3))", nil}, // MLS6: One component crosses R, another is inside R - {"MLS6", "MULTILINESTRING((0 3,6 3),(3 2.5,3 3.5))", "MULTILINESTRING((1 3,5 3),(3 2.5,3 3.5))"}, + {"MLS6", "MULTILINESTRING((0 3,6 3),(3 2.5,3 3.5))", "MULTILINESTRING((1 3,5 3),(3 2.5,3 3.5))", nil}, // MLS7: Component that produces multiple fragments when clipped - {"MLS7", "MULTILINESTRING((0 3,3 3,3 5,4 5,4 3,6 3))", "MULTILINESTRING((1 3,3 3,3 4),(4 4,4 3,5 3))"}, + {"MLS7", "MULTILINESTRING((0 3,3 3,3 5,4 5,4 3,6 3))", "MULTILINESTRING((1 3,3 3,3 4),(4 4,4 3,5 3))", nil}, // MLS8: MultiLineString containing empty LineStrings - {"MLS8", "MULTILINESTRING((2 3,4 3),EMPTY)", "MULTILINESTRING((2 3,4 3))"}, + {"MLS8", "MULTILINESTRING((2 3,4 3),EMPTY)", "MULTILINESTRING((2 3,4 3))", nil}, // MLS9: XYZ MultiLineString, all components outside R - {"MLS9", "MULTILINESTRING Z((6 5 1,7 6 2),(0 0 3,0 1 4))", "MULTILINESTRING Z EMPTY"}, + {"MLS9", "MULTILINESTRING Z((6 5 1,7 6 2),(0 0 3,0 1 4))", "MULTILINESTRING Z EMPTY", nil}, + + // PG1: Empty Polygon + {"PG1", "POLYGON EMPTY", "POLYGON EMPTY", nil}, + // PG2: Entirely inside R + {"PG2", "POLYGON((2 2.5,4 2.5,4 3.5,2 3.5,2 2.5))", "POLYGON((2 2.5,4 2.5,4 3.5,2 3.5,2 2.5))", []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PG3: Entirely outside R (no overlap) + {"PG3", "POLYGON((6 5,8 5,8 7,6 7,6 5))", "POLYGON EMPTY", nil}, + // PG4: R entirely inside Polygon + {"PG4", "POLYGON((0 0,6 0,6 6,0 6,0 0))", "POLYGON((1 2,5 2,5 4,1 4,1 2))", []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PG5: Partially overlapping one edge (left) of R + {"PG5", "POLYGON((0 2.5,3 2.5,3 3.5,0 3.5,0 2.5))", "POLYGON((1 2.5,3 2.5,3 3.5,1 3.5,1 2.5))", []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PG6: Partially overlapping two adjacent edges (bottom-left corner clip) + {"PG6", "POLYGON((0 1,3 1,3 3,0 3,0 1))", "POLYGON((1 2,3 2,3 3,1 3,1 2))", []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PG7: Partially overlapping two opposite edges (strip left-right) + {"PG7", "POLYGON((0 2.5,6 2.5,6 3.5,0 3.5,0 2.5))", "POLYGON((1 2.5,5 2.5,5 3.5,1 3.5,1 2.5))", []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PG8: Partially overlapping three edges (left, bottom, right) + {"PG8", "POLYGON((0 1,6 1,6 3,0 3,0 1))", "POLYGON((1 2,5 2,5 3,1 3,1 2))", []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PG9: Polygon shares entire bottom edge with R + {"PG9", "POLYGON((1 2,5 2,5 3,1 3,1 2))", "POLYGON((1 2,5 2,5 3,1 3,1 2))", []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PG10: Polygon vertex exactly on R left edge (x=1) + {"PG10", "POLYGON((1 3,2 2.5,2 3.5,1 3))", "POLYGON((1 3,2 2.5,2 3.5,1 3))", []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PG11: Polygon vertex exactly on R corner (1,2) + {"PG11", "POLYGON((1 2,3 2.5,3 3.5,1 2))", "POLYGON((1 2,3 2.5,3 3.5,1 2))", []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PG12: Polygon edge collinear with R bottom edge, polygon inside R + {"PG12", "POLYGON((2 2,4 2,3 3,2 2))", "POLYGON((2 2,4 2,3 3,2 2))", []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PG13: Polygon edge collinear with R bottom edge, polygon outside R + {"PG13", "POLYGON((2 2,4 2,3 1,2 2))", "POLYGON EMPTY", nil}, + // PG14: Polygon touches R at single point on left edge (vertex-to-edge) + {"PG14", "POLYGON((0 2.5,1 3,0 3.5,0 2.5))", "POLYGON EMPTY", nil}, + // PG15: Polygon touches R at single corner point (1,2) + {"PG15", "POLYGON((0 1,1 2,0 3,0 1))", "POLYGON EMPTY", nil}, + // PG16: Convex polygon (large square) clipped at all 4 edges + {"PG16", "POLYGON((-1 0,7 0,7 6,-1 6,-1 0))", "POLYGON((1 2,5 2,5 4,1 4,1 2))", []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PG17: Concave polygon, concavity inside R + {"PG17", "POLYGON((2 2.5,4 2.5,4 3.5,3 3,2 3.5,2 2.5))", "POLYGON((2 2.5,4 2.5,4 3.5,3 3,2 3.5,2 2.5))", []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PG18: Concave polygon, concavity facing left R boundary + {"PG18", "POLYGON((0 2.5,3 2.5,3 3,2 3,2 3.5,0 3.5,0 2.5))", "POLYGON((1 2.5,3 2.5,3 3,2 3,2 3.5,1 3.5,1 2.5))", []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PG19: C-shaped polygon, concavity crosses left edge → MultiPolygon + {"PG19", + "POLYGON((0 2.5,3 2.5,3 2.8,0.5 2.8,0.5 3.2,3 3.2,3 3.5,0 3.5,0 2.5))", + "MULTIPOLYGON(((1 2.5,3 2.5,3 2.8,1 2.8,1 2.5)),((1 3.2,3 3.2,3 3.5,1 3.5,1 3.2)))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PG20: Very thin sliver polygon partially inside R + {"PG20", "POLYGON((0 2.999,6 2.999,6 3.001,0 3.001,0 2.999))", "POLYGON((1 2.999,5 2.999,5 3.001,1 3.001,1 2.999))", []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PG21: Triangle clipped at all 4 rect edges producing a polygon + {"PG21", "POLYGON((3 0,7 3,3 6,3 0))", "POLYGON((3 4,3 2,5 2,5 4,3 4))", []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PG22: Counter-clockwise exterior ring (explicitly CCW, same as PG2 but winding verified by D4 reflections) + {"PG22", "POLYGON((2 2.5,2 3.5,4 3.5,4 2.5,2 2.5))", "POLYGON((2 2.5,2 3.5,4 3.5,4 2.5,2 2.5))", []geom.ExactEqualsOption{geom.IgnoreOrder}}, + + // PH1: Exterior and hole both inside R + {"PH1", + "POLYGON((2 2.5,4 2.5,4 3.5,2 3.5,2 2.5),(2.5 2.8,2.5 3.2,3.5 3.2,3.5 2.8,2.5 2.8))", + "POLYGON((2 2.5,4 2.5,4 3.5,2 3.5,2 2.5),(2.5 2.8,2.5 3.2,3.5 3.2,3.5 2.8,2.5 2.8))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PH2: Exterior clipped, hole entirely inside clipped region + {"PH2", + "POLYGON((0 2.5,4 2.5,4 3.5,0 3.5,0 2.5),(2 2.8,2 3.2,3 3.2,3 2.8,2 2.8))", + "POLYGON((1 2.5,4 2.5,4 3.5,1 3.5,1 2.5),(2 2.8,2 3.2,3 3.2,3 2.8,2 2.8))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PH3: Multiple holes, all inside R + {"PH3", + "POLYGON((0 0,6 0,6 6,0 6,0 0),(2 2.5,2 3,3 3,3 2.5,2 2.5),(3.5 2.5,3.5 3,4.5 3,4.5 2.5,3.5 2.5))", + "POLYGON((1 2,5 2,5 4,1 4,1 2),(2 2.5,2 3,3 3,3 2.5,2 2.5),(3.5 2.5,3.5 3,4.5 3,4.5 2.5,3.5 2.5))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PH4: Hole entirely outside R (inside exterior outside R) + {"PH4", + "POLYGON((0 0,6 0,6 6,0 6,0 0),(0.2 0.2,0.2 0.8,0.8 0.8,0.8 0.2,0.2 0.2))", + "POLYGON((1 2,5 2,5 4,1 4,1 2))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PH5: Hole in part of exterior that is clipped away + {"PH5", + "POLYGON((2 0,4 0,4 3,2 3,2 0),(2.5 0.5,2.5 1.5,3.5 1.5,3.5 0.5,2.5 0.5))", + "POLYGON((2 2,4 2,4 3,2 3,2 2))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PH6: Hole crosses one edge of R (left edge) + {"PH6", + "POLYGON((-2 -2,8 -2,8 8,-2 8,-2 -2),(0 2.5,0 3.5,2 3.5,2 2.5,0 2.5))", + "POLYGON((1 4,1 3.5,2 3.5,2 2.5,1 2.5,1 2,5 2,5 4,1 4))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PH7: Hole crosses two adjacent edges of R (bottom-left corner) + {"PH7", + "POLYGON((-2 -2,8 -2,8 8,-2 8,-2 -2),(0 1,0 3,2 3,2 1,0 1))", + "POLYGON((1 3,2 3,2 2,5 2,5 4,1 4,1 3))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PH8: Hole crosses two opposite edges of R (left and right) — splits polygon + {"PH8", + "POLYGON((-2 -2,8 -2,8 8,-2 8,-2 -2),(0 2.8,0 3.2,6 3.2,6 2.8,0 2.8))", + "MULTIPOLYGON(((1 2,5 2,5 2.8,1 2.8,1 2)),((1 3.2,5 3.2,5 4,1 4,1 3.2)))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PH9: Hole crosses all four edges of R, leaving top and bottom strips + {"PH9", + "POLYGON((-2 -2,8 -2,8 8,-2 8,-2 -2),(0.5 2.5,0.5 3.5,5.5 3.5,5.5 2.5,0.5 2.5))", + "MULTIPOLYGON(((1 2,5 2,5 2.5,1 2.5,1 2)),((1 3.5,5 3.5,5 4,1 4,1 3.5)))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PH10: Hole on R boundary, exterior extends beyond R — becomes concavity + {"PH10", + "POLYGON((-2 -2,8 -2,8 8,-2 8,-2 -2),(1 2.5,1 3.5,2 3.5,2 2.5,1 2.5))", + "POLYGON((1 4,1 3.5,2 3.5,2 2.5,1 2.5,1 2,5 2,5 4,1 4))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PH11: One hole inside R, another outside R + {"PH11", + "POLYGON((-2 -2,8 -2,8 8,-2 8,-2 -2),(2.5 2.8,2.5 3.2,3.5 3.2,3.5 2.8,2.5 2.8),(-1.5 -1.5,-1.5 -0.5,-0.5 -0.5,-0.5 -1.5,-1.5 -1.5))", + "POLYGON((1 2,5 2,5 4,1 4,1 2),(2.5 2.8,2.5 3.2,3.5 3.2,3.5 2.8,2.5 2.8))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PH12: One hole inside R, another splits polygon + {"PH12", + "POLYGON((-2 -2,8 -2,8 8,-2 8,-2 -2),(2.5 2.2,2.5 2.4,3.5 2.4,3.5 2.2,2.5 2.2),(0 2.5,0 3.5,6 3.5,6 2.5,0 2.5))", + "MULTIPOLYGON(((1 2,5 2,5 2.5,1 2.5,1 2),(2.5 2.2,2.5 2.4,3.5 2.4,3.5 2.2,2.5 2.2)),((1 3.5,5 3.5,5 4,1 4,1 3.5)))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PH13: Multiple holes each crossing one edge of R (left edge) + {"PH13", + "POLYGON((-2 -2,8 -2,8 8,-2 8,-2 -2),(0 2.2,0 2.6,2 2.6,2 2.2,0 2.2),(0 3.4,0 3.8,2 3.8,2 3.4,0 3.4))", + "POLYGON((1 4,1 3.8,2 3.8,2 3.4,1 3.4,1 2.6,2 2.6,2 2.2,1 2.2,1 2,5 2,5 4,1 4))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PH14: Two holes each crossing two opposite edges, splitting into three pieces + {"PH14", + "POLYGON((-2 -2,8 -2,8 8,-2 8,-2 -2),(0 2.5,0 2.8,6 2.8,6 2.5,0 2.5),(0 3.2,0 3.5,6 3.5,6 3.2,0 3.2))", + "MULTIPOLYGON(((1 2,5 2,5 2.5,1 2.5,1 2)),((1 2.8,5 2.8,5 3.2,1 3.2,1 2.8)),((1 3.5,5 3.5,5 4,1 4,1 3.5)))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // PH15: Hole covers entire clipped area + {"PH15", + "POLYGON((-2 -2,8 -2,8 8,-2 8,-2 -2),(0.5 1.5,0.5 4.5,5.5 4.5,5.5 1.5,0.5 1.5))", + "POLYGON EMPTY", + nil}, } { t.Run(tt.name, func(t *testing.T) { for _, tr := range d4Transforms { @@ -137,7 +262,7 @@ func TestClipByRect(t *testing.T) { want := geomFromWKT(t, tt.want).TransformXY(tr.fn) r := rect.TransformXY(tr.fn) got := geom.ClipByRect(input, r) - expectGeomEq(t, got, want) + expectGeomEq(t, got, want, tt.opts...) }) } }) From 3e2f6feca8b9ca0d430786efac3f40e0cc95a2de Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Sat, 4 Apr 2026 20:42:09 +1100 Subject: [PATCH 12/32] MultiPolygon --- geom/alg_clip_by_rect.go | 21 +++++++++++- geom/alg_sutherland_hodgman_test.go | 53 +++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/geom/alg_clip_by_rect.go b/geom/alg_clip_by_rect.go index e491499d..4f3540f1 100644 --- a/geom/alg_clip_by_rect.go +++ b/geom/alg_clip_by_rect.go @@ -76,7 +76,26 @@ func clipMultiLineStringByRect(mls MultiLineString, rect Envelope) MultiLineStri } func clipMultiPolygonByRect(mp MultiPolygon, rect Envelope) MultiPolygon { - panic("TODO") + n := mp.NumPolygons() + var polys []Polygon + for i := 0; i < n; i++ { + clipped := clipPolygonByRect(mp.PolygonN(i), rect) + switch clipped.Type() { + case TypePolygon: + p := clipped.MustAsPolygon() + if !p.IsEmpty() { + polys = append(polys, p) + } + case TypeMultiPolygon: + polys = append(polys, clipped.MustAsMultiPolygon().Dump()...) + default: + panic("unexpected type from clipPolygonByRect: " + clipped.Type().String()) + } + } + if len(polys) == 0 { + return NewMultiPolygon(nil).ForceCoordinatesType(mp.CoordinatesType()) + } + return NewMultiPolygon(polys) } func clipGeometryCollectionByRect(gc GeometryCollection, rect Envelope) GeometryCollection { diff --git a/geom/alg_sutherland_hodgman_test.go b/geom/alg_sutherland_hodgman_test.go index 68163c9f..6ef59430 100644 --- a/geom/alg_sutherland_hodgman_test.go +++ b/geom/alg_sutherland_hodgman_test.go @@ -254,6 +254,59 @@ func TestClipByRect(t *testing.T) { "POLYGON((-2 -2,8 -2,8 8,-2 8,-2 -2),(0.5 1.5,0.5 4.5,5.5 4.5,5.5 1.5,0.5 1.5))", "POLYGON EMPTY", nil}, + + // MPG1: Empty MultiPolygon + {"MPG1", "MULTIPOLYGON EMPTY", "MULTIPOLYGON EMPTY", nil}, + // MPG2: All component Polygons inside R + {"MPG2", + "MULTIPOLYGON(((2 2.5,3 2.5,3 3,2 3,2 2.5)),((3.5 2.5,4.5 2.5,4.5 3,3.5 3,3.5 2.5)))", + "MULTIPOLYGON(((2 2.5,3 2.5,3 3,2 3,2 2.5)),((3.5 2.5,4.5 2.5,4.5 3,3.5 3,3.5 2.5)))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // MPG3: All component Polygons outside R + {"MPG3", + "MULTIPOLYGON(((6 5,7 5,7 6,6 6,6 5)),((8 8,9 8,9 9,8 9,8 8)))", + "MULTIPOLYGON EMPTY", + nil}, + // MPG4: Some components inside, some outside + {"MPG4", + "MULTIPOLYGON(((2 2.5,3 2.5,3 3,2 3,2 2.5)),((6 5,7 5,7 6,6 6,6 5)))", + "MULTIPOLYGON(((2 2.5,3 2.5,3 3,2 3,2 2.5)))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // MPG5: One component partially clipped, another fully inside + {"MPG5", + "MULTIPOLYGON(((0 2.5,3 2.5,3 3.5,0 3.5,0 2.5)),((3.5 2.5,4.5 2.5,4.5 3.5,3.5 3.5,3.5 2.5)))", + "MULTIPOLYGON(((1 2.5,3 2.5,3 3.5,1 3.5,1 2.5)),((3.5 2.5,4.5 2.5,4.5 3.5,3.5 3.5,3.5 2.5)))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // MPG6: One component becomes multiple polygons when clipped (C-shape) + {"MPG6", + "MULTIPOLYGON(((0 2.5,3 2.5,3 2.8,0.5 2.8,0.5 3.2,3 3.2,3 3.5,0 3.5,0 2.5)),((4 2.5,4.5 2.5,4.5 3,4 3,4 2.5)))", + "MULTIPOLYGON(((1 2.5,3 2.5,3 2.8,1 2.8,1 2.5)),((1 3.2,3 3.2,3 3.5,1 3.5,1 3.2)),((4 2.5,4.5 2.5,4.5 3,4 3,4 2.5)))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // MPG7: Multiple components, each partially clipped + {"MPG7", + "MULTIPOLYGON(((0 2.3,3 2.3,3 2.7,0 2.7,0 2.3)),((0 3.3,3 3.3,3 3.7,0 3.7,0 3.3)))", + "MULTIPOLYGON(((1 2.3,3 2.3,3 2.7,1 2.7,1 2.3)),((1 3.3,3 3.3,3 3.7,1 3.7,1 3.3)))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // MPG8: Components with holes, some holes clipped + {"MPG8", + "MULTIPOLYGON(((0 2.5,3 2.5,3 3.5,0 3.5,0 2.5),(1.5 2.8,1.5 3.2,2.5 3.2,2.5 2.8,1.5 2.8)),((3.5 2.5,4.5 2.5,4.5 3.5,3.5 3.5,3.5 2.5)))", + "MULTIPOLYGON(((1 2.5,3 2.5,3 3.5,1 3.5,1 2.5),(1.5 2.8,1.5 3.2,2.5 3.2,2.5 2.8,1.5 2.8)),((3.5 2.5,4.5 2.5,4.5 3.5,3.5 3.5,3.5 2.5)))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // MPG9: Single component fully inside R + {"MPG9", + "MULTIPOLYGON(((2 2.5,4 2.5,4 3.5,2 3.5,2 2.5)))", + "MULTIPOLYGON(((2 2.5,4 2.5,4 3.5,2 3.5,2 2.5)))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // MPG10: MultiPolygon containing empty Polygons + {"MPG10", + "MULTIPOLYGON(((2 2.5,4 2.5,4 3.5,2 3.5,2 2.5)),EMPTY)", + "MULTIPOLYGON(((2 2.5,4 2.5,4 3.5,2 3.5,2 2.5)))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // MPG11: XYZ MultiPolygon, all components outside R + {"MPG11", + "MULTIPOLYGON Z(((6 5 1,7 5 2,7 6 3,6 6 4,6 5 1)),((8 8 5,9 8 6,9 9 7,8 9 8,8 8 5)))", + "MULTIPOLYGON Z EMPTY", + nil}, } { t.Run(tt.name, func(t *testing.T) { for _, tr := range d4Transforms { From f9c03e638ef00ac5f93c22ba124912712832593f Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Sun, 5 Apr 2026 08:50:40 +1000 Subject: [PATCH 13/32] polygonClipper refactor --- geom/alg_clip_by_rect_sutherland_hodgman.go | 218 ++++++++++---------- 1 file changed, 112 insertions(+), 106 deletions(-) diff --git a/geom/alg_clip_by_rect_sutherland_hodgman.go b/geom/alg_clip_by_rect_sutherland_hodgman.go index b351ddb4..95f7aee9 100644 --- a/geom/alg_clip_by_rect_sutherland_hodgman.go +++ b/geom/alg_clip_by_rect_sutherland_hodgman.go @@ -6,6 +6,30 @@ import ( "slices" ) +// polygonClipper holds precomputed values for clipping a polygon against an +// axis-aligned rectangle. +type polygonClipper struct { + lo, hi XY + w, h float64 + perim float64 + corners [4]float64 // CCW corner parameters: BL, BR, TR, TL + ctype CoordinatesType +} + +func newPolygonClipper(lo, hi XY, ctype CoordinatesType) polygonClipper { + w := hi.X - lo.X + h := hi.Y - lo.Y + return polygonClipper{ + lo: lo, + hi: hi, + w: w, + h: h, + perim: 2*w + 2*h, + corners: [4]float64{0, w, w + h, 2*w + h}, + ctype: ctype, + } +} + func clipPolygonByRect(p Polygon, rect Envelope) Geometry { ctype := p.CoordinatesType() emptyPoly := NewPolygon(nil).ForceCoordinatesType(ctype).AsGeometry() @@ -14,16 +38,12 @@ func clipPolygonByRect(p Polygon, rect Envelope) Geometry { return emptyPoly } - // Normalise to CCW exterior / CW holes. This ensures all clipped rings - // have known winding, which the topology resolution depends on. - p = p.ForceCCW() - // Degenerate rect: point or line envelope → empty polygon. if !rect.IsRectangle() { return emptyPoly } - min, max, ok := rect.MinMaxXYs() + lo, hi, ok := rect.MinMaxXYs() if !ok { return emptyPoly } @@ -40,8 +60,14 @@ func clipPolygonByRect(p Polygon, rect Envelope) Geometry { return p.AsGeometry() } + // Normalise to CCW exterior / CW holes. This ensures all clipped rings + // have known winding, which the topology resolution depends on. + p = p.ForceCCW() + + c := newPolygonClipper(lo, hi, ctype) + // Clip exterior ring. - clippedExt := clipRingSH(p.ExteriorRing().Coordinates(), min, max) + clippedExt := c.clipRingSH(p.ExteriorRing()) if len(clippedExt) == 0 { return emptyPoly } @@ -62,27 +88,28 @@ func clipPolygonByRect(p Polygon, rect Envelope) Geometry { // contact) only if its envelope is strictly inside the rect. If any // vertex lies on the rect boundary, it must be clipped to avoid // producing invalid polygons with shared edges. - if rect.Covers(holeEnv) && !holeOnRectBoundary(hole.Coordinates(), min, max) { + if rect.Covers(holeEnv) && !c.ringTouchesRectBoundary(hole) { freeHoles = append(freeHoles, hole) continue } // Hole touches or crosses rect boundary — clip it. - clippedHole := clipRingSH(hole.Coordinates(), min, max) + clippedHole := c.clipRingSH(hole) if len(clippedHole) > 0 { clippedHoles = append(clippedHoles, clippedHole) } } - return resolveClippedPolygon(clippedExt, freeHoles, clippedHoles, min, max, ctype) + return c.resolveClippedPolygon(clippedExt, freeHoles, clippedHoles) } -// holeOnRectBoundary reports whether any vertex of the sequence lies on the -// rect boundary. -func holeOnRectBoundary(seq Sequence, min, max XY) bool { +// ringTouchesRectBoundary reports whether any vertex of the ring lies on the rect +// boundary. +func (c *polygonClipper) ringTouchesRectBoundary(ring LineString) bool { + seq := ring.Coordinates() for i := 0; i < seq.Length(); i++ { xy := seq.GetXY(i) - if xy.X == min.X || xy.X == max.X || xy.Y == min.Y || xy.Y == max.Y { + if xy.X == c.lo.X || xy.X == c.hi.X || xy.Y == c.lo.Y || xy.Y == c.hi.Y { return true } } @@ -90,25 +117,23 @@ func holeOnRectBoundary(seq Sequence, min, max XY) bool { } // resolveClippedPolygon takes the clipped exterior ring, classified holes, and -// the rect bounds, and produces the output Geometry (Polygon or MultiPolygon). -// The input polygon must have been normalised to CCW (exterior CCW, holes CW) -// before clipping, so that all clipped rings have known winding. -func resolveClippedPolygon( +// produces the output Geometry (Polygon or MultiPolygon). The input polygon +// must have been normalised to CCW (exterior CCW, holes CW) before clipping, +// so that all clipped rings have known winding. +func (c *polygonClipper) resolveClippedPolygon( clippedExterior []Coordinates, freeHoles []LineString, clippedHoles [][]Coordinates, - min, max XY, - ctype CoordinatesType, ) Geometry { - extArcs := extractInteriorArcs(clippedExterior, min, max) + extArcs := c.extractInteriorArcs(clippedExterior) // Collect all arcs. var allArcs []interiorArc allArcs = append(allArcs, extArcs...) - emptyPoly := NewPolygon(nil).ForceCoordinatesType(ctype).AsGeometry() + emptyPoly := NewPolygon(nil).ForceCoordinatesType(c.ctype).AsGeometry() for _, hole := range clippedHoles { - arcs := extractInteriorArcs(hole, min, max) + arcs := c.extractInteriorArcs(hole) if len(arcs) == 0 { // Clipped hole is entirely on the rect boundary — it covers // the entire rect. No polygon area survives. @@ -121,23 +146,20 @@ func resolveClippedPolygon( if len(allArcs) == 0 { // No interior arcs: the polygon contains the entire rect. // Output is the rect itself (closed ring). - w := max.X - min.X - h := max.Y - min.Y - corners := [4]float64{0, w, w + h, 2*w + h} ring := make([]Coordinates, 5) - for i, cp := range corners { - ring[i] = Coordinates{XY: paramToXY(cp, min, max), Type: ctype} + for i, cp := range c.corners { + ring[i] = Coordinates{XY: c.paramToXY(cp), Type: c.ctype} } ring[4] = ring[0] // close the ring outputRings = append(outputRings, ring) } else { - outputRings = walkArcs(allArcs, min, max) + outputRings = c.walkArcs(allArcs) } // Build output polygons. Each output ring is an exterior (already closed). var polys []Polygon for _, ring := range outputRings { - seq := coordsToSeq(ring, ctype) + seq := coordsToSeq(ring, c.ctype) exterior := NewLineString(seq) polys = append(polys, NewPolygon([]LineString{exterior})) } @@ -148,7 +170,6 @@ func resolveClippedPolygon( if !ok { continue } - // TODO: Use R-Tree to speed up assignment to the correct exterior ring. for i, ring := range outputRings { if pointInRingXY(holeXY, ring) { existingRings := polys[i].DumpRings() @@ -161,7 +182,7 @@ func resolveClippedPolygon( // Return Polygon or MultiPolygon. if len(polys) == 0 { - return NewPolygon(nil).ForceCoordinatesType(ctype).AsGeometry() + return NewPolygon(nil).ForceCoordinatesType(c.ctype).AsGeometry() } if len(polys) == 1 { return polys[0].AsGeometry() @@ -172,11 +193,7 @@ func resolveClippedPolygon( // walkArcs performs the topology resolution walk, producing output rings from // the collected interior arcs. It pairs arc endpoints along the rect boundary // (CCW) and traces complete rings. -func walkArcs(arcs []interiorArc, min, max XY) [][]Coordinates { - w := max.X - min.X - h := max.Y - min.Y - perim := 2*w + 2*h - +func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]Coordinates { // Build a sorted list of arc "end" events and a map from "end param" to // the index of the arc that ends there. Also build a map from // "start param" to arc index. @@ -207,9 +224,9 @@ func walkArcs(arcs []interiorArc, min, max XY) [][]Coordinates { findNextStart := func(endParam float64) (float64, int) { // Find the first start param that is strictly after endParam (CCW). best := -1.0 - bestDist := perim + 1 + bestDist := c.perim + 1 for _, sp := range startParams { - d := ccwDist(endParam, sp, perim) + d := c.ccwDist(endParam, sp) if d > 0 && d < bestDist { bestDist = d best = sp @@ -278,7 +295,7 @@ func walkArcs(arcs []interiorArc, min, max XY) [][]Coordinates { endCoord := ring[len(ring)-1] startCoord := arcs[nextIdx].coords[0] - bpath := buildBoundaryPath(a.endParam, nextStartParam, endCoord, startCoord, min, max) + bpath := c.buildBoundaryPath(a.endParam, nextStartParam, endCoord, startCoord) ring = append(ring, bpath...) if nextIdx == firstIdx { @@ -304,21 +321,14 @@ func walkArcs(arcs []interiorArc, min, max XY) [][]Coordinates { // the last/first coordinates of the adjacent arcs). Z and M values at corners // are linearly interpolated between startCoord and endCoord based on boundary // distance. -func buildBoundaryPath(startParam, endParam float64, startCoord, endCoord Coordinates, min, max XY) []Coordinates { - w := max.X - min.X - h := max.Y - min.Y - perim := 2*w + 2*h - - // Corner parameters in CCW order. - corners := [4]float64{0, w, w + h, 2*w + h} - +func (c *polygonClipper) buildBoundaryPath(startParam, endParam float64, startCoord, endCoord Coordinates) []Coordinates { // Find the first corner after startParam going CCW. The corners are in // ascending parameter order, so this is the first with cp > startParam. // If startParam is past the last corner (on the left edge), no corner // qualifies and firstIdx stays at 0 — the bottom-left corner is the // next one going CCW. firstIdx := 0 - for i, cp := range corners { + for i, cp := range c.corners { if cp > startParam { firstIdx = i break @@ -327,18 +337,18 @@ func buildBoundaryPath(startParam, endParam float64, startCoord, endCoord Coordi // Iterate corners in CCW order from firstIdx, collecting those strictly // between startParam and endParam. - totalDist := ccwDist(startParam, endParam, perim) + totalDist := c.ccwDist(startParam, endParam) var path []Coordinates for k := 0; k < 4; k++ { - cp := corners[(firstIdx+k)%4] - d := ccwDist(startParam, cp, perim) + cp := c.corners[(firstIdx+k)%4] + d := c.ccwDist(startParam, cp) if d >= totalDist { break } frac := d / totalDist - c := interpolateCoords(startCoord, endCoord, frac) - c.XY = paramToXY(cp, min, max) - path = append(path, c) + coord := interpolateCoords(startCoord, endCoord, frac) + coord.XY = c.paramToXY(cp) + path = append(path, coord) } return path } @@ -348,7 +358,7 @@ func buildBoundaryPath(startParam, endParam float64, startCoord, endCoord Coordi // point on the rect boundary. Returns an empty slice if the ring has no // interior arcs (entirely on the boundary or entirely interior with no // boundary contact). -func extractInteriorArcs(ring []Coordinates, min, max XY) []interiorArc { +func (c *polygonClipper) extractInteriorArcs(ring []Coordinates) []interiorArc { n := len(ring) if n < 4 { return nil @@ -361,7 +371,7 @@ func extractInteriorArcs(ring []Coordinates, min, max XY) []interiorArc { // Classify each edge as boundary (both endpoints on same rect edge) or interior. isBdry := make([]bool, numEdges) for i := 0; i < numEdges; i++ { - isBdry[i] = isSameRectEdge(ring[i].XY, ring[i+1].XY, min, max) + isBdry[i] = c.isSameRectEdge(ring[i].XY, ring[i+1].XY) } // Find a starting boundary edge so we can walk from there. If there are @@ -407,8 +417,8 @@ func extractInteriorArcs(ring []Coordinates, min, max XY) []interiorArc { arcCoords = append(arcCoords, ring[start]) } - sp := rectBoundaryParam(arcCoords[0].XY, min, max) - ep := rectBoundaryParam(arcCoords[len(arcCoords)-1].XY, min, max) + sp := c.rectBoundaryParam(arcCoords[0].XY) + ep := c.rectBoundaryParam(arcCoords[len(arcCoords)-1].XY) arcs = append(arcs, interiorArc{ coords: arcCoords, startParam: sp, @@ -431,26 +441,26 @@ type interiorArc struct { // clipRingSH clips a closed ring against an axis-aligned rectangle using the // Sutherland-Hodgman algorithm. It returns the clipped ring as a closed slice // of [Coordinates] (first == last), or nil if the ring is entirely outside the -// rectangle. The input ring must be explicitly closed. -func clipRingSH(seq Sequence, min, max XY) []Coordinates { - coords := seqToCoords(seq) +// rectangle. The input [LineString] must be a closed ring. +func (c *polygonClipper) clipRingSH(ring LineString) []Coordinates { + coords := seqToCoords(ring.Coordinates()) if len(coords) < 4 { return nil } // Clip against each of the 4 edges. coords = clipToEdge(coords, - func(c Coordinates) bool { return c.X >= min.X }, - func(a, b Coordinates) Coordinates { return interpX(a, b, min.X) }) + func(co Coordinates) bool { return co.X >= c.lo.X }, + func(a, b Coordinates) Coordinates { return interpX(a, b, c.lo.X) }) coords = clipToEdge(coords, - func(c Coordinates) bool { return c.X <= max.X }, - func(a, b Coordinates) Coordinates { return interpX(a, b, max.X) }) + func(co Coordinates) bool { return co.X <= c.hi.X }, + func(a, b Coordinates) Coordinates { return interpX(a, b, c.hi.X) }) coords = clipToEdge(coords, - func(c Coordinates) bool { return c.Y >= min.Y }, - func(a, b Coordinates) Coordinates { return interpY(a, b, min.Y) }) + func(co Coordinates) bool { return co.Y >= c.lo.Y }, + func(a, b Coordinates) Coordinates { return interpY(a, b, c.lo.Y) }) coords = clipToEdge(coords, - func(c Coordinates) bool { return c.Y <= max.Y }, - func(a, b Coordinates) Coordinates { return interpY(a, b, max.Y) }) + func(co Coordinates) bool { return co.Y <= c.hi.Y }, + func(a, b Coordinates) Coordinates { return interpY(a, b, c.hi.Y) }) coords = removeDupConsecutiveCoords(coords) if len(coords) < 4 { @@ -519,51 +529,56 @@ func interpY(a, b Coordinates, y float64) Coordinates { // rectBoundaryParam returns the CCW boundary parameter for a point on the rect // boundary. The parameterisation starts at the bottom-left corner and goes // counter-clockwise: bottom→right→top→left. -func rectBoundaryParam(xy, min, max XY) float64 { - w := max.X - min.X - h := max.Y - min.Y +func (c *polygonClipper) rectBoundaryParam(xy XY) float64 { switch { - case xy.Y == min.Y: // bottom edge - return xy.X - min.X - case xy.X == max.X: // right edge - return w + (xy.Y - min.Y) - case xy.Y == max.Y: // top edge - return w + h + (max.X - xy.X) - case xy.X == min.X: // left edge - return 2*w + h + (max.Y - xy.Y) + case xy.Y == c.lo.Y: // bottom edge + return xy.X - c.lo.X + case xy.X == c.hi.X: // right edge + return c.w + (xy.Y - c.lo.Y) + case xy.Y == c.hi.Y: // top edge + return c.w + c.h + (c.hi.X - xy.X) + case xy.X == c.lo.X: // left edge + return 2*c.w + c.h + (c.hi.Y - xy.Y) default: - panic(fmt.Sprintf("point %v not on rect boundary [%v, %v]", xy, min, max)) + panic(fmt.Sprintf("point %v not on rect boundary [%v, %v]", xy, c.lo, c.hi)) } } // paramToXY converts a CCW boundary parameter back to an XY coordinate. -func paramToXY(param float64, min, max XY) XY { - w := max.X - min.X - h := max.Y - min.Y +func (c *polygonClipper) paramToXY(param float64) XY { switch { - case param < w: // bottom edge - return XY{X: min.X + param, Y: min.Y} - case param < w+h: // right edge - return XY{X: max.X, Y: min.Y + param - w} - case param < 2*w+h: // top edge - return XY{X: max.X - (param - w - h), Y: max.Y} - case param < 2*w+2*h: // left edge - return XY{X: min.X, Y: max.Y - (param - 2*w - h)} + case param < c.w: // bottom edge + return XY{X: c.lo.X + param, Y: c.lo.Y} + case param < c.w+c.h: // right edge + return XY{X: c.hi.X, Y: c.lo.Y + param - c.w} + case param < 2*c.w+c.h: // top edge + return XY{X: c.hi.X - (param - c.w - c.h), Y: c.hi.Y} + case param < c.perim: // left edge + return XY{X: c.lo.X, Y: c.hi.Y - (param - 2*c.w - c.h)} default: - panic(fmt.Sprintf("boundary parameter %v out of range [0, %v)", param, 2*w+2*h)) + panic(fmt.Sprintf("boundary parameter %v out of range [0, %v)", param, c.perim)) } } -// ccwDist returns the CCW distance from param a to param b on a boundary with -// total perimeter perim. -func ccwDist(a, b, perim float64) float64 { +// ccwDist returns the CCW distance from param a to param b on the rect +// boundary. +func (c *polygonClipper) ccwDist(a, b float64) float64 { d := b - a if d < 0 { - d += perim + d += c.perim } return d } +// isSameRectEdge returns true if both points lie on the same edge of the +// rectangle. +func (c *polygonClipper) isSameRectEdge(a, b XY) bool { + return (a.X == c.lo.X && b.X == c.lo.X) || + (a.X == c.hi.X && b.X == c.hi.X) || + (a.Y == c.lo.Y && b.Y == c.lo.Y) || + (a.Y == c.hi.Y && b.Y == c.hi.Y) +} + // pointInRingXY returns true if xy is inside the ring defined by the given // closed coordinates (first == last), using the ray-casting algorithm. func pointInRingXY(xy XY, ring []Coordinates) bool { @@ -619,12 +634,3 @@ func removeDupConsecutiveCoords(coords []Coordinates) []Coordinates { } return out } - -// isSameRectEdge returns true if both points lie on the same edge of the -// rectangle. -func isSameRectEdge(a, b, min, max XY) bool { - return (a.X == min.X && b.X == min.X) || - (a.X == max.X && b.X == max.X) || - (a.Y == min.Y && b.Y == min.Y) || - (a.Y == max.Y && b.Y == max.Y) -} From 18c63ddfa54fc7574d4497c81ef9a6679603baf2 Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Tue, 7 Apr 2026 19:28:05 +1000 Subject: [PATCH 14/32] GeometryCollection Implement clipGeometryCollectionByRect and add GC1-GC14 test cases. --- geom/alg_clip_by_rect.go | 13 +++++- geom/alg_sutherland_hodgman_test.go | 62 +++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/geom/alg_clip_by_rect.go b/geom/alg_clip_by_rect.go index 4f3540f1..b1fc7f56 100644 --- a/geom/alg_clip_by_rect.go +++ b/geom/alg_clip_by_rect.go @@ -99,5 +99,16 @@ func clipMultiPolygonByRect(mp MultiPolygon, rect Envelope) MultiPolygon { } func clipGeometryCollectionByRect(gc GeometryCollection, rect Envelope) GeometryCollection { - panic("TODO") + n := gc.NumGeometries() + var geoms []Geometry + for i := 0; i < n; i++ { + clipped := ClipByRect(gc.GeometryN(i), rect) + if !clipped.IsEmpty() { + geoms = append(geoms, clipped) + } + } + if len(geoms) == 0 { + return NewGeometryCollection(nil).ForceCoordinatesType(gc.CoordinatesType()) + } + return NewGeometryCollection(geoms) } diff --git a/geom/alg_sutherland_hodgman_test.go b/geom/alg_sutherland_hodgman_test.go index 6ef59430..f4ee475a 100644 --- a/geom/alg_sutherland_hodgman_test.go +++ b/geom/alg_sutherland_hodgman_test.go @@ -307,6 +307,68 @@ func TestClipByRect(t *testing.T) { "MULTIPOLYGON Z(((6 5 1,7 5 2,7 6 3,6 6 4,6 5 1)),((8 8 5,9 8 6,9 9 7,8 9 8,8 8 5)))", "MULTIPOLYGON Z EMPTY", nil}, + + // GC1: Empty GeometryCollection + {"GC1", "GEOMETRYCOLLECTION EMPTY", "GEOMETRYCOLLECTION EMPTY", nil}, + // GC2: Contains only Points, all inside R + {"GC2", "GEOMETRYCOLLECTION(POINT(3 3),POINT(4 3))", "GEOMETRYCOLLECTION(POINT(3 3),POINT(4 3))", nil}, + // GC3: Contains only Points, all outside R + {"GC3", "GEOMETRYCOLLECTION(POINT(0 0),POINT(6 6))", "GEOMETRYCOLLECTION EMPTY", nil}, + // GC4: Contains mixed types, all inside R + {"GC4", + "GEOMETRYCOLLECTION(POINT(3 3),LINESTRING(2 3,4 3))", + "GEOMETRYCOLLECTION(POINT(3 3),LINESTRING(2 3,4 3))", + nil}, + // GC5: Contains mixed types, all outside R + {"GC5", + "GEOMETRYCOLLECTION(POINT(0 0),LINESTRING(6 5,7 6))", + "GEOMETRYCOLLECTION EMPTY", + nil}, + // GC6: Contains mixed types, some inside, some outside + {"GC6", + "GEOMETRYCOLLECTION(POINT(3 3),POINT(0 0),LINESTRING(2 3,4 3))", + "GEOMETRYCOLLECTION(POINT(3 3),LINESTRING(2 3,4 3))", + nil}, + // GC7: Contains Point, LineString, and Polygon (each clipped independently) + {"GC7", + "GEOMETRYCOLLECTION(POINT(3 3),LINESTRING(0 3,6 3),POLYGON((2 2.5,4 2.5,4 3.5,2 3.5,2 2.5)))", + "GEOMETRYCOLLECTION(POINT(3 3),LINESTRING(1 3,5 3),POLYGON((2 2.5,4 2.5,4 3.5,2 3.5,2 2.5)))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // GC8: Contains nested GeometryCollection + {"GC8", + "GEOMETRYCOLLECTION(POINT(3 3),GEOMETRYCOLLECTION(POINT(4 3)))", + "GEOMETRYCOLLECTION(POINT(3 3),GEOMETRYCOLLECTION(POINT(4 3)))", + nil}, + // GC9: Contains nested GeometryCollection with mixed types + {"GC9", + "GEOMETRYCOLLECTION(POINT(3 3),GEOMETRYCOLLECTION(POINT(0 0),LINESTRING(2 3,4 3)))", + "GEOMETRYCOLLECTION(POINT(3 3),GEOMETRYCOLLECTION(LINESTRING(2 3,4 3)))", + nil}, + // GC10: All child geometries are empty + {"GC10", + "GEOMETRYCOLLECTION(POINT EMPTY,LINESTRING EMPTY)", + "GEOMETRYCOLLECTION EMPTY", + nil}, + // GC11: Contains MultiPoint, MultiLineString, MultiPolygon + {"GC11", + "GEOMETRYCOLLECTION(MULTIPOINT(3 3,0 0),MULTILINESTRING((2 3,4 3)),MULTIPOLYGON(((2 2.5,4 2.5,4 3.5,2 3.5,2 2.5))))", + "GEOMETRYCOLLECTION(MULTIPOINT(3 3),MULTILINESTRING((2 3,4 3)),MULTIPOLYGON(((2 2.5,4 2.5,4 3.5,2 3.5,2 2.5))))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // GC12: Deeply nested GeometryCollections (3+ levels) + {"GC12", + "GEOMETRYCOLLECTION(GEOMETRYCOLLECTION(GEOMETRYCOLLECTION(POINT(3 3))))", + "GEOMETRYCOLLECTION(GEOMETRYCOLLECTION(GEOMETRYCOLLECTION(POINT(3 3))))", + nil}, + // GC13: Nested GC whose children all clip to empty + {"GC13", + "GEOMETRYCOLLECTION(POINT(3 3),GEOMETRYCOLLECTION(POINT(0 0),POINT(6 6)))", + "GEOMETRYCOLLECTION(POINT(3 3))", + nil}, + // GC14: XYZ GeometryCollection, all children outside R + {"GC14", + "GEOMETRYCOLLECTION Z(POINT Z(0 0 1),LINESTRING Z(6 5 2,7 6 3))", + "GEOMETRYCOLLECTION Z EMPTY", + nil}, } { t.Run(tt.name, func(t *testing.T) { for _, tr := range d4Transforms { From 35a911705e935c29d057f54c6873656a62e2cab9 Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Tue, 7 Apr 2026 19:58:11 +1000 Subject: [PATCH 15/32] Add DR, NE, and CD test cases for ClipByRect --- geom/alg_sutherland_hodgman_test.go | 112 ++++++++++++++++++++++++---- 1 file changed, 98 insertions(+), 14 deletions(-) diff --git a/geom/alg_sutherland_hodgman_test.go b/geom/alg_sutherland_hodgman_test.go index f4ee475a..9c3394f1 100644 --- a/geom/alg_sutherland_hodgman_test.go +++ b/geom/alg_sutherland_hodgman_test.go @@ -6,21 +6,21 @@ import ( "github.com/peterstace/simplefeatures/geom" ) +var d4Transforms = []struct { + name string + fn func(geom.XY) geom.XY +}{ + {"identity", func(xy geom.XY) geom.XY { return xy }}, + {"rot90", func(xy geom.XY) geom.XY { return geom.XY{X: -xy.Y, Y: xy.X} }}, + {"rot180", func(xy geom.XY) geom.XY { return geom.XY{X: -xy.X, Y: -xy.Y} }}, + {"rot270", func(xy geom.XY) geom.XY { return geom.XY{X: xy.Y, Y: -xy.X} }}, + {"reflect_x", func(xy geom.XY) geom.XY { return geom.XY{X: -xy.X, Y: xy.Y} }}, + {"reflect_y", func(xy geom.XY) geom.XY { return geom.XY{X: xy.X, Y: -xy.Y} }}, + {"reflect_diag", func(xy geom.XY) geom.XY { return geom.XY{X: xy.Y, Y: xy.X} }}, + {"reflect_anti", func(xy geom.XY) geom.XY { return geom.XY{X: -xy.Y, Y: -xy.X} }}, +} + func TestClipByRect(t *testing.T) { - type d4Transform struct { - name string - fn func(geom.XY) geom.XY - } - d4Transforms := []d4Transform{ - {"identity", func(xy geom.XY) geom.XY { return xy }}, - {"rot90", func(xy geom.XY) geom.XY { return geom.XY{X: -xy.Y, Y: xy.X} }}, - {"rot180", func(xy geom.XY) geom.XY { return geom.XY{X: -xy.X, Y: -xy.Y} }}, - {"rot270", func(xy geom.XY) geom.XY { return geom.XY{X: xy.Y, Y: -xy.X} }}, - {"reflect_x", func(xy geom.XY) geom.XY { return geom.XY{X: -xy.X, Y: xy.Y} }}, - {"reflect_y", func(xy geom.XY) geom.XY { return geom.XY{X: xy.X, Y: -xy.Y} }}, - {"reflect_diag", func(xy geom.XY) geom.XY { return geom.XY{X: xy.Y, Y: xy.X} }}, - {"reflect_anti", func(xy geom.XY) geom.XY { return geom.XY{X: -xy.Y, Y: -xy.X} }}, - } // R is a non-square rectangle so that the D4 transforms produce distinct // configurations. rect := geom.NewEnvelope(geom.XY{X: 1, Y: 2}, geom.XY{X: 5, Y: 4}) @@ -369,6 +369,32 @@ func TestClipByRect(t *testing.T) { "GEOMETRYCOLLECTION Z(POINT Z(0 0 1),LINESTRING Z(6 5 2,7 6 3))", "GEOMETRYCOLLECTION Z EMPTY", nil}, + + // NE1: Very large coordinates (outside R) + {"NE1", "LINESTRING(1000000000000000 3,1000000000000006 3)", "LINESTRING EMPTY", nil}, + // NE2: Very small coordinates near float64 epsilon (inside R) + {"NE2", "POINT(3 3.0000000000000004)", "POINT(3 3.0000000000000004)", nil}, + // NE3: Negative coordinates (outside R) + {"NE3", "LINESTRING(-3 -3,-1 -1)", "LINESTRING EMPTY", nil}, + // NE4: Intersection parameter t very close to 0 + {"NE4", "LINESTRING(0.999999 3,4 3)", "LINESTRING(1 3,4 3)", nil}, + // NE5: Intersection parameter t very close to 1 + {"NE5", "LINESTRING(2 3,5.000001 3)", "LINESTRING(2 3,5 3)", nil}, + // NE6: Polygon vertex exactly at intersection point with R edge + {"NE6", "LINESTRING(1 3,5 3)", "LINESTRING(1 3,5 3)", nil}, + // NE7: Segment nearly parallel to R edge (small angle) + {"NE7", "LINESTRING(0 2.0001,6 2.0001)", "LINESTRING(1 2.0001,5 2.0001)", nil}, + // NE8: Zero-length segments in input (duplicate consecutive vertices) + {"NE8", "LINESTRING(2 3,2 3,4 3)", "LINESTRING(2 3,2 3,4 3)", nil}, + + // CD1: XY geometry clipped + {"CD1", "LINESTRING(0 3,6 3)", "LINESTRING(1 3,5 3)", nil}, + // CD2: XYZ geometry clipped, Z interpolated at intersections + {"CD2", "LINESTRING Z(0 3 0,6 3 6)", "LINESTRING Z(1 3 1,5 3 5)", nil}, + // CD3: XYM geometry clipped, M interpolated at intersections + {"CD3", "LINESTRING M(0 3 0,6 3 6)", "LINESTRING M(1 3 1,5 3 5)", nil}, + // CD4: XYZM geometry clipped, Z and M interpolated at intersections + {"CD4", "LINESTRING ZM(0 3 0 12,6 3 6 24)", "LINESTRING ZM(1 3 1 14,5 3 5 22)", nil}, } { t.Run(tt.name, func(t *testing.T) { for _, tr := range d4Transforms { @@ -383,3 +409,61 @@ func TestClipByRect(t *testing.T) { }) } } + +func TestClipByRectDegenerateRect(t *testing.T) { + emptyRect := geom.Envelope{} + pointRect := geom.NewEnvelope(geom.XY{X: 3, Y: 3}, geom.XY{X: 3, Y: 3}) + lineRect := geom.NewEnvelope(geom.XY{X: 1, Y: 3}, geom.XY{X: 5, Y: 3}) + + for _, tt := range []struct { + name string + rect geom.Envelope + input string + want string + }{ + // DR1: Empty envelope, Point + {"DR1", emptyRect, "POINT(3 3)", "POINT EMPTY"}, + // DR2: Empty envelope, LineString + {"DR2", emptyRect, "LINESTRING(2 3,4 3)", "LINESTRING EMPTY"}, + // DR3: Empty envelope, Polygon + {"DR3", emptyRect, "POLYGON((2 2,4 2,4 4,2 4,2 2))", "POLYGON EMPTY"}, + // DR4: Point envelope, Point at same location + {"DR4", pointRect, "POINT(3 3)", "POINT(3 3)"}, + // DR5: Point envelope, Point at different location + {"DR5", pointRect, "POINT(2 2)", "POINT EMPTY"}, + // DR6: Point envelope, MultiPoint with some points at location + {"DR6", pointRect, "MULTIPOINT(3 3,2 2)", "MULTIPOINT(3 3)"}, + // DR7: Point envelope, MultiPoint with no points at location + {"DR7", pointRect, "MULTIPOINT(2 2,4 4)", "MULTIPOINT EMPTY"}, + // DR8: Point envelope, LineString through that point + {"DR8", pointRect, "LINESTRING(0 0,6 6)", "LINESTRING EMPTY"}, + // DR9: Point envelope, Polygon containing that point + {"DR9", pointRect, "POLYGON((2 2,4 2,4 4,2 4,2 2))", "POLYGON EMPTY"}, + // DR10: Line envelope, Point on the line + {"DR10", lineRect, "POINT(3 3)", "POINT(3 3)"}, + // DR11: Line envelope, Point off the line + {"DR11", lineRect, "POINT(3 2)", "POINT EMPTY"}, + // DR12: Line envelope, MultiPoint with some points on the line + {"DR12", lineRect, "MULTIPOINT(3 3,3 2)", "MULTIPOINT(3 3)"}, + // DR13: Line envelope, MultiPoint with no points on the line + {"DR13", lineRect, "MULTIPOINT(0 0,6 6)", "MULTIPOINT EMPTY"}, + // DR14: Line envelope, LineString crossing the line + {"DR14", lineRect, "LINESTRING(3 0,3 6)", "LINESTRING EMPTY"}, + // DR15: Line envelope, LineString collinear with line + {"DR15", lineRect, "LINESTRING(1 3,5 3)", "LINESTRING(1 3,5 3)"}, + // DR16: Line envelope, Polygon crossing the line + {"DR16", lineRect, "POLYGON((0 0,6 0,6 6,0 6,0 0))", "POLYGON EMPTY"}, + } { + t.Run(tt.name, func(t *testing.T) { + for _, tr := range d4Transforms { + t.Run(tr.name, func(t *testing.T) { + input := geomFromWKT(t, tt.input).TransformXY(tr.fn) + want := geomFromWKT(t, tt.want).TransformXY(tr.fn) + r := tt.rect.TransformXY(tr.fn) + got := geom.ClipByRect(input, r) + expectGeomEq(t, got, want) + }) + } + }) + } +} From 488fb3bb899ca80209f7e850f2bec5081be48a6d Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Wed, 8 Apr 2026 19:34:42 +1000 Subject: [PATCH 16/32] Fix Z/M values lost when polygon fully contains clipping rect When the polygon contains the entire clipping rectangle, the S-H output is entirely boundary edges with no interior arcs. Previously this case constructed a fresh rect with Z=0, M=0, discarding the correctly interpolated values from S-H clipping. Now uses the clipped exterior ring directly. --- geom/alg_clip_by_rect_sutherland_hodgman.go | 10 +++------- geom/alg_sutherland_hodgman_test.go | 6 ++++++ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/geom/alg_clip_by_rect_sutherland_hodgman.go b/geom/alg_clip_by_rect_sutherland_hodgman.go index 95f7aee9..a4d86c3b 100644 --- a/geom/alg_clip_by_rect_sutherland_hodgman.go +++ b/geom/alg_clip_by_rect_sutherland_hodgman.go @@ -145,13 +145,9 @@ func (c *polygonClipper) resolveClippedPolygon( var outputRings [][]Coordinates if len(allArcs) == 0 { // No interior arcs: the polygon contains the entire rect. - // Output is the rect itself (closed ring). - ring := make([]Coordinates, 5) - for i, cp := range c.corners { - ring[i] = Coordinates{XY: c.paramToXY(cp), Type: c.ctype} - } - ring[4] = ring[0] // close the ring - outputRings = append(outputRings, ring) + // Use the clipped exterior directly — it is the rect with + // correctly interpolated Z/M values from S-H clipping. + outputRings = append(outputRings, clippedExterior) } else { outputRings = c.walkArcs(allArcs) } diff --git a/geom/alg_sutherland_hodgman_test.go b/geom/alg_sutherland_hodgman_test.go index 9c3394f1..e41b30ef 100644 --- a/geom/alg_sutherland_hodgman_test.go +++ b/geom/alg_sutherland_hodgman_test.go @@ -255,6 +255,12 @@ func TestClipByRect(t *testing.T) { "POLYGON EMPTY", nil}, + // PG_Z1: XYZ polygon containing R — Z values must be interpolated, not zero + {"PG_Z1", + "POLYGON Z((0 0 10,6 0 10,6 6 10,0 6 10,0 0 10))", + "POLYGON Z((1 2 10,5 2 10,5 4 10,1 4 10,1 2 10))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // MPG1: Empty MultiPolygon {"MPG1", "MULTIPOLYGON EMPTY", "MULTIPOLYGON EMPTY", nil}, // MPG2: All component Polygons inside R From 9353ebe7d5e363a7862becd825f7b12f2e361ba3 Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Wed, 8 Apr 2026 20:16:12 +1000 Subject: [PATCH 17/32] Replace []Coordinates with mutableSequence in polygon clipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce mutableSequence as the mutable counterpart of Sequence, using the same flat []float64 storage. The S-H polygon clipping code now works with mutableSequence throughout instead of converting Sequence → []Coordinates → Sequence. This avoids the space overhead of unpacking every coordinate into a padded struct. mutableSequence behaves like a "fat slice" — all methods use value receivers and mutating operations return the updated value. --- geom/alg_clip_by_rect_sutherland_hodgman.go | 172 +++++++++----------- geom/type_mutable_sequence.go | 73 +++++++++ 2 files changed, 150 insertions(+), 95 deletions(-) create mode 100644 geom/type_mutable_sequence.go diff --git a/geom/alg_clip_by_rect_sutherland_hodgman.go b/geom/alg_clip_by_rect_sutherland_hodgman.go index a4d86c3b..7ad4254c 100644 --- a/geom/alg_clip_by_rect_sutherland_hodgman.go +++ b/geom/alg_clip_by_rect_sutherland_hodgman.go @@ -67,14 +67,14 @@ func clipPolygonByRect(p Polygon, rect Envelope) Geometry { c := newPolygonClipper(lo, hi, ctype) // Clip exterior ring. - clippedExt := c.clipRingSH(p.ExteriorRing()) - if len(clippedExt) == 0 { + clippedExt, ok := c.clipRingSH(p.ExteriorRing()) + if !ok { return emptyPoly } // Classify holes. var freeHoles []LineString - var clippedHoles [][]Coordinates + var clippedHoles []mutableSequence for i := 0; i < p.NumInteriorRings(); i++ { hole := p.InteriorRingN(i) holeEnv := hole.Envelope() @@ -94,8 +94,8 @@ func clipPolygonByRect(p Polygon, rect Envelope) Geometry { } // Hole touches or crosses rect boundary — clip it. - clippedHole := c.clipRingSH(hole) - if len(clippedHole) > 0 { + clippedHole, ok := c.clipRingSH(hole) + if ok { clippedHoles = append(clippedHoles, clippedHole) } } @@ -121,9 +121,9 @@ func (c *polygonClipper) ringTouchesRectBoundary(ring LineString) bool { // must have been normalised to CCW (exterior CCW, holes CW) before clipping, // so that all clipped rings have known winding. func (c *polygonClipper) resolveClippedPolygon( - clippedExterior []Coordinates, + clippedExterior mutableSequence, freeHoles []LineString, - clippedHoles [][]Coordinates, + clippedHoles []mutableSequence, ) Geometry { extArcs := c.extractInteriorArcs(clippedExterior) @@ -142,7 +142,7 @@ func (c *polygonClipper) resolveClippedPolygon( allArcs = append(allArcs, arcs...) } - var outputRings [][]Coordinates + var outputRings []mutableSequence if len(allArcs) == 0 { // No interior arcs: the polygon contains the entire rect. // Use the clipped exterior directly — it is the rect with @@ -155,7 +155,7 @@ func (c *polygonClipper) resolveClippedPolygon( // Build output polygons. Each output ring is an exterior (already closed). var polys []Polygon for _, ring := range outputRings { - seq := coordsToSeq(ring, c.ctype) + seq := ring.ToSequence() exterior := NewLineString(seq) polys = append(polys, NewPolygon([]LineString{exterior})) } @@ -189,7 +189,7 @@ func (c *polygonClipper) resolveClippedPolygon( // walkArcs performs the topology resolution walk, producing output rings from // the collected interior arcs. It pairs arc endpoints along the rect boundary // (CCW) and traces complete rings. -func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]Coordinates { +func (c *polygonClipper) walkArcs(arcs []interiorArc) []mutableSequence { // Build a sorted list of arc "end" events and a map from "end param" to // the index of the arc that ends there. Also build a map from // "start param" to arc index. @@ -259,7 +259,7 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]Coordinates { } used := make([]bool, len(arcs)) - var rings [][]Coordinates + var rings []mutableSequence for { // Find first unused arc. @@ -274,7 +274,7 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]Coordinates { break } - var ring []Coordinates + ring := newMutableSequence(c.ctype) curIdx := firstIdx for { @@ -282,17 +282,17 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]Coordinates { a := arcs[curIdx] // Append arc coordinates. - ring = append(ring, a.coords...) + ring = ring.AppendMutable(a.coords) // Find next arc via boundary. nextStartParam, nextIdx := findNextStart(a.endParam) // Build boundary path from this arc's end to the next arc's start. - endCoord := ring[len(ring)-1] - startCoord := arcs[nextIdx].coords[0] + endCoord := ring.Get(ring.Length() - 1) + startCoord := arcs[nextIdx].coords.Get(0) bpath := c.buildBoundaryPath(a.endParam, nextStartParam, endCoord, startCoord) - ring = append(ring, bpath...) + ring = ring.AppendMutable(bpath) if nextIdx == firstIdx { break // Ring complete. @@ -301,9 +301,9 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]Coordinates { } // Close the ring. - ring = append(ring, ring[0]) + ring = ring.Append(ring.Get(0)) ring = removeDupConsecutiveCoords(ring) - if len(ring) >= 4 { + if ring.Length() >= 4 { rings = append(rings, ring) } } @@ -317,7 +317,7 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]Coordinates { // the last/first coordinates of the adjacent arcs). Z and M values at corners // are linearly interpolated between startCoord and endCoord based on boundary // distance. -func (c *polygonClipper) buildBoundaryPath(startParam, endParam float64, startCoord, endCoord Coordinates) []Coordinates { +func (c *polygonClipper) buildBoundaryPath(startParam, endParam float64, startCoord, endCoord Coordinates) mutableSequence { // Find the first corner after startParam going CCW. The corners are in // ascending parameter order, so this is the first with cp > startParam. // If startParam is past the last corner (on the left edge), no corner @@ -334,7 +334,7 @@ func (c *polygonClipper) buildBoundaryPath(startParam, endParam float64, startCo // Iterate corners in CCW order from firstIdx, collecting those strictly // between startParam and endParam. totalDist := c.ccwDist(startParam, endParam) - var path []Coordinates + path := newMutableSequence(c.ctype) for k := 0; k < 4; k++ { cp := c.corners[(firstIdx+k)%4] d := c.ccwDist(startParam, cp) @@ -344,7 +344,7 @@ func (c *polygonClipper) buildBoundaryPath(startParam, endParam float64, startCo frac := d / totalDist coord := interpolateCoords(startCoord, endCoord, frac) coord.XY = c.paramToXY(cp) - path = append(path, coord) + path = path.Append(coord) } return path } @@ -354,8 +354,8 @@ func (c *polygonClipper) buildBoundaryPath(startParam, endParam float64, startCo // point on the rect boundary. Returns an empty slice if the ring has no // interior arcs (entirely on the boundary or entirely interior with no // boundary contact). -func (c *polygonClipper) extractInteriorArcs(ring []Coordinates) []interiorArc { - n := len(ring) +func (c *polygonClipper) extractInteriorArcs(ring mutableSequence) []interiorArc { + n := ring.Length() if n < 4 { return nil } @@ -367,7 +367,7 @@ func (c *polygonClipper) extractInteriorArcs(ring []Coordinates) []interiorArc { // Classify each edge as boundary (both endpoints on same rect edge) or interior. isBdry := make([]bool, numEdges) for i := 0; i < numEdges; i++ { - isBdry[i] = c.isSameRectEdge(ring[i].XY, ring[i+1].XY) + isBdry[i] = c.isSameRectEdge(ring.GetXY(i), ring.GetXY(i+1)) } // Find a starting boundary edge so we can walk from there. If there are @@ -396,25 +396,25 @@ func (c *polygonClipper) extractInteriorArcs(ring []Coordinates) []interiorArc { break } // Start of an interior arc at ring[i%numEdges]. - var arcCoords []Coordinates - arcCoords = append(arcCoords, ring[i%numEdges]) + arcCoords := newMutableSequence(c.ctype) + arcCoords = arcCoords.Append(ring.Get(i % numEdges)) i++ steps++ for steps < numEdges && !isBdry[i%numEdges] { - arcCoords = append(arcCoords, ring[i%numEdges]) + arcCoords = arcCoords.Append(ring.Get(i % numEdges)) i++ steps++ } if steps < numEdges { // The arc ends at ring[i%numEdges] (the start of the next boundary edge). - arcCoords = append(arcCoords, ring[i%numEdges]) + arcCoords = arcCoords.Append(ring.Get(i % numEdges)) } else { // Wrapped around; the arc ends at ring[start] (where we began). - arcCoords = append(arcCoords, ring[start]) + arcCoords = arcCoords.Append(ring.Get(start)) } - sp := c.rectBoundaryParam(arcCoords[0].XY) - ep := c.rectBoundaryParam(arcCoords[len(arcCoords)-1].XY) + sp := c.rectBoundaryParam(arcCoords.GetXY(0)) + ep := c.rectBoundaryParam(arcCoords.GetXY(arcCoords.Length() - 1)) arcs = append(arcs, interiorArc{ coords: arcCoords, startParam: sp, @@ -429,19 +429,19 @@ func (c *polygonClipper) extractInteriorArcs(ring []Coordinates) []interiorArc { // last elements of coords are on the rectangle boundary; everything in between // is in the interior. type interiorArc struct { - coords []Coordinates + coords mutableSequence startParam float64 // boundary parameter of coords[0] - endParam float64 // boundary parameter of coords[len(coords)-1] + endParam float64 // boundary parameter of coords[len-1] } // clipRingSH clips a closed ring against an axis-aligned rectangle using the -// Sutherland-Hodgman algorithm. It returns the clipped ring as a closed slice -// of [Coordinates] (first == last), or nil if the ring is entirely outside the -// rectangle. The input [LineString] must be a closed ring. -func (c *polygonClipper) clipRingSH(ring LineString) []Coordinates { - coords := seqToCoords(ring.Coordinates()) - if len(coords) < 4 { - return nil +// Sutherland-Hodgman algorithm. It returns the clipped ring as a closed +// [mutableSequence] (first == last), and true if the ring survived clipping. +// The input [LineString] must be a closed ring. +func (c *polygonClipper) clipRingSH(ring LineString) (mutableSequence, bool) { + coords := sequenceToMutable(ring.Coordinates()) + if coords.Length() < 4 { + return mutableSequence{}, false } // Clip against each of the 4 edges. @@ -459,10 +459,10 @@ func (c *polygonClipper) clipRingSH(ring LineString) []Coordinates { func(a, b Coordinates) Coordinates { return interpY(a, b, c.hi.Y) }) coords = removeDupConsecutiveCoords(coords) - if len(coords) < 4 { - return nil + if coords.Length() < 4 { + return mutableSequence{}, false } - return coords + return coords, true } // clipToEdge performs one pass of the Sutherland-Hodgman algorithm, clipping a @@ -470,34 +470,36 @@ func (c *polygonClipper) clipRingSH(ring LineString) []Coordinates { // function computes the intersection of segment a→b with the clipping edge. // The output is also an explicitly closed ring. func clipToEdge( - coords []Coordinates, + coords mutableSequence, isInside func(Coordinates) bool, intersect func(Coordinates, Coordinates) Coordinates, -) []Coordinates { - if len(coords) == 0 { - return nil - } - var output []Coordinates - a := coords[len(coords)-1] - for _, b := range coords { +) mutableSequence { + n := coords.Length() + if n == 0 { + return coords + } + output := newMutableSequence(coords.ctype) + a := coords.Get(n - 1) + for i := 0; i < n; i++ { + b := coords.Get(i) aIn := isInside(a) bIn := isInside(b) switch { case aIn && bIn: - output = append(output, b) + output = output.Append(b) case aIn && !bIn: - output = append(output, intersect(a, b)) + output = output.Append(intersect(a, b)) case !aIn && bIn: - output = append(output, intersect(a, b)) - output = append(output, b) + output = output.Append(intersect(a, b)) + output = output.Append(b) } a = b } // Ensure the output is explicitly closed. When the input's closing vertex // is outside the clip region, the degenerate closing edge emits nothing, // leaving the output open. - if len(output) > 0 && output[0].XY != output[len(output)-1].XY { - output = append(output, output[0]) + if output.Length() > 0 && output.GetXY(0) != output.GetXY(output.Length()-1) { + output = output.Append(output.Get(0)) } return output } @@ -576,17 +578,17 @@ func (c *polygonClipper) isSameRectEdge(a, b XY) bool { } // pointInRingXY returns true if xy is inside the ring defined by the given -// closed coordinates (first == last), using the ray-casting algorithm. -func pointInRingXY(xy XY, ring []Coordinates) bool { +// closed mutableSequence (first == last), using the ray-casting algorithm. +func pointInRingXY(xy XY, ring mutableSequence) bool { inside := false - n := len(ring) - for i := range ring { + n := ring.Length() + for i := 0; i < n; i++ { j := (i + 1) % n - yi, yj := ring[i].Y, ring[j].Y - xi, xj := ring[i].X, ring[j].X - if (yi > xy.Y) != (yj > xy.Y) { - slope := (xy.Y - yi) / (yj - yi) - xIntersect := xi + slope*(xj-xi) + pi := ring.GetXY(i) + pj := ring.GetXY(j) + if (pi.Y > xy.Y) != (pj.Y > xy.Y) { + slope := (xy.Y - pi.Y) / (pj.Y - pi.Y) + xIntersect := pi.X + slope*(pj.X-pi.X) if xy.X < xIntersect { inside = !inside } @@ -595,37 +597,17 @@ func pointInRingXY(xy XY, ring []Coordinates) bool { return inside } -// seqToCoords extracts all Coordinates from a Sequence into a slice. -func seqToCoords(seq Sequence) []Coordinates { - n := seq.Length() - coords := make([]Coordinates, n) - for i := range coords { - coords[i] = seq.Get(i) - } - return coords -} - -// coordsToSeq converts a slice of Coordinates into a Sequence. -func coordsToSeq(coords []Coordinates, ctype CoordinatesType) Sequence { - dim := ctype.Dimension() - floats := make([]float64, 0, len(coords)*dim) - for _, c := range coords { - c.Type = ctype - floats = c.appendFloat64s(floats) - } - return NewSequence(floats, ctype) -} - // removeDupConsecutiveCoords removes consecutive vertices with identical XY. // For closed rings (first == last), the closing vertex is preserved. -func removeDupConsecutiveCoords(coords []Coordinates) []Coordinates { - if len(coords) == 0 { - return nil - } - out := coords[:1] - for _, c := range coords[1:] { - if c.XY != out[len(out)-1].XY { - out = append(out, c) +func removeDupConsecutiveCoords(coords mutableSequence) mutableSequence { + if coords.Length() == 0 { + return coords + } + out := newMutableSequence(coords.ctype) + out = out.Append(coords.Get(0)) + for i := 1; i < coords.Length(); i++ { + if coords.GetXY(i) != out.GetXY(out.Length()-1) { + out = out.Append(coords.Get(i)) } } return out diff --git a/geom/type_mutable_sequence.go b/geom/type_mutable_sequence.go new file mode 100644 index 00000000..9b6f9991 --- /dev/null +++ b/geom/type_mutable_sequence.go @@ -0,0 +1,73 @@ +package geom + +import "fmt" + +// mutableSequence is a variable-length sequence of coordinates with +// homogeneous [CoordinatesType]. It is the mutable counterpart of [Sequence], +// using the same flat []float64 storage. It behaves like a "fat slice" — a +// []float64 plus coordinate type information. Like a Go slice, mutating +// operations return the updated value. +type mutableSequence struct { + floats []float64 + ctype CoordinatesType +} + +func newMutableSequence(ctype CoordinatesType) mutableSequence { + return mutableSequence{ctype: ctype} +} + +func sequenceToMutable(seq Sequence) mutableSequence { + floats := make([]float64, len(seq.floats)) + copy(floats, seq.floats) + return mutableSequence{floats: floats, ctype: seq.CoordinatesType()} +} + +func (s mutableSequence) Append(c Coordinates) mutableSequence { + c.Type = s.ctype + s.floats = c.appendFloat64s(s.floats) + return s +} + +func (s mutableSequence) AppendMutable(other mutableSequence) mutableSequence { + if s.ctype != other.ctype { + panic(fmt.Sprintf("AppendMutable: mismatched CoordinatesType: %s vs %s", s.ctype, other.ctype)) + } + s.floats = append(s.floats, other.floats...) + return s +} + +func (s mutableSequence) Length() int { + return len(s.floats) / s.ctype.Dimension() +} + +func (s mutableSequence) Get(i int) Coordinates { + dim := s.ctype.Dimension() + offset := i * dim + c := Coordinates{ + XY: XY{X: s.floats[offset], Y: s.floats[offset+1]}, + Type: s.ctype, + } + if s.ctype.Is3D() { + c.Z = s.floats[offset+2] + } + if s.ctype.IsMeasured() { + c.M = s.floats[offset+dim-1] + } + return c +} + +func (s mutableSequence) GetXY(i int) XY { + dim := s.ctype.Dimension() + offset := i * dim + return XY{X: s.floats[offset], Y: s.floats[offset+1]} +} + +func (s mutableSequence) Slice(i, j int) mutableSequence { + dim := s.ctype.Dimension() + s.floats = s.floats[i*dim : j*dim] + return s +} + +func (s mutableSequence) ToSequence() Sequence { + return NewSequence(s.floats, s.ctype) +} From 1fac7c7cb6a3f08f83fcea97c0bf0361c28bee14 Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Thu, 9 Apr 2026 20:13:29 +1000 Subject: [PATCH 18/32] Revert "Replace []Coordinates with mutableSequence in polygon clipping" This reverts commit 76a48c62c4a458cce34800bc3723cbda2a224e6b. --- geom/alg_clip_by_rect_sutherland_hodgman.go | 172 +++++++++++--------- geom/type_mutable_sequence.go | 73 --------- 2 files changed, 95 insertions(+), 150 deletions(-) delete mode 100644 geom/type_mutable_sequence.go diff --git a/geom/alg_clip_by_rect_sutherland_hodgman.go b/geom/alg_clip_by_rect_sutherland_hodgman.go index 7ad4254c..a4d86c3b 100644 --- a/geom/alg_clip_by_rect_sutherland_hodgman.go +++ b/geom/alg_clip_by_rect_sutherland_hodgman.go @@ -67,14 +67,14 @@ func clipPolygonByRect(p Polygon, rect Envelope) Geometry { c := newPolygonClipper(lo, hi, ctype) // Clip exterior ring. - clippedExt, ok := c.clipRingSH(p.ExteriorRing()) - if !ok { + clippedExt := c.clipRingSH(p.ExteriorRing()) + if len(clippedExt) == 0 { return emptyPoly } // Classify holes. var freeHoles []LineString - var clippedHoles []mutableSequence + var clippedHoles [][]Coordinates for i := 0; i < p.NumInteriorRings(); i++ { hole := p.InteriorRingN(i) holeEnv := hole.Envelope() @@ -94,8 +94,8 @@ func clipPolygonByRect(p Polygon, rect Envelope) Geometry { } // Hole touches or crosses rect boundary — clip it. - clippedHole, ok := c.clipRingSH(hole) - if ok { + clippedHole := c.clipRingSH(hole) + if len(clippedHole) > 0 { clippedHoles = append(clippedHoles, clippedHole) } } @@ -121,9 +121,9 @@ func (c *polygonClipper) ringTouchesRectBoundary(ring LineString) bool { // must have been normalised to CCW (exterior CCW, holes CW) before clipping, // so that all clipped rings have known winding. func (c *polygonClipper) resolveClippedPolygon( - clippedExterior mutableSequence, + clippedExterior []Coordinates, freeHoles []LineString, - clippedHoles []mutableSequence, + clippedHoles [][]Coordinates, ) Geometry { extArcs := c.extractInteriorArcs(clippedExterior) @@ -142,7 +142,7 @@ func (c *polygonClipper) resolveClippedPolygon( allArcs = append(allArcs, arcs...) } - var outputRings []mutableSequence + var outputRings [][]Coordinates if len(allArcs) == 0 { // No interior arcs: the polygon contains the entire rect. // Use the clipped exterior directly — it is the rect with @@ -155,7 +155,7 @@ func (c *polygonClipper) resolveClippedPolygon( // Build output polygons. Each output ring is an exterior (already closed). var polys []Polygon for _, ring := range outputRings { - seq := ring.ToSequence() + seq := coordsToSeq(ring, c.ctype) exterior := NewLineString(seq) polys = append(polys, NewPolygon([]LineString{exterior})) } @@ -189,7 +189,7 @@ func (c *polygonClipper) resolveClippedPolygon( // walkArcs performs the topology resolution walk, producing output rings from // the collected interior arcs. It pairs arc endpoints along the rect boundary // (CCW) and traces complete rings. -func (c *polygonClipper) walkArcs(arcs []interiorArc) []mutableSequence { +func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]Coordinates { // Build a sorted list of arc "end" events and a map from "end param" to // the index of the arc that ends there. Also build a map from // "start param" to arc index. @@ -259,7 +259,7 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) []mutableSequence { } used := make([]bool, len(arcs)) - var rings []mutableSequence + var rings [][]Coordinates for { // Find first unused arc. @@ -274,7 +274,7 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) []mutableSequence { break } - ring := newMutableSequence(c.ctype) + var ring []Coordinates curIdx := firstIdx for { @@ -282,17 +282,17 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) []mutableSequence { a := arcs[curIdx] // Append arc coordinates. - ring = ring.AppendMutable(a.coords) + ring = append(ring, a.coords...) // Find next arc via boundary. nextStartParam, nextIdx := findNextStart(a.endParam) // Build boundary path from this arc's end to the next arc's start. - endCoord := ring.Get(ring.Length() - 1) - startCoord := arcs[nextIdx].coords.Get(0) + endCoord := ring[len(ring)-1] + startCoord := arcs[nextIdx].coords[0] bpath := c.buildBoundaryPath(a.endParam, nextStartParam, endCoord, startCoord) - ring = ring.AppendMutable(bpath) + ring = append(ring, bpath...) if nextIdx == firstIdx { break // Ring complete. @@ -301,9 +301,9 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) []mutableSequence { } // Close the ring. - ring = ring.Append(ring.Get(0)) + ring = append(ring, ring[0]) ring = removeDupConsecutiveCoords(ring) - if ring.Length() >= 4 { + if len(ring) >= 4 { rings = append(rings, ring) } } @@ -317,7 +317,7 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) []mutableSequence { // the last/first coordinates of the adjacent arcs). Z and M values at corners // are linearly interpolated between startCoord and endCoord based on boundary // distance. -func (c *polygonClipper) buildBoundaryPath(startParam, endParam float64, startCoord, endCoord Coordinates) mutableSequence { +func (c *polygonClipper) buildBoundaryPath(startParam, endParam float64, startCoord, endCoord Coordinates) []Coordinates { // Find the first corner after startParam going CCW. The corners are in // ascending parameter order, so this is the first with cp > startParam. // If startParam is past the last corner (on the left edge), no corner @@ -334,7 +334,7 @@ func (c *polygonClipper) buildBoundaryPath(startParam, endParam float64, startCo // Iterate corners in CCW order from firstIdx, collecting those strictly // between startParam and endParam. totalDist := c.ccwDist(startParam, endParam) - path := newMutableSequence(c.ctype) + var path []Coordinates for k := 0; k < 4; k++ { cp := c.corners[(firstIdx+k)%4] d := c.ccwDist(startParam, cp) @@ -344,7 +344,7 @@ func (c *polygonClipper) buildBoundaryPath(startParam, endParam float64, startCo frac := d / totalDist coord := interpolateCoords(startCoord, endCoord, frac) coord.XY = c.paramToXY(cp) - path = path.Append(coord) + path = append(path, coord) } return path } @@ -354,8 +354,8 @@ func (c *polygonClipper) buildBoundaryPath(startParam, endParam float64, startCo // point on the rect boundary. Returns an empty slice if the ring has no // interior arcs (entirely on the boundary or entirely interior with no // boundary contact). -func (c *polygonClipper) extractInteriorArcs(ring mutableSequence) []interiorArc { - n := ring.Length() +func (c *polygonClipper) extractInteriorArcs(ring []Coordinates) []interiorArc { + n := len(ring) if n < 4 { return nil } @@ -367,7 +367,7 @@ func (c *polygonClipper) extractInteriorArcs(ring mutableSequence) []interiorArc // Classify each edge as boundary (both endpoints on same rect edge) or interior. isBdry := make([]bool, numEdges) for i := 0; i < numEdges; i++ { - isBdry[i] = c.isSameRectEdge(ring.GetXY(i), ring.GetXY(i+1)) + isBdry[i] = c.isSameRectEdge(ring[i].XY, ring[i+1].XY) } // Find a starting boundary edge so we can walk from there. If there are @@ -396,25 +396,25 @@ func (c *polygonClipper) extractInteriorArcs(ring mutableSequence) []interiorArc break } // Start of an interior arc at ring[i%numEdges]. - arcCoords := newMutableSequence(c.ctype) - arcCoords = arcCoords.Append(ring.Get(i % numEdges)) + var arcCoords []Coordinates + arcCoords = append(arcCoords, ring[i%numEdges]) i++ steps++ for steps < numEdges && !isBdry[i%numEdges] { - arcCoords = arcCoords.Append(ring.Get(i % numEdges)) + arcCoords = append(arcCoords, ring[i%numEdges]) i++ steps++ } if steps < numEdges { // The arc ends at ring[i%numEdges] (the start of the next boundary edge). - arcCoords = arcCoords.Append(ring.Get(i % numEdges)) + arcCoords = append(arcCoords, ring[i%numEdges]) } else { // Wrapped around; the arc ends at ring[start] (where we began). - arcCoords = arcCoords.Append(ring.Get(start)) + arcCoords = append(arcCoords, ring[start]) } - sp := c.rectBoundaryParam(arcCoords.GetXY(0)) - ep := c.rectBoundaryParam(arcCoords.GetXY(arcCoords.Length() - 1)) + sp := c.rectBoundaryParam(arcCoords[0].XY) + ep := c.rectBoundaryParam(arcCoords[len(arcCoords)-1].XY) arcs = append(arcs, interiorArc{ coords: arcCoords, startParam: sp, @@ -429,19 +429,19 @@ func (c *polygonClipper) extractInteriorArcs(ring mutableSequence) []interiorArc // last elements of coords are on the rectangle boundary; everything in between // is in the interior. type interiorArc struct { - coords mutableSequence + coords []Coordinates startParam float64 // boundary parameter of coords[0] - endParam float64 // boundary parameter of coords[len-1] + endParam float64 // boundary parameter of coords[len(coords)-1] } // clipRingSH clips a closed ring against an axis-aligned rectangle using the -// Sutherland-Hodgman algorithm. It returns the clipped ring as a closed -// [mutableSequence] (first == last), and true if the ring survived clipping. -// The input [LineString] must be a closed ring. -func (c *polygonClipper) clipRingSH(ring LineString) (mutableSequence, bool) { - coords := sequenceToMutable(ring.Coordinates()) - if coords.Length() < 4 { - return mutableSequence{}, false +// Sutherland-Hodgman algorithm. It returns the clipped ring as a closed slice +// of [Coordinates] (first == last), or nil if the ring is entirely outside the +// rectangle. The input [LineString] must be a closed ring. +func (c *polygonClipper) clipRingSH(ring LineString) []Coordinates { + coords := seqToCoords(ring.Coordinates()) + if len(coords) < 4 { + return nil } // Clip against each of the 4 edges. @@ -459,10 +459,10 @@ func (c *polygonClipper) clipRingSH(ring LineString) (mutableSequence, bool) { func(a, b Coordinates) Coordinates { return interpY(a, b, c.hi.Y) }) coords = removeDupConsecutiveCoords(coords) - if coords.Length() < 4 { - return mutableSequence{}, false + if len(coords) < 4 { + return nil } - return coords, true + return coords } // clipToEdge performs one pass of the Sutherland-Hodgman algorithm, clipping a @@ -470,36 +470,34 @@ func (c *polygonClipper) clipRingSH(ring LineString) (mutableSequence, bool) { // function computes the intersection of segment a→b with the clipping edge. // The output is also an explicitly closed ring. func clipToEdge( - coords mutableSequence, + coords []Coordinates, isInside func(Coordinates) bool, intersect func(Coordinates, Coordinates) Coordinates, -) mutableSequence { - n := coords.Length() - if n == 0 { - return coords - } - output := newMutableSequence(coords.ctype) - a := coords.Get(n - 1) - for i := 0; i < n; i++ { - b := coords.Get(i) +) []Coordinates { + if len(coords) == 0 { + return nil + } + var output []Coordinates + a := coords[len(coords)-1] + for _, b := range coords { aIn := isInside(a) bIn := isInside(b) switch { case aIn && bIn: - output = output.Append(b) + output = append(output, b) case aIn && !bIn: - output = output.Append(intersect(a, b)) + output = append(output, intersect(a, b)) case !aIn && bIn: - output = output.Append(intersect(a, b)) - output = output.Append(b) + output = append(output, intersect(a, b)) + output = append(output, b) } a = b } // Ensure the output is explicitly closed. When the input's closing vertex // is outside the clip region, the degenerate closing edge emits nothing, // leaving the output open. - if output.Length() > 0 && output.GetXY(0) != output.GetXY(output.Length()-1) { - output = output.Append(output.Get(0)) + if len(output) > 0 && output[0].XY != output[len(output)-1].XY { + output = append(output, output[0]) } return output } @@ -578,17 +576,17 @@ func (c *polygonClipper) isSameRectEdge(a, b XY) bool { } // pointInRingXY returns true if xy is inside the ring defined by the given -// closed mutableSequence (first == last), using the ray-casting algorithm. -func pointInRingXY(xy XY, ring mutableSequence) bool { +// closed coordinates (first == last), using the ray-casting algorithm. +func pointInRingXY(xy XY, ring []Coordinates) bool { inside := false - n := ring.Length() - for i := 0; i < n; i++ { + n := len(ring) + for i := range ring { j := (i + 1) % n - pi := ring.GetXY(i) - pj := ring.GetXY(j) - if (pi.Y > xy.Y) != (pj.Y > xy.Y) { - slope := (xy.Y - pi.Y) / (pj.Y - pi.Y) - xIntersect := pi.X + slope*(pj.X-pi.X) + yi, yj := ring[i].Y, ring[j].Y + xi, xj := ring[i].X, ring[j].X + if (yi > xy.Y) != (yj > xy.Y) { + slope := (xy.Y - yi) / (yj - yi) + xIntersect := xi + slope*(xj-xi) if xy.X < xIntersect { inside = !inside } @@ -597,17 +595,37 @@ func pointInRingXY(xy XY, ring mutableSequence) bool { return inside } +// seqToCoords extracts all Coordinates from a Sequence into a slice. +func seqToCoords(seq Sequence) []Coordinates { + n := seq.Length() + coords := make([]Coordinates, n) + for i := range coords { + coords[i] = seq.Get(i) + } + return coords +} + +// coordsToSeq converts a slice of Coordinates into a Sequence. +func coordsToSeq(coords []Coordinates, ctype CoordinatesType) Sequence { + dim := ctype.Dimension() + floats := make([]float64, 0, len(coords)*dim) + for _, c := range coords { + c.Type = ctype + floats = c.appendFloat64s(floats) + } + return NewSequence(floats, ctype) +} + // removeDupConsecutiveCoords removes consecutive vertices with identical XY. // For closed rings (first == last), the closing vertex is preserved. -func removeDupConsecutiveCoords(coords mutableSequence) mutableSequence { - if coords.Length() == 0 { - return coords - } - out := newMutableSequence(coords.ctype) - out = out.Append(coords.Get(0)) - for i := 1; i < coords.Length(); i++ { - if coords.GetXY(i) != out.GetXY(out.Length()-1) { - out = out.Append(coords.Get(i)) +func removeDupConsecutiveCoords(coords []Coordinates) []Coordinates { + if len(coords) == 0 { + return nil + } + out := coords[:1] + for _, c := range coords[1:] { + if c.XY != out[len(out)-1].XY { + out = append(out, c) } } return out diff --git a/geom/type_mutable_sequence.go b/geom/type_mutable_sequence.go deleted file mode 100644 index 9b6f9991..00000000 --- a/geom/type_mutable_sequence.go +++ /dev/null @@ -1,73 +0,0 @@ -package geom - -import "fmt" - -// mutableSequence is a variable-length sequence of coordinates with -// homogeneous [CoordinatesType]. It is the mutable counterpart of [Sequence], -// using the same flat []float64 storage. It behaves like a "fat slice" — a -// []float64 plus coordinate type information. Like a Go slice, mutating -// operations return the updated value. -type mutableSequence struct { - floats []float64 - ctype CoordinatesType -} - -func newMutableSequence(ctype CoordinatesType) mutableSequence { - return mutableSequence{ctype: ctype} -} - -func sequenceToMutable(seq Sequence) mutableSequence { - floats := make([]float64, len(seq.floats)) - copy(floats, seq.floats) - return mutableSequence{floats: floats, ctype: seq.CoordinatesType()} -} - -func (s mutableSequence) Append(c Coordinates) mutableSequence { - c.Type = s.ctype - s.floats = c.appendFloat64s(s.floats) - return s -} - -func (s mutableSequence) AppendMutable(other mutableSequence) mutableSequence { - if s.ctype != other.ctype { - panic(fmt.Sprintf("AppendMutable: mismatched CoordinatesType: %s vs %s", s.ctype, other.ctype)) - } - s.floats = append(s.floats, other.floats...) - return s -} - -func (s mutableSequence) Length() int { - return len(s.floats) / s.ctype.Dimension() -} - -func (s mutableSequence) Get(i int) Coordinates { - dim := s.ctype.Dimension() - offset := i * dim - c := Coordinates{ - XY: XY{X: s.floats[offset], Y: s.floats[offset+1]}, - Type: s.ctype, - } - if s.ctype.Is3D() { - c.Z = s.floats[offset+2] - } - if s.ctype.IsMeasured() { - c.M = s.floats[offset+dim-1] - } - return c -} - -func (s mutableSequence) GetXY(i int) XY { - dim := s.ctype.Dimension() - offset := i * dim - return XY{X: s.floats[offset], Y: s.floats[offset+1]} -} - -func (s mutableSequence) Slice(i, j int) mutableSequence { - dim := s.ctype.Dimension() - s.floats = s.floats[i*dim : j*dim] - return s -} - -func (s mutableSequence) ToSequence() Sequence { - return NewSequence(s.floats, s.ctype) -} From 4e8582ae42094270a6f6f2af814119e19faa21af Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Fri, 10 Apr 2026 06:14:41 +1000 Subject: [PATCH 19/32] alg_clip_by_rect_sutherland_hodgman.go: Alt way to handle coordinates --- geom/alg_clip_by_rect_sutherland_hodgman.go | 217 +++++++++++--------- 1 file changed, 122 insertions(+), 95 deletions(-) diff --git a/geom/alg_clip_by_rect_sutherland_hodgman.go b/geom/alg_clip_by_rect_sutherland_hodgman.go index a4d86c3b..199c128e 100644 --- a/geom/alg_clip_by_rect_sutherland_hodgman.go +++ b/geom/alg_clip_by_rect_sutherland_hodgman.go @@ -74,7 +74,7 @@ func clipPolygonByRect(p Polygon, rect Envelope) Geometry { // Classify holes. var freeHoles []LineString - var clippedHoles [][]Coordinates + var clippedHoles [][]float64 for i := 0; i < p.NumInteriorRings(); i++ { hole := p.InteriorRingN(i) holeEnv := hole.Envelope() @@ -121,9 +121,9 @@ func (c *polygonClipper) ringTouchesRectBoundary(ring LineString) bool { // must have been normalised to CCW (exterior CCW, holes CW) before clipping, // so that all clipped rings have known winding. func (c *polygonClipper) resolveClippedPolygon( - clippedExterior []Coordinates, + clippedExterior []float64, freeHoles []LineString, - clippedHoles [][]Coordinates, + clippedHoles [][]float64, ) Geometry { extArcs := c.extractInteriorArcs(clippedExterior) @@ -142,7 +142,7 @@ func (c *polygonClipper) resolveClippedPolygon( allArcs = append(allArcs, arcs...) } - var outputRings [][]Coordinates + var outputRings [][]float64 if len(allArcs) == 0 { // No interior arcs: the polygon contains the entire rect. // Use the clipped exterior directly — it is the rect with @@ -155,7 +155,7 @@ func (c *polygonClipper) resolveClippedPolygon( // Build output polygons. Each output ring is an exterior (already closed). var polys []Polygon for _, ring := range outputRings { - seq := coordsToSeq(ring, c.ctype) + seq := NewSequence(ring, c.ctype) exterior := NewLineString(seq) polys = append(polys, NewPolygon([]LineString{exterior})) } @@ -167,7 +167,7 @@ func (c *polygonClipper) resolveClippedPolygon( continue } for i, ring := range outputRings { - if pointInRingXY(holeXY, ring) { + if c.pointInRingXY(holeXY, ring) { existingRings := polys[i].DumpRings() existingRings = append(existingRings, hole) polys[i] = NewPolygon(existingRings) @@ -189,7 +189,7 @@ func (c *polygonClipper) resolveClippedPolygon( // walkArcs performs the topology resolution walk, producing output rings from // the collected interior arcs. It pairs arc endpoints along the rect boundary // (CCW) and traces complete rings. -func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]Coordinates { +func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]float64 { // Build a sorted list of arc "end" events and a map from "end param" to // the index of the arc that ends there. Also build a map from // "start param" to arc index. @@ -259,7 +259,7 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]Coordinates { } used := make([]bool, len(arcs)) - var rings [][]Coordinates + var rings [][]float64 for { // Find first unused arc. @@ -274,7 +274,7 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]Coordinates { break } - var ring []Coordinates + var ring []float64 curIdx := firstIdx for { @@ -288,8 +288,8 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]Coordinates { nextStartParam, nextIdx := findNextStart(a.endParam) // Build boundary path from this arc's end to the next arc's start. - endCoord := ring[len(ring)-1] - startCoord := arcs[nextIdx].coords[0] + endCoord := c.coordsGet(ring, c.coordsLen(ring)-1) + startCoord := c.coordsGet(arcs[nextIdx].coords, 0) bpath := c.buildBoundaryPath(a.endParam, nextStartParam, endCoord, startCoord) ring = append(ring, bpath...) @@ -301,9 +301,9 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]Coordinates { } // Close the ring. - ring = append(ring, ring[0]) - ring = removeDupConsecutiveCoords(ring) - if len(ring) >= 4 { + ring = c.coordsAppend(ring, c.coordsGet(ring, 0)) + ring = c.removeDupConsecutiveCoords(ring) + if c.coordsLen(ring) >= 4 { rings = append(rings, ring) } } @@ -317,7 +317,7 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]Coordinates { // the last/first coordinates of the adjacent arcs). Z and M values at corners // are linearly interpolated between startCoord and endCoord based on boundary // distance. -func (c *polygonClipper) buildBoundaryPath(startParam, endParam float64, startCoord, endCoord Coordinates) []Coordinates { +func (c *polygonClipper) buildBoundaryPath(startParam, endParam float64, startCoord, endCoord Coordinates) []float64 { // Find the first corner after startParam going CCW. The corners are in // ascending parameter order, so this is the first with cp > startParam. // If startParam is past the last corner (on the left edge), no corner @@ -334,7 +334,7 @@ func (c *polygonClipper) buildBoundaryPath(startParam, endParam float64, startCo // Iterate corners in CCW order from firstIdx, collecting those strictly // between startParam and endParam. totalDist := c.ccwDist(startParam, endParam) - var path []Coordinates + var path []float64 for k := 0; k < 4; k++ { cp := c.corners[(firstIdx+k)%4] d := c.ccwDist(startParam, cp) @@ -344,7 +344,7 @@ func (c *polygonClipper) buildBoundaryPath(startParam, endParam float64, startCo frac := d / totalDist coord := interpolateCoords(startCoord, endCoord, frac) coord.XY = c.paramToXY(cp) - path = append(path, coord) + path = c.coordsAppend(path, coord) } return path } @@ -354,8 +354,8 @@ func (c *polygonClipper) buildBoundaryPath(startParam, endParam float64, startCo // point on the rect boundary. Returns an empty slice if the ring has no // interior arcs (entirely on the boundary or entirely interior with no // boundary contact). -func (c *polygonClipper) extractInteriorArcs(ring []Coordinates) []interiorArc { - n := len(ring) +func (c *polygonClipper) extractInteriorArcs(ring []float64) []interiorArc { + n := c.coordsLen(ring) if n < 4 { return nil } @@ -367,7 +367,7 @@ func (c *polygonClipper) extractInteriorArcs(ring []Coordinates) []interiorArc { // Classify each edge as boundary (both endpoints on same rect edge) or interior. isBdry := make([]bool, numEdges) for i := 0; i < numEdges; i++ { - isBdry[i] = c.isSameRectEdge(ring[i].XY, ring[i+1].XY) + isBdry[i] = c.isSameRectEdge(c.coordsGetXY(ring, i), c.coordsGetXY(ring, i+1)) } // Find a starting boundary edge so we can walk from there. If there are @@ -396,25 +396,25 @@ func (c *polygonClipper) extractInteriorArcs(ring []Coordinates) []interiorArc { break } // Start of an interior arc at ring[i%numEdges]. - var arcCoords []Coordinates - arcCoords = append(arcCoords, ring[i%numEdges]) + var arcCoords []float64 + arcCoords = c.coordsAppend(arcCoords, c.coordsGet(ring, i%numEdges)) i++ steps++ for steps < numEdges && !isBdry[i%numEdges] { - arcCoords = append(arcCoords, ring[i%numEdges]) + arcCoords = c.coordsAppend(arcCoords, c.coordsGet(ring, i%numEdges)) i++ steps++ } if steps < numEdges { // The arc ends at ring[i%numEdges] (the start of the next boundary edge). - arcCoords = append(arcCoords, ring[i%numEdges]) + arcCoords = c.coordsAppend(arcCoords, c.coordsGet(ring, i%numEdges)) } else { // Wrapped around; the arc ends at ring[start] (where we began). - arcCoords = append(arcCoords, ring[start]) + arcCoords = c.coordsAppend(arcCoords, c.coordsGet(ring, start)) } - sp := c.rectBoundaryParam(arcCoords[0].XY) - ep := c.rectBoundaryParam(arcCoords[len(arcCoords)-1].XY) + sp := c.rectBoundaryParam(c.coordsGetXY(arcCoords, 0)) + ep := c.rectBoundaryParam(c.coordsGetXY(arcCoords, c.coordsLen(arcCoords)-1)) arcs = append(arcs, interiorArc{ coords: arcCoords, startParam: sp, @@ -426,40 +426,45 @@ func (c *polygonClipper) extractInteriorArcs(ring []Coordinates) []interiorArc { // interiorArc represents a portion of a clipped ring that passes through the // interior of the clipping rectangle (not along its boundary). The first and -// last elements of coords are on the rectangle boundary; everything in between -// is in the interior. +// last coordinates are on the rectangle boundary; everything in between is in +// the interior. The coords slice uses the same stride as the polygonClipper's +// ctype. type interiorArc struct { - coords []Coordinates - startParam float64 // boundary parameter of coords[0] - endParam float64 // boundary parameter of coords[len(coords)-1] + coords []float64 + startParam float64 // boundary parameter of first coordinate + endParam float64 // boundary parameter of last coordinate } // clipRingSH clips a closed ring against an axis-aligned rectangle using the -// Sutherland-Hodgman algorithm. It returns the clipped ring as a closed slice -// of [Coordinates] (first == last), or nil if the ring is entirely outside the -// rectangle. The input [LineString] must be a closed ring. -func (c *polygonClipper) clipRingSH(ring LineString) []Coordinates { - coords := seqToCoords(ring.Coordinates()) - if len(coords) < 4 { +// Sutherland-Hodgman algorithm. It returns the clipped ring as a closed +// []float64 (first coord == last coord), or nil if the ring is entirely +// outside the rectangle. The input [LineString] must be a closed ring. +func (c *polygonClipper) clipRingSH(ring LineString) []float64 { + seq := ring.Coordinates() + if seq.Length() < 4 { return nil } + // Copy the sequence's float data so we can mutate it through the passes. + coords := make([]float64, len(seq.floats)) + copy(coords, seq.floats) + // Clip against each of the 4 edges. - coords = clipToEdge(coords, + coords = c.clipToEdge(coords, func(co Coordinates) bool { return co.X >= c.lo.X }, func(a, b Coordinates) Coordinates { return interpX(a, b, c.lo.X) }) - coords = clipToEdge(coords, + coords = c.clipToEdge(coords, func(co Coordinates) bool { return co.X <= c.hi.X }, func(a, b Coordinates) Coordinates { return interpX(a, b, c.hi.X) }) - coords = clipToEdge(coords, + coords = c.clipToEdge(coords, func(co Coordinates) bool { return co.Y >= c.lo.Y }, func(a, b Coordinates) Coordinates { return interpY(a, b, c.lo.Y) }) - coords = clipToEdge(coords, + coords = c.clipToEdge(coords, func(co Coordinates) bool { return co.Y <= c.hi.Y }, func(a, b Coordinates) Coordinates { return interpY(a, b, c.hi.Y) }) - coords = removeDupConsecutiveCoords(coords) - if len(coords) < 4 { + coords = c.removeDupConsecutiveCoords(coords) + if c.coordsLen(coords) < 4 { return nil } return coords @@ -469,35 +474,38 @@ func (c *polygonClipper) clipRingSH(ring LineString) []Coordinates { // closed ring against a single half-plane defined by isInside. The intersect // function computes the intersection of segment a→b with the clipping edge. // The output is also an explicitly closed ring. -func clipToEdge( - coords []Coordinates, +func (c *polygonClipper) clipToEdge( + coords []float64, isInside func(Coordinates) bool, intersect func(Coordinates, Coordinates) Coordinates, -) []Coordinates { - if len(coords) == 0 { +) []float64 { + n := c.coordsLen(coords) + if n == 0 { return nil } - var output []Coordinates - a := coords[len(coords)-1] - for _, b := range coords { + var output []float64 + a := c.coordsGet(coords, n-1) + for i := 0; i < n; i++ { + b := c.coordsGet(coords, i) aIn := isInside(a) bIn := isInside(b) switch { case aIn && bIn: - output = append(output, b) + output = c.coordsAppend(output, b) case aIn && !bIn: - output = append(output, intersect(a, b)) + output = c.coordsAppend(output, intersect(a, b)) case !aIn && bIn: - output = append(output, intersect(a, b)) - output = append(output, b) + output = c.coordsAppend(output, intersect(a, b)) + output = c.coordsAppend(output, b) } a = b } // Ensure the output is explicitly closed. When the input's closing vertex // is outside the clip region, the degenerate closing edge emits nothing, // leaving the output open. - if len(output) > 0 && output[0].XY != output[len(output)-1].XY { - output = append(output, output[0]) + outLen := c.coordsLen(output) + if outLen > 0 && c.coordsGetXY(output, 0) != c.coordsGetXY(output, outLen-1) { + output = c.coordsAppend(output, c.coordsGet(output, 0)) } return output } @@ -507,9 +515,9 @@ func clipToEdge( // detection. Z and M are interpolated via [interpolateCoords]. func interpX(a, b Coordinates, x float64) Coordinates { t := (x - a.X) / (b.X - a.X) - c := interpolateCoords(a, b, t) - c.XY.X = x - return c + co := interpolateCoords(a, b, t) + co.XY.X = x + return co } // interpY returns the intersection of segment a→b with the horizontal line @@ -517,9 +525,9 @@ func interpX(a, b Coordinates, x float64) Coordinates { // boundary detection. Z and M are interpolated via [interpolateCoords]. func interpY(a, b Coordinates, y float64) Coordinates { t := (y - a.Y) / (b.Y - a.Y) - c := interpolateCoords(a, b, t) - c.XY.Y = y - return c + co := interpolateCoords(a, b, t) + co.XY.Y = y + return co } // rectBoundaryParam returns the CCW boundary parameter for a point on the rect @@ -576,17 +584,18 @@ func (c *polygonClipper) isSameRectEdge(a, b XY) bool { } // pointInRingXY returns true if xy is inside the ring defined by the given -// closed coordinates (first == last), using the ray-casting algorithm. -func pointInRingXY(xy XY, ring []Coordinates) bool { +// closed []float64 (first coord == last coord), using the ray-casting +// algorithm. +func (c *polygonClipper) pointInRingXY(xy XY, ring []float64) bool { inside := false - n := len(ring) - for i := range ring { + n := c.coordsLen(ring) + for i := 0; i < n; i++ { j := (i + 1) % n - yi, yj := ring[i].Y, ring[j].Y - xi, xj := ring[i].X, ring[j].X - if (yi > xy.Y) != (yj > xy.Y) { - slope := (xy.Y - yi) / (yj - yi) - xIntersect := xi + slope*(xj-xi) + pi := c.coordsGetXY(ring, i) + pj := c.coordsGetXY(ring, j) + if (pi.Y > xy.Y) != (pj.Y > xy.Y) { + slope := (xy.Y - pi.Y) / (pj.Y - pi.Y) + xIntersect := pi.X + slope*(pj.X-pi.X) if xy.X < xIntersect { inside = !inside } @@ -595,37 +604,55 @@ func pointInRingXY(xy XY, ring []Coordinates) bool { return inside } -// seqToCoords extracts all Coordinates from a Sequence into a slice. -func seqToCoords(seq Sequence) []Coordinates { - n := seq.Length() - coords := make([]Coordinates, n) - for i := range coords { - coords[i] = seq.Get(i) - } - return coords +// coordsLen returns the number of coordinates in a []float64 slice. +func (c *polygonClipper) coordsLen(floats []float64) int { + return len(floats) / c.ctype.Dimension() } -// coordsToSeq converts a slice of Coordinates into a Sequence. -func coordsToSeq(coords []Coordinates, ctype CoordinatesType) Sequence { - dim := ctype.Dimension() - floats := make([]float64, 0, len(coords)*dim) - for _, c := range coords { - c.Type = ctype - floats = c.appendFloat64s(floats) - } - return NewSequence(floats, ctype) +// coordsGet returns the Coordinates at index i in a []float64 slice. +func (c *polygonClipper) coordsGet(floats []float64, i int) Coordinates { + dim := c.ctype.Dimension() + offset := i * dim + co := Coordinates{ + XY: XY{X: floats[offset], Y: floats[offset+1]}, + Type: c.ctype, + } + switch c.ctype { + case DimXYZ: + co.Z = floats[offset+2] + case DimXYM: + co.M = floats[offset+2] + case DimXYZM: + co.Z = floats[offset+2] + co.M = floats[offset+3] + } + return co +} + +// coordsGetXY returns the XY at index i in a []float64 slice. +func (c *polygonClipper) coordsGetXY(floats []float64, i int) XY { + dim := c.ctype.Dimension() + offset := i * dim + return XY{X: floats[offset], Y: floats[offset+1]} +} + +// coordsAppend appends a Coordinates value to a []float64 slice. +func (c *polygonClipper) coordsAppend(floats []float64, co Coordinates) []float64 { + co.Type = c.ctype + return co.appendFloat64s(floats) } // removeDupConsecutiveCoords removes consecutive vertices with identical XY. // For closed rings (first == last), the closing vertex is preserved. -func removeDupConsecutiveCoords(coords []Coordinates) []Coordinates { - if len(coords) == 0 { +func (c *polygonClipper) removeDupConsecutiveCoords(coords []float64) []float64 { + n := c.coordsLen(coords) + if n == 0 { return nil } - out := coords[:1] - for _, c := range coords[1:] { - if c.XY != out[len(out)-1].XY { - out = append(out, c) + out := c.coordsAppend(nil, c.coordsGet(coords, 0)) + for i := 1; i < n; i++ { + if c.coordsGetXY(coords, i) != c.coordsGetXY(out, c.coordsLen(out)-1) { + out = c.coordsAppend(out, c.coordsGet(coords, i)) } } return out From 71bc5cf3796269ad5f95bf0e2cfdc439504ac7ab Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Fri, 10 Apr 2026 19:36:05 +1000 Subject: [PATCH 20/32] Revert back to using []Coordinates --- geom/alg_clip_by_rect_sutherland_hodgman.go | 217 +++++++++----------- 1 file changed, 95 insertions(+), 122 deletions(-) diff --git a/geom/alg_clip_by_rect_sutherland_hodgman.go b/geom/alg_clip_by_rect_sutherland_hodgman.go index 199c128e..a4d86c3b 100644 --- a/geom/alg_clip_by_rect_sutherland_hodgman.go +++ b/geom/alg_clip_by_rect_sutherland_hodgman.go @@ -74,7 +74,7 @@ func clipPolygonByRect(p Polygon, rect Envelope) Geometry { // Classify holes. var freeHoles []LineString - var clippedHoles [][]float64 + var clippedHoles [][]Coordinates for i := 0; i < p.NumInteriorRings(); i++ { hole := p.InteriorRingN(i) holeEnv := hole.Envelope() @@ -121,9 +121,9 @@ func (c *polygonClipper) ringTouchesRectBoundary(ring LineString) bool { // must have been normalised to CCW (exterior CCW, holes CW) before clipping, // so that all clipped rings have known winding. func (c *polygonClipper) resolveClippedPolygon( - clippedExterior []float64, + clippedExterior []Coordinates, freeHoles []LineString, - clippedHoles [][]float64, + clippedHoles [][]Coordinates, ) Geometry { extArcs := c.extractInteriorArcs(clippedExterior) @@ -142,7 +142,7 @@ func (c *polygonClipper) resolveClippedPolygon( allArcs = append(allArcs, arcs...) } - var outputRings [][]float64 + var outputRings [][]Coordinates if len(allArcs) == 0 { // No interior arcs: the polygon contains the entire rect. // Use the clipped exterior directly — it is the rect with @@ -155,7 +155,7 @@ func (c *polygonClipper) resolveClippedPolygon( // Build output polygons. Each output ring is an exterior (already closed). var polys []Polygon for _, ring := range outputRings { - seq := NewSequence(ring, c.ctype) + seq := coordsToSeq(ring, c.ctype) exterior := NewLineString(seq) polys = append(polys, NewPolygon([]LineString{exterior})) } @@ -167,7 +167,7 @@ func (c *polygonClipper) resolveClippedPolygon( continue } for i, ring := range outputRings { - if c.pointInRingXY(holeXY, ring) { + if pointInRingXY(holeXY, ring) { existingRings := polys[i].DumpRings() existingRings = append(existingRings, hole) polys[i] = NewPolygon(existingRings) @@ -189,7 +189,7 @@ func (c *polygonClipper) resolveClippedPolygon( // walkArcs performs the topology resolution walk, producing output rings from // the collected interior arcs. It pairs arc endpoints along the rect boundary // (CCW) and traces complete rings. -func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]float64 { +func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]Coordinates { // Build a sorted list of arc "end" events and a map from "end param" to // the index of the arc that ends there. Also build a map from // "start param" to arc index. @@ -259,7 +259,7 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]float64 { } used := make([]bool, len(arcs)) - var rings [][]float64 + var rings [][]Coordinates for { // Find first unused arc. @@ -274,7 +274,7 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]float64 { break } - var ring []float64 + var ring []Coordinates curIdx := firstIdx for { @@ -288,8 +288,8 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]float64 { nextStartParam, nextIdx := findNextStart(a.endParam) // Build boundary path from this arc's end to the next arc's start. - endCoord := c.coordsGet(ring, c.coordsLen(ring)-1) - startCoord := c.coordsGet(arcs[nextIdx].coords, 0) + endCoord := ring[len(ring)-1] + startCoord := arcs[nextIdx].coords[0] bpath := c.buildBoundaryPath(a.endParam, nextStartParam, endCoord, startCoord) ring = append(ring, bpath...) @@ -301,9 +301,9 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]float64 { } // Close the ring. - ring = c.coordsAppend(ring, c.coordsGet(ring, 0)) - ring = c.removeDupConsecutiveCoords(ring) - if c.coordsLen(ring) >= 4 { + ring = append(ring, ring[0]) + ring = removeDupConsecutiveCoords(ring) + if len(ring) >= 4 { rings = append(rings, ring) } } @@ -317,7 +317,7 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]float64 { // the last/first coordinates of the adjacent arcs). Z and M values at corners // are linearly interpolated between startCoord and endCoord based on boundary // distance. -func (c *polygonClipper) buildBoundaryPath(startParam, endParam float64, startCoord, endCoord Coordinates) []float64 { +func (c *polygonClipper) buildBoundaryPath(startParam, endParam float64, startCoord, endCoord Coordinates) []Coordinates { // Find the first corner after startParam going CCW. The corners are in // ascending parameter order, so this is the first with cp > startParam. // If startParam is past the last corner (on the left edge), no corner @@ -334,7 +334,7 @@ func (c *polygonClipper) buildBoundaryPath(startParam, endParam float64, startCo // Iterate corners in CCW order from firstIdx, collecting those strictly // between startParam and endParam. totalDist := c.ccwDist(startParam, endParam) - var path []float64 + var path []Coordinates for k := 0; k < 4; k++ { cp := c.corners[(firstIdx+k)%4] d := c.ccwDist(startParam, cp) @@ -344,7 +344,7 @@ func (c *polygonClipper) buildBoundaryPath(startParam, endParam float64, startCo frac := d / totalDist coord := interpolateCoords(startCoord, endCoord, frac) coord.XY = c.paramToXY(cp) - path = c.coordsAppend(path, coord) + path = append(path, coord) } return path } @@ -354,8 +354,8 @@ func (c *polygonClipper) buildBoundaryPath(startParam, endParam float64, startCo // point on the rect boundary. Returns an empty slice if the ring has no // interior arcs (entirely on the boundary or entirely interior with no // boundary contact). -func (c *polygonClipper) extractInteriorArcs(ring []float64) []interiorArc { - n := c.coordsLen(ring) +func (c *polygonClipper) extractInteriorArcs(ring []Coordinates) []interiorArc { + n := len(ring) if n < 4 { return nil } @@ -367,7 +367,7 @@ func (c *polygonClipper) extractInteriorArcs(ring []float64) []interiorArc { // Classify each edge as boundary (both endpoints on same rect edge) or interior. isBdry := make([]bool, numEdges) for i := 0; i < numEdges; i++ { - isBdry[i] = c.isSameRectEdge(c.coordsGetXY(ring, i), c.coordsGetXY(ring, i+1)) + isBdry[i] = c.isSameRectEdge(ring[i].XY, ring[i+1].XY) } // Find a starting boundary edge so we can walk from there. If there are @@ -396,25 +396,25 @@ func (c *polygonClipper) extractInteriorArcs(ring []float64) []interiorArc { break } // Start of an interior arc at ring[i%numEdges]. - var arcCoords []float64 - arcCoords = c.coordsAppend(arcCoords, c.coordsGet(ring, i%numEdges)) + var arcCoords []Coordinates + arcCoords = append(arcCoords, ring[i%numEdges]) i++ steps++ for steps < numEdges && !isBdry[i%numEdges] { - arcCoords = c.coordsAppend(arcCoords, c.coordsGet(ring, i%numEdges)) + arcCoords = append(arcCoords, ring[i%numEdges]) i++ steps++ } if steps < numEdges { // The arc ends at ring[i%numEdges] (the start of the next boundary edge). - arcCoords = c.coordsAppend(arcCoords, c.coordsGet(ring, i%numEdges)) + arcCoords = append(arcCoords, ring[i%numEdges]) } else { // Wrapped around; the arc ends at ring[start] (where we began). - arcCoords = c.coordsAppend(arcCoords, c.coordsGet(ring, start)) + arcCoords = append(arcCoords, ring[start]) } - sp := c.rectBoundaryParam(c.coordsGetXY(arcCoords, 0)) - ep := c.rectBoundaryParam(c.coordsGetXY(arcCoords, c.coordsLen(arcCoords)-1)) + sp := c.rectBoundaryParam(arcCoords[0].XY) + ep := c.rectBoundaryParam(arcCoords[len(arcCoords)-1].XY) arcs = append(arcs, interiorArc{ coords: arcCoords, startParam: sp, @@ -426,45 +426,40 @@ func (c *polygonClipper) extractInteriorArcs(ring []float64) []interiorArc { // interiorArc represents a portion of a clipped ring that passes through the // interior of the clipping rectangle (not along its boundary). The first and -// last coordinates are on the rectangle boundary; everything in between is in -// the interior. The coords slice uses the same stride as the polygonClipper's -// ctype. +// last elements of coords are on the rectangle boundary; everything in between +// is in the interior. type interiorArc struct { - coords []float64 - startParam float64 // boundary parameter of first coordinate - endParam float64 // boundary parameter of last coordinate + coords []Coordinates + startParam float64 // boundary parameter of coords[0] + endParam float64 // boundary parameter of coords[len(coords)-1] } // clipRingSH clips a closed ring against an axis-aligned rectangle using the -// Sutherland-Hodgman algorithm. It returns the clipped ring as a closed -// []float64 (first coord == last coord), or nil if the ring is entirely -// outside the rectangle. The input [LineString] must be a closed ring. -func (c *polygonClipper) clipRingSH(ring LineString) []float64 { - seq := ring.Coordinates() - if seq.Length() < 4 { +// Sutherland-Hodgman algorithm. It returns the clipped ring as a closed slice +// of [Coordinates] (first == last), or nil if the ring is entirely outside the +// rectangle. The input [LineString] must be a closed ring. +func (c *polygonClipper) clipRingSH(ring LineString) []Coordinates { + coords := seqToCoords(ring.Coordinates()) + if len(coords) < 4 { return nil } - // Copy the sequence's float data so we can mutate it through the passes. - coords := make([]float64, len(seq.floats)) - copy(coords, seq.floats) - // Clip against each of the 4 edges. - coords = c.clipToEdge(coords, + coords = clipToEdge(coords, func(co Coordinates) bool { return co.X >= c.lo.X }, func(a, b Coordinates) Coordinates { return interpX(a, b, c.lo.X) }) - coords = c.clipToEdge(coords, + coords = clipToEdge(coords, func(co Coordinates) bool { return co.X <= c.hi.X }, func(a, b Coordinates) Coordinates { return interpX(a, b, c.hi.X) }) - coords = c.clipToEdge(coords, + coords = clipToEdge(coords, func(co Coordinates) bool { return co.Y >= c.lo.Y }, func(a, b Coordinates) Coordinates { return interpY(a, b, c.lo.Y) }) - coords = c.clipToEdge(coords, + coords = clipToEdge(coords, func(co Coordinates) bool { return co.Y <= c.hi.Y }, func(a, b Coordinates) Coordinates { return interpY(a, b, c.hi.Y) }) - coords = c.removeDupConsecutiveCoords(coords) - if c.coordsLen(coords) < 4 { + coords = removeDupConsecutiveCoords(coords) + if len(coords) < 4 { return nil } return coords @@ -474,38 +469,35 @@ func (c *polygonClipper) clipRingSH(ring LineString) []float64 { // closed ring against a single half-plane defined by isInside. The intersect // function computes the intersection of segment a→b with the clipping edge. // The output is also an explicitly closed ring. -func (c *polygonClipper) clipToEdge( - coords []float64, +func clipToEdge( + coords []Coordinates, isInside func(Coordinates) bool, intersect func(Coordinates, Coordinates) Coordinates, -) []float64 { - n := c.coordsLen(coords) - if n == 0 { +) []Coordinates { + if len(coords) == 0 { return nil } - var output []float64 - a := c.coordsGet(coords, n-1) - for i := 0; i < n; i++ { - b := c.coordsGet(coords, i) + var output []Coordinates + a := coords[len(coords)-1] + for _, b := range coords { aIn := isInside(a) bIn := isInside(b) switch { case aIn && bIn: - output = c.coordsAppend(output, b) + output = append(output, b) case aIn && !bIn: - output = c.coordsAppend(output, intersect(a, b)) + output = append(output, intersect(a, b)) case !aIn && bIn: - output = c.coordsAppend(output, intersect(a, b)) - output = c.coordsAppend(output, b) + output = append(output, intersect(a, b)) + output = append(output, b) } a = b } // Ensure the output is explicitly closed. When the input's closing vertex // is outside the clip region, the degenerate closing edge emits nothing, // leaving the output open. - outLen := c.coordsLen(output) - if outLen > 0 && c.coordsGetXY(output, 0) != c.coordsGetXY(output, outLen-1) { - output = c.coordsAppend(output, c.coordsGet(output, 0)) + if len(output) > 0 && output[0].XY != output[len(output)-1].XY { + output = append(output, output[0]) } return output } @@ -515,9 +507,9 @@ func (c *polygonClipper) clipToEdge( // detection. Z and M are interpolated via [interpolateCoords]. func interpX(a, b Coordinates, x float64) Coordinates { t := (x - a.X) / (b.X - a.X) - co := interpolateCoords(a, b, t) - co.XY.X = x - return co + c := interpolateCoords(a, b, t) + c.XY.X = x + return c } // interpY returns the intersection of segment a→b with the horizontal line @@ -525,9 +517,9 @@ func interpX(a, b Coordinates, x float64) Coordinates { // boundary detection. Z and M are interpolated via [interpolateCoords]. func interpY(a, b Coordinates, y float64) Coordinates { t := (y - a.Y) / (b.Y - a.Y) - co := interpolateCoords(a, b, t) - co.XY.Y = y - return co + c := interpolateCoords(a, b, t) + c.XY.Y = y + return c } // rectBoundaryParam returns the CCW boundary parameter for a point on the rect @@ -584,18 +576,17 @@ func (c *polygonClipper) isSameRectEdge(a, b XY) bool { } // pointInRingXY returns true if xy is inside the ring defined by the given -// closed []float64 (first coord == last coord), using the ray-casting -// algorithm. -func (c *polygonClipper) pointInRingXY(xy XY, ring []float64) bool { +// closed coordinates (first == last), using the ray-casting algorithm. +func pointInRingXY(xy XY, ring []Coordinates) bool { inside := false - n := c.coordsLen(ring) - for i := 0; i < n; i++ { + n := len(ring) + for i := range ring { j := (i + 1) % n - pi := c.coordsGetXY(ring, i) - pj := c.coordsGetXY(ring, j) - if (pi.Y > xy.Y) != (pj.Y > xy.Y) { - slope := (xy.Y - pi.Y) / (pj.Y - pi.Y) - xIntersect := pi.X + slope*(pj.X-pi.X) + yi, yj := ring[i].Y, ring[j].Y + xi, xj := ring[i].X, ring[j].X + if (yi > xy.Y) != (yj > xy.Y) { + slope := (xy.Y - yi) / (yj - yi) + xIntersect := xi + slope*(xj-xi) if xy.X < xIntersect { inside = !inside } @@ -604,55 +595,37 @@ func (c *polygonClipper) pointInRingXY(xy XY, ring []float64) bool { return inside } -// coordsLen returns the number of coordinates in a []float64 slice. -func (c *polygonClipper) coordsLen(floats []float64) int { - return len(floats) / c.ctype.Dimension() -} - -// coordsGet returns the Coordinates at index i in a []float64 slice. -func (c *polygonClipper) coordsGet(floats []float64, i int) Coordinates { - dim := c.ctype.Dimension() - offset := i * dim - co := Coordinates{ - XY: XY{X: floats[offset], Y: floats[offset+1]}, - Type: c.ctype, - } - switch c.ctype { - case DimXYZ: - co.Z = floats[offset+2] - case DimXYM: - co.M = floats[offset+2] - case DimXYZM: - co.Z = floats[offset+2] - co.M = floats[offset+3] - } - return co -} - -// coordsGetXY returns the XY at index i in a []float64 slice. -func (c *polygonClipper) coordsGetXY(floats []float64, i int) XY { - dim := c.ctype.Dimension() - offset := i * dim - return XY{X: floats[offset], Y: floats[offset+1]} +// seqToCoords extracts all Coordinates from a Sequence into a slice. +func seqToCoords(seq Sequence) []Coordinates { + n := seq.Length() + coords := make([]Coordinates, n) + for i := range coords { + coords[i] = seq.Get(i) + } + return coords } -// coordsAppend appends a Coordinates value to a []float64 slice. -func (c *polygonClipper) coordsAppend(floats []float64, co Coordinates) []float64 { - co.Type = c.ctype - return co.appendFloat64s(floats) +// coordsToSeq converts a slice of Coordinates into a Sequence. +func coordsToSeq(coords []Coordinates, ctype CoordinatesType) Sequence { + dim := ctype.Dimension() + floats := make([]float64, 0, len(coords)*dim) + for _, c := range coords { + c.Type = ctype + floats = c.appendFloat64s(floats) + } + return NewSequence(floats, ctype) } // removeDupConsecutiveCoords removes consecutive vertices with identical XY. // For closed rings (first == last), the closing vertex is preserved. -func (c *polygonClipper) removeDupConsecutiveCoords(coords []float64) []float64 { - n := c.coordsLen(coords) - if n == 0 { +func removeDupConsecutiveCoords(coords []Coordinates) []Coordinates { + if len(coords) == 0 { return nil } - out := c.coordsAppend(nil, c.coordsGet(coords, 0)) - for i := 1; i < n; i++ { - if c.coordsGetXY(coords, i) != c.coordsGetXY(out, c.coordsLen(out)-1) { - out = c.coordsAppend(out, c.coordsGet(coords, i)) + out := coords[:1] + for _, c := range coords[1:] { + if c.XY != out[len(out)-1].XY { + out = append(out, c) } } return out From f35ddea1e91ac5fc271fb2a3193f2e99d5e71bc3 Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Fri, 10 Apr 2026 20:12:57 +1000 Subject: [PATCH 21/32] Pass around raw []float64 --- geom/alg_clip_by_rect_sutherland_hodgman.go | 277 ++++++++++---------- 1 file changed, 143 insertions(+), 134 deletions(-) diff --git a/geom/alg_clip_by_rect_sutherland_hodgman.go b/geom/alg_clip_by_rect_sutherland_hodgman.go index a4d86c3b..dfdc7627 100644 --- a/geom/alg_clip_by_rect_sutherland_hodgman.go +++ b/geom/alg_clip_by_rect_sutherland_hodgman.go @@ -14,6 +14,7 @@ type polygonClipper struct { perim float64 corners [4]float64 // CCW corner parameters: BL, BR, TR, TL ctype CoordinatesType + s int // stride: floats per point (2 for XY, 3 for XYZ/XYM, 4 for XYZM) } func newPolygonClipper(lo, hi XY, ctype CoordinatesType) polygonClipper { @@ -27,6 +28,7 @@ func newPolygonClipper(lo, hi XY, ctype CoordinatesType) polygonClipper { perim: 2*w + 2*h, corners: [4]float64{0, w, w + h, 2*w + h}, ctype: ctype, + s: ctype.Dimension(), } } @@ -74,7 +76,7 @@ func clipPolygonByRect(p Polygon, rect Envelope) Geometry { // Classify holes. var freeHoles []LineString - var clippedHoles [][]Coordinates + var clippedHoles [][]float64 for i := 0; i < p.NumInteriorRings(); i++ { hole := p.InteriorRingN(i) holeEnv := hole.Envelope() @@ -121,9 +123,9 @@ func (c *polygonClipper) ringTouchesRectBoundary(ring LineString) bool { // must have been normalised to CCW (exterior CCW, holes CW) before clipping, // so that all clipped rings have known winding. func (c *polygonClipper) resolveClippedPolygon( - clippedExterior []Coordinates, + clippedExterior []float64, freeHoles []LineString, - clippedHoles [][]Coordinates, + clippedHoles [][]float64, ) Geometry { extArcs := c.extractInteriorArcs(clippedExterior) @@ -142,7 +144,7 @@ func (c *polygonClipper) resolveClippedPolygon( allArcs = append(allArcs, arcs...) } - var outputRings [][]Coordinates + var outputRings [][]float64 if len(allArcs) == 0 { // No interior arcs: the polygon contains the entire rect. // Use the clipped exterior directly — it is the rect with @@ -155,7 +157,7 @@ func (c *polygonClipper) resolveClippedPolygon( // Build output polygons. Each output ring is an exterior (already closed). var polys []Polygon for _, ring := range outputRings { - seq := coordsToSeq(ring, c.ctype) + seq := NewSequence(ring, c.ctype) exterior := NewLineString(seq) polys = append(polys, NewPolygon([]LineString{exterior})) } @@ -167,7 +169,7 @@ func (c *polygonClipper) resolveClippedPolygon( continue } for i, ring := range outputRings { - if pointInRingXY(holeXY, ring) { + if c.pointInRing(holeXY, ring) { existingRings := polys[i].DumpRings() existingRings = append(existingRings, hole) polys[i] = NewPolygon(existingRings) @@ -189,7 +191,7 @@ func (c *polygonClipper) resolveClippedPolygon( // walkArcs performs the topology resolution walk, producing output rings from // the collected interior arcs. It pairs arc endpoints along the rect boundary // (CCW) and traces complete rings. -func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]Coordinates { +func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]float64 { // Build a sorted list of arc "end" events and a map from "end param" to // the index of the arc that ends there. Also build a map from // "start param" to arc index. @@ -259,7 +261,7 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]Coordinates { } used := make([]bool, len(arcs)) - var rings [][]Coordinates + var rings [][]float64 for { // Find first unused arc. @@ -274,7 +276,7 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]Coordinates { break } - var ring []Coordinates + var ring []float64 curIdx := firstIdx for { @@ -288,11 +290,10 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]Coordinates { nextStartParam, nextIdx := findNextStart(a.endParam) // Build boundary path from this arc's end to the next arc's start. - endCoord := ring[len(ring)-1] - startCoord := arcs[nextIdx].coords[0] + endPt := ring[len(ring)-c.s:] + startPt := arcs[nextIdx].coords[:c.s] - bpath := c.buildBoundaryPath(a.endParam, nextStartParam, endCoord, startCoord) - ring = append(ring, bpath...) + ring = c.appendBoundaryPath(ring, a.endParam, nextStartParam, endPt, startPt) if nextIdx == firstIdx { break // Ring complete. @@ -301,9 +302,9 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]Coordinates { } // Close the ring. - ring = append(ring, ring[0]) - ring = removeDupConsecutiveCoords(ring) - if len(ring) >= 4 { + ring = append(ring, ring[:c.s]...) + ring = c.removeDupConsecutive(ring) + if len(ring) >= 4*c.s { rings = append(rings, ring) } } @@ -311,13 +312,13 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]Coordinates { return rings } -// buildBoundaryPath returns the coordinates along the rect boundary going CCW +// appendBoundaryPath appends coordinates along the rect boundary going CCW // from startParam to endParam. It includes rect corners between the two // parameters but does NOT include the start or end points themselves (those are // the last/first coordinates of the adjacent arcs). Z and M values at corners -// are linearly interpolated between startCoord and endCoord based on boundary +// are linearly interpolated between startPt and endPt based on boundary // distance. -func (c *polygonClipper) buildBoundaryPath(startParam, endParam float64, startCoord, endCoord Coordinates) []Coordinates { +func (c *polygonClipper) appendBoundaryPath(dst []float64, startParam, endParam float64, startPt, endPt []float64) []float64 { // Find the first corner after startParam going CCW. The corners are in // ascending parameter order, so this is the first with cp > startParam. // If startParam is past the last corner (on the left edge), no corner @@ -334,7 +335,6 @@ func (c *polygonClipper) buildBoundaryPath(startParam, endParam float64, startCo // Iterate corners in CCW order from firstIdx, collecting those strictly // between startParam and endParam. totalDist := c.ccwDist(startParam, endParam) - var path []Coordinates for k := 0; k < 4; k++ { cp := c.corners[(firstIdx+k)%4] d := c.ccwDist(startParam, cp) @@ -342,11 +342,13 @@ func (c *polygonClipper) buildBoundaryPath(startParam, endParam float64, startCo break } frac := d / totalDist - coord := interpolateCoords(startCoord, endCoord, frac) - coord.XY = c.paramToXY(cp) - path = append(path, coord) + xy := c.paramToXY(cp) + dst = append(dst, xy.X, xy.Y) + for dim := 2; dim < c.s; dim++ { + dst = append(dst, lerp(startPt[dim], endPt[dim], frac)) + } } - return path + return dst } // extractInteriorArcs decomposes a clipped ring into interior arcs. The ring @@ -354,8 +356,9 @@ func (c *polygonClipper) buildBoundaryPath(startParam, endParam float64, startCo // point on the rect boundary. Returns an empty slice if the ring has no // interior arcs (entirely on the boundary or entirely interior with no // boundary contact). -func (c *polygonClipper) extractInteriorArcs(ring []Coordinates) []interiorArc { - n := len(ring) +func (c *polygonClipper) extractInteriorArcs(ring []float64) []interiorArc { + s := c.s + n := len(ring) / s if n < 4 { return nil } @@ -367,7 +370,9 @@ func (c *polygonClipper) extractInteriorArcs(ring []Coordinates) []interiorArc { // Classify each edge as boundary (both endpoints on same rect edge) or interior. isBdry := make([]bool, numEdges) for i := 0; i < numEdges; i++ { - isBdry[i] = c.isSameRectEdge(ring[i].XY, ring[i+1].XY) + ax, ay := ring[s*i], ring[s*i+1] + bx, by := ring[s*(i+1)], ring[s*(i+1)+1] + isBdry[i] = c.isSameRectEdge(XY{ax, ay}, XY{bx, by}) } // Find a starting boundary edge so we can walk from there. If there are @@ -396,25 +401,28 @@ func (c *polygonClipper) extractInteriorArcs(ring []Coordinates) []interiorArc { break } // Start of an interior arc at ring[i%numEdges]. - var arcCoords []Coordinates - arcCoords = append(arcCoords, ring[i%numEdges]) + arcStart := (i % numEdges) * s + var arcCoords []float64 + arcCoords = append(arcCoords, ring[arcStart:arcStart+s]...) i++ steps++ for steps < numEdges && !isBdry[i%numEdges] { - arcCoords = append(arcCoords, ring[i%numEdges]) + off := (i % numEdges) * s + arcCoords = append(arcCoords, ring[off:off+s]...) i++ steps++ } if steps < numEdges { // The arc ends at ring[i%numEdges] (the start of the next boundary edge). - arcCoords = append(arcCoords, ring[i%numEdges]) + off := (i % numEdges) * s + arcCoords = append(arcCoords, ring[off:off+s]...) } else { // Wrapped around; the arc ends at ring[start] (where we began). - arcCoords = append(arcCoords, ring[start]) + arcCoords = append(arcCoords, ring[start*s:start*s+s]...) } - sp := c.rectBoundaryParam(arcCoords[0].XY) - ep := c.rectBoundaryParam(arcCoords[len(arcCoords)-1].XY) + sp := c.rectBoundaryParam(XY{arcCoords[0], arcCoords[1]}) + ep := c.rectBoundaryParam(XY{arcCoords[len(arcCoords)-s], arcCoords[len(arcCoords)-s+1]}) arcs = append(arcs, interiorArc{ coords: arcCoords, startParam: sp, @@ -426,100 +434,116 @@ func (c *polygonClipper) extractInteriorArcs(ring []Coordinates) []interiorArc { // interiorArc represents a portion of a clipped ring that passes through the // interior of the clipping rectangle (not along its boundary). The first and -// last elements of coords are on the rectangle boundary; everything in between -// is in the interior. +// last s floats (where s is the stride) are on the rectangle boundary; +// everything in between is in the interior. type interiorArc struct { - coords []Coordinates - startParam float64 // boundary parameter of coords[0] - endParam float64 // boundary parameter of coords[len(coords)-1] + coords []float64 + startParam float64 // boundary parameter of first point + endParam float64 // boundary parameter of last point } // clipRingSH clips a closed ring against an axis-aligned rectangle using the -// Sutherland-Hodgman algorithm. It returns the clipped ring as a closed slice -// of [Coordinates] (first == last), or nil if the ring is entirely outside the -// rectangle. The input [LineString] must be a closed ring. -func (c *polygonClipper) clipRingSH(ring LineString) []Coordinates { - coords := seqToCoords(ring.Coordinates()) - if len(coords) < 4 { +// Sutherland-Hodgman algorithm. It returns the clipped ring as a closed +// []float64 (first point == last point), or nil if the ring is entirely +// outside the rectangle. The input [LineString] must be a closed ring. +func (c *polygonClipper) clipRingSH(ring LineString) []float64 { + seq := ring.Coordinates() + coords := seq.appendAllPoints(nil) + if len(coords)/c.s < 4 { return nil } // Clip against each of the 4 edges. - coords = clipToEdge(coords, - func(co Coordinates) bool { return co.X >= c.lo.X }, - func(a, b Coordinates) Coordinates { return interpX(a, b, c.lo.X) }) - coords = clipToEdge(coords, - func(co Coordinates) bool { return co.X <= c.hi.X }, - func(a, b Coordinates) Coordinates { return interpX(a, b, c.hi.X) }) - coords = clipToEdge(coords, - func(co Coordinates) bool { return co.Y >= c.lo.Y }, - func(a, b Coordinates) Coordinates { return interpY(a, b, c.lo.Y) }) - coords = clipToEdge(coords, - func(co Coordinates) bool { return co.Y <= c.hi.Y }, - func(a, b Coordinates) Coordinates { return interpY(a, b, c.hi.Y) }) - - coords = removeDupConsecutiveCoords(coords) - if len(coords) < 4 { + coords = c.clipToEdge(coords, + func(x, y float64) bool { return x >= c.lo.X }, + func(dst, src []float64, ai, bi int) []float64 { return c.appendInterpX(dst, src, ai, bi, c.lo.X) }) + coords = c.clipToEdge(coords, + func(x, y float64) bool { return x <= c.hi.X }, + func(dst, src []float64, ai, bi int) []float64 { return c.appendInterpX(dst, src, ai, bi, c.hi.X) }) + coords = c.clipToEdge(coords, + func(x, y float64) bool { return y >= c.lo.Y }, + func(dst, src []float64, ai, bi int) []float64 { return c.appendInterpY(dst, src, ai, bi, c.lo.Y) }) + coords = c.clipToEdge(coords, + func(x, y float64) bool { return y <= c.hi.Y }, + func(dst, src []float64, ai, bi int) []float64 { return c.appendInterpY(dst, src, ai, bi, c.hi.Y) }) + + coords = c.removeDupConsecutive(coords) + if len(coords)/c.s < 4 { return nil } return coords } // clipToEdge performs one pass of the Sutherland-Hodgman algorithm, clipping a -// closed ring against a single half-plane defined by isInside. The intersect -// function computes the intersection of segment a→b with the clipping edge. -// The output is also an explicitly closed ring. -func clipToEdge( - coords []Coordinates, - isInside func(Coordinates) bool, - intersect func(Coordinates, Coordinates) Coordinates, -) []Coordinates { - if len(coords) == 0 { +// closed ring against a single half-plane defined by isInside. The +// appendIntersection function appends the intersection point between points at +// indices ai and bi to dst. The output is also an explicitly closed ring. +func (c *polygonClipper) clipToEdge( + coords []float64, + isInside func(x, y float64) bool, + appendIntersection func(dst, coords []float64, ai, bi int) []float64, +) []float64 { + s := c.s + n := len(coords) / s + if n == 0 { return nil } - var output []Coordinates - a := coords[len(coords)-1] - for _, b := range coords { - aIn := isInside(a) - bIn := isInside(b) + var out []float64 + ai := n - 1 + for bi := 0; bi < n; bi++ { + ax, ay := coords[s*ai], coords[s*ai+1] + bx, by := coords[s*bi], coords[s*bi+1] + aIn := isInside(ax, ay) + bIn := isInside(bx, by) switch { case aIn && bIn: - output = append(output, b) + out = append(out, coords[s*bi:s*(bi+1)]...) case aIn && !bIn: - output = append(output, intersect(a, b)) + out = appendIntersection(out, coords, ai, bi) case !aIn && bIn: - output = append(output, intersect(a, b)) - output = append(output, b) + out = appendIntersection(out, coords, ai, bi) + out = append(out, coords[s*bi:s*(bi+1)]...) } - a = b + ai = bi } // Ensure the output is explicitly closed. When the input's closing vertex // is outside the clip region, the degenerate closing edge emits nothing, // leaving the output open. - if len(output) > 0 && output[0].XY != output[len(output)-1].XY { - output = append(output, output[0]) + nOut := len(out) / s + if nOut > 0 && (out[0] != out[(nOut-1)*s] || out[1] != out[(nOut-1)*s+1]) { + out = append(out, out[:s]...) } - return output + return out } -// interpX returns the intersection of segment a→b with the vertical line x=k. -// The result's X coordinate is set to x exactly to ensure reliable boundary -// detection. Z and M are interpolated via [interpolateCoords]. -func interpX(a, b Coordinates, x float64) Coordinates { - t := (x - a.X) / (b.X - a.X) - c := interpolateCoords(a, b, t) - c.XY.X = x - return c +// appendInterpX appends the intersection of the segment between points ai and +// bi with the vertical line x=k. The X coordinate is set exactly; Z and M are +// linearly interpolated. +func (c *polygonClipper) appendInterpX(dst, coords []float64, ai, bi int, x float64) []float64 { + s := c.s + ax := coords[s*ai] + bx := coords[s*bi] + t := (x - ax) / (bx - ax) + for d := 0; d < s; d++ { + dst = append(dst, lerp(coords[s*ai+d], coords[s*bi+d], t)) + } + dst[len(dst)-s] = x // set exact X + return dst } -// interpY returns the intersection of segment a→b with the horizontal line -// y=k. The result's Y coordinate is set to y exactly to ensure reliable -// boundary detection. Z and M are interpolated via [interpolateCoords]. -func interpY(a, b Coordinates, y float64) Coordinates { - t := (y - a.Y) / (b.Y - a.Y) - c := interpolateCoords(a, b, t) - c.XY.Y = y - return c +// appendInterpY appends the intersection of the segment between points ai and +// bi with the horizontal line y=k. The Y coordinate is set exactly; Z and M +// are linearly interpolated. +func (c *polygonClipper) appendInterpY(dst, coords []float64, ai, bi int, y float64) []float64 { + s := c.s + ay := coords[s*ai+1] + by := coords[s*bi+1] + t := (y - ay) / (by - ay) + for d := 0; d < s; d++ { + dst = append(dst, lerp(coords[s*ai+d], coords[s*bi+d], t)) + } + dst[len(dst)-s+1] = y // set exact Y + return dst } // rectBoundaryParam returns the CCW boundary parameter for a point on the rect @@ -575,15 +599,17 @@ func (c *polygonClipper) isSameRectEdge(a, b XY) bool { (a.Y == c.hi.Y && b.Y == c.hi.Y) } -// pointInRingXY returns true if xy is inside the ring defined by the given -// closed coordinates (first == last), using the ray-casting algorithm. -func pointInRingXY(xy XY, ring []Coordinates) bool { +// pointInRing returns true if xy is inside the ring defined by the given +// closed []float64 (first point == last point), using the ray-casting +// algorithm. +func (c *polygonClipper) pointInRing(xy XY, ring []float64) bool { + s := c.s + n := len(ring) / s inside := false - n := len(ring) - for i := range ring { + for i := 0; i < n; i++ { j := (i + 1) % n - yi, yj := ring[i].Y, ring[j].Y - xi, xj := ring[i].X, ring[j].X + yi, yj := ring[s*i+1], ring[s*j+1] + xi, xj := ring[s*i], ring[s*j] if (yi > xy.Y) != (yj > xy.Y) { slope := (xy.Y - yi) / (yj - yi) xIntersect := xi + slope*(xj-xi) @@ -595,37 +621,20 @@ func pointInRingXY(xy XY, ring []Coordinates) bool { return inside } -// seqToCoords extracts all Coordinates from a Sequence into a slice. -func seqToCoords(seq Sequence) []Coordinates { - n := seq.Length() - coords := make([]Coordinates, n) - for i := range coords { - coords[i] = seq.Get(i) - } - return coords -} - -// coordsToSeq converts a slice of Coordinates into a Sequence. -func coordsToSeq(coords []Coordinates, ctype CoordinatesType) Sequence { - dim := ctype.Dimension() - floats := make([]float64, 0, len(coords)*dim) - for _, c := range coords { - c.Type = ctype - floats = c.appendFloat64s(floats) - } - return NewSequence(floats, ctype) -} - -// removeDupConsecutiveCoords removes consecutive vertices with identical XY. -// For closed rings (first == last), the closing vertex is preserved. -func removeDupConsecutiveCoords(coords []Coordinates) []Coordinates { - if len(coords) == 0 { +// removeDupConsecutive removes consecutive points with identical X,Y. +// For closed rings (first == last), the closing point is preserved. +func (c *polygonClipper) removeDupConsecutive(coords []float64) []float64 { + s := c.s + n := len(coords) / s + if n == 0 { return nil } - out := coords[:1] - for _, c := range coords[1:] { - if c.XY != out[len(out)-1].XY { - out = append(out, c) + out := coords[:s] // keep first point + for i := 1; i < n; i++ { + prev := out[len(out)-s:] + cur := coords[s*i : s*(i+1)] + if cur[0] != prev[0] || cur[1] != prev[1] { + out = append(out, cur...) } } return out From 8447c1ef9c4734d32d7d3f4b351a578e742dfda0 Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Fri, 22 May 2026 15:15:33 +1000 Subject: [PATCH 22/32] Rename ClipByRect to ClipByRect2D with 2D-only output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Force input to DimXY at the public entry point and drop all Z/M interpolation from both clipping algorithms. The previous behaviour synthesised Z/M values at rectangle corners (where the clipped boundary must traverse around the rect) which are not derivable from the input data — better to commit to 2D semantics than to fabricate. Both algorithms now operate on []XY internally. The Sutherland-Hodgman polygon clipper drops its ctype/stride fields and all dimension-aware loops; the Liang-Barsky line clipper drops interpolateSeqCoord. New helpers introduced: - Sequence.asXYs() for the conversion at the input boundary - xysToSeq for converting back at the output boundary - lerpXY in alg_linear_interpolation.go Tests updated: cases with XYZ/XYM/XYZM inputs now expect XY outputs, and new CD5-CD8 cases pin the Z/M-dropped contract across Point, Polygon, MultiPoint, and GeometryCollection. --- CHANGELOG.md | 8 + geom/alg_clip_by_rect.go | 51 +++-- geom/alg_clip_by_rect_liang_barsky.go | 41 ++-- geom/alg_clip_by_rect_sutherland_hodgman.go | 216 ++++++++------------ geom/alg_linear_interpolation.go | 16 ++ geom/alg_sutherland_hodgman_test.go | 54 +++-- geom/type_sequence.go | 10 + 7 files changed, 197 insertions(+), 199 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8c49802..ddd90bf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ - Add `NewEnvelopeXY` constructor for building an `Envelope` from variadic x/y coordinate pairs, following the existing `New*XY` constructor pattern. +- Add `ClipByRect2D` function that clips a geometry to a 2D axis-aligned + rectangle (defined by an `Envelope`). It uses the Sutherland-Hodgman + algorithm for polygons and the Liang-Barsky algorithm for line strings. The + result is always 2D: any Z or M values on the input are discarded, avoiding + the ambiguity of synthesising Z/M values at rectangle corners introduced by + the clip. + + ## v0.59.0 2026-03-27 diff --git a/geom/alg_clip_by_rect.go b/geom/alg_clip_by_rect.go index b1fc7f56..485cedb1 100644 --- a/geom/alg_clip_by_rect.go +++ b/geom/alg_clip_by_rect.go @@ -1,10 +1,23 @@ package geom -// ClipByRect clips a geometry to an axis-aligned rectangle defined by the -// given [Envelope], returning the portion of the geometry that lies within the -// rectangle. It uses the Sutherland-Hodgman algorithm for polygon clipping -// and related approaches for other geometry types. -func ClipByRect(g Geometry, rect Envelope) Geometry { +// ClipByRect2D clips a geometry to the 2D axis-aligned rectangle defined by +// the given [Envelope], returning the portion of the geometry that lies within +// the rectangle. It uses the Sutherland-Hodgman algorithm for polygon clipping +// and the Liang-Barsky algorithm for line clipping. +// +// The result is always 2D ([DimXY]): any Z or M values on the input are +// discarded. Linear interpolation of Z/M at clip-edge intersections is +// reasonably well-defined, but values that would have to be synthesised at +// rectangle corners (where the clipped boundary must traverse the rect) are +// not, so this function avoids the problem by reducing to two dimensions +// throughout. +func ClipByRect2D(g Geometry, rect Envelope) Geometry { + return clipByRect2D(g.Force2D(), rect) +} + +// clipByRect2D dispatches by geometry type. The input must already have been +// reduced to [DimXY] by the caller. +func clipByRect2D(g Geometry, rect Envelope) Geometry { switch g.Type() { case TypePoint: return clipPointByRect(g.MustAsPoint(), rect).AsGeometry() @@ -33,22 +46,18 @@ func clipPointByRect(p Point, rect Envelope) Point { if rect.Contains(xy) { return p } - return NewEmptyPoint(p.CoordinatesType()) + return Point{} } func clipMultiPointByRect(mp MultiPoint, rect Envelope) MultiPoint { n := mp.NumPoints() var pts []Point for i := 0; i < n; i++ { - p := mp.PointN(i) - clipped := clipPointByRect(p, rect) + clipped := clipPointByRect(mp.PointN(i), rect) if !clipped.IsEmpty() { pts = append(pts, clipped) } } - if len(pts) == 0 { - return NewMultiPoint(nil).ForceCoordinatesType(mp.CoordinatesType()) - } return NewMultiPoint(pts) } @@ -69,9 +78,6 @@ func clipMultiLineStringByRect(mls MultiLineString, rect Envelope) MultiLineStri panic("unexpected type from clipLineStringByRect: " + clipped.Type().String()) } } - if len(lines) == 0 { - return NewMultiLineString(nil).ForceCoordinatesType(mls.CoordinatesType()) - } return NewMultiLineString(lines) } @@ -92,9 +98,6 @@ func clipMultiPolygonByRect(mp MultiPolygon, rect Envelope) MultiPolygon { panic("unexpected type from clipPolygonByRect: " + clipped.Type().String()) } } - if len(polys) == 0 { - return NewMultiPolygon(nil).ForceCoordinatesType(mp.CoordinatesType()) - } return NewMultiPolygon(polys) } @@ -102,13 +105,19 @@ func clipGeometryCollectionByRect(gc GeometryCollection, rect Envelope) Geometry n := gc.NumGeometries() var geoms []Geometry for i := 0; i < n; i++ { - clipped := ClipByRect(gc.GeometryN(i), rect) + clipped := clipByRect2D(gc.GeometryN(i), rect) if !clipped.IsEmpty() { geoms = append(geoms, clipped) } } - if len(geoms) == 0 { - return NewGeometryCollection(nil).ForceCoordinatesType(gc.CoordinatesType()) - } return NewGeometryCollection(geoms) } + +// xysToSeq builds a [DimXY] [Sequence] from a slice of [XY]. +func xysToSeq(xys []XY) Sequence { + floats := make([]float64, 0, 2*len(xys)) + for _, xy := range xys { + floats = append(floats, xy.X, xy.Y) + } + return NewSequence(floats, DimXY) +} diff --git a/geom/alg_clip_by_rect_liang_barsky.go b/geom/alg_clip_by_rect_liang_barsky.go index 9dcca008..f90d4622 100644 --- a/geom/alg_clip_by_rect_liang_barsky.go +++ b/geom/alg_clip_by_rect_liang_barsky.go @@ -1,22 +1,21 @@ package geom func clipLineStringByRect(ls LineString, rect Envelope) Geometry { + emptyLine := LineString{}.AsGeometry() + seq := ls.Coordinates() n := seq.Length() if n == 0 { - return ls.AsGeometry() + return emptyLine } min, max, ok := rect.MinMaxXYs() if !ok { - return NewLineString(NewSequence(nil, seq.CoordinatesType())).AsGeometry() + return emptyLine } - ctype := seq.CoordinatesType() - dim := ctype.Dimension() - - var chains [][]float64 - var cur []float64 + var chains [][]XY + var cur []XY for i := 0; i < n-1; i++ { a := seq.GetXY(i) @@ -31,17 +30,16 @@ func clipLineStringByRect(ls LineString, rect Envelope) Geometry { continue } - ca := interpolateSeqCoord(seq, i, i+1, tMin) - cb := interpolateSeqCoord(seq, i, i+1, tMax) + ca := lerpXY(a, b, tMin) + cb := lerpXY(a, b, tMax) - if len(cur) > 0 && cur[len(cur)-dim] == ca.X && cur[len(cur)-dim+1] == ca.Y { - cur = cb.appendFloat64s(cur) + if len(cur) > 0 && cur[len(cur)-1] == ca { + cur = append(cur, cb) } else { if len(cur) > 0 { chains = append(chains, cur) } - cur = ca.appendFloat64s(nil) - cur = cb.appendFloat64s(cur) + cur = []XY{ca, cb} } } if len(cur) > 0 { @@ -49,12 +47,12 @@ func clipLineStringByRect(ls LineString, rect Envelope) Geometry { } if len(chains) == 0 { - return NewLineString(NewSequence(nil, ctype)).AsGeometry() + return emptyLine } lines := make([]LineString, len(chains)) for i, c := range chains { - lines[i] = NewLineString(NewSequence(c, ctype)) + lines[i] = NewLineString(xysToSeq(c)) } if len(lines) == 1 { @@ -101,16 +99,3 @@ func clipSegmentParams(a, b, min, max XY) (float64, float64, bool) { } return tMin, tMax, true } - -// interpolateSeqCoord linearly interpolates between coordinates at index i and -// j in seq at parameter t (0=coord i, 1=coord j). Delegates to -// [interpolateCoords] which uses a numerically robust lerp. -func interpolateSeqCoord(seq Sequence, i, j int, t float64) Coordinates { - if t == 0 { - return seq.Get(i) - } - if t == 1 { - return seq.Get(j) - } - return interpolateCoords(seq.Get(i), seq.Get(j), t) -} diff --git a/geom/alg_clip_by_rect_sutherland_hodgman.go b/geom/alg_clip_by_rect_sutherland_hodgman.go index dfdc7627..aa7fd123 100644 --- a/geom/alg_clip_by_rect_sutherland_hodgman.go +++ b/geom/alg_clip_by_rect_sutherland_hodgman.go @@ -13,11 +13,9 @@ type polygonClipper struct { w, h float64 perim float64 corners [4]float64 // CCW corner parameters: BL, BR, TR, TL - ctype CoordinatesType - s int // stride: floats per point (2 for XY, 3 for XYZ/XYM, 4 for XYZM) } -func newPolygonClipper(lo, hi XY, ctype CoordinatesType) polygonClipper { +func newPolygonClipper(lo, hi XY) polygonClipper { w := hi.X - lo.X h := hi.Y - lo.Y return polygonClipper{ @@ -27,14 +25,11 @@ func newPolygonClipper(lo, hi XY, ctype CoordinatesType) polygonClipper { h: h, perim: 2*w + 2*h, corners: [4]float64{0, w, w + h, 2*w + h}, - ctype: ctype, - s: ctype.Dimension(), } } func clipPolygonByRect(p Polygon, rect Envelope) Geometry { - ctype := p.CoordinatesType() - emptyPoly := NewPolygon(nil).ForceCoordinatesType(ctype).AsGeometry() + emptyPoly := Polygon{}.AsGeometry() if p.IsEmpty() { return emptyPoly @@ -66,7 +61,7 @@ func clipPolygonByRect(p Polygon, rect Envelope) Geometry { // have known winding, which the topology resolution depends on. p = p.ForceCCW() - c := newPolygonClipper(lo, hi, ctype) + c := newPolygonClipper(lo, hi) // Clip exterior ring. clippedExt := c.clipRingSH(p.ExteriorRing()) @@ -76,7 +71,7 @@ func clipPolygonByRect(p Polygon, rect Envelope) Geometry { // Classify holes. var freeHoles []LineString - var clippedHoles [][]float64 + var clippedHoles [][]XY for i := 0; i < p.NumInteriorRings(); i++ { hole := p.InteriorRingN(i) holeEnv := hole.Envelope() @@ -123,9 +118,9 @@ func (c *polygonClipper) ringTouchesRectBoundary(ring LineString) bool { // must have been normalised to CCW (exterior CCW, holes CW) before clipping, // so that all clipped rings have known winding. func (c *polygonClipper) resolveClippedPolygon( - clippedExterior []float64, + clippedExterior []XY, freeHoles []LineString, - clippedHoles [][]float64, + clippedHoles [][]XY, ) Geometry { extArcs := c.extractInteriorArcs(clippedExterior) @@ -133,7 +128,7 @@ func (c *polygonClipper) resolveClippedPolygon( var allArcs []interiorArc allArcs = append(allArcs, extArcs...) - emptyPoly := NewPolygon(nil).ForceCoordinatesType(c.ctype).AsGeometry() + emptyPoly := Polygon{}.AsGeometry() for _, hole := range clippedHoles { arcs := c.extractInteriorArcs(hole) if len(arcs) == 0 { @@ -144,11 +139,10 @@ func (c *polygonClipper) resolveClippedPolygon( allArcs = append(allArcs, arcs...) } - var outputRings [][]float64 + var outputRings [][]XY if len(allArcs) == 0 { // No interior arcs: the polygon contains the entire rect. - // Use the clipped exterior directly — it is the rect with - // correctly interpolated Z/M values from S-H clipping. + // Use the clipped exterior directly — it is the rect itself. outputRings = append(outputRings, clippedExterior) } else { outputRings = c.walkArcs(allArcs) @@ -157,8 +151,7 @@ func (c *polygonClipper) resolveClippedPolygon( // Build output polygons. Each output ring is an exterior (already closed). var polys []Polygon for _, ring := range outputRings { - seq := NewSequence(ring, c.ctype) - exterior := NewLineString(seq) + exterior := NewLineString(xysToSeq(ring)) polys = append(polys, NewPolygon([]LineString{exterior})) } @@ -180,7 +173,7 @@ func (c *polygonClipper) resolveClippedPolygon( // Return Polygon or MultiPolygon. if len(polys) == 0 { - return NewPolygon(nil).ForceCoordinatesType(c.ctype).AsGeometry() + return emptyPoly } if len(polys) == 1 { return polys[0].AsGeometry() @@ -191,7 +184,7 @@ func (c *polygonClipper) resolveClippedPolygon( // walkArcs performs the topology resolution walk, producing output rings from // the collected interior arcs. It pairs arc endpoints along the rect boundary // (CCW) and traces complete rings. -func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]float64 { +func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]XY { // Build a sorted list of arc "end" events and a map from "end param" to // the index of the arc that ends there. Also build a map from // "start param" to arc index. @@ -261,7 +254,7 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]float64 { } used := make([]bool, len(arcs)) - var rings [][]float64 + var rings [][]XY for { // Find first unused arc. @@ -276,7 +269,7 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]float64 { break } - var ring []float64 + var ring []XY curIdx := firstIdx for { @@ -290,10 +283,7 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]float64 { nextStartParam, nextIdx := findNextStart(a.endParam) // Build boundary path from this arc's end to the next arc's start. - endPt := ring[len(ring)-c.s:] - startPt := arcs[nextIdx].coords[:c.s] - - ring = c.appendBoundaryPath(ring, a.endParam, nextStartParam, endPt, startPt) + ring = c.appendBoundaryPath(ring, a.endParam, nextStartParam) if nextIdx == firstIdx { break // Ring complete. @@ -302,9 +292,9 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]float64 { } // Close the ring. - ring = append(ring, ring[:c.s]...) - ring = c.removeDupConsecutive(ring) - if len(ring) >= 4*c.s { + ring = append(ring, ring[0]) + ring = removeDupConsecutive(ring) + if len(ring) >= 4 { rings = append(rings, ring) } } @@ -315,10 +305,8 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]float64 { // appendBoundaryPath appends coordinates along the rect boundary going CCW // from startParam to endParam. It includes rect corners between the two // parameters but does NOT include the start or end points themselves (those are -// the last/first coordinates of the adjacent arcs). Z and M values at corners -// are linearly interpolated between startPt and endPt based on boundary -// distance. -func (c *polygonClipper) appendBoundaryPath(dst []float64, startParam, endParam float64, startPt, endPt []float64) []float64 { +// the last/first coordinates of the adjacent arcs). +func (c *polygonClipper) appendBoundaryPath(dst []XY, startParam, endParam float64) []XY { // Find the first corner after startParam going CCW. The corners are in // ascending parameter order, so this is the first with cp > startParam. // If startParam is past the last corner (on the left edge), no corner @@ -341,12 +329,7 @@ func (c *polygonClipper) appendBoundaryPath(dst []float64, startParam, endParam if d >= totalDist { break } - frac := d / totalDist - xy := c.paramToXY(cp) - dst = append(dst, xy.X, xy.Y) - for dim := 2; dim < c.s; dim++ { - dst = append(dst, lerp(startPt[dim], endPt[dim], frac)) - } + dst = append(dst, c.paramToXY(cp)) } return dst } @@ -356,9 +339,8 @@ func (c *polygonClipper) appendBoundaryPath(dst []float64, startParam, endParam // point on the rect boundary. Returns an empty slice if the ring has no // interior arcs (entirely on the boundary or entirely interior with no // boundary contact). -func (c *polygonClipper) extractInteriorArcs(ring []float64) []interiorArc { - s := c.s - n := len(ring) / s +func (c *polygonClipper) extractInteriorArcs(ring []XY) []interiorArc { + n := len(ring) if n < 4 { return nil } @@ -370,9 +352,7 @@ func (c *polygonClipper) extractInteriorArcs(ring []float64) []interiorArc { // Classify each edge as boundary (both endpoints on same rect edge) or interior. isBdry := make([]bool, numEdges) for i := 0; i < numEdges; i++ { - ax, ay := ring[s*i], ring[s*i+1] - bx, by := ring[s*(i+1)], ring[s*(i+1)+1] - isBdry[i] = c.isSameRectEdge(XY{ax, ay}, XY{bx, by}) + isBdry[i] = c.isSameRectEdge(ring[i], ring[i+1]) } // Find a starting boundary edge so we can walk from there. If there are @@ -401,28 +381,25 @@ func (c *polygonClipper) extractInteriorArcs(ring []float64) []interiorArc { break } // Start of an interior arc at ring[i%numEdges]. - arcStart := (i % numEdges) * s - var arcCoords []float64 - arcCoords = append(arcCoords, ring[arcStart:arcStart+s]...) + var arcCoords []XY + arcCoords = append(arcCoords, ring[i%numEdges]) i++ steps++ for steps < numEdges && !isBdry[i%numEdges] { - off := (i % numEdges) * s - arcCoords = append(arcCoords, ring[off:off+s]...) + arcCoords = append(arcCoords, ring[i%numEdges]) i++ steps++ } if steps < numEdges { // The arc ends at ring[i%numEdges] (the start of the next boundary edge). - off := (i % numEdges) * s - arcCoords = append(arcCoords, ring[off:off+s]...) + arcCoords = append(arcCoords, ring[i%numEdges]) } else { // Wrapped around; the arc ends at ring[start] (where we began). - arcCoords = append(arcCoords, ring[start*s:start*s+s]...) + arcCoords = append(arcCoords, ring[start]) } - sp := c.rectBoundaryParam(XY{arcCoords[0], arcCoords[1]}) - ep := c.rectBoundaryParam(XY{arcCoords[len(arcCoords)-s], arcCoords[len(arcCoords)-s+1]}) + sp := c.rectBoundaryParam(arcCoords[0]) + ep := c.rectBoundaryParam(arcCoords[len(arcCoords)-1]) arcs = append(arcs, interiorArc{ coords: arcCoords, startParam: sp, @@ -434,41 +411,40 @@ func (c *polygonClipper) extractInteriorArcs(ring []float64) []interiorArc { // interiorArc represents a portion of a clipped ring that passes through the // interior of the clipping rectangle (not along its boundary). The first and -// last s floats (where s is the stride) are on the rectangle boundary; -// everything in between is in the interior. +// last coordinates are on the rectangle boundary; everything in between is in +// the interior. type interiorArc struct { - coords []float64 + coords []XY startParam float64 // boundary parameter of first point endParam float64 // boundary parameter of last point } // clipRingSH clips a closed ring against an axis-aligned rectangle using the -// Sutherland-Hodgman algorithm. It returns the clipped ring as a closed -// []float64 (first point == last point), or nil if the ring is entirely -// outside the rectangle. The input [LineString] must be a closed ring. -func (c *polygonClipper) clipRingSH(ring LineString) []float64 { - seq := ring.Coordinates() - coords := seq.appendAllPoints(nil) - if len(coords)/c.s < 4 { +// Sutherland-Hodgman algorithm. It returns the clipped ring as a closed []XY +// (first point == last point), or nil if the ring is entirely outside the +// rectangle. The input [LineString] must be a closed ring. +func (c *polygonClipper) clipRingSH(ring LineString) []XY { + coords := ring.Coordinates().asXYs() + if len(coords) < 4 { return nil } // Clip against each of the 4 edges. coords = c.clipToEdge(coords, - func(x, y float64) bool { return x >= c.lo.X }, - func(dst, src []float64, ai, bi int) []float64 { return c.appendInterpX(dst, src, ai, bi, c.lo.X) }) + func(xy XY) bool { return xy.X >= c.lo.X }, + func(dst, src []XY, ai, bi int) []XY { return appendInterpX(dst, src, ai, bi, c.lo.X) }) coords = c.clipToEdge(coords, - func(x, y float64) bool { return x <= c.hi.X }, - func(dst, src []float64, ai, bi int) []float64 { return c.appendInterpX(dst, src, ai, bi, c.hi.X) }) + func(xy XY) bool { return xy.X <= c.hi.X }, + func(dst, src []XY, ai, bi int) []XY { return appendInterpX(dst, src, ai, bi, c.hi.X) }) coords = c.clipToEdge(coords, - func(x, y float64) bool { return y >= c.lo.Y }, - func(dst, src []float64, ai, bi int) []float64 { return c.appendInterpY(dst, src, ai, bi, c.lo.Y) }) + func(xy XY) bool { return xy.Y >= c.lo.Y }, + func(dst, src []XY, ai, bi int) []XY { return appendInterpY(dst, src, ai, bi, c.lo.Y) }) coords = c.clipToEdge(coords, - func(x, y float64) bool { return y <= c.hi.Y }, - func(dst, src []float64, ai, bi int) []float64 { return c.appendInterpY(dst, src, ai, bi, c.hi.Y) }) + func(xy XY) bool { return xy.Y <= c.hi.Y }, + func(dst, src []XY, ai, bi int) []XY { return appendInterpY(dst, src, ai, bi, c.hi.Y) }) - coords = c.removeDupConsecutive(coords) - if len(coords)/c.s < 4 { + coords = removeDupConsecutive(coords) + if len(coords) < 4 { return nil } return coords @@ -479,71 +455,57 @@ func (c *polygonClipper) clipRingSH(ring LineString) []float64 { // appendIntersection function appends the intersection point between points at // indices ai and bi to dst. The output is also an explicitly closed ring. func (c *polygonClipper) clipToEdge( - coords []float64, - isInside func(x, y float64) bool, - appendIntersection func(dst, coords []float64, ai, bi int) []float64, -) []float64 { - s := c.s - n := len(coords) / s + coords []XY, + isInside func(xy XY) bool, + appendIntersection func(dst, coords []XY, ai, bi int) []XY, +) []XY { + n := len(coords) if n == 0 { return nil } - var out []float64 + var out []XY ai := n - 1 for bi := 0; bi < n; bi++ { - ax, ay := coords[s*ai], coords[s*ai+1] - bx, by := coords[s*bi], coords[s*bi+1] - aIn := isInside(ax, ay) - bIn := isInside(bx, by) + aIn := isInside(coords[ai]) + bIn := isInside(coords[bi]) switch { case aIn && bIn: - out = append(out, coords[s*bi:s*(bi+1)]...) + out = append(out, coords[bi]) case aIn && !bIn: out = appendIntersection(out, coords, ai, bi) case !aIn && bIn: out = appendIntersection(out, coords, ai, bi) - out = append(out, coords[s*bi:s*(bi+1)]...) + out = append(out, coords[bi]) } ai = bi } // Ensure the output is explicitly closed. When the input's closing vertex // is outside the clip region, the degenerate closing edge emits nothing, // leaving the output open. - nOut := len(out) / s - if nOut > 0 && (out[0] != out[(nOut-1)*s] || out[1] != out[(nOut-1)*s+1]) { - out = append(out, out[:s]...) + if len(out) > 0 && out[0] != out[len(out)-1] { + out = append(out, out[0]) } return out } // appendInterpX appends the intersection of the segment between points ai and -// bi with the vertical line x=k. The X coordinate is set exactly; Z and M are +// bi with the vertical line x=k. The X coordinate is set exactly; Y is // linearly interpolated. -func (c *polygonClipper) appendInterpX(dst, coords []float64, ai, bi int, x float64) []float64 { - s := c.s - ax := coords[s*ai] - bx := coords[s*bi] - t := (x - ax) / (bx - ax) - for d := 0; d < s; d++ { - dst = append(dst, lerp(coords[s*ai+d], coords[s*bi+d], t)) - } - dst[len(dst)-s] = x // set exact X - return dst +func appendInterpX(dst, coords []XY, ai, bi int, x float64) []XY { + a := coords[ai] + b := coords[bi] + t := (x - a.X) / (b.X - a.X) + return append(dst, XY{X: x, Y: lerp(a.Y, b.Y, t)}) } // appendInterpY appends the intersection of the segment between points ai and -// bi with the horizontal line y=k. The Y coordinate is set exactly; Z and M -// are linearly interpolated. -func (c *polygonClipper) appendInterpY(dst, coords []float64, ai, bi int, y float64) []float64 { - s := c.s - ay := coords[s*ai+1] - by := coords[s*bi+1] - t := (y - ay) / (by - ay) - for d := 0; d < s; d++ { - dst = append(dst, lerp(coords[s*ai+d], coords[s*bi+d], t)) - } - dst[len(dst)-s+1] = y // set exact Y - return dst +// bi with the horizontal line y=k. The Y coordinate is set exactly; X is +// linearly interpolated. +func appendInterpY(dst, coords []XY, ai, bi int, y float64) []XY { + a := coords[ai] + b := coords[bi] + t := (y - a.Y) / (b.Y - a.Y) + return append(dst, XY{X: lerp(a.X, b.X, t), Y: y}) } // rectBoundaryParam returns the CCW boundary parameter for a point on the rect @@ -600,16 +562,14 @@ func (c *polygonClipper) isSameRectEdge(a, b XY) bool { } // pointInRing returns true if xy is inside the ring defined by the given -// closed []float64 (first point == last point), using the ray-casting -// algorithm. -func (c *polygonClipper) pointInRing(xy XY, ring []float64) bool { - s := c.s - n := len(ring) / s +// closed []XY (first point == last point), using the ray-casting algorithm. +func (c *polygonClipper) pointInRing(xy XY, ring []XY) bool { + n := len(ring) inside := false for i := 0; i < n; i++ { j := (i + 1) % n - yi, yj := ring[s*i+1], ring[s*j+1] - xi, xj := ring[s*i], ring[s*j] + yi, yj := ring[i].Y, ring[j].Y + xi, xj := ring[i].X, ring[j].X if (yi > xy.Y) != (yj > xy.Y) { slope := (xy.Y - yi) / (yj - yi) xIntersect := xi + slope*(xj-xi) @@ -623,18 +583,14 @@ func (c *polygonClipper) pointInRing(xy XY, ring []float64) bool { // removeDupConsecutive removes consecutive points with identical X,Y. // For closed rings (first == last), the closing point is preserved. -func (c *polygonClipper) removeDupConsecutive(coords []float64) []float64 { - s := c.s - n := len(coords) / s - if n == 0 { +func removeDupConsecutive(coords []XY) []XY { + if len(coords) == 0 { return nil } - out := coords[:s] // keep first point - for i := 1; i < n; i++ { - prev := out[len(out)-s:] - cur := coords[s*i : s*(i+1)] - if cur[0] != prev[0] || cur[1] != prev[1] { - out = append(out, cur...) + out := coords[:1] + for i := 1; i < len(coords); i++ { + if coords[i] != out[len(out)-1] { + out = append(out, coords[i]) } } return out diff --git a/geom/alg_linear_interpolation.go b/geom/alg_linear_interpolation.go index 5bd5eb46..b7c1b92f 100644 --- a/geom/alg_linear_interpolation.go +++ b/geom/alg_linear_interpolation.go @@ -74,6 +74,22 @@ func lerp(a, b, t float64) float64 { return math.Min(b, x) } +// lerpXY linearly interpolates between [XY] points a and b at parameter t. +// The endpoints are returned exactly when t is 0 or 1, so a point that +// nominally survives interpolation unchanged is bit-identical to its input. +func lerpXY(a, b XY, t float64) XY { + if t == 0 { + return a + } + if t == 1 { + return b + } + return XY{ + X: lerp(a.X, b.X, t), + Y: lerp(a.Y, b.Y, t), + } +} + func interpolateCoords(c0, c1 Coordinates, frac float64) Coordinates { return Coordinates{ XY: XY{ diff --git a/geom/alg_sutherland_hodgman_test.go b/geom/alg_sutherland_hodgman_test.go index e41b30ef..b40ce12b 100644 --- a/geom/alg_sutherland_hodgman_test.go +++ b/geom/alg_sutherland_hodgman_test.go @@ -20,7 +20,7 @@ var d4Transforms = []struct { {"reflect_anti", func(xy geom.XY) geom.XY { return geom.XY{X: -xy.Y, Y: -xy.X} }}, } -func TestClipByRect(t *testing.T) { +func TestClipByRect2D(t *testing.T) { // R is a non-square rectangle so that the D4 transforms produce distinct // configurations. rect := geom.NewEnvelope(geom.XY{X: 1, Y: 2}, geom.XY{X: 5, Y: 4}) @@ -58,8 +58,8 @@ func TestClipByRect(t *testing.T) { {"MP7", "MULTIPOINT(3 3,1 3,0 0)", "MULTIPOINT(3 3,1 3)", nil}, // MP8: MultiPoint containing empty points {"MP8", "MULTIPOINT(3 3,EMPTY)", "MULTIPOINT(3 3)", nil}, - // MP9: XYZ MultiPoint, all points outside R - {"MP9", "MULTIPOINT Z(0 0 7,6 6 8)", "MULTIPOINT Z EMPTY", nil}, + // MP9: XYZ MultiPoint, all points outside R — output is XY EMPTY + {"MP9", "MULTIPOINT Z(0 0 7,6 6 8)", "MULTIPOINT EMPTY", nil}, // LS1: Empty LineString {"LS1", "LINESTRING EMPTY", "LINESTRING EMPTY", nil}, @@ -128,8 +128,8 @@ func TestClipByRect(t *testing.T) { {"MLS7", "MULTILINESTRING((0 3,3 3,3 5,4 5,4 3,6 3))", "MULTILINESTRING((1 3,3 3,3 4),(4 4,4 3,5 3))", nil}, // MLS8: MultiLineString containing empty LineStrings {"MLS8", "MULTILINESTRING((2 3,4 3),EMPTY)", "MULTILINESTRING((2 3,4 3))", nil}, - // MLS9: XYZ MultiLineString, all components outside R - {"MLS9", "MULTILINESTRING Z((6 5 1,7 6 2),(0 0 3,0 1 4))", "MULTILINESTRING Z EMPTY", nil}, + // MLS9: XYZ MultiLineString, all components outside R — output is XY EMPTY + {"MLS9", "MULTILINESTRING Z((6 5 1,7 6 2),(0 0 3,0 1 4))", "MULTILINESTRING EMPTY", nil}, // PG1: Empty Polygon {"PG1", "POLYGON EMPTY", "POLYGON EMPTY", nil}, @@ -255,10 +255,10 @@ func TestClipByRect(t *testing.T) { "POLYGON EMPTY", nil}, - // PG_Z1: XYZ polygon containing R — Z values must be interpolated, not zero + // PG_Z1: XYZ polygon containing R — Z is dropped, output is XY only {"PG_Z1", "POLYGON Z((0 0 10,6 0 10,6 6 10,0 6 10,0 0 10))", - "POLYGON Z((1 2 10,5 2 10,5 4 10,1 4 10,1 2 10))", + "POLYGON((1 2,5 2,5 4,1 4,1 2))", []geom.ExactEqualsOption{geom.IgnoreOrder}}, // MPG1: Empty MultiPolygon @@ -308,10 +308,10 @@ func TestClipByRect(t *testing.T) { "MULTIPOLYGON(((2 2.5,4 2.5,4 3.5,2 3.5,2 2.5)),EMPTY)", "MULTIPOLYGON(((2 2.5,4 2.5,4 3.5,2 3.5,2 2.5)))", []geom.ExactEqualsOption{geom.IgnoreOrder}}, - // MPG11: XYZ MultiPolygon, all components outside R + // MPG11: XYZ MultiPolygon, all components outside R — output is XY EMPTY {"MPG11", "MULTIPOLYGON Z(((6 5 1,7 5 2,7 6 3,6 6 4,6 5 1)),((8 8 5,9 8 6,9 9 7,8 9 8,8 8 5)))", - "MULTIPOLYGON Z EMPTY", + "MULTIPOLYGON EMPTY", nil}, // GC1: Empty GeometryCollection @@ -370,10 +370,10 @@ func TestClipByRect(t *testing.T) { "GEOMETRYCOLLECTION(POINT(3 3),GEOMETRYCOLLECTION(POINT(0 0),POINT(6 6)))", "GEOMETRYCOLLECTION(POINT(3 3))", nil}, - // GC14: XYZ GeometryCollection, all children outside R + // GC14: XYZ GeometryCollection, all children outside R — output is XY EMPTY {"GC14", "GEOMETRYCOLLECTION Z(POINT Z(0 0 1),LINESTRING Z(6 5 2,7 6 3))", - "GEOMETRYCOLLECTION Z EMPTY", + "GEOMETRYCOLLECTION EMPTY", nil}, // NE1: Very large coordinates (outside R) @@ -395,12 +395,26 @@ func TestClipByRect(t *testing.T) { // CD1: XY geometry clipped {"CD1", "LINESTRING(0 3,6 3)", "LINESTRING(1 3,5 3)", nil}, - // CD2: XYZ geometry clipped, Z interpolated at intersections - {"CD2", "LINESTRING Z(0 3 0,6 3 6)", "LINESTRING Z(1 3 1,5 3 5)", nil}, - // CD3: XYM geometry clipped, M interpolated at intersections - {"CD3", "LINESTRING M(0 3 0,6 3 6)", "LINESTRING M(1 3 1,5 3 5)", nil}, - // CD4: XYZM geometry clipped, Z and M interpolated at intersections - {"CD4", "LINESTRING ZM(0 3 0 12,6 3 6 24)", "LINESTRING ZM(1 3 1 14,5 3 5 22)", nil}, + // CD2: XYZ input → XY output (Z dropped) + {"CD2", "LINESTRING Z(0 3 0,6 3 6)", "LINESTRING(1 3,5 3)", nil}, + // CD3: XYM input → XY output (M dropped) + {"CD3", "LINESTRING M(0 3 0,6 3 6)", "LINESTRING(1 3,5 3)", nil}, + // CD4: XYZM input → XY output (Z and M dropped) + {"CD4", "LINESTRING ZM(0 3 0 12,6 3 6 24)", "LINESTRING(1 3,5 3)", nil}, + // CD5: XYZ Point input → XY output + {"CD5", "POINT Z(3 3 7)", "POINT(3 3)", nil}, + // CD6: XYZM Polygon input → XY output, polygon clipped + {"CD6", + "POLYGON ZM((0 2.5 1 11,6 2.5 2 12,6 3.5 3 13,0 3.5 4 14,0 2.5 1 11))", + "POLYGON((1 2.5,5 2.5,5 3.5,1 3.5,1 2.5))", + []geom.ExactEqualsOption{geom.IgnoreOrder}}, + // CD7: XYM MultiPoint input → XY output, partial survival + {"CD7", "MULTIPOINT M(3 3 1,0 0 2)", "MULTIPOINT(3 3)", nil}, + // CD8: XYZ GeometryCollection input → XY output, mixed survival + {"CD8", + "GEOMETRYCOLLECTION Z(POINT Z(3 3 1),LINESTRING Z(0 3 0,6 3 6))", + "GEOMETRYCOLLECTION(POINT(3 3),LINESTRING(1 3,5 3))", + nil}, } { t.Run(tt.name, func(t *testing.T) { for _, tr := range d4Transforms { @@ -408,7 +422,7 @@ func TestClipByRect(t *testing.T) { input := geomFromWKT(t, tt.input).TransformXY(tr.fn) want := geomFromWKT(t, tt.want).TransformXY(tr.fn) r := rect.TransformXY(tr.fn) - got := geom.ClipByRect(input, r) + got := geom.ClipByRect2D(input, r) expectGeomEq(t, got, want, tt.opts...) }) } @@ -416,7 +430,7 @@ func TestClipByRect(t *testing.T) { } } -func TestClipByRectDegenerateRect(t *testing.T) { +func TestClipByRect2DDegenerateRect(t *testing.T) { emptyRect := geom.Envelope{} pointRect := geom.NewEnvelope(geom.XY{X: 3, Y: 3}, geom.XY{X: 3, Y: 3}) lineRect := geom.NewEnvelope(geom.XY{X: 1, Y: 3}, geom.XY{X: 5, Y: 3}) @@ -466,7 +480,7 @@ func TestClipByRectDegenerateRect(t *testing.T) { input := geomFromWKT(t, tt.input).TransformXY(tr.fn) want := geomFromWKT(t, tt.want).TransformXY(tr.fn) r := tt.rect.TransformXY(tr.fn) - got := geom.ClipByRect(input, r) + got := geom.ClipByRect2D(input, r) expectGeomEq(t, got, want) }) } diff --git a/geom/type_sequence.go b/geom/type_sequence.go index c2b320f8..c747a561 100644 --- a/geom/type_sequence.go +++ b/geom/type_sequence.go @@ -96,6 +96,16 @@ func (s Sequence) GetXY(i int) XY { } } +// asXYs returns the [XY] of every point location in the [Sequence], in order. +func (s Sequence) asXYs() []XY { + stride := s.ctype.Dimension() + xys := make([]XY, 0, len(s.floats)/stride) + for off := 0; off < len(s.floats); off += stride { + xys = append(xys, XY{X: s.floats[off], Y: s.floats[off+1]}) + } + return xys +} + // Reverse returns a new [Sequence] containing the same point locations, but in // reversed order. func (s Sequence) Reverse() Sequence { From 7a6674b49dc10b6701e4c037d301e837c73b0cee Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Fri, 22 May 2026 16:56:41 +1000 Subject: [PATCH 23/32] Update ClipByRect tests to use consolidated test helpers After rebasing onto master, the per-package test helpers geomFromWKT and expectGeomEq no longer exist - they were replaced by test.FromWKT and test.ExactEquals in the internal/test package. --- geom/alg_sutherland_hodgman_test.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/geom/alg_sutherland_hodgman_test.go b/geom/alg_sutherland_hodgman_test.go index b40ce12b..3fbcf639 100644 --- a/geom/alg_sutherland_hodgman_test.go +++ b/geom/alg_sutherland_hodgman_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/peterstace/simplefeatures/geom" + "github.com/peterstace/simplefeatures/internal/test" ) var d4Transforms = []struct { @@ -419,11 +420,11 @@ func TestClipByRect2D(t *testing.T) { t.Run(tt.name, func(t *testing.T) { for _, tr := range d4Transforms { t.Run(tr.name, func(t *testing.T) { - input := geomFromWKT(t, tt.input).TransformXY(tr.fn) - want := geomFromWKT(t, tt.want).TransformXY(tr.fn) + input := test.FromWKT(t, tt.input).TransformXY(tr.fn) + want := test.FromWKT(t, tt.want).TransformXY(tr.fn) r := rect.TransformXY(tr.fn) got := geom.ClipByRect2D(input, r) - expectGeomEq(t, got, want, tt.opts...) + test.ExactEquals(t, got, want, tt.opts...) }) } }) @@ -477,11 +478,11 @@ func TestClipByRect2DDegenerateRect(t *testing.T) { t.Run(tt.name, func(t *testing.T) { for _, tr := range d4Transforms { t.Run(tr.name, func(t *testing.T) { - input := geomFromWKT(t, tt.input).TransformXY(tr.fn) - want := geomFromWKT(t, tt.want).TransformXY(tr.fn) + input := test.FromWKT(t, tt.input).TransformXY(tr.fn) + want := test.FromWKT(t, tt.want).TransformXY(tr.fn) r := tt.rect.TransformXY(tr.fn) got := geom.ClipByRect2D(input, r) - expectGeomEq(t, got, want) + test.ExactEquals(t, got, want) }) } }) From 100ab7c34d011276ba3f738c596dc2dcc2ec2557 Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Sun, 24 May 2026 09:20:24 +1000 Subject: [PATCH 24/32] Fix lint failures from CI - Rename min/max to lo/hi in alg_clip_by_rect_liang_barsky.go to avoid shadowing the Go 1.21+ predeclared builtins (predeclared linter). - Reformat alg_sutherland_hodgman_test.go to satisfy gofumpt: multi-line composite literals must have their opening { and closing } on their own lines. --- geom/alg_clip_by_rect_liang_barsky.go | 16 +- geom/alg_sutherland_hodgman_test.go | 240 +++++++++++++++++--------- 2 files changed, 168 insertions(+), 88 deletions(-) diff --git a/geom/alg_clip_by_rect_liang_barsky.go b/geom/alg_clip_by_rect_liang_barsky.go index f90d4622..b4bec917 100644 --- a/geom/alg_clip_by_rect_liang_barsky.go +++ b/geom/alg_clip_by_rect_liang_barsky.go @@ -9,7 +9,7 @@ func clipLineStringByRect(ls LineString, rect Envelope) Geometry { return emptyLine } - min, max, ok := rect.MinMaxXYs() + lo, hi, ok := rect.MinMaxXYs() if !ok { return emptyLine } @@ -21,7 +21,7 @@ func clipLineStringByRect(ls LineString, rect Envelope) Geometry { a := seq.GetXY(i) b := seq.GetXY(i + 1) - tMin, tMax, ok := clipSegmentParams(a, b, min, max) + tMin, tMax, ok := clipSegmentParams(a, b, lo, hi) if !ok { if len(cur) > 0 { chains = append(chains, cur) @@ -63,18 +63,18 @@ func clipLineStringByRect(ls LineString, rect Envelope) Geometry { // clipSegmentParams uses the Liang-Barsky algorithm to compute the parametric // range [tMin, tMax] of segment a->b that lies inside the axis-aligned -// rectangle from min to max. It returns false if no segment of positive length +// rectangle from lo to hi. It returns false if no segment of positive length // survives. -func clipSegmentParams(a, b, min, max XY) (float64, float64, bool) { +func clipSegmentParams(a, b, lo, hi XY) (float64, float64, bool) { tMin := 0.0 tMax := 1.0 dx := b.X - a.X dy := b.Y - a.Y for _, pq := range [4][2]float64{ - {-dx, a.X - min.X}, // left - {dx, max.X - a.X}, // right - {-dy, a.Y - min.Y}, // bottom - {dy, max.Y - a.Y}, // top + {-dx, a.X - lo.X}, // left + {dx, hi.X - a.X}, // right + {-dy, a.Y - lo.Y}, // bottom + {dy, hi.Y - a.Y}, // top } { p, q := pq[0], pq[1] if p == 0 { diff --git a/geom/alg_sutherland_hodgman_test.go b/geom/alg_sutherland_hodgman_test.go index 3fbcf639..d7930c6c 100644 --- a/geom/alg_sutherland_hodgman_test.go +++ b/geom/alg_sutherland_hodgman_test.go @@ -169,10 +169,12 @@ func TestClipByRect2D(t *testing.T) { // PG18: Concave polygon, concavity facing left R boundary {"PG18", "POLYGON((0 2.5,3 2.5,3 3,2 3,2 3.5,0 3.5,0 2.5))", "POLYGON((1 2.5,3 2.5,3 3,2 3,2 3.5,1 3.5,1 2.5))", []geom.ExactEqualsOption{geom.IgnoreOrder}}, // PG19: C-shaped polygon, concavity crosses left edge → MultiPolygon - {"PG19", + { + "PG19", "POLYGON((0 2.5,3 2.5,3 2.8,0.5 2.8,0.5 3.2,3 3.2,3 3.5,0 3.5,0 2.5))", "MULTIPOLYGON(((1 2.5,3 2.5,3 2.8,1 2.8,1 2.5)),((1 3.2,3 3.2,3 3.5,1 3.5,1 3.2)))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // PG20: Very thin sliver polygon partially inside R {"PG20", "POLYGON((0 2.999,6 2.999,6 3.001,0 3.001,0 2.999))", "POLYGON((1 2.999,5 2.999,5 3.001,1 3.001,1 2.999))", []geom.ExactEqualsOption{geom.IgnoreOrder}}, // PG21: Triangle clipped at all 4 rect edges producing a polygon @@ -181,139 +183,191 @@ func TestClipByRect2D(t *testing.T) { {"PG22", "POLYGON((2 2.5,2 3.5,4 3.5,4 2.5,2 2.5))", "POLYGON((2 2.5,2 3.5,4 3.5,4 2.5,2 2.5))", []geom.ExactEqualsOption{geom.IgnoreOrder}}, // PH1: Exterior and hole both inside R - {"PH1", + { + "PH1", "POLYGON((2 2.5,4 2.5,4 3.5,2 3.5,2 2.5),(2.5 2.8,2.5 3.2,3.5 3.2,3.5 2.8,2.5 2.8))", "POLYGON((2 2.5,4 2.5,4 3.5,2 3.5,2 2.5),(2.5 2.8,2.5 3.2,3.5 3.2,3.5 2.8,2.5 2.8))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // PH2: Exterior clipped, hole entirely inside clipped region - {"PH2", + { + "PH2", "POLYGON((0 2.5,4 2.5,4 3.5,0 3.5,0 2.5),(2 2.8,2 3.2,3 3.2,3 2.8,2 2.8))", "POLYGON((1 2.5,4 2.5,4 3.5,1 3.5,1 2.5),(2 2.8,2 3.2,3 3.2,3 2.8,2 2.8))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // PH3: Multiple holes, all inside R - {"PH3", + { + "PH3", "POLYGON((0 0,6 0,6 6,0 6,0 0),(2 2.5,2 3,3 3,3 2.5,2 2.5),(3.5 2.5,3.5 3,4.5 3,4.5 2.5,3.5 2.5))", "POLYGON((1 2,5 2,5 4,1 4,1 2),(2 2.5,2 3,3 3,3 2.5,2 2.5),(3.5 2.5,3.5 3,4.5 3,4.5 2.5,3.5 2.5))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // PH4: Hole entirely outside R (inside exterior outside R) - {"PH4", + { + "PH4", "POLYGON((0 0,6 0,6 6,0 6,0 0),(0.2 0.2,0.2 0.8,0.8 0.8,0.8 0.2,0.2 0.2))", "POLYGON((1 2,5 2,5 4,1 4,1 2))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // PH5: Hole in part of exterior that is clipped away - {"PH5", + { + "PH5", "POLYGON((2 0,4 0,4 3,2 3,2 0),(2.5 0.5,2.5 1.5,3.5 1.5,3.5 0.5,2.5 0.5))", "POLYGON((2 2,4 2,4 3,2 3,2 2))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // PH6: Hole crosses one edge of R (left edge) - {"PH6", + { + "PH6", "POLYGON((-2 -2,8 -2,8 8,-2 8,-2 -2),(0 2.5,0 3.5,2 3.5,2 2.5,0 2.5))", "POLYGON((1 4,1 3.5,2 3.5,2 2.5,1 2.5,1 2,5 2,5 4,1 4))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // PH7: Hole crosses two adjacent edges of R (bottom-left corner) - {"PH7", + { + "PH7", "POLYGON((-2 -2,8 -2,8 8,-2 8,-2 -2),(0 1,0 3,2 3,2 1,0 1))", "POLYGON((1 3,2 3,2 2,5 2,5 4,1 4,1 3))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // PH8: Hole crosses two opposite edges of R (left and right) — splits polygon - {"PH8", + { + "PH8", "POLYGON((-2 -2,8 -2,8 8,-2 8,-2 -2),(0 2.8,0 3.2,6 3.2,6 2.8,0 2.8))", "MULTIPOLYGON(((1 2,5 2,5 2.8,1 2.8,1 2)),((1 3.2,5 3.2,5 4,1 4,1 3.2)))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // PH9: Hole crosses all four edges of R, leaving top and bottom strips - {"PH9", + { + "PH9", "POLYGON((-2 -2,8 -2,8 8,-2 8,-2 -2),(0.5 2.5,0.5 3.5,5.5 3.5,5.5 2.5,0.5 2.5))", "MULTIPOLYGON(((1 2,5 2,5 2.5,1 2.5,1 2)),((1 3.5,5 3.5,5 4,1 4,1 3.5)))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // PH10: Hole on R boundary, exterior extends beyond R — becomes concavity - {"PH10", + { + "PH10", "POLYGON((-2 -2,8 -2,8 8,-2 8,-2 -2),(1 2.5,1 3.5,2 3.5,2 2.5,1 2.5))", "POLYGON((1 4,1 3.5,2 3.5,2 2.5,1 2.5,1 2,5 2,5 4,1 4))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // PH11: One hole inside R, another outside R - {"PH11", + { + "PH11", "POLYGON((-2 -2,8 -2,8 8,-2 8,-2 -2),(2.5 2.8,2.5 3.2,3.5 3.2,3.5 2.8,2.5 2.8),(-1.5 -1.5,-1.5 -0.5,-0.5 -0.5,-0.5 -1.5,-1.5 -1.5))", "POLYGON((1 2,5 2,5 4,1 4,1 2),(2.5 2.8,2.5 3.2,3.5 3.2,3.5 2.8,2.5 2.8))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // PH12: One hole inside R, another splits polygon - {"PH12", + { + "PH12", "POLYGON((-2 -2,8 -2,8 8,-2 8,-2 -2),(2.5 2.2,2.5 2.4,3.5 2.4,3.5 2.2,2.5 2.2),(0 2.5,0 3.5,6 3.5,6 2.5,0 2.5))", "MULTIPOLYGON(((1 2,5 2,5 2.5,1 2.5,1 2),(2.5 2.2,2.5 2.4,3.5 2.4,3.5 2.2,2.5 2.2)),((1 3.5,5 3.5,5 4,1 4,1 3.5)))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // PH13: Multiple holes each crossing one edge of R (left edge) - {"PH13", + { + "PH13", "POLYGON((-2 -2,8 -2,8 8,-2 8,-2 -2),(0 2.2,0 2.6,2 2.6,2 2.2,0 2.2),(0 3.4,0 3.8,2 3.8,2 3.4,0 3.4))", "POLYGON((1 4,1 3.8,2 3.8,2 3.4,1 3.4,1 2.6,2 2.6,2 2.2,1 2.2,1 2,5 2,5 4,1 4))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // PH14: Two holes each crossing two opposite edges, splitting into three pieces - {"PH14", + { + "PH14", "POLYGON((-2 -2,8 -2,8 8,-2 8,-2 -2),(0 2.5,0 2.8,6 2.8,6 2.5,0 2.5),(0 3.2,0 3.5,6 3.5,6 3.2,0 3.2))", "MULTIPOLYGON(((1 2,5 2,5 2.5,1 2.5,1 2)),((1 2.8,5 2.8,5 3.2,1 3.2,1 2.8)),((1 3.5,5 3.5,5 4,1 4,1 3.5)))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // PH15: Hole covers entire clipped area - {"PH15", + { + "PH15", "POLYGON((-2 -2,8 -2,8 8,-2 8,-2 -2),(0.5 1.5,0.5 4.5,5.5 4.5,5.5 1.5,0.5 1.5))", "POLYGON EMPTY", - nil}, + nil, + }, // PG_Z1: XYZ polygon containing R — Z is dropped, output is XY only - {"PG_Z1", + { + "PG_Z1", "POLYGON Z((0 0 10,6 0 10,6 6 10,0 6 10,0 0 10))", "POLYGON((1 2,5 2,5 4,1 4,1 2))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // MPG1: Empty MultiPolygon {"MPG1", "MULTIPOLYGON EMPTY", "MULTIPOLYGON EMPTY", nil}, // MPG2: All component Polygons inside R - {"MPG2", + { + "MPG2", "MULTIPOLYGON(((2 2.5,3 2.5,3 3,2 3,2 2.5)),((3.5 2.5,4.5 2.5,4.5 3,3.5 3,3.5 2.5)))", "MULTIPOLYGON(((2 2.5,3 2.5,3 3,2 3,2 2.5)),((3.5 2.5,4.5 2.5,4.5 3,3.5 3,3.5 2.5)))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // MPG3: All component Polygons outside R - {"MPG3", + { + "MPG3", "MULTIPOLYGON(((6 5,7 5,7 6,6 6,6 5)),((8 8,9 8,9 9,8 9,8 8)))", "MULTIPOLYGON EMPTY", - nil}, + nil, + }, // MPG4: Some components inside, some outside - {"MPG4", + { + "MPG4", "MULTIPOLYGON(((2 2.5,3 2.5,3 3,2 3,2 2.5)),((6 5,7 5,7 6,6 6,6 5)))", "MULTIPOLYGON(((2 2.5,3 2.5,3 3,2 3,2 2.5)))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // MPG5: One component partially clipped, another fully inside - {"MPG5", + { + "MPG5", "MULTIPOLYGON(((0 2.5,3 2.5,3 3.5,0 3.5,0 2.5)),((3.5 2.5,4.5 2.5,4.5 3.5,3.5 3.5,3.5 2.5)))", "MULTIPOLYGON(((1 2.5,3 2.5,3 3.5,1 3.5,1 2.5)),((3.5 2.5,4.5 2.5,4.5 3.5,3.5 3.5,3.5 2.5)))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // MPG6: One component becomes multiple polygons when clipped (C-shape) - {"MPG6", + { + "MPG6", "MULTIPOLYGON(((0 2.5,3 2.5,3 2.8,0.5 2.8,0.5 3.2,3 3.2,3 3.5,0 3.5,0 2.5)),((4 2.5,4.5 2.5,4.5 3,4 3,4 2.5)))", "MULTIPOLYGON(((1 2.5,3 2.5,3 2.8,1 2.8,1 2.5)),((1 3.2,3 3.2,3 3.5,1 3.5,1 3.2)),((4 2.5,4.5 2.5,4.5 3,4 3,4 2.5)))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // MPG7: Multiple components, each partially clipped - {"MPG7", + { + "MPG7", "MULTIPOLYGON(((0 2.3,3 2.3,3 2.7,0 2.7,0 2.3)),((0 3.3,3 3.3,3 3.7,0 3.7,0 3.3)))", "MULTIPOLYGON(((1 2.3,3 2.3,3 2.7,1 2.7,1 2.3)),((1 3.3,3 3.3,3 3.7,1 3.7,1 3.3)))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // MPG8: Components with holes, some holes clipped - {"MPG8", + { + "MPG8", "MULTIPOLYGON(((0 2.5,3 2.5,3 3.5,0 3.5,0 2.5),(1.5 2.8,1.5 3.2,2.5 3.2,2.5 2.8,1.5 2.8)),((3.5 2.5,4.5 2.5,4.5 3.5,3.5 3.5,3.5 2.5)))", "MULTIPOLYGON(((1 2.5,3 2.5,3 3.5,1 3.5,1 2.5),(1.5 2.8,1.5 3.2,2.5 3.2,2.5 2.8,1.5 2.8)),((3.5 2.5,4.5 2.5,4.5 3.5,3.5 3.5,3.5 2.5)))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // MPG9: Single component fully inside R - {"MPG9", + { + "MPG9", "MULTIPOLYGON(((2 2.5,4 2.5,4 3.5,2 3.5,2 2.5)))", "MULTIPOLYGON(((2 2.5,4 2.5,4 3.5,2 3.5,2 2.5)))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // MPG10: MultiPolygon containing empty Polygons - {"MPG10", + { + "MPG10", "MULTIPOLYGON(((2 2.5,4 2.5,4 3.5,2 3.5,2 2.5)),EMPTY)", "MULTIPOLYGON(((2 2.5,4 2.5,4 3.5,2 3.5,2 2.5)))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // MPG11: XYZ MultiPolygon, all components outside R — output is XY EMPTY - {"MPG11", + { + "MPG11", "MULTIPOLYGON Z(((6 5 1,7 5 2,7 6 3,6 6 4,6 5 1)),((8 8 5,9 8 6,9 9 7,8 9 8,8 8 5)))", "MULTIPOLYGON EMPTY", - nil}, + nil, + }, // GC1: Empty GeometryCollection {"GC1", "GEOMETRYCOLLECTION EMPTY", "GEOMETRYCOLLECTION EMPTY", nil}, @@ -322,60 +376,82 @@ func TestClipByRect2D(t *testing.T) { // GC3: Contains only Points, all outside R {"GC3", "GEOMETRYCOLLECTION(POINT(0 0),POINT(6 6))", "GEOMETRYCOLLECTION EMPTY", nil}, // GC4: Contains mixed types, all inside R - {"GC4", + { + "GC4", "GEOMETRYCOLLECTION(POINT(3 3),LINESTRING(2 3,4 3))", "GEOMETRYCOLLECTION(POINT(3 3),LINESTRING(2 3,4 3))", - nil}, + nil, + }, // GC5: Contains mixed types, all outside R - {"GC5", + { + "GC5", "GEOMETRYCOLLECTION(POINT(0 0),LINESTRING(6 5,7 6))", "GEOMETRYCOLLECTION EMPTY", - nil}, + nil, + }, // GC6: Contains mixed types, some inside, some outside - {"GC6", + { + "GC6", "GEOMETRYCOLLECTION(POINT(3 3),POINT(0 0),LINESTRING(2 3,4 3))", "GEOMETRYCOLLECTION(POINT(3 3),LINESTRING(2 3,4 3))", - nil}, + nil, + }, // GC7: Contains Point, LineString, and Polygon (each clipped independently) - {"GC7", + { + "GC7", "GEOMETRYCOLLECTION(POINT(3 3),LINESTRING(0 3,6 3),POLYGON((2 2.5,4 2.5,4 3.5,2 3.5,2 2.5)))", "GEOMETRYCOLLECTION(POINT(3 3),LINESTRING(1 3,5 3),POLYGON((2 2.5,4 2.5,4 3.5,2 3.5,2 2.5)))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // GC8: Contains nested GeometryCollection - {"GC8", + { + "GC8", "GEOMETRYCOLLECTION(POINT(3 3),GEOMETRYCOLLECTION(POINT(4 3)))", "GEOMETRYCOLLECTION(POINT(3 3),GEOMETRYCOLLECTION(POINT(4 3)))", - nil}, + nil, + }, // GC9: Contains nested GeometryCollection with mixed types - {"GC9", + { + "GC9", "GEOMETRYCOLLECTION(POINT(3 3),GEOMETRYCOLLECTION(POINT(0 0),LINESTRING(2 3,4 3)))", "GEOMETRYCOLLECTION(POINT(3 3),GEOMETRYCOLLECTION(LINESTRING(2 3,4 3)))", - nil}, + nil, + }, // GC10: All child geometries are empty - {"GC10", + { + "GC10", "GEOMETRYCOLLECTION(POINT EMPTY,LINESTRING EMPTY)", "GEOMETRYCOLLECTION EMPTY", - nil}, + nil, + }, // GC11: Contains MultiPoint, MultiLineString, MultiPolygon - {"GC11", + { + "GC11", "GEOMETRYCOLLECTION(MULTIPOINT(3 3,0 0),MULTILINESTRING((2 3,4 3)),MULTIPOLYGON(((2 2.5,4 2.5,4 3.5,2 3.5,2 2.5))))", "GEOMETRYCOLLECTION(MULTIPOINT(3 3),MULTILINESTRING((2 3,4 3)),MULTIPOLYGON(((2 2.5,4 2.5,4 3.5,2 3.5,2 2.5))))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // GC12: Deeply nested GeometryCollections (3+ levels) - {"GC12", + { + "GC12", "GEOMETRYCOLLECTION(GEOMETRYCOLLECTION(GEOMETRYCOLLECTION(POINT(3 3))))", "GEOMETRYCOLLECTION(GEOMETRYCOLLECTION(GEOMETRYCOLLECTION(POINT(3 3))))", - nil}, + nil, + }, // GC13: Nested GC whose children all clip to empty - {"GC13", + { + "GC13", "GEOMETRYCOLLECTION(POINT(3 3),GEOMETRYCOLLECTION(POINT(0 0),POINT(6 6)))", "GEOMETRYCOLLECTION(POINT(3 3))", - nil}, + nil, + }, // GC14: XYZ GeometryCollection, all children outside R — output is XY EMPTY - {"GC14", + { + "GC14", "GEOMETRYCOLLECTION Z(POINT Z(0 0 1),LINESTRING Z(6 5 2,7 6 3))", "GEOMETRYCOLLECTION EMPTY", - nil}, + nil, + }, // NE1: Very large coordinates (outside R) {"NE1", "LINESTRING(1000000000000000 3,1000000000000006 3)", "LINESTRING EMPTY", nil}, @@ -405,17 +481,21 @@ func TestClipByRect2D(t *testing.T) { // CD5: XYZ Point input → XY output {"CD5", "POINT Z(3 3 7)", "POINT(3 3)", nil}, // CD6: XYZM Polygon input → XY output, polygon clipped - {"CD6", + { + "CD6", "POLYGON ZM((0 2.5 1 11,6 2.5 2 12,6 3.5 3 13,0 3.5 4 14,0 2.5 1 11))", "POLYGON((1 2.5,5 2.5,5 3.5,1 3.5,1 2.5))", - []geom.ExactEqualsOption{geom.IgnoreOrder}}, + []geom.ExactEqualsOption{geom.IgnoreOrder}, + }, // CD7: XYM MultiPoint input → XY output, partial survival {"CD7", "MULTIPOINT M(3 3 1,0 0 2)", "MULTIPOINT(3 3)", nil}, // CD8: XYZ GeometryCollection input → XY output, mixed survival - {"CD8", + { + "CD8", "GEOMETRYCOLLECTION Z(POINT Z(3 3 1),LINESTRING Z(0 3 0,6 3 6))", "GEOMETRYCOLLECTION(POINT(3 3),LINESTRING(1 3,5 3))", - nil}, + nil, + }, } { t.Run(tt.name, func(t *testing.T) { for _, tr := range d4Transforms { From cb7b00a312f8a90199ad5d7832ae5955340de0de Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Sun, 24 May 2026 09:24:49 +1000 Subject: [PATCH 25/32] Use sort package instead of slices/cmp The project targets Go 1.18 (per go.mod), which doesn't have the slices or cmp standard library packages (added in Go 1.21). Switch to the equivalent sort.Slice and sort.Float64s calls. --- geom/alg_clip_by_rect_sutherland_hodgman.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/geom/alg_clip_by_rect_sutherland_hodgman.go b/geom/alg_clip_by_rect_sutherland_hodgman.go index aa7fd123..18122186 100644 --- a/geom/alg_clip_by_rect_sutherland_hodgman.go +++ b/geom/alg_clip_by_rect_sutherland_hodgman.go @@ -1,9 +1,8 @@ package geom import ( - "cmp" "fmt" - "slices" + "sort" ) // polygonClipper holds precomputed values for clipping a polygon against an @@ -200,8 +199,8 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]XY { } // Sort endpoints by param. - slices.SortFunc(endPoints, func(a, b endpoint) int { - return cmp.Compare(a.param, b.param) + sort.Slice(endPoints, func(i, j int) bool { + return endPoints[i].param < endPoints[j].param }) // Collect all start params sorted. @@ -209,7 +208,7 @@ func (c *polygonClipper) walkArcs(arcs []interiorArc) [][]XY { for p := range startByParam { startParams = append(startParams, p) } - slices.Sort(startParams) + sort.Float64s(startParams) // For a given end param, find the next start param going CCW. findNextStart := func(endParam float64) (float64, int) { From fdfd0cbec3ee6a96d5b8586a8307e5a0a5413b3f Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Sun, 24 May 2026 12:36:26 +1000 Subject: [PATCH 26/32] Remove clip-by-rect algorithm docs --- docs/clip_polygon_algorithm.md | 428 ----------------- docs/sutherland_hodgman.md | 822 --------------------------------- 2 files changed, 1250 deletions(-) delete mode 100644 docs/clip_polygon_algorithm.md delete mode 100644 docs/sutherland_hodgman.md diff --git a/docs/clip_polygon_algorithm.md b/docs/clip_polygon_algorithm.md deleted file mode 100644 index 02265828..00000000 --- a/docs/clip_polygon_algorithm.md +++ /dev/null @@ -1,428 +0,0 @@ -# Polygon Clipping Algorithm - -This document describes the algorithm used by `clipPolygonByRect` to clip a -polygon against an axis-aligned rectangle. It covers simple polygons (no -holes), concave polygons that split into multiple pieces, and polygons with -holes that cross the rectangle boundary. - -## Overview - -The algorithm has four phases: - -1. **Sutherland-Hodgman clipping** — clip each ring independently against the - rectangle's four edges. -2. **Arc extraction** — decompose each clipped ring into "interior arcs" - (portions that pass through the rectangle's interior) and "boundary arcs" - (portions that lie along the rectangle's edges). -3. **Topology resolution** — reconnect interior arcs using correct boundary - paths to produce output rings. -4. **Assembly** — classify output rings, assign free holes, and build the - output geometry. - -## Phase 1: Sutherland-Hodgman Clipping - -Each ring (exterior and holes) is clipped independently. The Sutherland-Hodgman -algorithm clips a closed polygon ring against a single half-plane. Four -sequential passes clip against the four rectangle edges: - -1. Left: x ≥ min.X -2. Right: x ≤ max.X -3. Bottom: y ≥ min.Y -4. Top: y ≤ max.Y - -### Single-edge pass - -The input is an open ring (a list of vertices without a repeated closing -vertex). The algorithm walks the edges of the ring. For each edge from vertex A -to vertex B, it applies one of four rules: - -| A | B | Action | -| ------- | ------- | ------------------------------ | -| inside | inside | emit B | -| inside | outside | emit intersection(A,B) | -| outside | inside | emit intersection(A,B), emit B | -| outside | outside | emit nothing | - -"Inside" means the vertex satisfies the half-plane condition (e.g. x ≥ min.X -for the left edge). - -The walk starts from the last vertex in the list (treating the ring as -implicitly closed), so the first edge processed is from the last vertex to the -first vertex. - -### Intersection computation - -For a vertical clipping edge x = k, the intersection of segment A→B is -computed as: - -``` -t = (k - A.x) / (B.x - A.x) -result = interpolateCoords(A, B, t) -result.x = k // set exactly to avoid float imprecision -``` - -Horizontal edges (y = k) are analogous. The `interpolateCoords` function -handles Z and M dimensions using a numerically robust lerp. The explicit -assignment of the boundary coordinate ensures that later boundary detection -(which uses `==`) is reliable. - -### After all four passes - -Duplicate consecutive vertices (by XY) are removed, including wrap-around -(first == last). If fewer than 3 vertices remain, the ring was entirely outside -the rectangle and is discarded. - -### Properties of the S-H output - -The output ring is a mixture of two kinds of edges: - -- **Interior edges** — edges where the path passes through the rectangle's - interior (at least one endpoint is not on the rectangle boundary). -- **Boundary edges** — edges where both endpoints lie on the same rectangle - edge. - -The S-H algorithm preserves the winding direction of the input ring. - -For convex polygons, the output is always a valid simple ring. For concave -polygons, the output ring may **self-touch** along the rectangle boundary. For -example, a C-shaped polygon clipped across its opening produces a ring where -two boundary arcs overlap on the same rectangle edge. The topology resolution -phase (Phase 3) handles this. - -## Phase 2: Arc Extraction - -Each clipped ring is decomposed into **interior arcs**. An interior arc is a -maximal sequence of consecutive interior edges. Each arc starts and ends at a -vertex on the rectangle boundary. - -### Identifying boundary edges - -An edge between vertices P and Q is a boundary edge if both P and Q lie on the -same rectangle edge: - -- both P.x == min.X and Q.x == min.X (left edge), or -- both P.x == max.X and Q.x == max.X (right edge), or -- both P.y == min.Y and Q.y == min.Y (bottom edge), or -- both P.y == max.Y and Q.y == max.Y (top edge). - -### Extracting arcs - -Walk the ring starting from a boundary edge. Skip consecutive boundary edges. -When a non-boundary edge is encountered, begin an interior arc at the current -vertex. Continue collecting vertices until the next boundary edge is -encountered. The arc ends at the vertex where the boundary edge begins. - -Each arc records: - -- Its coordinate sequence -- Its **start parameter** and **end parameter** on the rectangle boundary (see - parameterisation below) - -### Special cases - -- **No boundary edges at all**: the entire ring is interior (no contact with - the rectangle boundary). This happens when a hole is entirely inside the - rectangle and has no vertices on the rectangle boundary. Such a ring is - classified as a "free hole" and is not decomposed into arcs. It will be - assigned to an output polygon in Phase 4. A hole whose envelope is covered - by the rectangle but that has vertices on the rectangle boundary is **not** - free — it must be clipped to avoid producing invalid polygons with shared - edges between the exterior and hole rings. - -- **No interior edges at all**: the entire ring is boundary. For the exterior - ring, this means the original polygon completely contains the rectangle. No - arcs are extracted and in Phase 3 the output is the rectangle itself. For a - hole ring, this means the hole covers the entire rectangle. No polygon area - survives — the result is an empty polygon. - -## Phase 3: Topology Resolution - -### Rectangle boundary parameterisation - -The rectangle boundary is parameterised counter-clockwise starting from the -bottom-left corner: - -``` -Bottom edge (left to right): parameter 0 to W -Right edge (bottom to top): parameter W to W+H -Top edge (right to left): parameter W+H to 2W+H -Left edge (top to bottom): parameter 2W+H to 2W+2H (= perimeter) -``` - -Where W = max.X - min.X and H = max.Y - min.Y. The parameter wraps: the -bottom-left corner has parameter 0 and also parameter 2W+2H. - -A point on the boundary maps to a unique parameter based on which edge it lies -on. Corner points are assigned to the edge they appear on first in the CCW -traversal (e.g. the bottom-right corner has parameter W, assigned to the start -of the right edge). - -### Winding normalisation - -Before topology resolution, the clipped exterior ring is normalised to CCW. If -it has negative signed area (CW), it is reversed. Hole rings are **not** -reversed during this step — each hole's winding is handled independently. After -topology resolution, if the exterior was reversed, all output rings are -reversed back to restore the original winding convention. - -This means the topology resolution algorithm only needs to handle the CCW -exterior case. - -### Preparing arcs - -Interior arcs from the exterior ring are used as-is. - -Interior arcs from hole rings may or may not need reversal, depending on the -hole's own winding direction. In the simplefeatures data model, interior rings -are not required to have any particular orientation relative to the exterior -ring — a hole may be CW or CCW independently. - -The rule: after the exterior is normalised to CCW, a hole arc must be reversed -if and only if the hole ring is CCW (positive signed area). This is because a -CCW ring has its enclosed area to the left — for a hole, that enclosed area is -the empty space, so the polygon interior is to the right. Reversal puts the -polygon interior to the left, matching the CCW output convention. A CW hole -ring already has the polygon interior to the left and its arcs are used as-is. -This is determined by computing the signed area of each clipped hole ring -independently. - -Reversed arcs have their start and end parameters **swapped** (so that the -"output start" is the arc's original end, and vice versa) and are marked with a -`reverse` flag so the walking algorithm traverses their coordinates backwards. - -### The walking algorithm - -All arcs (from the exterior and from holes) are collected. Each arc has an -"output start" parameter and an "output end" parameter on the rectangle -boundary. - -The algorithm produces output rings by walking: - -1. Pick any unused arc. Call it the "first arc" of this ring. -2. Traverse the arc's coordinates (reversed if flagged). The ring is now at the - arc's output end parameter on the boundary. -3. Find the **next arc**: the arc whose output start parameter is the next one - going CCW from the current position on the boundary. This is the arc with - the smallest positive CCW distance from the current end parameter. -4. Build a **boundary path** from the current end parameter to the next arc's - start parameter. This path follows the rectangle boundary CCW and includes - any rectangle corners in between. -5. If the next arc is the first arc, the ring is complete. Otherwise, go to - step 2 with the next arc. -6. Repeat from step 1 for any remaining unused arcs. - -### Building boundary paths - -Given a start parameter p₁ and end parameter p₂, the boundary path includes -any rectangle corners whose parameters lie strictly between p₁ and p₂ going -CCW. - -For example, if p₁ = 3 (on the bottom edge) and p₂ = 10 (on the top edge) for -a rectangle with W=4, H=2 (corners at 0, 4, 6, 10): - -- Corners at parameters 4 and 6 are between 3 and 10. -- The path is: corner at param 4 (bottom-right), corner at param 6 - (top-right). - -The boundary path does not include the start and end points themselves (those -are already the last and first coordinates of the adjacent arcs). - -For Z/M coordinates, corner values are linearly interpolated between the Z/M -values at the start and end of the boundary path, proportional to the boundary -distance. - -### Why this produces correct output - -The key invariant is: **along the rectangle boundary going CCW, arc endpoints -alternate between "ends" and "starts"**. This is because each arc enters the -interior from the boundary and returns to the boundary. Between consecutive -end/start pairs, the boundary segment is part of the output polygon's boundary. - -For exterior arcs, the boundary between an arc's end and the next arc's start -represents a portion of the rectangle boundary that replaces the part of the -original polygon that extended outside the rectangle. - -For hole arcs (reversed), the boundary between the reversed arc's end and the -next arc's start represents a portion of the rectangle boundary that was -"inside the polygon but outside the hole" — i.e., it's still part of the -polygon. - -When a concave polygon self-touches after S-H clipping (e.g., a C-shape -clipped across its opening), the overlapping boundary arcs are replaced by -correct pairings that produce two separate output rings. - -## Phase 4: Assembly - -### Ring classification - -All output rings from topology resolution are exterior rings (CCW after -normalisation). Boundary-crossing holes create concavities or split the -exterior into multiple pieces, but never produce new holes. This is a property -of rectangle clipping: a hole that crosses the boundary "opens up" onto the -boundary, becoming part of the exterior's boundary rather than enclosing a -separate hole. - -Holes in the output come only from "free holes" — holes that are entirely -inside the rectangle and have no contact with the boundary. - -### Free hole assignment - -Each free hole is assigned to the output exterior ring that contains it. A -point from the hole (its first vertex) is tested against each output ring using -a ray-casting point-in-polygon test. - -### Output construction - -- If there are no output rings: return an empty polygon. -- If there is one output ring (with any free holes): return a Polygon. -- If there are multiple output rings: return a MultiPolygon. - -## Fast Paths - -Before running the full algorithm, several fast paths are checked: - -1. **Empty polygon**: return empty polygon. -2. **Degenerate rectangle** (point or line envelope): return empty polygon. A - polygon intersected with a lower-dimensional shape produces at most a - lower-dimensional result, which is discarded. -3. **Disjoint envelopes**: return empty polygon. -4. **Polygon entirely inside rectangle**: return the polygon unchanged. - -## Worked Example: C-shaped Polygon - -Input polygon (CCW): - -``` -(0, 2.5) → (3, 2.5) → (3, 2.8) → (0.5, 2.8) → -(0.5, 3.2) → (3, 3.2) → (3, 3.5) → (0, 3.5) -``` - -Rectangle: (1, 2) to (5, 4). - -### Phase 1: S-H clipping - -Only the left edge (x ≥ 1) produces changes. The other three edges leave all -vertices unchanged. - -After clipping: - -``` -(1, 2.5) → (3, 2.5) → (3, 2.8) → (1, 2.8) → -(1, 3.2) → (3, 3.2) → (3, 3.5) → (1, 3.5) -``` - -### Phase 2: Arc extraction - -Edge classification (boundary = both endpoints on same rect edge): - -| Edge | Type | -| ----------------- | -------- | -| (1,2.5) → (3,2.5) | interior | -| (3,2.5) → (3,2.8) | interior | -| (3,2.8) → (1,2.8) | interior | -| (1,2.8) → (1,3.2) | boundary | -| (1,3.2) → (3,3.2) | interior | -| (3,3.2) → (3,3.5) | interior | -| (3,3.5) → (1,3.5) | interior | -| (1,3.5) → (1,2.5) | boundary | - -Interior arcs: - -- **Arc 1**: (1,2.5) → (3,2.5) → (3,2.8) → (1,2.8). Start param = 11.5, end - param = 11.2. -- **Arc 2**: (1,3.2) → (3,3.2) → (3,3.5) → (1,3.5). Start param = 10.8, end - param = 10.5. - -### Phase 3: Topology resolution - -No hole arcs. Signed area is positive (CCW), so no reversal needed. - -Sorted arc endpoints on the boundary (going CCW from param 0): - -- 10.5 — Arc 2 end -- 10.8 — Arc 2 start -- 11.2 — Arc 1 end -- 11.5 — Arc 1 start - -Walking: - -**Ring A**: Start with Arc 2. Traverse (1,3.2) → (3,3.2) → (3,3.5) → (1,3.5). -End at param 10.5. Next start going CCW: param 10.8 (Arc 2's own start). Build -boundary path from 10.5 to 10.8: no corners between them (both on left edge). -Arc 2 is the first arc, so the ring is complete: (1,3.2), (3,3.2), (3,3.5), -(1,3.5). - -**Ring B**: Start with Arc 1. Traverse (1,2.5) → (3,2.5) → (3,2.8) → (1,2.8). -End at param 11.2. Next start going CCW: param 11.5 (Arc 1's own start). Build -boundary path from 11.2 to 11.5: no corners. Ring complete: (1,2.5), (3,2.5), -(3,2.8), (1,2.8). - -### Phase 4: Assembly - -Two output rings → MultiPolygon: - -``` -MULTIPOLYGON( - ((1 2.5, 3 2.5, 3 2.8, 1 2.8, 1 2.5)), - ((1 3.2, 3 3.2, 3 3.5, 1 3.5, 1 3.2)) -) -``` - -## Worked Example: Polygon Containing Rectangle (with hole) - -Input polygon: - -- Exterior (CCW): (-2, -2) → (6, -2) → (6, 6) → (-2, 6) — contains the - entire rectangle. -- Hole (CW): (-1, 1) → (2, 1) → (2, 3) → (-1, 3) — crosses left edge of - rectangle. - -Rectangle: (0, 0) to (4, 4). - -### Phase 1: S-H clipping - -Exterior clips to the rectangle itself (all boundary): (0,0), (4,0), (4,4), -(0,4). - -Hole clips to: (0,1), (2,1), (2,3), (0,3). - -### Phase 2: Arc extraction - -Exterior: no interior arcs (all boundary). - -Hole edges: - -| Edge | Type | -| ------------- | -------- | -| (0,1) → (2,1) | interior | -| (2,1) → (2,3) | interior | -| (2,3) → (0,3) | interior | -| (0,3) → (0,1) | boundary | - -Hole interior arc: (0,1) → (2,1) → (2,3) → (0,3). Start param = 15 (on left -edge), end param = 13 (on left edge). - -### Phase 3: Topology resolution - -Exterior signed area is positive (CCW), no reversal. The hole arc is CW. Its -start/end are swapped for the CCW output: output start = 13 (original end), -output end = 15 (original start). - -Only one arc. Walking: - -Start with the hole arc. Since `fromHole = true`, traverse in reverse: (0,3) → -(2,3) → (2,1) → (0,1). End at output end param = 15. Next start going CCW: -param 13 (the same arc). Build boundary path from param 15 to param 13: this -wraps around the entire rectangle. Corners at params 0, 4, 8, 12 are all -between 15 and 13 (going CCW past 16/0). Path: (0,0), (4,0), (4,4), (0,4). -Ring complete. - -Output ring: (0,3), (2,3), (2,1), (0,1), (0,0), (4,0), (4,4), (0,4). - -### Phase 4: Assembly - -Single output ring. The rectangle with a concavity where the hole was: - -``` -POLYGON((0 3, 2 3, 2 1, 0 1, 0 0, 4 0, 4 4, 0 4, 0 3)) -``` diff --git a/docs/sutherland_hodgman.md b/docs/sutherland_hodgman.md deleted file mode 100644 index 505af209..00000000 --- a/docs/sutherland_hodgman.md +++ /dev/null @@ -1,822 +0,0 @@ -# Sutherland-Hodgman Polygon Clipping Algorithm - -## Motivation - -ClipByRect computes the intersection of a geometry with an axis-aligned -rectangle. While this can be computed using a general-purpose overlay algorithm, -a dedicated rectangle clipper is significantly faster because it exploits the -simple structure of the clipping region. - -The Sutherland-Hodgman algorithm is the classic approach. It clips a polygon -against a convex clipping region by decomposing the problem into a sequence of -simpler clips, one for each edge of the clipping region. For an axis-aligned -rectangle, this means four passes. - -## Core Idea - -A convex polygon can be described as the intersection of half-planes. An -axis-aligned rectangle is the intersection of four half-planes: - -``` -left: x >= xmin -right: x <= xmax -bottom: y >= ymin -top: y <= ymax -``` - -Clipping a polygon against the rectangle is equivalent to clipping it against -each of these four half-planes in sequence. The output of each pass becomes the -input to the next. - -``` -input polygon - | - v -[clip to left edge] - | - v -[clip to right edge] - | - v -[clip to bottom edge] - | - v -[clip to top edge] - | - v -output polygon -``` - -The order of the four passes does not matter. The result is the same regardless -of which edge is processed first. - -## Clipping Against a Single Half-Plane - -Each pass walks the edges of the input polygon. An edge is a segment from -vertex A to the next vertex B (wrapping from the last vertex back to the -first). - -For each edge A to B, there are exactly four cases depending on whether A and B -are inside or outside the half-plane: - -### Case 1: Both Inside - -``` - | - A--->B A is inside, B is inside. - | - | Emit: B -``` - -Both vertices are on the retained side. The entire edge survives. Emit B. (A -was already emitted by the previous edge, or will be handled as the first -vertex.) - -### Case 2: Inside to Outside - -``` - | - A--->+--B A is inside, B is outside. - | - | Emit: intersection point I -``` - -The edge crosses from the retained side to the clipped side. Emit the -intersection point where the segment crosses the clipping edge. B is discarded. - -### Case 3: Outside to Inside - -``` - | - B<---+--A A is outside, B is inside. - | - | Emit: intersection point I, then B -``` - -The edge crosses from the clipped side to the retained side. Emit the -intersection point, then emit B. Two vertices are produced for this edge -because the output polygon needs to include both the point where it re-enters -the clipping boundary and the destination vertex. - -### Case 4: Both Outside - -``` - | - | A-->B A is outside, B is outside. - | - | Emit: nothing -``` - -The entire edge is on the clipped side. It contributes nothing to the output. - -### Summary Table - -| A | B | Emitted vertices | -| ------- | ------- | ---------------- | -| inside | inside | B | -| inside | outside | I | -| outside | inside | I, B | -| outside | outside | (none) | - -Where I is the intersection of segment AB with the clipping edge. - -## Inside Test - -For each of the four edges of the rectangle, the inside test is a single -comparison: - -| Clipping edge | Condition for "inside" | -| ----------------- | ---------------------- | -| left (x = xmin) | point.x >= xmin | -| right (x = xmax) | point.x <= xmax | -| bottom (y = ymin) | point.y >= ymin | -| top (y = ymax) | point.y <= ymax | - -## Computing Intersections - -Because the clipping edges are axis-aligned, intersection computation reduces -to simple linear interpolation. - -### Intersection With a Vertical Edge (x = k) - -Given segment from A to B and a vertical clipping edge at x = k: - -``` -t = (k - A.x) / (B.x - A.x) -I = (k, A.y + t * (B.y - A.y)) -``` - -The parameter t is the fractional distance along the segment from A to B where -it crosses the edge. Since we only compute this when A and B are on opposite -sides of the edge, B.x - A.x is guaranteed to be non-zero. - -### Intersection With a Horizontal Edge (y = k) - -Given segment from A to B and a horizontal clipping edge at y = k: - -``` -t = (k - A.y) / (B.y - A.y) -I = (A.x + t * (B.x - A.x), k) -``` - -## Worked Example - -Clip the triangle with vertices P0(0, 1), P1(4, 3), P2(2, -1) against a -rectangle with xmin=1, xmax=3, ymin=0, ymax=2. - -### Pass 1: Clip to Left Edge (x >= 1) - -Process each edge of the triangle: - -**Edge P0(0,1) to P1(4,3):** P0 is outside (x=0 < 1), P1 is inside (x=4 >= 1). -Case 3: emit intersection, then P1. - -``` -t = (1 - 0) / (4 - 0) = 0.25 -I_a = (1, 1 + 0.25 * (3 - 1)) = (1, 1.5) -``` - -Emit: (1, 1.5), (4, 3). - -**Edge P1(4,3) to P2(2,-1):** Both inside (x=4 >= 1, x=2 >= 1). Case 1: emit -P2. - -Emit: (2, -1). - -**Edge P2(2,-1) to P0(0,1):** P2 is inside (x=2 >= 1), P0 is outside (x=0 < -1). Case 2: emit intersection. - -``` -t = (1 - 2) / (0 - 2) = 0.5 -I_b = (1, -1 + 0.5 * (1 - (-1))) = (1, 0) -``` - -Emit: (1, 0). - -**Result after pass 1:** (1, 1.5), (4, 3), (2, -1), (1, 0). - -### Pass 2: Clip to Right Edge (x <= 3) - -Input: (1, 1.5), (4, 3), (2, -1), (1, 0). - -**Edge (1, 1.5) to (4, 3):** Inside to outside. Emit intersection. - -``` -t = (3 - 1) / (4 - 1) = 2/3 -I = (3, 1.5 + 2/3 * (3 - 1.5)) = (3, 2.5) -``` - -Emit: (3, 2.5). - -**Edge (4, 3) to (2, -1):** Outside to inside. Emit intersection, then (2, -1). - -``` -t = (3 - 4) / (2 - 4) = 0.5 -I = (3, 3 + 0.5 * (-1 - 3)) = (3, 1) -``` - -Emit: (3, 1), (2, -1). - -**Edge (2, -1) to (1, 0):** Both inside. Emit (1, 0). - -Emit: (1, 0). - -**Edge (1, 0) to (1, 1.5):** Both inside. Emit (1, 1.5). - -Emit: (1, 1.5). - -**Result after pass 2:** (3, 2.5), (3, 1), (2, -1), (1, 0), (1, 1.5). - -### Pass 3: Clip to Bottom Edge (y >= 0) - -Input: (3, 2.5), (3, 1), (2, -1), (1, 0), (1, 1.5). - -**Edge (3, 2.5) to (3, 1):** Both inside. Emit (3, 1). - -**Edge (3, 1) to (2, -1):** Inside to outside. Emit intersection. - -``` -t = (0 - 1) / (-1 - 1) = 0.5 -I = (3 + 0.5 * (2 - 3), 0) = (2.5, 0) -``` - -Emit: (2.5, 0). - -**Edge (2, -1) to (1, 0):** Outside to inside. Emit intersection, then (1, 0). - -``` -t = (0 - (-1)) / (0 - (-1)) = 1 -I = (2 + 1 * (1 - 2), 0) = (1, 0) -``` - -Emit: (1, 0), (1, 0). - -Note: The intersection coincides with (1, 0). This produces a duplicate vertex, -which is harmless and can be cleaned up afterward. - -**Edge (1, 0) to (1, 1.5):** Both inside. Emit (1, 1.5). - -Emit: (1, 1.5). - -**Edge (1, 1.5) to (3, 2.5):** Both inside. Emit (3, 2.5). - -Emit: (3, 2.5). - -**Result after pass 3:** (3, 1), (2.5, 0), (1, 0), (1, 0), (1, 1.5), (3, 2.5). - -### Pass 4: Clip to Top Edge (y <= 2) - -Input: (3, 1), (2.5, 0), (1, 0), (1, 0), (1, 1.5), (3, 2.5). - -**Edge (3, 1) to (2.5, 0):** Both inside. Emit (2.5, 0). - -**Edge (2.5, 0) to (1, 0):** Both inside. Emit (1, 0). - -**Edge (1, 0) to (1, 0):** Both inside. Emit (1, 0). - -**Edge (1, 0) to (1, 1.5):** Both inside. Emit (1, 1.5). - -**Edge (1, 1.5) to (3, 2.5):** Inside to outside. Emit intersection. - -``` -t = (2 - 1.5) / (2.5 - 1.5) = 0.5 -I = (1 + 0.5 * (3 - 1), 2) = (2, 2) -``` - -Emit: (2, 2). - -**Edge (3, 2.5) to (3, 1):** Outside to inside. Emit intersection, then (3, 1). - -``` -t = (2 - 2.5) / (1 - 2.5) = 1/3 -I = (3 + 1/3 * (3 - 3), 2) = (3, 2) -``` - -Emit: (3, 2), (3, 1). - -**Final result:** (2.5, 0), (1, 0), (1, 0), (1, 1.5), (2, 2), (3, 2), (3, 1). - -After removing the duplicate vertex at (1, 0): - -**(2.5, 0), (1, 0), (1, 1.5), (2, 2), (3, 2), (3, 1).** - -This is the triangle clipped to the rectangle. - -## Pseudocode - -``` -function clipPolygonToRect(vertices, xmin, ymin, xmax, ymax): - output = vertices - output = clipToEdge(output, LEFT, xmin) - output = clipToEdge(output, RIGHT, xmax) - output = clipToEdge(output, BOTTOM, ymin) - output = clipToEdge(output, TOP, ymax) - return output - -function clipToEdge(vertices, edge, value): - if len(vertices) == 0: - return [] - - output = [] - A = vertices[last] - - for each B in vertices: - aInside = isInside(A, edge, value) - bInside = isInside(B, edge, value) - - if aInside and bInside: - append B to output - else if aInside and not bInside: - append intersection(A, B, edge, value) to output - else if not aInside and bInside: - append intersection(A, B, edge, value) to output - append B to output - // else: both outside, emit nothing - - A = B - - return output - -function isInside(point, edge, value): - switch edge: - LEFT: return point.x >= value - RIGHT: return point.x <= value - BOTTOM: return point.y >= value - TOP: return point.y <= value - -function intersection(A, B, edge, value): - switch edge: - LEFT, RIGHT: - t = (value - A.x) / (B.x - A.x) - return (value, A.y + t * (B.y - A.y)) - BOTTOM, TOP: - t = (value - A.y) / (B.y - A.y) - return (A.x + t * (B.x - A.x), value) -``` - -## Complexity - -- **Time:** O(N) per clipping edge, where N is the number of vertices. With 4 - edges, the total is O(4N) = O(N). In the worst case, each pass can at most - double the vertex count (every edge crosses the clipping line), so the - intermediate vertex lists remain bounded. - -- **Space:** O(N) for the output vertex list. The algorithm can be implemented - with two buffers that are swapped between passes. - -## Properties - -- **Preserves winding order.** If the input polygon has counter-clockwise - winding, the output will too. - -- **Works for concave polygons.** Unlike some clipping algorithms, - Sutherland-Hodgman handles concave (non-convex) input polygons. However, when - a concave polygon is clipped, the result may contain coincident (overlapping) - edges along the clipping boundary. These are topologically valid but may need - post-processing depending on the application. - -- **May produce degenerate edges.** When a polygon vertex lies exactly on the - clipping edge, the intersection point coincides with the vertex, producing - zero-length edges or duplicate vertices. A post-processing step to remove - duplicate consecutive vertices handles this. - -## Extension to LineStrings - -Sutherland-Hodgman is designed for closed polygonal rings. For open LineStrings, -a related but simpler approach works: clip each segment independently against -the rectangle (using Cohen-Sutherland or Liang-Barsky line clipping), then -merge consecutive surviving segments into output LineStrings. Segments that are -entirely outside produce gaps, potentially splitting one LineString into -multiple. - -## Extension to Other Geometry Types - -- **Point:** Test whether the point lies within the rectangle. Emit it or - discard it. - -- **MultiPoint:** Test each point individually. - -- **LineString:** Clip each segment against the rectangle. Consecutive surviving - segments form output LineStrings. A single input LineString may produce - multiple output LineStrings (yielding a MultiLineString). - -- **Polygon (no holes):** Clip the exterior ring using Sutherland-Hodgman. If - the result is empty, the polygon is entirely outside the rectangle. - -- **Polygon (with holes):** Clip the exterior ring and each hole ring - independently. Discard hole rings that become empty. Hole rings that are - entirely inside the rectangle survive unchanged. This is explained in - detail below. - -- **Multi-geometries:** Process each component independently and collect the - results. - -- **GeometryCollection:** Recurse into each element. - -## Handling Polygons With Holes - -Polygons with holes require care because holes can interact with the clipping -rectangle in ways that change the topology. - -### Case 1: Hole Entirely Inside the Rectangle - -The hole survives unchanged. No special handling needed. - -``` -+--rect-----------+ -| | -| +-exterior-+ | -| | | | -| | +-hole+ | | -| | | | | | -| | +-----+ | | -| | | | -| +----------+ | -| | -+------------------+ -``` - -### Case 2: Hole Entirely Outside the Rectangle - -The hole is irrelevant to the clipped result. Discard it. - -### Case 3: Hole Crosses the Rectangle Boundary - -This is the complex case. When a hole's ring crosses the clipping boundary, -parts of the clipping boundary become part of the polygon's boundary. - -``` -+-rect-----+ -| | -| +--exterior--+ -| | | | -| | +--hole---+--+ -| | | | | -| | +----+-------+ -| | | | -| +-------+----+ -| | -+-----------+ -``` - -In this situation, clipping the exterior ring and hole ring independently and -then naively combining them will not produce a valid polygon. The clipped hole -shares edges along the rectangle boundary with the clipped exterior ring, -effectively splitting the polygon. - -There are two strategies for handling this: - -**Strategy A: Clip rings then resolve topology.** Clip each ring -independently, then use a topology-aware algorithm to merge the clipped rings -into a valid polygon or multipolygon. This is essentially a simplified overlay -operation restricted to the rectangle boundary. - -**Strategy B: Fallback to general intersection.** Detect when holes cross the -rectangle boundary and fall back to a general-purpose intersection algorithm -for those cases. This avoids implementing the topology resolution but sacrifices -the performance advantage for these inputs. - -### Topology Resolution for Strategy A - -When holes cross the rectangle boundary, the clipped result may need to be a -MultiPolygon rather than a single Polygon. For example, a U-shaped polygon -clipped by a rectangle across the opening can produce two separate polygons. - -The resolution algorithm: - -1. Clip the exterior ring and all hole rings independently. -2. Identify clipped ring segments that lie along the rectangle boundary. -3. Pair up boundary segments: where the exterior ring enters the boundary, a - hole ring (or the same ring) must exit, and vice versa. -4. Walk the combined ring structure, tracing the outline of each resulting - polygon. -5. Determine which resulting rings are exteriors and which are holes based on - winding order (or signed area). -6. Group holes with their enclosing exterior rings. - -This is the most algorithmically complex part of implementing ClipByRect. It -resembles the edge-tracing phase of an overlay algorithm, but is constrained to -only four possible boundary edges, which simplifies the data structures -involved. - -## Output Type Rules - -Two rules govern the output geometry type: - -1. **Dimension preservation.** The topological dimension of the output always - matches the input. A LineString input never produces a Point output; it - produces an empty LineString instead. - -2. **Multi-type promotion only.** A singular type may be promoted to its - multi-type when clipping produces multiple components (e.g. a LineString that - is split into multiple segments becomes a MultiLineString). However, a - multi-type input is never demoted: a MultiPoint input always produces a - MultiPoint output, even if only one point survives. - -The output type only ever stays the same or gets wider (singular to multi), never -narrower. For multi-type inputs, the output type is always the same as the input -type. For singular-type inputs, the output is the same singular type unless the -result has multiple components, in which case it is promoted to the corresponding -multi-type. - -When a child element of a multi-type is clipped away entirely, it is omitted from -the result (not included as an empty geometry). For example, a MultiPoint with -three points where one is outside R produces a MultiPoint with two points. In -contrast, a singular type that is clipped away entirely becomes an empty geometry -of the same type: a Point outside R produces an empty Point. - -GeometryCollections are processed recursively. Each non-GeometryCollection child -is clipped independently per the rules above. Children that clip to empty (or -were already empty) are omitted from the result. Nested GeometryCollections that -become empty after their children are omitted are themselves omitted. The -top-level GeometryCollection is never omitted: if all children are removed, the -result is an empty GeometryCollection. - -## Unit Test Cases - -This section enumerates unit test cases for ClipByRect. The clipping rectangle -is denoted R. Each test specifies the input geometry, the rectangle, and the -expected output. - -Each test case is defined with concrete coordinates in a single canonical -orientation. The test implementation systematically applies all 8 symmetry -transformations of the dihedral group D4 (4 rotations × 2 reflections) to both -the input geometry and the clipping rectangle, generating 8 sub-tests per case. -This ensures every case is tested against all edges and corners of R without -needing to list each orientation explicitly. - -### Rectangle Configurations - -The clipping rectangle can take four forms: - -- **Normal rectangle** (positive width and height): The standard case. All - geometry-type tests below assume a normal rectangle. - -- **Empty envelope**: Always returns an empty geometry of the input type. - -- **Point envelope** (min = max): Degenerate. Lower-dimensional intersections - are discarded. A Point at the same location survives, but a LineString passing - through the point produces an empty LineString (the intersection is a point, - which is lower-dimensional than a LineString). - -- **Line envelope** (zero width or zero height): Degenerate. Lower-dimensional - intersections are discarded. A LineString collinear with the line survives, but - a LineString that merely crosses it produces an empty LineString (the - intersection is a point). Similarly, a Polygon crossing the line produces an - empty Polygon (the intersection is at most a line, which is lower-dimensional - than a Polygon). - -The Degenerate Rectangle Cases section below exercises the empty, point, and line -envelope configurations with representative geometries. - -### Point - -| # | Case | Expected output | -| --- | ------------------------ | --------------- | -| PT1 | Empty Point | Empty Point | -| PT2 | Point strictly inside R | Same Point | -| PT3 | Point strictly outside R | Empty Point | -| PT4 | Point on edge of R | Same Point | -| PT5 | Point on corner of R | Same Point | - -### MultiPoint - -| # | Case | Expected output | -| --- | --------------------------------------- | ----------------------------------- | -| MP1 | Empty MultiPoint | Empty MultiPoint | -| MP2 | All points inside R | Same MultiPoint | -| MP3 | All points outside R | Empty MultiPoint | -| MP4 | Some points inside, some outside | MultiPoint with only inside points | -| MP5 | Single point inside R | MultiPoint with 1 point | -| MP6 | Points on edges and corners of R | All retained | -| MP7 | Mix of inside, on-boundary, and outside | Inside and boundary points retained | -| MP8 | MultiPoint containing empty points | Empty points excluded from output | -| MP9 | XYZ MultiPoint, all points outside R | Empty XYZ MultiPoint | - -### LineString - -#### Spatial relationship to R - -| # | Case | Expected output | -| --- | --------------------------------------------------- | ----------------------------------------------- | -| LS1 | Empty LineString | Empty LineString | -| LS2 | Entirely inside R | Same LineString | -| LS3 | Entirely outside R | Empty LineString | -| LS4 | Entirely outside R on one side (e.g. all left of R) | Empty LineString | -| LS5 | Crosses R, entering and exiting once | LineString clipped to entry and exit points | -| LS6 | Crosses R, entering and exiting multiple times | MultiLineString of surviving segments | -| LS7 | One endpoint inside, one outside | LineString from inside endpoint to intersection | -| LS8 | One endpoint outside, one inside | LineString from intersection to inside endpoint | -| LS9 | Both endpoints outside, segment passes through R | LineString clipped to two intersection points | - -#### Boundary interactions - -| # | Case | Expected output | -| ---- | --------------------------------------------------------------------------- | ---------------------------------------------- | -| LS10 | Endpoint exactly on edge of R, other inside | LineString preserved | -| LS11 | Endpoint exactly on corner of R, other inside | LineString preserved | -| LS12 | Both endpoints on boundary of R | LineString preserved | -| LS13 | LineString lies entirely along one edge of R | LineString preserved (collinear with boundary) | -| LS14 | LineString lies entirely along two adjacent edges (L-shaped along boundary) | LineString preserved | -| LS15 | Segment touches corner of R but does not enter (V-shape touching corner) | Empty LineString | -| LS16 | Segment touches edge of R tangentially (parallel approach, touches, leaves) | Empty LineString | - -#### Direction and shape - -| # | Case | Expected output | -| ---- | ---------------------------------------------------------------- | ------------------------------------------------- | -| LS17 | Diagonal line crossing two edges of R | LineString with 2 intersection points | -| LS18 | Axis-aligned line crossing two opposite edges of R | LineString clipped to opposite edge intersections | -| LS19 | Closed LineString (ring) inside R | Same closed LineString | -| LS20 | Closed LineString (ring) partially overlapping R | LineString of surviving arcs | -| LS21 | Multi-segment LineString with some segments inside, some outside | MultiLineString of surviving segments | -| LS22 | Zigzag LineString entering and exiting R many times | MultiLineString with multiple components | - -#### Corner and edge-coincident cases - -| # | Case | Expected output | -| ---- | ---------------------------------------------------- | -------------------------------------------- | -| LS23 | Vertex exactly on R boundary, adjacent edges inside | LineString preserved through boundary vertex | -| LS24 | Vertex exactly on R boundary, adjacent edges outside | Empty LineString | -| LS25 | LineString passes through two opposite corners of R | LineString clipped between corners | - -### MultiLineString - -| # | Case | Expected output | -| ---- | ------------------------------------------------------- | ------------------------------------------------ | -| MLS1 | Empty MultiLineString | Empty MultiLineString | -| MLS2 | All component LineStrings inside R | Same MultiLineString | -| MLS3 | All component LineStrings outside R | Empty MultiLineString | -| MLS4 | Some components inside, some outside | MultiLineString with surviving components | -| MLS5 | Single component, clipped to a single segment | MultiLineString with 1 component | -| MLS6 | One component crosses R, another is inside R | MultiLineString with clipped and unclipped parts | -| MLS7 | Component that produces multiple fragments when clipped | Fragments included in output MultiLineString | -| MLS8 | MultiLineString containing empty LineStrings | Empty components excluded | -| MLS9 | XYZ MultiLineString, all components outside R | Empty XYZ MultiLineString | - -### Polygon (No Holes) - -#### Spatial relationship to R - -| # | Case | Expected output | -| --- | ---------------------------------------------------------- | ------------------------------ | -| PG1 | Empty Polygon | Empty Polygon | -| PG2 | Entirely inside R | Same Polygon | -| PG3 | Entirely outside R (no overlap) | Empty Polygon | -| PG4 | R entirely inside Polygon | Polygon equal to R | -| PG5 | Partially overlapping one edge of R | Polygon clipped to R boundary | -| PG6 | Partially overlapping two adjacent edges (corner clip) | Polygon clipped at corner | -| PG7 | Partially overlapping two opposite edges (strip through R) | Polygon clipped on two sides | -| PG8 | Partially overlapping three edges | Polygon clipped on three sides | - -#### Boundary interactions - -| # | Case | Expected output | -| ---- | ----------------------------------------------------- | ----------------------------------- | -| PG9 | Polygon shares an entire edge with R | Polygon preserved along shared edge | -| PG10 | Polygon vertex exactly on R edge | Polygon with vertex on boundary | -| PG11 | Polygon vertex exactly on R corner | Polygon with vertex on corner | -| PG12 | Polygon edge collinear with R edge, polygon inside R | Polygon preserved | -| PG13 | Polygon edge collinear with R edge, polygon outside R | Empty Polygon | -| PG14 | Polygon touches R at a single point (vertex-to-edge) | Empty Polygon | -| PG15 | Polygon touches R at a single corner point | Empty Polygon | - -#### Shape variations - -| # | Case | Expected output | -| ---- | ------------------------------------------------- | ------------------------------------------- | -| PG16 | Convex polygon clipped by R | Convex clipped polygon | -| PG17 | Concave polygon, concavity inside R | Concave clipped polygon | -| PG18 | Concave polygon, concavity facing R boundary | Clipped polygon reflecting concavity | -| PG19 | U-shaped polygon clipped across the opening | MultiPolygon (two separate pieces) | -| PG20 | Very thin sliver polygon partially inside R | Thin clipped polygon | -| PG21 | Triangle clipped to produce various vertex counts | Polygon with 3-7 vertices depending on clip | - -#### Winding order - -| # | Case | Expected output | -| ---- | ------------------------------- | ---------------------------------------------------- | -| PG22 | Counter-clockwise exterior ring | Output preserves winding order (reflections test CW) | - -### Polygon (With Holes) - -#### Hole entirely inside R - -| # | Case | Expected output | -| --- | ----------------------------------------------------- | -------------------------------- | -| PH1 | Exterior and hole both inside R | Same Polygon with hole | -| PH2 | Exterior clipped, hole entirely inside clipped region | Polygon with hole preserved | -| PH3 | Multiple holes, all inside R | Polygon with all holes preserved | - -#### Hole entirely outside R - -| # | Case | Expected output | -| --- | ------------------------------------------------------------ | -------------------------------------- | -| PH4 | Hole entirely outside R (but inside exterior ring outside R) | Polygon without hole (hole discarded) | -| PH5 | Hole in part of exterior ring that is clipped away | Polygon without hole (hole irrelevant) | - -#### Hole crossing R boundary - -| # | Case | Expected output | -| ---- | -------------------------------------------------------------------- | ------------------------------------------ | -| PH6 | Hole crosses one edge of R | Polygon with concavity (no hole in output) | -| PH7 | Hole crosses two adjacent edges of R (corner) | Polygon with concavity (no hole in output) | -| PH8 | Hole crosses two opposite edges of R (splits polygon) | MultiPolygon | -| PH9 | Hole crosses all four edges of R, leaving corner fragments | MultiPolygon | -| PH10 | Hole inside R shares edge with R boundary, exterior extends beyond R | Polygon with concavity (no hole in output) | - -#### Multiple holes interacting with R - -| # | Case | Expected output | -| ---- | ------------------------------------------ | --------------------------------------- | -| PH11 | One hole inside R, another outside R | Polygon with only inside hole | -| PH12 | One hole inside R, another splits polygon | MultiPolygon, hole in correct component | -| PH13 | Multiple holes each crossing one edge of R | Polygon with multiple concavities | - -#### Topology-changing cases - -| # | Case | Expected output | -| ---- | --------------------------------------------- | --------------- | -| PH14 | Hole splits polygon into three or more pieces | MultiPolygon | -| PH15 | Hole covers entire clipped area | Empty Polygon | - -### MultiPolygon - -| # | Case | Expected output | -| ----- | ------------------------------------------------------------------- | ---------------------------------------- | -| MPG1 | Empty MultiPolygon | Empty MultiPolygon | -| MPG2 | All component Polygons inside R | Same MultiPolygon | -| MPG3 | All component Polygons outside R | Empty MultiPolygon | -| MPG4 | Some components inside, some outside | MultiPolygon with surviving components | -| MPG5 | One component partially clipped, another fully inside | MultiPolygon combining both | -| MPG6 | One component becomes multiple polygons when clipped (e.g. U-shape) | MultiPolygon with all resulting polygons | -| MPG7 | Multiple components, each partially clipped | MultiPolygon with all fragments | -| MPG8 | Components with holes, some holes clipped | MultiPolygon preserving relevant holes | -| MPG9 | Single component fully inside R | MultiPolygon with 1 component | -| MPG10 | MultiPolygon containing empty Polygons | Empty components excluded | -| MPG11 | XYZ MultiPolygon, all components outside R | Empty XYZ MultiPolygon | - -### GeometryCollection - -| # | Case | Expected output | -| ---- | --------------------------------------------------- | ----------------------------------------------- | -| GC1 | Empty GeometryCollection | Empty GeometryCollection | -| GC2 | Contains only Points, all inside R | GeometryCollection with all Points | -| GC3 | Contains only Points, all outside R | Empty GeometryCollection | -| GC4 | Contains mixed types, all inside R | GeometryCollection with all components | -| GC5 | Contains mixed types, all outside R | Empty GeometryCollection | -| GC6 | Contains mixed types, some inside, some outside | GeometryCollection with surviving components | -| GC7 | Contains Point, LineString, and Polygon | Each component clipped independently | -| GC8 | Contains nested GeometryCollection | Recursive clipping into nested collection | -| GC9 | Contains nested GeometryCollection with mixed types | Recursive clipping at all levels | -| GC10 | All child geometries are empty | Empty GeometryCollection | -| GC11 | Contains MultiPoint, MultiLineString, MultiPolygon | Each multi-type component clipped independently | -| GC12 | Deeply nested GeometryCollections (3+ levels) | Recursive clipping at all levels | -| GC13 | Nested GC whose children all clip to empty | Nested GC omitted from parent | -| GC14 | XYZ GeometryCollection, all children outside R | Empty XYZ GeometryCollection | - -### Degenerate Rectangle Cases - -These cases test non-standard rectangles with representative geometries from -each type. - -| # | Case | Geometry | Expected output | -| ---- | -------------------- | --------------------------------------- | ------------------ | -| DR1 | Empty envelope | Point inside would-be area | Empty Point | -| DR2 | Empty envelope | LineString | Empty LineString | -| DR3 | Empty envelope | Polygon | Empty Polygon | -| DR4 | Point envelope (0x0) | Point at same location | Same Point | -| DR5 | Point envelope (0x0) | Point at different location | Empty Point | -| DR6 | Point envelope (0x0) | MultiPoint with some points at location | Clipped MultiPoint | -| DR7 | Point envelope (0x0) | MultiPoint with no points at location | Empty MultiPoint | -| DR8 | Point envelope (0x0) | LineString through that point | Empty LineString | -| DR9 | Point envelope (0x0) | Polygon containing that point | Empty Polygon | -| DR10 | Line envelope | Point on the line | Same Point | -| DR11 | Line envelope | Point off the line | Empty Point | -| DR12 | Line envelope | MultiPoint with some points on the line | Clipped MultiPoint | -| DR13 | Line envelope | MultiPoint with no points on the line | Empty MultiPoint | -| DR14 | Line envelope | LineString crossing the line | Empty LineString | -| DR15 | Line envelope | LineString collinear with line | Clipped LineString | -| DR16 | Line envelope | Polygon crossing the line | Empty Polygon | - -### Numerical Edge Cases - -| # | Case | Notes | -| --- | ------------------------------------------------------------------ | ------------------------------------------ | -| NE1 | Very large coordinates (near float64 max) | Test numerical stability | -| NE2 | Very small coordinates (near float64 epsilon) | Test precision | -| NE3 | Negative coordinates | All quadrants should work | -| NE4 | Coordinates that produce intersection parameters t very close to 0 | Near-vertex intersection | -| NE5 | Coordinates that produce intersection parameters t very close to 1 | Near-vertex intersection | -| NE6 | Polygon vertex exactly at intersection point with R edge | Degenerate intersection (duplicate vertex) | -| NE7 | Segment nearly parallel to R edge (small angle) | Intersection computation precision | -| NE8 | Zero-length segments in input (duplicate consecutive vertices) | Should not cause division by zero | - -### Coordinate Dimension Preservation - -| # | Case | Notes | -| --- | --------------------- | ----------------------------------------------------------- | -| CD1 | XY geometry clipped | Output has XY coordinates | -| CD2 | XYZ geometry clipped | Output has XYZ coordinates, Z interpolated at intersections | -| CD3 | XYM geometry clipped | Output has XYM coordinates, M interpolated at intersections | -| CD4 | XYZM geometry clipped | Output has XYZM coordinates, Z and M interpolated | From c4b3d3b8c9a5b0491fa175619261bcf80abaffb2 Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Sun, 24 May 2026 13:12:12 +1000 Subject: [PATCH 27/32] Rename Sutherland-Hodgman test file to match source Match alg_clip_by_rect_sutherland_hodgman_test.go to its corresponding implementation file alg_clip_by_rect_sutherland_hodgman.go. --- ...odgman_test.go => alg_clip_by_rect_sutherland_hodgman_test.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename geom/{alg_sutherland_hodgman_test.go => alg_clip_by_rect_sutherland_hodgman_test.go} (100%) diff --git a/geom/alg_sutherland_hodgman_test.go b/geom/alg_clip_by_rect_sutherland_hodgman_test.go similarity index 100% rename from geom/alg_sutherland_hodgman_test.go rename to geom/alg_clip_by_rect_sutherland_hodgman_test.go From c5e97f0b49ae4b5d35db63727f67b02a05daa89c Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Sun, 24 May 2026 13:14:20 +1000 Subject: [PATCH 28/32] Remove redundant endpoint short-circuits in lerpXY lerp already returns the endpoints exactly at t=0 and t=1, so the guards in lerpXY were dead weight. The doc comment's bit-identical-endpoints contract still holds. --- geom/alg_linear_interpolation.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/geom/alg_linear_interpolation.go b/geom/alg_linear_interpolation.go index b7c1b92f..0bac2670 100644 --- a/geom/alg_linear_interpolation.go +++ b/geom/alg_linear_interpolation.go @@ -78,12 +78,6 @@ func lerp(a, b, t float64) float64 { // The endpoints are returned exactly when t is 0 or 1, so a point that // nominally survives interpolation unchanged is bit-identical to its input. func lerpXY(a, b XY, t float64) XY { - if t == 0 { - return a - } - if t == 1 { - return b - } return XY{ X: lerp(a.X, b.X, t), Y: lerp(a.Y, b.Y, t), From 4dbf7b58466654947be0f62ac6a3d2a9a9f999e0 Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Mon, 25 May 2026 18:47:20 +1000 Subject: [PATCH 29/32] Remove vertical whitespace Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddd90bf3..7ea502bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,6 @@ the ambiguity of synthesising Z/M values at rectangle corners introduced by the clip. - ## v0.59.0 2026-03-27 From 034e5a1d75d40bd8449ac9fb7f481e25f4f937e0 Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Mon, 25 May 2026 18:52:12 +1000 Subject: [PATCH 30/32] Trim lerpXY doc comment to its core contract The removed sentence described bit-identical endpoint behaviour, which is a property of the underlying lerp helper rather than the lerpXY wrapper. Keeping the wrapper's doc focused on what the function does avoids documenting an invariant that lives one layer below. --- geom/alg_linear_interpolation.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/geom/alg_linear_interpolation.go b/geom/alg_linear_interpolation.go index 0bac2670..adce8703 100644 --- a/geom/alg_linear_interpolation.go +++ b/geom/alg_linear_interpolation.go @@ -75,8 +75,6 @@ func lerp(a, b, t float64) float64 { } // lerpXY linearly interpolates between [XY] points a and b at parameter t. -// The endpoints are returned exactly when t is 0 or 1, so a point that -// nominally survives interpolation unchanged is bit-identical to its input. func lerpXY(a, b XY, t float64) XY { return XY{ X: lerp(a.X, b.X, t), From b3702d367b3d85e82681b870cf76a0fd048230b7 Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Mon, 25 May 2026 18:59:59 +1000 Subject: [PATCH 31/32] Move xysToSeq next to its inverse asXYs in type_sequence.go --- geom/alg_clip_by_rect.go | 9 --------- geom/type_sequence.go | 9 +++++++++ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/geom/alg_clip_by_rect.go b/geom/alg_clip_by_rect.go index 485cedb1..9f9715f6 100644 --- a/geom/alg_clip_by_rect.go +++ b/geom/alg_clip_by_rect.go @@ -112,12 +112,3 @@ func clipGeometryCollectionByRect(gc GeometryCollection, rect Envelope) Geometry } return NewGeometryCollection(geoms) } - -// xysToSeq builds a [DimXY] [Sequence] from a slice of [XY]. -func xysToSeq(xys []XY) Sequence { - floats := make([]float64, 0, 2*len(xys)) - for _, xy := range xys { - floats = append(floats, xy.X, xy.Y) - } - return NewSequence(floats, DimXY) -} diff --git a/geom/type_sequence.go b/geom/type_sequence.go index c747a561..e3521f4c 100644 --- a/geom/type_sequence.go +++ b/geom/type_sequence.go @@ -106,6 +106,15 @@ func (s Sequence) asXYs() []XY { return xys } +// xysToSeq builds a [DimXY] [Sequence] from a slice of [XY]. +func xysToSeq(xys []XY) Sequence { + floats := make([]float64, 0, 2*len(xys)) + for _, xy := range xys { + floats = append(floats, xy.X, xy.Y) + } + return NewSequence(floats, DimXY) +} + // Reverse returns a new [Sequence] containing the same point locations, but in // reversed order. func (s Sequence) Reverse() Sequence { From cc7075d872fd6f20be58e630ecdbb8a51762dbfd Mon Sep 17 00:00:00 2001 From: Peter Stace Date: Mon, 25 May 2026 19:07:32 +1000 Subject: [PATCH 32/32] Remove redundant empty-sequence guard in clipLineStringByRect The natural fall-through already handles n == 0: the loop bound n-1 is -1 (Length returns int), so the segment loop is skipped, chains stays empty, and the function returns emptyLine via the existing post-loop check. --- geom/alg_clip_by_rect_liang_barsky.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/geom/alg_clip_by_rect_liang_barsky.go b/geom/alg_clip_by_rect_liang_barsky.go index b4bec917..f5fd96a9 100644 --- a/geom/alg_clip_by_rect_liang_barsky.go +++ b/geom/alg_clip_by_rect_liang_barsky.go @@ -5,9 +5,6 @@ func clipLineStringByRect(ls LineString, rect Envelope) Geometry { seq := ls.Coordinates() n := seq.Length() - if n == 0 { - return emptyLine - } lo, hi, ok := rect.MinMaxXYs() if !ok {