diff --git a/docs/strategies.md b/docs/strategies.md index 2d81f9d..1dae54c 100644 --- a/docs/strategies.md +++ b/docs/strategies.md @@ -13,7 +13,7 @@ This table describes the capabilities of the repository's current implementation | Class. | Output | SRID in output | Dimensions written | Usage | | --------------------- | ------------------------------- | ----------------- | ------------------ | ---------------------------------------------------------------- | -| `GeoJsonStrategy` | GeoJSON geometry text | No | XY, XYZ | Point, LineString, MultiPoint and MultiLineString encoding according to RFC 7946 | +| `GeoJsonStrategy` | GeoJSON geometry text | No | XY, XYZ | Point, LineString, MultiPoint, MultiLineString and Polygon encoding according to RFC 7946 | | `WktTextStrategy` | WKT Well Known Text | No | XY, XYZ, XYM, XYZM | Display, text exchanges, functions accepting WKT | | `EwktTextStrategy` | EWKT Extended Well Known Text. | Yes, when nonzero | XY, XYZ, XYM, XYZM | Text exchanges that include the spatial reference | | `WkbBinaryStrategy` | ISO WKB Well Known Binary. | No | XY, XYZ, XYM, XYZM | Exchanges with a standard WKB consumer | @@ -235,9 +235,10 @@ and the [testing guide](../tests/README.md). To add a format, implement ## GeoJSON — `GeoJsonStrategy` -This increment supports Geometry and Geography Points, LineStrings, MultiPoints and MultiLineStrings in XY and -XYZ, including EMPTY. Other geometry types are not yet supported. Feature and FeatureCollection -composition belongs to the consuming application. +This increment supports Geometry and Geography Points, LineStrings, MultiPoints, +MultiLineStrings and Polygons in XY and XYZ, including EMPTY. Other geometry +types are not yet supported. Feature and FeatureCollection composition belongs +to the consuming application. ```php $writer->setStrategy(new \LongitudeOne\SpatialWriter\Strategy\GeoJsonStrategy()); @@ -342,6 +343,44 @@ Antimeridian-crossing members are preserved unchanged under the deliberate non-application of RFC 7946 section 3.1.9 described above. The same caller responsibilities, reference omission and JSON failure contract apply. +Polygons preserve exterior and interior ring order, all positions and Z: + +```php +$polygon = new \LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\Polygon([ + [[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]], + [[1, 1], [1, 2], [2, 2], [2, 1], [1, 1]], +]); +echo $writer->convert($polygon); +// {"type":"Polygon","coordinates":[[[0,0],[4,0],[4,4],[0,4],[0,0]],[[1,1],[1,2],[2,2],[2,1],[1,1]]]} +``` + +EMPTY XY and XYZ Polygons produce `{"type":"Polygon","coordinates":[]}`. +XYM and XYZM are rejected even when EMPTY. The same reference omission and JSON +failure contracts apply to Polygons. + +Every exterior ring must have strictly positive signed XY area (counterclockwise) +and every interior ring strictly negative signed XY area (clockwise). Incompatible +orientation, including zero signed area, throws +`UnsupportedGeometryStructureException`. The writer never reverses, closes or +repairs a ring and never returns partial output. Z is preserved but does not +participate in the orientation calculation. The signed-area algorithm and +zero-area rejection are the [approved LongitudeOne encoding policy](https://github.com/longitude-one/spatial-writer/issues/13#issuecomment-5765188095); +RFC 7946 section 3.1.6 requires winding but does not prescribe that algorithm or +explicitly prescribe zero-area rejection. + +The installed model already enforces at least four positions, closure and the +absence of consecutive duplicate positions. It permits collinear and crossed +rings. The writer rejects a crossed ring if its signed area is zero, but does not +separately check simplicity, self-intersections, hole containment or other topology. +A nonzero signed area is not proof of topological validity; the caller remains +responsible for that validation. + +Antimeridian-crossing Polygon coordinates are preserved without unwrapping, +detection for rejection or cutting; orientation uses the supplied XY coordinates. +This deliberately does not apply the SHOULD in RFC 7946 section 3.1.9. Consumers +may display an uncut polygon across the long way around the globe, so callers +must prepare any required cutting before encoding. + The approved contracts for later geometry contributions preserve nested, singleton and homogeneous GeometryCollections. These deliberately do not apply the SHOULD guidance of RFC 7946 section 3.1.8. Consumers may have limited support @@ -352,5 +391,6 @@ this increment. References: [RFC 7946 geometry objects and positions](https://www.rfc-editor.org/rfc/rfc7946.html#section-3.1), [MultiPoint positions](https://www.rfc-editor.org/rfc/rfc7946.html#section-3.1.3), [MultiLineString members](https://www.rfc-editor.org/rfc/rfc7946.html#section-3.1.5), +[Polygon rings](https://www.rfc-editor.org/rfc/rfc7946.html#section-3.1.6), [coordinate reference system](https://www.rfc-editor.org/rfc/rfc7946.html#section-4), [non-extensible types](https://www.rfc-editor.org/rfc/rfc7946.html#section-7). diff --git a/lib/LongitudeOne/SpatialWriter/Strategy/GeoJson/GeometryEncoder.php b/lib/LongitudeOne/SpatialWriter/Strategy/GeoJson/GeometryEncoder.php new file mode 100644 index 0000000..da3b707 --- /dev/null +++ b/lib/LongitudeOne/SpatialWriter/Strategy/GeoJson/GeometryEncoder.php @@ -0,0 +1,182 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialWriter\Strategy\GeoJson; + +use LongitudeOne\Core\Enum\GeometryTypeEnum; +use LongitudeOne\SpatialTypes\Interfaces\LineStringInterface; +use LongitudeOne\SpatialTypes\Interfaces\MultiLineStringInterface; +use LongitudeOne\SpatialTypes\Interfaces\MultiPointInterface; +use LongitudeOne\SpatialTypes\Interfaces\PointInterface; +use LongitudeOne\SpatialTypes\Interfaces\PolygonInterface; +use LongitudeOne\SpatialTypes\Interfaces\SpatialInterface; +use LongitudeOne\SpatialWriter\Exception\UnsupportedGeometryStructureException; +use LongitudeOne\SpatialWriter\Exception\UnsupportedSpatialInterfaceException; +use LongitudeOne\SpatialWriter\Exception\UnsupportedSpatialTypeException; + +/** + * Builds GeoJSON geometry objects without changing their coordinates. + * + * @internal + */ +final class GeometryEncoder +{ + /** + * Encode a supported geometry using its corresponding spatial interface. + * + * @param SpatialInterface $spatial the geometry to encode + * + * @return array{type: string, coordinates: array} + */ + public function encode(SpatialInterface $spatial): array + { + return match ($spatial->getType()) { + GeometryTypeEnum::POINT => $this->encodePoint($spatial), + GeometryTypeEnum::LINESTRING => $this->encodeLineString($spatial), + GeometryTypeEnum::MULTIPOINT => $this->encodeMultiPoint($spatial), + GeometryTypeEnum::MULTILINESTRING => $this->encodeMultiLineString($spatial), + GeometryTypeEnum::POLYGON => $this->encodePolygon($spatial), + default => throw new UnsupportedSpatialTypeException('This GeoJSON strategy does not support '.$spatial->getType()->name.'.'), + }; + } + + /** + * Preserve a line's positions, allowing EMPTY but rejecting singleton lines. + * + * @param SpatialInterface $spatial the line string to encode + * + * @return array{type: string, coordinates: (float|int)[][]} + */ + private function encodeLineString(SpatialInterface $spatial): array + { + if (!$spatial instanceof LineStringInterface) { + throw new UnsupportedSpatialInterfaceException($spatial::class); + } + + $coordinates = $spatial->toArray(); + if (1 === count($coordinates)) { + throw new UnsupportedGeometryStructureException('A non-empty GeoJSON LineString requires at least two positions.'); + } + + return ['type' => 'LineString', 'coordinates' => $coordinates]; + } + + /** + * Preserve line membership and order, rejecting EMPTY and singleton members. + * + * @param SpatialInterface $spatial the multi-line string to encode + * + * @return array{type: string, coordinates: (float|int)[][][]} + */ + private function encodeMultiLineString(SpatialInterface $spatial): array + { + if (!$spatial instanceof MultiLineStringInterface) { + throw new UnsupportedSpatialInterfaceException($spatial::class); + } + + $coordinates = $spatial->toArray(); + foreach ($coordinates as $line) { + if (count($line) < 2) { + throw new UnsupportedGeometryStructureException('Every GeoJSON MultiLineString member requires at least two positions.'); + } + } + + return ['type' => 'MultiLineString', 'coordinates' => $coordinates]; + } + + /** + * Preserve all positions, rejecting EMPTY members without omitting them. + * + * @param SpatialInterface $spatial the multi-point to encode + * + * @return array{type: string, coordinates: (float|int)[][]} + */ + private function encodeMultiPoint(SpatialInterface $spatial): array + { + if (!$spatial instanceof MultiPointInterface) { + throw new UnsupportedSpatialInterfaceException($spatial::class); + } + + $coordinates = $spatial->toArray(); + if (in_array([], $coordinates, true)) { + throw new UnsupportedGeometryStructureException('A GeoJSON MultiPoint cannot contain an EMPTY Point.'); + } + + return ['type' => 'MultiPoint', 'coordinates' => $coordinates]; + } + + /** + * Preserve a point's coordinates. + * + * @param SpatialInterface $spatial the point to encode + * + * @return array{type: string, coordinates: (float|int)[]} + */ + private function encodePoint(SpatialInterface $spatial): array + { + if (!$spatial instanceof PointInterface) { + throw new UnsupportedSpatialInterfaceException($spatial::class); + } + + return ['type' => 'Point', 'coordinates' => $spatial->toArray()]; + } + + /** + * Preserve every ring and ordinate after checking the approved XY orientation. + * + * @param SpatialInterface $spatial the polygon to encode + * + * @return array{type: string, coordinates: (float|int)[][][]} + */ + private function encodePolygon(SpatialInterface $spatial): array + { + if (!$spatial instanceof PolygonInterface) { + throw new UnsupportedSpatialInterfaceException($spatial::class); + } + + $coordinates = $spatial->toArray(); + foreach ($coordinates as $index => $ring) { + $area = $this->signedRingArea($ring); + if (0.0 === $area || (0 === $index && $area < 0) || (0 !== $index && $area > 0)) { + throw new UnsupportedGeometryStructureException('GeoJSON requires counterclockwise exterior rings and clockwise interior rings with nonzero XY signed area.'); + } + } + + return ['type' => 'Polygon', 'coordinates' => $coordinates]; + } + + /** + * Compute twice the signed XY area, translated to the first vertex for precision. + * + * The model already enforces ring length and closure. This is not a + * simplicity or topology check. Non-finite ordinates retain the JSON failure path. + * + * @param (float|int)[][] $ring the positions of a closed ring + */ + private function signedRingArea(array $ring): float + { + $area = 0.0; + $origin = $ring[0]; + $previous = $origin; + foreach ($ring as $position) { + $area += ($previous[0] - $origin[0]) * ($position[1] - $origin[1]) + - ($position[0] - $origin[0]) * ($previous[1] - $origin[1]); + $previous = $position; + } + + return $area; + } +} diff --git a/lib/LongitudeOne/SpatialWriter/Strategy/GeoJsonStrategy.php b/lib/LongitudeOne/SpatialWriter/Strategy/GeoJsonStrategy.php index 0e6ddf9..1742c82 100644 --- a/lib/LongitudeOne/SpatialWriter/Strategy/GeoJsonStrategy.php +++ b/lib/LongitudeOne/SpatialWriter/Strategy/GeoJsonStrategy.php @@ -16,17 +16,13 @@ namespace LongitudeOne\SpatialWriter\Strategy; -use LongitudeOne\Core\Enum\GeometryTypeEnum; -use LongitudeOne\SpatialTypes\Interfaces\LineStringInterface; -use LongitudeOne\SpatialTypes\Interfaces\MultiLineStringInterface; -use LongitudeOne\SpatialTypes\Interfaces\MultiPointInterface; -use LongitudeOne\SpatialTypes\Interfaces\PointInterface; use LongitudeOne\SpatialTypes\Interfaces\SpatialInterface; use LongitudeOne\SpatialWriter\Exception\JsonEncodingException; use LongitudeOne\SpatialWriter\Exception\UnsupportedDimensionException; use LongitudeOne\SpatialWriter\Exception\UnsupportedGeometryStructureException; use LongitudeOne\SpatialWriter\Exception\UnsupportedSpatialInterfaceException; use LongitudeOne\SpatialWriter\Exception\UnsupportedSpatialTypeException; +use LongitudeOne\SpatialWriter\Strategy\GeoJson\GeometryEncoder; /** * Encodes supported geometries using RFC 7946 without transforming coordinates. @@ -50,13 +46,7 @@ public function executeStrategy(SpatialInterface $spatial): string throw new UnsupportedDimensionException('GeoJSON does not support measured coordinates.'); } - $geometry = match ($spatial->getType()) { - GeometryTypeEnum::POINT => $this->encodePoint($spatial), - GeometryTypeEnum::LINESTRING => $this->encodeLineString($spatial), - GeometryTypeEnum::MULTIPOINT => $this->encodeMultiPoint($spatial), - GeometryTypeEnum::MULTILINESTRING => $this->encodeMultiLineString($spatial), - default => throw new UnsupportedSpatialTypeException('This GeoJSON strategy does not support '.$spatial->getType()->name.'.'), - }; + $geometry = (new GeometryEncoder())->encode($spatial); try { return json_encode($geometry, JSON_THROW_ON_ERROR); @@ -64,85 +54,4 @@ public function executeStrategy(SpatialInterface $spatial): string throw new JsonEncodingException('Unable to encode the geometry as GeoJSON.', previous: $exception); } } - - /** - * Preserve a line's positions, allowing EMPTY but rejecting singleton lines. - * - * @param SpatialInterface $spatial the line string to encode - * - * @return array{type: string, coordinates: (float|int)[][]} - */ - private function encodeLineString(SpatialInterface $spatial): array - { - if (!$spatial instanceof LineStringInterface) { - throw new UnsupportedSpatialInterfaceException($spatial::class); - } - - $coordinates = $spatial->toArray(); - if (1 === count($coordinates)) { - throw new UnsupportedGeometryStructureException('A non-empty GeoJSON LineString requires at least two positions.'); - } - - return ['type' => 'LineString', 'coordinates' => $coordinates]; - } - - /** - * Preserve line membership and order, rejecting EMPTY and singleton members. - * - * @param SpatialInterface $spatial the multi-line string to encode - * - * @return array{type: string, coordinates: (float|int)[][][]} - */ - private function encodeMultiLineString(SpatialInterface $spatial): array - { - if (!$spatial instanceof MultiLineStringInterface) { - throw new UnsupportedSpatialInterfaceException($spatial::class); - } - - $coordinates = $spatial->toArray(); - foreach ($coordinates as $line) { - if (count($line) < 2) { - throw new UnsupportedGeometryStructureException('Every GeoJSON MultiLineString member requires at least two positions.'); - } - } - - return ['type' => 'MultiLineString', 'coordinates' => $coordinates]; - } - - /** - * Preserve all positions, rejecting EMPTY members without omitting them. - * - * @param SpatialInterface $spatial the multi-point to encode - * - * @return array{type: string, coordinates: (float|int)[][]} - */ - private function encodeMultiPoint(SpatialInterface $spatial): array - { - if (!$spatial instanceof MultiPointInterface) { - throw new UnsupportedSpatialInterfaceException($spatial::class); - } - - $coordinates = $spatial->toArray(); - if (in_array([], $coordinates, true)) { - throw new UnsupportedGeometryStructureException('A GeoJSON MultiPoint cannot contain an EMPTY Point.'); - } - - return ['type' => 'MultiPoint', 'coordinates' => $coordinates]; - } - - /** - * Preserve a point's coordinates. - * - * @param SpatialInterface $spatial the point to encode - * - * @return array{type: string, coordinates: (float|int)[]} - */ - private function encodePoint(SpatialInterface $spatial): array - { - if (!$spatial instanceof PointInterface) { - throw new UnsupportedSpatialInterfaceException($spatial::class); - } - - return ['type' => 'Point', 'coordinates' => $spatial->toArray()]; - } } diff --git a/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/ExceptionContractTest.php b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/ExceptionContractTest.php index 2a3533f..20d37b7 100644 --- a/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/ExceptionContractTest.php +++ b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/ExceptionContractTest.php @@ -22,12 +22,14 @@ use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\MultiLineString; use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\MultiPoint; use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\Point; +use LongitudeOne\SpatialTypes\Types\Dimension3z\Geometry\Polygon; use LongitudeOne\SpatialWriter\Exception\ExceptionInterface; use LongitudeOne\SpatialWriter\Exception\JsonEncodingException; use LongitudeOne\SpatialWriter\Exception\UnsupportedDimensionException; use LongitudeOne\SpatialWriter\Exception\UnsupportedGeometryStructureException; use LongitudeOne\SpatialWriter\Exception\UnsupportedSpatialInterfaceException; use LongitudeOne\SpatialWriter\Exception\UnsupportedSpatialTypeException; +use LongitudeOne\SpatialWriter\Strategy\GeoJson\GeometryEncoder; use LongitudeOne\SpatialWriter\Strategy\GeoJsonStrategy; use LongitudeOne\SpatialWriter\Writer; use PHPUnit\Framework\Attributes\CoversClass; @@ -39,6 +41,7 @@ * @internal */ #[CoversClass(GeoJsonStrategy::class)] +#[CoversClass(GeometryEncoder::class)] #[CoversClass(JsonEncodingException::class)] #[CoversClass(UnsupportedGeometryStructureException::class)] #[CoversClass(UnsupportedSpatialTypeException::class)] @@ -149,6 +152,20 @@ public function testNegativeInfinityEncodingFailure(): void } } + /** A real Polygon with non-finite Z uses the ordinary JSON failure path. */ + public function testPolygonEncodingFailure(): void + { + $polygon = new Polygon([[[0, 0, 1], [2, 0, INF], [0, 2, 3], [0, 0, 1]]]); + + try { + (new Writer(new GeoJsonStrategy()))->convert($polygon); + static::fail('JSON encoding must fail.'); + } catch (JsonEncodingException $exception) { + static::assertInstanceOf(\JsonException::class, $exception->getPrevious()); + static::assertSame(JSON_ERROR_INF_OR_NAN, $exception->getPrevious()->getCode()); + } + } + /** Preserve the approved public exception inheritance and constructor contract. */ public function testUnsupportedDimensionExceptionContract(): void { @@ -224,6 +241,17 @@ public function testUnsupportedPointInterfaceThroughWriter(): void $writer->convert($spatial); } + /** Reject a declared Polygon without its corresponding interface. */ + public function testUnsupportedPolygonInterfaceThroughWriter(): void + { + $spatial = static::createStub(SpatialInterface::class); + $spatial->method('getType')->willReturn(GeometryTypeEnum::POLYGON); + $spatial->method('hasM')->willReturn(false); + + $this->expectException(UnsupportedSpatialInterfaceException::class); + (new Writer(new GeoJsonStrategy()))->convert($spatial); + } + /** Preserve the approved public exception inheritance and constructor contract. */ public function testUnsupportedSpatialTypeExceptionContract(): void { diff --git a/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeographyLineStringTest.php b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeographyLineStringTest.php index 62d9711..e078a4b 100644 --- a/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeographyLineStringTest.php +++ b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeographyLineStringTest.php @@ -20,6 +20,7 @@ use LongitudeOne\SpatialTypes\Types\Dimension2\Geography\LineString; use LongitudeOne\SpatialWriter\Exception\UnsupportedDimensionException; use LongitudeOne\SpatialWriter\Exception\UnsupportedGeometryStructureException; +use LongitudeOne\SpatialWriter\Strategy\GeoJson\GeometryEncoder; use LongitudeOne\SpatialWriter\Strategy\GeoJsonStrategy; use LongitudeOne\SpatialWriter\Writer; use PHPUnit\Framework\Attributes\CoversClass; @@ -31,6 +32,7 @@ * @internal */ #[CoversClass(GeoJsonStrategy::class)] +#[CoversClass(GeometryEncoder::class)] #[CoversClass(Writer::class)] class GeographyLineStringTest extends TestCase { diff --git a/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeographyMultiLineStringTest.php b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeographyMultiLineStringTest.php index c19a82e..0517322 100644 --- a/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeographyMultiLineStringTest.php +++ b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeographyMultiLineStringTest.php @@ -21,6 +21,7 @@ use LongitudeOne\SpatialTypes\Types\Dimension2\Geography\MultiLineString; use LongitudeOne\SpatialWriter\Exception\UnsupportedDimensionException; use LongitudeOne\SpatialWriter\Exception\UnsupportedGeometryStructureException; +use LongitudeOne\SpatialWriter\Strategy\GeoJson\GeometryEncoder; use LongitudeOne\SpatialWriter\Strategy\GeoJsonStrategy; use LongitudeOne\SpatialWriter\Writer; use PHPUnit\Framework\Attributes\CoversClass; @@ -32,6 +33,7 @@ * @internal */ #[CoversClass(GeoJsonStrategy::class)] +#[CoversClass(GeometryEncoder::class)] #[CoversClass(Writer::class)] class GeographyMultiLineStringTest extends TestCase { diff --git a/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeographyMultiPointTest.php b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeographyMultiPointTest.php index dc2009c..3685a4d 100644 --- a/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeographyMultiPointTest.php +++ b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeographyMultiPointTest.php @@ -21,6 +21,7 @@ use LongitudeOne\SpatialTypes\Types\Dimension2\Geography\Point; use LongitudeOne\SpatialWriter\Exception\UnsupportedDimensionException; use LongitudeOne\SpatialWriter\Exception\UnsupportedGeometryStructureException; +use LongitudeOne\SpatialWriter\Strategy\GeoJson\GeometryEncoder; use LongitudeOne\SpatialWriter\Strategy\GeoJsonStrategy; use LongitudeOne\SpatialWriter\Writer; use PHPUnit\Framework\Attributes\CoversClass; @@ -32,6 +33,7 @@ * @internal */ #[CoversClass(GeoJsonStrategy::class)] +#[CoversClass(GeometryEncoder::class)] #[CoversClass(Writer::class)] class GeographyMultiPointTest extends TestCase { diff --git a/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeographyPointTest.php b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeographyPointTest.php index baca242..99e24c0 100644 --- a/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeographyPointTest.php +++ b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeographyPointTest.php @@ -21,6 +21,7 @@ use LongitudeOne\SpatialTypes\Types\Dimension2\Geography\Triangle; use LongitudeOne\SpatialWriter\Exception\UnsupportedDimensionException; use LongitudeOne\SpatialWriter\Exception\UnsupportedSpatialTypeException; +use LongitudeOne\SpatialWriter\Strategy\GeoJson\GeometryEncoder; use LongitudeOne\SpatialWriter\Strategy\GeoJsonStrategy; use LongitudeOne\SpatialWriter\Writer; use PHPUnit\Framework\Attributes\CoversClass; @@ -32,6 +33,7 @@ * @internal */ #[CoversClass(GeoJsonStrategy::class)] +#[CoversClass(GeometryEncoder::class)] #[CoversClass(Writer::class)] class GeographyPointTest extends TestCase { diff --git a/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeographyPolygonTest.php b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeographyPolygonTest.php new file mode 100644 index 0000000..190bb02 --- /dev/null +++ b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeographyPolygonTest.php @@ -0,0 +1,205 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialWriter\Tests\Unit\Strategy\GeoJson; + +use LongitudeOne\SpatialTypes\Reference\SpatialReference; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geography\Polygon; +use LongitudeOne\SpatialWriter\Exception\UnsupportedDimensionException; +use LongitudeOne\SpatialWriter\Exception\UnsupportedGeometryStructureException; +use LongitudeOne\SpatialWriter\Strategy\GeoJson\GeometryEncoder; +use LongitudeOne\SpatialWriter\Strategy\GeoJsonStrategy; +use LongitudeOne\SpatialWriter\Writer; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; + +/** + * Explicit Geography Polygon GeoJSON examples. + * + * @internal + */ +#[CoversClass(GeoJsonStrategy::class)] +#[CoversClass(GeometryEncoder::class)] +#[CoversClass(Writer::class)] +class GeographyPolygonTest extends TestCase +{ + /** Keep supplied longitudes without cutting or unwrapping the ring. */ + public function testAntimeridian(): void + { + $polygon = new Polygon([[[170, 40], [170, 50], [-170, 50], [-170, 40], [170, 40]]], 4326); + + static::assertJsonStringEqualsJsonString('{"type":"Polygon","coordinates":[[[170,40],[170,50],[-170,50],[-170,40],[170,40]]]}', (new Writer(new GeoJsonStrategy()))->convert($polygon)); + } + + /** Reject clockwise exteriors without reversing the source ring. */ + public function testClockwiseExterior(): void + { + $polygon = new Polygon([[[0, 0], [0, 2], [2, 0], [0, 0]]]); + + try { + (new Writer(new GeoJsonStrategy()))->convert($polygon); + static::fail('Clockwise exterior must be rejected.'); + } catch (UnsupportedGeometryStructureException) { + static::assertSame([[[0, 0], [0, 2], [2, 0], [0, 0]]], $polygon->toArray()); + } + } + + /** Reject an incompatible hole without returning partial output. */ + public function testCounterclockwiseHole(): void + { + $polygon = new Polygon([[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]], [[1, 1], [2, 1], [2, 2], [1, 2], [1, 1]]]); + + $this->expectException(UnsupportedGeometryStructureException::class); + (new Writer(new GeoJsonStrategy()))->convert($polygon); + } + + /** A positive signed area does not claim or require ring simplicity. */ + public function testCrossedRingWithPositiveArea(): void + { + $polygon = new Polygon([[[0, 0], [3, 3], [0, 3], [2, 0], [0, 0]]]); + + static::assertJsonStringEqualsJsonString('{"type":"Polygon","coordinates":[[[0,0],[3,3],[0,3],[2,0],[0,0]]]}', (new Writer(new GeoJsonStrategy()))->convert($polygon)); + } + + /** EMPTY is an empty coordinates array. */ + public function testEmptyXy(): void + { + $polygon = new Polygon([]); + + static::assertJsonStringEqualsJsonString('{"type":"Polygon","coordinates":[]}', (new Writer(new GeoJsonStrategy()))->convert($polygon)); + } + + /** EMPTY has no artificial dimension marker. */ + public function testEmptyXyz(): void + { + $polygon = new \LongitudeOne\SpatialTypes\Types\Dimension3z\Geography\Polygon([]); + + static::assertJsonStringEqualsJsonString('{"type":"Polygon","coordinates":[]}', (new Writer(new GeoJsonStrategy()))->convert($polygon)); + } + + /** Preserve exterior and interior ring order and winding. */ + public function testHole(): void + { + $polygon = new Polygon([[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]], [[1, 1], [1, 2], [2, 2], [2, 1], [1, 1]]]); + + static::assertJsonStringEqualsJsonString('{"type":"Polygon","coordinates":[[[0,0],[4,0],[4,4],[0,4],[0,0]],[[1,1],[1,2],[2,2],[2,1],[1,1]]]}', (new Writer(new GeoJsonStrategy()))->convert($polygon)); + } + + /** Reject measured dimensions even when EMPTY. */ + public function testRejectEmptyXym(): void + { + $polygon = new \LongitudeOne\SpatialTypes\Types\Dimension3m\Geography\Polygon([]); + + $this->expectException(UnsupportedDimensionException::class); + (new Writer(new GeoJsonStrategy()))->convert($polygon); + } + + /** Reject measured dimensions even when EMPTY. */ + public function testRejectEmptyXyzm(): void + { + $polygon = new \LongitudeOne\SpatialTypes\Types\Dimension4zm\Geography\Polygon([]); + + $this->expectException(UnsupportedDimensionException::class); + (new Writer(new GeoJsonStrategy()))->convert($polygon); + } + + /** A later hole must also be checked before returning any output. */ + public function testRejectLaterCounterclockwiseHole(): void + { + $polygon = new Polygon([ + [[0, 0], [8, 0], [8, 8], [0, 8], [0, 0]], + [[1, 1], [1, 2], [2, 2], [2, 1], [1, 1]], + [[4, 4], [5, 4], [5, 5], [4, 5], [4, 4]], + ]); + + $this->expectException(UnsupportedGeometryStructureException::class); + (new Writer(new GeoJsonStrategy()))->convert($polygon); + } + + /** M must not be serialized as altitude. */ + public function testRejectXym(): void + { + $polygon = new \LongitudeOne\SpatialTypes\Types\Dimension3m\Geography\Polygon([[[0, 0, 3], [2, 0, 3], [0, 2, 3], [0, 0, 3]]]); + + $this->expectException(UnsupportedDimensionException::class); + (new Writer(new GeoJsonStrategy()))->convert($polygon); + } + + /** M must not be discarded. */ + public function testRejectXyzm(): void + { + $polygon = new \LongitudeOne\SpatialTypes\Types\Dimension4zm\Geography\Polygon([[[0, 0, 3, 4], [2, 0, 3, 4], [0, 2, 3, 4], [0, 0, 3, 4]]]); + + $this->expectException(UnsupportedDimensionException::class); + (new Writer(new GeoJsonStrategy()))->convert($polygon); + } + + /** The bow-tie is rejected for zero signed area, without topology analysis. */ + public function testRejectZeroAreaCrossedRing(): void + { + $polygon = new Polygon([[[0, 0], [2, 2], [0, 2], [2, 0], [0, 0]]]); + + $this->expectException(UnsupportedGeometryStructureException::class); + (new Writer(new GeoJsonStrategy()))->convert($polygon); + } + + /** Collinear rings have no winding and are rejected by the approved policy. */ + public function testRejectZeroAreaExterior(): void + { + $polygon = new Polygon([[[0, 0], [1, 0], [2, 0], [0, 0]]]); + + $this->expectException(UnsupportedGeometryStructureException::class); + (new Writer(new GeoJsonStrategy()))->convert($polygon); + } + + /** The orientation policy applies to every interior ring as well. */ + public function testRejectZeroAreaHole(): void + { + $polygon = new Polygon([[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]], [[1, 1], [2, 1], [3, 1], [1, 1]]]); + + $this->expectException(UnsupportedGeometryStructureException::class); + (new Writer(new GeoJsonStrategy()))->convert($polygon); + } + + /** Varying height does not change the XY winding or position order. */ + public function testVaryingAltitudeAndMultipleHoles(): void + { + $polygon = new \LongitudeOne\SpatialTypes\Types\Dimension3z\Geography\Polygon([ + [[0, 0, 1], [8, 0, 2], [8, 8, 3], [0, 8, 4], [0, 0, 1]], + [[1, 1, 5], [1, 2, 6], [2, 2, 7], [2, 1, 8], [1, 1, 5]], + [[4, 4, 9], [4, 5, 10], [5, 5, 11], [5, 4, 12], [4, 4, 9]], + ]); + + static::assertJsonStringEqualsJsonString('{"type":"Polygon","coordinates":[[[0,0,1],[8,0,2],[8,8,3],[0,8,4],[0,0,1]],[[1,1,5],[1,2,6],[2,2,7],[2,1,8],[1,1,5]],[[4,4,9],[4,5,10],[5,5,11],[5,4,12],[4,4,9]]]}', (new Writer(new GeoJsonStrategy()))->convert($polygon)); + } + + /** Preserve the exterior ring and the original object. */ + public function testXy(): void + { + $polygon = new Polygon([[[0, 0], [2, 0], [0, 2], [0, 0]]], 4326); + + static::assertJsonStringEqualsJsonString('{"type":"Polygon","coordinates":[[[0,0],[2,0],[0,2],[0,0]]]}', (new Writer(new GeoJsonStrategy()))->convert($polygon)); + static::assertSame([[[0, 0], [2, 0], [0, 2], [0, 0]]], $polygon->toArray()); + } + + /** Preserve every Z value and omit declared reference metadata. */ + public function testXyz(): void + { + $polygon = new \LongitudeOne\SpatialTypes\Types\Dimension3z\Geography\Polygon([[[0, 0, 3], [2, 0, 3], [0, 2, 3], [0, 0, 3]]], new SpatialReference(999999, 'CUSTOM')); + + static::assertJsonStringEqualsJsonString('{"type":"Polygon","coordinates":[[[0,0,3],[2,0,3],[0,2,3],[0,0,3]]]}', (new Writer(new GeoJsonStrategy()))->convert($polygon)); + } +} diff --git a/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeometryLineStringTest.php b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeometryLineStringTest.php index 08e39c7..49390b5 100644 --- a/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeometryLineStringTest.php +++ b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeometryLineStringTest.php @@ -20,6 +20,7 @@ use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\LineString; use LongitudeOne\SpatialWriter\Exception\UnsupportedDimensionException; use LongitudeOne\SpatialWriter\Exception\UnsupportedGeometryStructureException; +use LongitudeOne\SpatialWriter\Strategy\GeoJson\GeometryEncoder; use LongitudeOne\SpatialWriter\Strategy\GeoJsonStrategy; use LongitudeOne\SpatialWriter\Writer; use PHPUnit\Framework\Attributes\CoversClass; @@ -31,6 +32,7 @@ * @internal */ #[CoversClass(GeoJsonStrategy::class)] +#[CoversClass(GeometryEncoder::class)] #[CoversClass(Writer::class)] class GeometryLineStringTest extends TestCase { diff --git a/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeometryMultiLineStringTest.php b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeometryMultiLineStringTest.php index 1310156..6b74f8b 100644 --- a/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeometryMultiLineStringTest.php +++ b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeometryMultiLineStringTest.php @@ -21,6 +21,7 @@ use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\MultiLineString; use LongitudeOne\SpatialWriter\Exception\UnsupportedDimensionException; use LongitudeOne\SpatialWriter\Exception\UnsupportedGeometryStructureException; +use LongitudeOne\SpatialWriter\Strategy\GeoJson\GeometryEncoder; use LongitudeOne\SpatialWriter\Strategy\GeoJsonStrategy; use LongitudeOne\SpatialWriter\Writer; use PHPUnit\Framework\Attributes\CoversClass; @@ -32,6 +33,7 @@ * @internal */ #[CoversClass(GeoJsonStrategy::class)] +#[CoversClass(GeometryEncoder::class)] #[CoversClass(Writer::class)] class GeometryMultiLineStringTest extends TestCase { diff --git a/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeometryMultiPointTest.php b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeometryMultiPointTest.php index 63cbbea..7f6dedc 100644 --- a/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeometryMultiPointTest.php +++ b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeometryMultiPointTest.php @@ -21,6 +21,7 @@ use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\Point; use LongitudeOne\SpatialWriter\Exception\UnsupportedDimensionException; use LongitudeOne\SpatialWriter\Exception\UnsupportedGeometryStructureException; +use LongitudeOne\SpatialWriter\Strategy\GeoJson\GeometryEncoder; use LongitudeOne\SpatialWriter\Strategy\GeoJsonStrategy; use LongitudeOne\SpatialWriter\Writer; use PHPUnit\Framework\Attributes\CoversClass; @@ -32,6 +33,7 @@ * @internal */ #[CoversClass(GeoJsonStrategy::class)] +#[CoversClass(GeometryEncoder::class)] #[CoversClass(Writer::class)] class GeometryMultiPointTest extends TestCase { diff --git a/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeometryPointTest.php b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeometryPointTest.php index 39747db..2be8066 100644 --- a/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeometryPointTest.php +++ b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeometryPointTest.php @@ -21,6 +21,7 @@ use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\Triangle; use LongitudeOne\SpatialWriter\Exception\UnsupportedDimensionException; use LongitudeOne\SpatialWriter\Exception\UnsupportedSpatialTypeException; +use LongitudeOne\SpatialWriter\Strategy\GeoJson\GeometryEncoder; use LongitudeOne\SpatialWriter\Strategy\GeoJsonStrategy; use LongitudeOne\SpatialWriter\Writer; use PHPUnit\Framework\Attributes\CoversClass; @@ -32,6 +33,7 @@ * @internal */ #[CoversClass(GeoJsonStrategy::class)] +#[CoversClass(GeometryEncoder::class)] #[CoversClass(Writer::class)] class GeometryPointTest extends TestCase { diff --git a/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeometryPolygonTest.php b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeometryPolygonTest.php new file mode 100644 index 0000000..1d376d3 --- /dev/null +++ b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeometryPolygonTest.php @@ -0,0 +1,205 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialWriter\Tests\Unit\Strategy\GeoJson; + +use LongitudeOne\SpatialTypes\Reference\SpatialReference; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\Polygon; +use LongitudeOne\SpatialWriter\Exception\UnsupportedDimensionException; +use LongitudeOne\SpatialWriter\Exception\UnsupportedGeometryStructureException; +use LongitudeOne\SpatialWriter\Strategy\GeoJson\GeometryEncoder; +use LongitudeOne\SpatialWriter\Strategy\GeoJsonStrategy; +use LongitudeOne\SpatialWriter\Writer; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; + +/** + * Explicit Geometry Polygon GeoJSON examples. + * + * @internal + */ +#[CoversClass(GeoJsonStrategy::class)] +#[CoversClass(GeometryEncoder::class)] +#[CoversClass(Writer::class)] +class GeometryPolygonTest extends TestCase +{ + /** Keep supplied longitudes without cutting or unwrapping the ring. */ + public function testAntimeridian(): void + { + $polygon = new Polygon([[[170, 40], [170, 50], [-170, 50], [-170, 40], [170, 40]]], 4326); + + static::assertJsonStringEqualsJsonString('{"type":"Polygon","coordinates":[[[170,40],[170,50],[-170,50],[-170,40],[170,40]]]}', (new Writer(new GeoJsonStrategy()))->convert($polygon)); + } + + /** Reject clockwise exteriors without reversing the source ring. */ + public function testClockwiseExterior(): void + { + $polygon = new Polygon([[[0, 0], [0, 2], [2, 0], [0, 0]]]); + + try { + (new Writer(new GeoJsonStrategy()))->convert($polygon); + static::fail('Clockwise exterior must be rejected.'); + } catch (UnsupportedGeometryStructureException) { + static::assertSame([[[0, 0], [0, 2], [2, 0], [0, 0]]], $polygon->toArray()); + } + } + + /** Reject an incompatible hole without returning partial output. */ + public function testCounterclockwiseHole(): void + { + $polygon = new Polygon([[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]], [[1, 1], [2, 1], [2, 2], [1, 2], [1, 1]]]); + + $this->expectException(UnsupportedGeometryStructureException::class); + (new Writer(new GeoJsonStrategy()))->convert($polygon); + } + + /** A positive signed area does not claim or require ring simplicity. */ + public function testCrossedRingWithPositiveArea(): void + { + $polygon = new Polygon([[[0, 0], [3, 3], [0, 3], [2, 0], [0, 0]]]); + + static::assertJsonStringEqualsJsonString('{"type":"Polygon","coordinates":[[[0,0],[3,3],[0,3],[2,0],[0,0]]]}', (new Writer(new GeoJsonStrategy()))->convert($polygon)); + } + + /** EMPTY is an empty coordinates array. */ + public function testEmptyXy(): void + { + $polygon = new Polygon([]); + + static::assertJsonStringEqualsJsonString('{"type":"Polygon","coordinates":[]}', (new Writer(new GeoJsonStrategy()))->convert($polygon)); + } + + /** EMPTY has no artificial dimension marker. */ + public function testEmptyXyz(): void + { + $polygon = new \LongitudeOne\SpatialTypes\Types\Dimension3z\Geometry\Polygon([]); + + static::assertJsonStringEqualsJsonString('{"type":"Polygon","coordinates":[]}', (new Writer(new GeoJsonStrategy()))->convert($polygon)); + } + + /** Preserve exterior and interior ring order and winding. */ + public function testHole(): void + { + $polygon = new Polygon([[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]], [[1, 1], [1, 2], [2, 2], [2, 1], [1, 1]]]); + + static::assertJsonStringEqualsJsonString('{"type":"Polygon","coordinates":[[[0,0],[4,0],[4,4],[0,4],[0,0]],[[1,1],[1,2],[2,2],[2,1],[1,1]]]}', (new Writer(new GeoJsonStrategy()))->convert($polygon)); + } + + /** Reject measured dimensions even when EMPTY. */ + public function testRejectEmptyXym(): void + { + $polygon = new \LongitudeOne\SpatialTypes\Types\Dimension3m\Geometry\Polygon([]); + + $this->expectException(UnsupportedDimensionException::class); + (new Writer(new GeoJsonStrategy()))->convert($polygon); + } + + /** Reject measured dimensions even when EMPTY. */ + public function testRejectEmptyXyzm(): void + { + $polygon = new \LongitudeOne\SpatialTypes\Types\Dimension4zm\Geometry\Polygon([]); + + $this->expectException(UnsupportedDimensionException::class); + (new Writer(new GeoJsonStrategy()))->convert($polygon); + } + + /** A later hole must also be checked before returning any output. */ + public function testRejectLaterCounterclockwiseHole(): void + { + $polygon = new Polygon([ + [[0, 0], [8, 0], [8, 8], [0, 8], [0, 0]], + [[1, 1], [1, 2], [2, 2], [2, 1], [1, 1]], + [[4, 4], [5, 4], [5, 5], [4, 5], [4, 4]], + ]); + + $this->expectException(UnsupportedGeometryStructureException::class); + (new Writer(new GeoJsonStrategy()))->convert($polygon); + } + + /** M must not be serialized as altitude. */ + public function testRejectXym(): void + { + $polygon = new \LongitudeOne\SpatialTypes\Types\Dimension3m\Geometry\Polygon([[[0, 0, 3], [2, 0, 3], [0, 2, 3], [0, 0, 3]]]); + + $this->expectException(UnsupportedDimensionException::class); + (new Writer(new GeoJsonStrategy()))->convert($polygon); + } + + /** M must not be discarded. */ + public function testRejectXyzm(): void + { + $polygon = new \LongitudeOne\SpatialTypes\Types\Dimension4zm\Geometry\Polygon([[[0, 0, 3, 4], [2, 0, 3, 4], [0, 2, 3, 4], [0, 0, 3, 4]]]); + + $this->expectException(UnsupportedDimensionException::class); + (new Writer(new GeoJsonStrategy()))->convert($polygon); + } + + /** The bow-tie is rejected for zero signed area, without topology analysis. */ + public function testRejectZeroAreaCrossedRing(): void + { + $polygon = new Polygon([[[0, 0], [2, 2], [0, 2], [2, 0], [0, 0]]]); + + $this->expectException(UnsupportedGeometryStructureException::class); + (new Writer(new GeoJsonStrategy()))->convert($polygon); + } + + /** Collinear rings have no winding and are rejected by the approved policy. */ + public function testRejectZeroAreaExterior(): void + { + $polygon = new Polygon([[[0, 0], [1, 0], [2, 0], [0, 0]]]); + + $this->expectException(UnsupportedGeometryStructureException::class); + (new Writer(new GeoJsonStrategy()))->convert($polygon); + } + + /** The orientation policy applies to every interior ring as well. */ + public function testRejectZeroAreaHole(): void + { + $polygon = new Polygon([[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]], [[1, 1], [2, 1], [3, 1], [1, 1]]]); + + $this->expectException(UnsupportedGeometryStructureException::class); + (new Writer(new GeoJsonStrategy()))->convert($polygon); + } + + /** Varying height does not change the XY winding or position order. */ + public function testVaryingAltitudeAndMultipleHoles(): void + { + $polygon = new \LongitudeOne\SpatialTypes\Types\Dimension3z\Geometry\Polygon([ + [[0, 0, 1], [8, 0, 2], [8, 8, 3], [0, 8, 4], [0, 0, 1]], + [[1, 1, 5], [1, 2, 6], [2, 2, 7], [2, 1, 8], [1, 1, 5]], + [[4, 4, 9], [4, 5, 10], [5, 5, 11], [5, 4, 12], [4, 4, 9]], + ]); + + static::assertJsonStringEqualsJsonString('{"type":"Polygon","coordinates":[[[0,0,1],[8,0,2],[8,8,3],[0,8,4],[0,0,1]],[[1,1,5],[1,2,6],[2,2,7],[2,1,8],[1,1,5]],[[4,4,9],[4,5,10],[5,5,11],[5,4,12],[4,4,9]]]}', (new Writer(new GeoJsonStrategy()))->convert($polygon)); + } + + /** Preserve the exterior ring and the original object. */ + public function testXy(): void + { + $polygon = new Polygon([[[0, 0], [2, 0], [0, 2], [0, 0]]], 4326); + + static::assertJsonStringEqualsJsonString('{"type":"Polygon","coordinates":[[[0,0],[2,0],[0,2],[0,0]]]}', (new Writer(new GeoJsonStrategy()))->convert($polygon)); + static::assertSame([[[0, 0], [2, 0], [0, 2], [0, 0]]], $polygon->toArray()); + } + + /** Preserve every Z value and omit declared reference metadata. */ + public function testXyz(): void + { + $polygon = new \LongitudeOne\SpatialTypes\Types\Dimension3z\Geometry\Polygon([[[0, 0, 3], [2, 0, 3], [0, 2, 3], [0, 0, 3]]], new SpatialReference(999999, 'CUSTOM')); + + static::assertJsonStringEqualsJsonString('{"type":"Polygon","coordinates":[[[0,0,3],[2,0,3],[0,2,3],[0,0,3]]]}', (new Writer(new GeoJsonStrategy()))->convert($polygon)); + } +} diff --git a/tests/README.md b/tests/README.md index f794eb3..ca40857 100644 --- a/tests/README.md +++ b/tests/README.md @@ -165,7 +165,7 @@ collections remain accepted. vendor/bin/phpunit --no-coverage --filter InvalidInputTest ``` -## GeoJSON Point, LineString and MultiPoint strategy +## GeoJSON strategy Run `vendor/bin/phpunit --no-coverage --filter GeoJson` for explicit Geometry and Geography Point examples, XY/XYZ EMPTY, measured rejection, spatial references, @@ -187,3 +187,11 @@ XYM/XYZM rejection including empty aggregates. Concrete XY/XYZ examples also verify rejection of mixed non-empty/EMPTY members and aggregates containing only EMPTY Points in both families, using spatial-types `0.0.1-alpha.2`. + +Polygon examples cover both families in XY/XYZ, EMPTY, holes, Z and ring order, +reference omission, antimeridian preservation, measured rejection and invalid +winding. Zero-area collinear and crossed rings are rejected under the approved +orientation policy; a crossed ring with positive area demonstrates that no general +topology validator was added. Fixtures use the ordinary model constructors. +`ExceptionContractTest` verifies Polygon interface errors and a non-finite altitude +encoding failure with the original JSON exception preserved.