Skip to content

Commit e222a10

Browse files
committed
feat: Adds 'Cookie' method to cookie cut one geometry from another.
1 parent 1e4d216 commit e222a10

File tree

4 files changed

+572
-0
lines changed

4 files changed

+572
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ The following generative and manipulation functions are currently implemented:
133133
-`bboxClip` - Clips a feature to the bbox
134134
-`bboxPolygon` - Takes a bbox and returns an equivalent polygon.
135135
-`circle` - Calculates a circle of a given radius around a center point
136+
-`cookie` - Cuts a Feature/FeatureCollection of Polygons or Multipolygons by another like a cookie cutter
136137
-`destination` - Calculate the location of a destination point from an origin point
137138
-`difference` - Finds the difference between multiple polygons by clipping the subsequent
138139
polygons from the first

src/Packages/Cookie.php

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Turf\Packages;
6+
7+
use GeoJson\Feature\Feature;
8+
use GeoJson\Feature\FeatureCollection;
9+
use GeoJson\Geometry\MultiPolygon;
10+
use GeoJson\Geometry\Polygon;
11+
use Polyclip\Clipper;
12+
use Turf\Turf;
13+
14+
class Cookie
15+
{
16+
public function __invoke(
17+
Feature|FeatureCollection|Polygon|MultiPolygon $source,
18+
Polygon|MultiPolygon $cutter,
19+
bool $containedOnly = false
20+
): FeatureCollection {
21+
// Pre-compute cutter bounding box for spatial filtering
22+
$cutterBbox = Turf::bbox($cutter);
23+
24+
// Collect all features to process
25+
$sourceFeatures = [];
26+
27+
if ($source instanceof FeatureCollection) {
28+
$sourceFeatures = $source->getFeatures();
29+
} elseif ($source instanceof Feature) {
30+
$sourceFeatures = [$source];
31+
} else {
32+
// It's a Polygon or MultiPolygon, wrap it in a Feature
33+
$sourceFeatures = [new Feature($source)];
34+
}
35+
36+
$clippedFeatures = [];
37+
38+
foreach ($sourceFeatures as $feature) {
39+
$geometry = $feature->getGeometry();
40+
41+
// Only process polygon-like geometries
42+
if (! ($geometry instanceof Polygon) && ! ($geometry instanceof MultiPolygon)) {
43+
continue;
44+
}
45+
46+
// Early spatial filtering - skip if bounding boxes don't intersect
47+
$featureBbox = Turf::bbox($geometry);
48+
if (! $this->bboxesIntersect($featureBbox, $cutterBbox)) {
49+
continue;
50+
}
51+
52+
// Check if feature is fully contained within cutter
53+
$isFullyContained = $this->isFullyContained($featureBbox, $cutterBbox, $geometry, $cutter);
54+
55+
if ($containedOnly) {
56+
// Only include fully contained features
57+
if ($isFullyContained) {
58+
// Use rewind to ensure consistent winding without expensive clipping
59+
$rewindedGeometry = Turf::rewind($geometry);
60+
$clippedFeatures[] = new Feature(
61+
$rewindedGeometry,
62+
$feature->getProperties(),
63+
$feature->getId()
64+
);
65+
}
66+
continue;
67+
}
68+
69+
// Normal mode: include both contained and intersecting features
70+
if ($isFullyContained) {
71+
// Optimization: pass through contained features with just winding normalization
72+
$rewindedGeometry = Turf::rewind($geometry);
73+
$clippedFeatures[] = new Feature(
74+
$rewindedGeometry,
75+
$feature->getProperties(),
76+
$feature->getId()
77+
);
78+
continue;
79+
}
80+
81+
// Compute intersection for partially intersecting features
82+
$intersection = Clipper::intersection($geometry, $cutter);
83+
$intersectionGeometry = $intersection->getGeometry();
84+
if ($intersectionGeometry === null) {
85+
continue;
86+
}
87+
88+
if ($intersectionGeometry instanceof Polygon || $intersectionGeometry instanceof MultiPolygon) {
89+
// Basic validation - just check that coordinates exist
90+
$coords = $intersectionGeometry->getCoordinates();
91+
if (!empty($coords)) {
92+
$clippedFeatures[] = new Feature(
93+
$intersectionGeometry,
94+
$feature->getProperties(),
95+
$feature->getId()
96+
);
97+
}
98+
}
99+
}
100+
101+
return new FeatureCollection($clippedFeatures);
102+
}
103+
104+
/**
105+
* Fast bounding box intersection test
106+
*
107+
* @param float[] $bbox1 [minX, minY, maxX, maxY]
108+
* @param float[] $bbox2 [minX, minY, maxX, maxY]
109+
*/
110+
private function bboxesIntersect(array $bbox1, array $bbox2): bool
111+
{
112+
return !($bbox1[2] < $bbox2[0] || // bbox1.maxX < bbox2.minX
113+
$bbox1[0] > $bbox2[2] || // bbox1.minX > bbox2.maxX
114+
$bbox1[3] < $bbox2[1] || // bbox1.maxY < bbox2.minY
115+
$bbox1[1] > $bbox2[3]); // bbox1.minY > bbox2.maxY
116+
}
117+
118+
/**
119+
* Check if a feature is fully contained within the cutter
120+
* Uses fast bbox check first, then more expensive geometry check if needed
121+
*
122+
* @param float[] $featureBbox
123+
* @param float[] $cutterBbox
124+
*/
125+
private function isFullyContained(
126+
array $featureBbox,
127+
array $cutterBbox,
128+
Polygon|MultiPolygon $geometry,
129+
Polygon|MultiPolygon $cutter
130+
): bool {
131+
// Fast bbox containment check first
132+
if (!$this->bboxContains($cutterBbox, $featureBbox)) {
133+
return false;
134+
}
135+
136+
// Be conservative: only optimize for simple rectangular cases
137+
// For single polygon cutter without holes AND the cutter is rectangular, bbox containment is sufficient
138+
if ($cutter instanceof Polygon && count($cutter->getCoordinates()) === 1) {
139+
$cutterRing = $cutter->getCoordinates()[0];
140+
// Check if cutter is axis-aligned rectangle (4 or 5 points - last point might be duplicate)
141+
$ringSize = count($cutterRing);
142+
if (($ringSize === 4 || $ringSize === 5) && $this->isAxisAlignedRectangle($cutterRing)) {
143+
return true; // Feature bbox is contained and cutter is simple rectangle
144+
}
145+
}
146+
147+
// For complex cases (MultiPolygon, Polygon with holes, or non-rectangular polygons),
148+
// skip optimization - let intersection handle it correctly
149+
return false;
150+
}
151+
152+
/**
153+
* Check if bbox1 fully contains bbox2
154+
*
155+
* @param float[] $bbox1 [minX, minY, maxX, maxY] - container
156+
* @param float[] $bbox2 [minX, minY, maxX, maxY] - contained
157+
*/
158+
private function bboxContains(array $bbox1, array $bbox2): bool
159+
{
160+
return $bbox1[0] <= $bbox2[0] && // container.minX <= contained.minX
161+
$bbox1[1] <= $bbox2[1] && // container.minY <= contained.minY
162+
$bbox1[2] >= $bbox2[2] && // container.maxX >= contained.maxX
163+
$bbox1[3] >= $bbox2[3]; // container.maxY >= contained.maxY
164+
}
165+
166+
/**
167+
* Check if a ring represents an axis-aligned rectangle
168+
*
169+
* @param float[][] $ring
170+
*/
171+
private function isAxisAlignedRectangle(array $ring): bool
172+
{
173+
// Remove duplicate closing point if present
174+
$points = count($ring) === 5 && $ring[0] === $ring[4] ? array_slice($ring, 0, 4) : $ring;
175+
176+
if (count($points) !== 4) {
177+
return false;
178+
}
179+
180+
// Check if all points form axis-aligned rectangle
181+
// Extract unique X and Y coordinates
182+
$xCoords = array_unique(array_column($points, 0));
183+
$yCoords = array_unique(array_column($points, 1));
184+
185+
// Should have exactly 2 unique X and 2 unique Y coordinates
186+
return count($xCoords) === 2 && count($yCoords) === 2;
187+
}
188+
189+
}

src/Turf.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
use Turf\Packages\Distance;
4343
use Turf\Packages\Envelope;
4444
use Turf\Packages\Kinks;
45+
use Turf\Packages\Cookie;
4546
use Turf\Packages\RectangleGrid;
4647
use Turf\Packages\Rewind;
4748
use Turf\Packages\Simplify;
@@ -300,6 +301,20 @@ public static function kinks(
300301
return (new Kinks)($geoJSON);
301302
}
302303

304+
/**
305+
* Clips input geometries using a cookie cutter polygon or multipolygon, returning only the intersecting parts.
306+
* Works like a cookie cutter, removing any geometry outside the cutter's outer rings or inside holes.
307+
*
308+
* @param bool $containedOnly If true, only returns features that are fully contained within the cutter
309+
*/
310+
public static function cookie(
311+
Feature|FeatureCollection|Polygon|MultiPolygon $source,
312+
Polygon|MultiPolygon $cutter,
313+
bool $containedOnly = false
314+
): FeatureCollection {
315+
return (new Cookie)($source, $cutter, $containedOnly);
316+
}
317+
303318
/**
304319
* Rewinds a polygon or multipolygon.
305320
*/

0 commit comments

Comments
 (0)