Passed
Push — master ( 1ff697...8a7137 )
by Swen
03:35
created

Polygon::numInteriorRings()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 1
c 0
b 0
f 0
dl 0
loc 3
rs 10
cc 2
nc 2
nop 0
1
<?php
2
3
namespace geoPHP\Geometry;
4
5
use geoPHP\Exception\InvalidGeometryException;
6
use geoPHP\geoPHP;
7
8
/**
9
 * Polygon: A polygon is a plane figure that is bounded by a closed path,
10
 * composed of a finite sequence of straight line segments.
11
 *
12
 * @property LineString[] $components
13
 * @method   LineString[] getComponents()
14
 * @package GeoPHPGeometry
15
 */
16
class Polygon extends Surface
17
{
18
19
    /**
20
     * @param  LineString[] $components
21
     * @param  bool         $forceCreate force creation even if polygon is invalid because it is not closed.
22
     *                      Default false.
23
     * @throws InvalidGeometryException
24
     */
25
    public function __construct(array $components = [], bool $forceCreate = false)
26
    {
27
        parent::__construct($components, true, LineString::class);
28
29
        foreach ($this->getComponents() as $i => $component) {
30
            if ($component->numPoints() < 4) {
31
                throw new InvalidGeometryException(
32
                    'Cannot create Polygon: Invalid number of points in LinearRing. Found ' .
33
                    $component->numPoints() . ', but expected more than 3.'
34
                );
35
            }
36
            if (!$component->isClosed()) {
37
                if ($forceCreate) {
38
                    $this->components[$i] = new LineString(
39
                        array_merge($component->getComponents(), [$component->startPoint()])
40
                    );
41
                } else {
42
                    $startPt = $component->startPoint();
43
                    $endPt = $component->endPoint();
44
                    throw new InvalidGeometryException(
45
                        'Cannot create Polygon: contains a non-closed ring (first point: '
46
                            . implode(' ', $startPt->asArray()) . ', last point: '
47
                            . implode(' ', $endPt->asArray()) . ')'
48
                    );
49
                }
50
            }
51
            // This check is tooo expensive
52
            //if (!$component->isSimple() && !$forceCreate) {
53
            //    throw new \Exception('Cannot create Polygon: geometry should be simple');
54
            //}
55
        }
56
    }
57
58
    /**
59
     * @return string "Polygon"
60
     */
61
    public function geometryType(): string
62
    {
63
        return Geometry::POLYGON;
64
    }
65
66
    /**
67
     * @return int 2
68
     */
69
    public function dimension(): int
70
    {
71
        return 2;
72
    }
73
74
    /**
75
     * @param bool|false $exteriorOnly Calculate the area of exterior ring only, or the polygon with holes
76
     * @param bool|false $signed       Usually we want to get positive area, but vertices order (CW or CCW) can be
77
     *                                 determined from signed area.
78
     *
79
     * @return float
80
     */
81
    public function getArea(bool $exteriorOnly = false, bool $signed = false): float
82
    {
83
        if ($this->isEmpty()) {
84
            return 0.0;
85
        }
86
87
        $geosObj = $this->getGeos();
88
        if (is_object($geosObj) && $exteriorOnly === false) {
89
            // @codeCoverageIgnoreStart
90
            /** @noinspection PhpUndefinedMethodInspection */
91
            return (float) $geosObj->area();
92
            // @codeCoverageIgnoreEnd
93
        }
94
95
        $exteriorRing = $this->components[0];
96
        $points = $exteriorRing->getComponents();
97
98
        $numPoints = count($points);
99
        if ($numPoints === 0) {
100
            return 0.0;
101
        }
102
        $a = 0;
103
        foreach ($points as $k => $p) {
104
            $j = ($k + 1) % $numPoints;
105
            $a = $a + ($p->getX() * $points[$j]->getY()) - ($p->getY() * $points[$j]->getX());
106
        }
107
108
        $area = $signed ? ($a / 2) : abs(($a / 2));
109
110
        if ($exteriorOnly === true) {
111
            return (float) $area;
112
        }
113
        foreach ($this->components as $delta => $component) {
114
            if ($delta != 0) {
115
                $innerPoly = new Polygon([$component]);
116
                $area -= $innerPoly->getArea();
117
            }
118
        }
119
        return (float) $area;
120
    }
121
122
    /**
123
     * @return Point
124
     */
125
    public function getCentroid(): Point
126
    {
127
        if ($this->isEmpty()) {
128
            return new Point();
129
        }
130
131
        $geosObj = $this->getGeos();
132
        if (is_object($geosObj)) {
133
            // @codeCoverageIgnoreStart
134
            /** @noinspection PhpUndefinedMethodInspection */
135
            /** @var Point|null $geometry */
136
            $geometry = geoPHP::geosToGeometry($geosObj->centroid());
137
            return $geometry !== null ? $geometry : new Point();
138
            // @codeCoverageIgnoreEnd
139
        }
140
141
        $x = 0;
142
        $y = 0;
143
        $totalArea = 0.0;
144
        foreach ($this->getComponents() as $i => $component) {
145
            $ca = $this->getRingCentroidAndArea($component);
146
            if ($i === 0) {
147
                $totalArea += $ca['area'];
148
                $x += $ca['x'] * $ca['area'];
149
                $y += $ca['y'] * $ca['area'];
150
            } else {
151
                $totalArea -= $ca['area'];
152
                $x -= $ca['x'] * $ca['area'];
153
                $y -= $ca['y'] * $ca['area'];
154
            }
155
        }
156
157
        return $totalArea !== 0.0 ? new Point($x / $totalArea, $y / $totalArea) : new Point();
158
    }
159
160
    /**
161
     * @param  LineString $ring
162
     * @return array<string, int|float|null>
163
     */
164
    protected function getRingCentroidAndArea(LineString $ring): array
165
    {
166
        $area = (new Polygon([$ring]))->getArea(true, true);
167
168
        $points = $ring->getPoints();
169
        $numPoints = count($points);
170
        if ($numPoints === 0 || $area == 0.0) {
171
            return ['area' => 0.0, 'x' => null, 'y' => null];
172
        }
173
        $x = 0;
174
        $y = 0;
175
        foreach ($points as $k => $point) {
176
            $j = ($k + 1) % $numPoints;
177
            $P = ($point->getX() * $points[$j]->getY()) - ($point->getY() * $points[$j]->getX());
178
            $x += ($point->getX() + $points[$j]->getX()) * $P;
179
            $y += ($point->getY() + $points[$j]->getY()) * $P;
180
        }
181
        
182
        return [
183
            'area' => abs($area),
184
            'x' => $x / (6 * $area),
185
            'y' => $y / (6 * $area)
186
        ];
187
    }
188
189
    /**
190
     * Find the outermost point from the centroid
191
     *
192
     * @return Point the outermost point
193
     */
194
    public function outermostPoint(): Point
195
    {
196
        $centroid = $this->getCentroid();
197
        if ($centroid->isEmpty()) {
198
            return $centroid;
199
        }
200
201
        $maxDistance = 0;
202
        $maxPoint = new Point;
203
204
        foreach ($this->exteriorRing()->getPoints() as $point) {
205
            $distance = $centroid->distance($point);
206
207
            if ($distance > $maxDistance) {
208
                $maxDistance = $distance;
209
                $maxPoint = $point;
210
            }
211
        }
212
213
        return $maxPoint;
214
    }
215
216
    /**
217
     * @return LineString
218
     */
219
    public function exteriorRing()
220
    {
221
        if ($this->isEmpty()) {
222
            return new LineString();
223
        }
224
        return $this->components[0];
225
    }
226
227
    /**
228
     * @return int
229
     */
230
    public function numInteriorRings(): int
231
    {
232
        return $this->isEmpty() ? 0 : $this->numGeometries() - 1;
233
    }
234
235
    /**
236
     * Returns the linestring for the nth interior ring of the polygon. Interior rings are holes in the polygon.
237
     *
238
     * @param  int $n 1-based geometry number
239
     * @return Geometry|null
240
     */
241
    public function interiorRingN(int $n)
242
    {
243
        return $n > $this->numInteriorRings() ? new LineString : $this->geometryN($n + 1);
244
    }
245
246
    /**
247
     * @return bool
248
     */
249
    public function isSimple(): bool
250
    {
251
        $geosObj = $this->getGeos();
252
        if (is_object($geosObj)) {
253
            // @codeCoverageIgnoreStart
254
            /** @noinspection PhpUndefinedMethodInspection */
255
            return $geosObj->isSimple();
256
            // @codeCoverageIgnoreEnd
257
        }
258
259
        /** @var array<array> $segments */
260
        $segments = $this->explode(true);
261
        
262
        //TODO: instead of this O(n^2) algorithm implement Shamos-Hoey Algorithm which is only O(n*log(n))
263
        foreach ($segments as $i => $segment) {
264
            foreach ($segments as $j => $checkSegment) {
265
                if ($i != $j) {
266
                    if (Geometry::segmentIntersects($segment[0], $segment[1], $checkSegment[0], $checkSegment[1])) {
267
                        return false;
268
                    }
269
                }
270
            }
271
        }
272
        
273
        return true;
274
    }
275
    
276
    /**
277
     * If GEOS is not available, it is still a quite simple test of validity for polygons.
278
     * E.g. a test for self-intersections is missing.
279
     *
280
     * @return bool
281
     */
282
    public function isValid(): bool
283
    {
284
        $geosObj = $this->getGeos();
285
        if (is_object($geosObj)) {
286
            // @codeCoverageIgnoreStart
287
            /** @noinspection PhpUndefinedMethodInspection */
288
            return $geosObj->checkValidity()['valid'];
289
            // @codeCoverageIgnoreEnd
290
        }
291
        
292
        // all rings (LineStrings) have to be valid itself
293
        /** @var \geoPHP\Geometry\LineString $ring */
294
        foreach ($this->components as $ring) {
295
            if ($ring->isEmpty()) {
296
                continue;
297
            }
298
            if (!$ring->isValid()) {
299
                return false;
300
            }
301
            /** @var string $wkt */
302
            $wkt = str_ireplace(['LINESTRING(', ')'], '', $ring->asText());
303
            $pts = array_unique(array_map('trim', explode(',', $wkt)));
304
            if (count($pts) < 3) {
305
                return false;
306
            }
307
        }
308
        
309
        return $this->isSimple();
310
    }
311
312
    /**
313
     * For a given point, determine whether it's bounded by the given polygon.
314
     * Adapted from @source http://www.assemblysys.com/dataServices/php_pointinpolygon.php
315
     *
316
     * @see http://en.wikipedia.org/wiki/Point%5Fin%5Fpolygon
317
     *
318
     * @param  Point   $point
319
     * @param  boolean $pointOnBoundary - whether a boundary should be considered "in" or not
320
     * @param  boolean $pointOnVertex   - whether a vertex should be considered "in" or not
321
     * @return bool
322
     */
323
    public function pointInPolygon(Point $point, bool $pointOnBoundary = true, bool $pointOnVertex = true): bool
324
    {
325
        // Check if the point sits exactly on a vertex
326
        if ($this->pointOnVertex($point)) {
327
            return $pointOnVertex;
328
        }
329
330
        // Check if the point is inside the polygon or on the boundary
331
        $vertices = $this->getPoints();
332
        $intersections = 0;
333
        $verticesCount = count($vertices);
334
        for ($i = 1; $i < $verticesCount; ++$i) {
335
            $vertex1 = $vertices[$i - 1];
336
            $vertex2 = $vertices[$i];
337
            if ($vertex1->getY() == $vertex2->getY()
338
                && $vertex1->getY() == $point->getY()
339
                && $point->getX() > min($vertex1->getX(), $vertex2->getX())
340
                && $point->getX() < max($vertex1->getX(), $vertex2->getX())
341
            ) {
342
                // Check if point is on an horizontal polygon boundary
343
                return $pointOnBoundary;
344
            }
345
            if ($point->getY() > min($vertex1->getY(), $vertex2->getY())
346
                && $point->getY() <= max($vertex1->getY(), $vertex2->getY())
347
                && $point->getX() <= max($vertex1->getX(), $vertex2->getX())
348
                && $vertex1->getY() != $vertex2->getY()
349
            ) {
350
                $xinters =
351
                        ($point->getY() - $vertex1->getY()) * ($vertex2->getX() - $vertex1->getX())
352
                        / ($vertex2->getY() - $vertex1->getY())
353
                        + $vertex1->getX();
354
                if ($xinters == $point->getX()) {
355
                    // Check if point is on the polygon boundary (other than horizontal)
356
                    return $pointOnBoundary;
357
                }
358
                if ($vertex1->getX() == $vertex2->getX() || $point->getX() <= $xinters) {
359
                    $intersections++;
360
                }
361
            }
362
        }
363
        
364
        // If the number of edges we passed through is even, then it's in the polygon.
365
        return ($intersections % 2 != 0);
366
    }
367
368
    /**
369
     * @param  Point $point
370
     * @return bool
371
     */
372
    public function pointOnVertex(Point $point): bool
373
    {
374
        foreach ($this->getPoints() as $vertex) {
375
            if ($point->equals($vertex)) {
376
                return true;
377
            }
378
        }
379
        return false;
380
    }
381
382
    /**
383
     * Checks whether the given geometry is spatially inside the Polygon
384
     * TODO: rewrite this. Currently supports point, linestring and polygon with only outer ring
385
     *
386
     * @param  Geometry $geometry
387
     * @return bool
388
     */
389
    public function contains(Geometry $geometry): bool
390
    {
391
        $geosObj = $this->getGeos();
392
        if (is_object($geosObj)) {
393
            // @codeCoverageIgnoreStart
394
            /** @noinspection PhpUndefinedMethodInspection */
395
            $geosObj2 = $geometry->getGeos();
396
            return $geosObj2 !== false ? $geosObj->contains($geosObj2) : false;
397
            // @codeCoverageIgnoreEnd
398
        }
399
400
        $isInside = false;
401
        foreach ($geometry->getPoints() as $p) {
402
            if ($this->pointInPolygon($p)) {
403
                $isInside = true; // at least one point of the innerPoly is inside the outerPoly
404
                break;
405
            }
406
        }
407
        if (!$isInside) {
408
            return false;
409
        }
410
411
        if ($geometry->geometryType() === Geometry::LINESTRING) {
412
            // do nothing
413
        } elseif ($geometry->geometryType() === Geometry::POLYGON) {
414
            $geometry = $geometry->exteriorRing();
415
        } else {
416
            return false;
417
        }
418
419
        /** @var Point[] $innerEdge */
420
        foreach ($geometry->explode(true) as $innerEdge) {
421
            /** @var array<array> $outerRing */
422
            $outerRing = $this->exteriorRing()->explode(true);
423
            foreach ($outerRing as $outerEdge) {
424
                if (Geometry::segmentIntersects($innerEdge[0], $innerEdge[1], $outerEdge[0], $outerEdge[1])) {
425
                    return false;
426
                }
427
            }
428
        }
429
430
        return true;
431
    }
432
433
    /**
434
     * @return array{'minx'?:float|null, 'miny'?:float|null, 'maxx'?:float|null, 'maxy'?:float|null}
435
     */
436
    public function getBBox(): array
437
    {
438
        return $this->exteriorRing()->getBBox();
439
    }
440
441
    /**
442
     * @return LineString|MultiLineString
443
     */
444
    public function boundary(): Geometry
445
    {
446
        $rings = $this->getComponents();
447
        return count($rings) > 0 ? new MultiLineString($rings) : new LineString($rings);
448
    }
449
}
450