Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions docs/strategies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, MultiLineString and Polygon encoding according to RFC 7946 |
| `GeoJsonStrategy` | GeoJSON geometry text | No | XY, XYZ | Point, LineString, MultiPoint, MultiLineString, Polygon and MultiPolygon 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 |
Expand Down Expand Up @@ -236,7 +236,7 @@ and the [testing guide](../tests/README.md). To add a format, implement
## GeoJSON — `GeoJsonStrategy`

This increment supports Geometry and Geography Points, LineStrings, MultiPoints,
MultiLineStrings and Polygons in XY and XYZ, including EMPTY. Other geometry
MultiLineStrings, Polygons and MultiPolygons in XY and XYZ, including EMPTY. Other geometry
types are not yet supported. Feature and FeatureCollection composition belongs
to the consuming application.

Expand Down Expand Up @@ -381,6 +381,31 @@ 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.

MultiPolygons preserve Polygon order, ring order, position order and Z. A singleton
remains a MultiPolygon. Every member uses the same Polygon orientation checks
above, with no second validation policy:

```php
$multiPolygon = new \LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\MultiPolygon([
[[[0, 0], [2, 0], [0, 2], [0, 0]]],
[[[3, 0], [5, 0], [3, 2], [3, 0]]],
]);
echo $writer->convert($multiPolygon);
// {"type":"MultiPolygon","coordinates":[[[[0,0],[2,0],[0,2],[0,0]]],[[[3,0],[5,0],[3,2],[3,0]]]]}
```

An XY or XYZ MultiPolygon with no members produces
`{"type":"MultiPolygon","coordinates":[]}`. Any EMPTY Polygon member throws
`UnsupportedGeometryStructureException`, including when all members are EMPTY.
An incompatible exterior or interior ring also rejects the whole conversion.
No member is omitted, repaired, reoriented or replaced, and no partial output is
returned. Measured XYM and XYZM aggregates are rejected even with no members.

The same reference omission, caller responsibilities and JSON failure contract
apply. Antimeridian-crossing members retain their supplied coordinates under the
deliberate non-application of RFC 7946 section 3.1.9 described above, with the same
possible long-way-around interpretation by consumers.

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
Expand All @@ -392,5 +417,6 @@ References: [RFC 7946 geometry objects and positions](https://www.rfc-editor.org
[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),
[MultiPolygon members](https://www.rfc-editor.org/rfc/rfc7946.html#section-3.1.7),
[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).
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use LongitudeOne\SpatialTypes\Interfaces\LineStringInterface;
use LongitudeOne\SpatialTypes\Interfaces\MultiLineStringInterface;
use LongitudeOne\SpatialTypes\Interfaces\MultiPointInterface;
use LongitudeOne\SpatialTypes\Interfaces\MultiPolygonInterface;
use LongitudeOne\SpatialTypes\Interfaces\PointInterface;
use LongitudeOne\SpatialTypes\Interfaces\PolygonInterface;
use LongitudeOne\SpatialTypes\Interfaces\SpatialInterface;
Expand Down Expand Up @@ -49,6 +50,7 @@ public function encode(SpatialInterface $spatial): array
GeometryTypeEnum::MULTIPOINT => $this->encodeMultiPoint($spatial),
GeometryTypeEnum::MULTILINESTRING => $this->encodeMultiLineString($spatial),
GeometryTypeEnum::POLYGON => $this->encodePolygon($spatial),
GeometryTypeEnum::MULTIPOLYGON => $this->encodeMultiPolygon($spatial),
default => throw new UnsupportedSpatialTypeException('This GeoJSON strategy does not support '.$spatial->getType()->name.'.'),
};
}
Expand Down Expand Up @@ -118,6 +120,32 @@ private function encodeMultiPoint(SpatialInterface $spatial): array
return ['type' => 'MultiPoint', 'coordinates' => $coordinates];
}

/**
* Preserve polygon membership using the shared Polygon orientation policy.
*
* @param SpatialInterface $spatial the multi-polygon to encode
*
* @return array{type: string, coordinates: (float|int)[][][][]}
*/
private function encodeMultiPolygon(SpatialInterface $spatial): array
{
if (!$spatial instanceof MultiPolygonInterface) {
throw new UnsupportedSpatialInterfaceException($spatial::class);
}

$coordinates = [];
foreach ($spatial->getPolygons() as $polygon) {
$rings = $this->encodePolygon($polygon)['coordinates'];
if ([] === $rings) {
throw new UnsupportedGeometryStructureException('A GeoJSON MultiPolygon cannot contain an EMPTY Polygon.');
}

$coordinates[] = $rings;
}

return ['type' => 'MultiPolygon', 'coordinates' => $coordinates];
}

/**
* Preserve a point's coordinates.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
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\MultiPolygon;
use LongitudeOne\SpatialTypes\Types\Dimension3z\Geometry\Polygon;
use LongitudeOne\SpatialWriter\Exception\ExceptionInterface;
use LongitudeOne\SpatialWriter\Exception\JsonEncodingException;
Expand Down Expand Up @@ -122,6 +123,20 @@ public function testMultiPointEncodingFailure(): void
}
}

/** A real polygon member with non-finite Z retains the shared JSON failure. */
public function testMultiPolygonEncodingFailure(): void
{
$multiPolygon = new MultiPolygon([[[[0, 0, 1], [2, 0, INF], [0, 2, 3], [0, 0, 1]]]]);

try {
(new Writer(new GeoJsonStrategy()))->convert($multiPolygon);
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());
}
}

/** The installed model accepts this float through its ordinary constructor. */
public function testNanEncodingFailure(): void
{
Expand Down Expand Up @@ -229,6 +244,17 @@ public function testUnsupportedMultiPointInterfaceThroughWriter(): void
(new Writer(new GeoJsonStrategy()))->convert($spatial);
}

/** Preserve the internal exception for a missing MultiPolygon interface. */
public function testUnsupportedMultiPolygonInterfaceThroughWriter(): void
{
$spatial = static::createStub(SpatialInterface::class);
$spatial->method('getType')->willReturn(GeometryTypeEnum::MULTIPOLYGON);
$spatial->method('hasM')->willReturn(false);

$this->expectException(UnsupportedSpatialInterfaceException::class);
(new Writer(new GeoJsonStrategy()))->convert($spatial);
}

/** Reject a declared Point without its interface through the public writer. */
public function testUnsupportedPointInterfaceThroughWriter(): void
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<?php
/**
* This file is part of the binary-writer project.
*
* PHP 8.4 | 8.5
*
* Copyright Alexandre Tranchant <alexandre.tranchant@gmail.com> 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\MultiPolygon;
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 MultiPolygon GeoJSON examples.
*
* @internal
*/
#[CoversClass(GeoJsonStrategy::class)]
#[CoversClass(GeometryEncoder::class)]
#[CoversClass(Writer::class)]
class GeographyMultiPolygonTest extends TestCase
{
/** Keep crossings without cutting or unwrapping. */
public function testAntimeridian(): void
{
$multiPolygon = new MultiPolygon([[[[170, 40], [170, 50], [-170, 50], [-170, 40], [170, 40]]]], 4326);

static::assertJsonStringEqualsJsonString('{"type":"MultiPolygon","coordinates":[[[[170,40],[170,50],[-170,50],[-170,40],[170,40]]]]}', (new Writer(new GeoJsonStrategy()))->convert($multiPolygon));
static::assertJsonStringEqualsJsonString('{"type":"MultiPolygon","coordinates":[[[[170,40],[170,50],[-170,50],[-170,40],[170,40]]]]}', (new GeoJsonStrategy())->executeStrategy($multiPolygon));
}

/** An aggregate without members is supported. */
public function testEmptyXy(): void
{
$multiPolygon = new MultiPolygon([]);

static::assertJsonStringEqualsJsonString('{"type":"MultiPolygon","coordinates":[]}', (new Writer(new GeoJsonStrategy()))->convert($multiPolygon));
static::assertJsonStringEqualsJsonString('{"type":"MultiPolygon","coordinates":[]}', (new GeoJsonStrategy())->executeStrategy($multiPolygon));
}

/** An empty XYZ aggregate has no dimension marker. */
public function testEmptyXyz(): void
{
$multiPolygon = new \LongitudeOne\SpatialTypes\Types\Dimension3z\Geography\MultiPolygon([]);

static::assertJsonStringEqualsJsonString('{"type":"MultiPolygon","coordinates":[]}', (new Writer(new GeoJsonStrategy()))->convert($multiPolygon));
static::assertJsonStringEqualsJsonString('{"type":"MultiPolygon","coordinates":[]}', (new GeoJsonStrategy())->executeStrategy($multiPolygon));
}

/** Reject the whole aggregate through the approved structural contract. */
public function testRejectClockwiseLaterExterior(): void
{
$multiPolygon = new MultiPolygon([[[[0, 0], [2, 0], [0, 2], [0, 0]]], [[[3, 0], [3, 2], [5, 0], [3, 0]]]]);

$this->expectException(UnsupportedGeometryStructureException::class);
(new Writer(new GeoJsonStrategy()))->convert($multiPolygon);
}

/** Reject the whole aggregate through the approved structural contract. */
public function testRejectCounterclockwiseLaterHole(): void
{
$multiPolygon = new MultiPolygon([[[[10, 0], [12, 0], [10, 2], [10, 0]]], [[[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($multiPolygon);
}

/** Reject the whole aggregate through the approved structural contract. */
public function testRejectEmptyMember(): void
{
$multiPolygon = new MultiPolygon([new Polygon([[[0, 0], [2, 0], [0, 2], [0, 0]]]), new Polygon([])]);

$this->expectException(UnsupportedGeometryStructureException::class);
(new Writer(new GeoJsonStrategy()))->convert($multiPolygon);
}

/** Reject measured dimensions without discarding M. */
public function testRejectEmptyXym(): void
{
$multiPolygon = new \LongitudeOne\SpatialTypes\Types\Dimension3m\Geography\MultiPolygon([]);

$this->expectException(UnsupportedDimensionException::class);
(new Writer(new GeoJsonStrategy()))->convert($multiPolygon);
}

/** Reject measured dimensions without discarding M. */
public function testRejectEmptyXyzm(): void
{
$multiPolygon = new \LongitudeOne\SpatialTypes\Types\Dimension4zm\Geography\MultiPolygon([]);

$this->expectException(UnsupportedDimensionException::class);
(new Writer(new GeoJsonStrategy()))->convert($multiPolygon);
}

/** Reject the whole aggregate through the approved structural contract. */
public function testRejectOnlyEmptyMembers(): void
{
$multiPolygon = new MultiPolygon([new Polygon([]), new Polygon([])]);

$this->expectException(UnsupportedGeometryStructureException::class);
(new Writer(new GeoJsonStrategy()))->convert($multiPolygon);
}

/** Reject measured dimensions without discarding M. */
public function testRejectXym(): void
{
$multiPolygon = new \LongitudeOne\SpatialTypes\Types\Dimension3m\Geography\MultiPolygon([[[[0, 0, 3], [2, 0, 3], [0, 2, 3], [0, 0, 3]]]]);

$this->expectException(UnsupportedDimensionException::class);
(new Writer(new GeoJsonStrategy()))->convert($multiPolygon);
}

/** Reject measured dimensions without discarding M. */
public function testRejectXyzm(): void
{
$multiPolygon = new \LongitudeOne\SpatialTypes\Types\Dimension4zm\Geography\MultiPolygon([[[[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($multiPolygon);
}

/** Reject the whole aggregate through the approved structural contract. */
public function testRejectZeroAreaExterior(): void
{
$multiPolygon = new MultiPolygon([[[[0, 0], [1, 0], [2, 0], [0, 0]]]]);

$this->expectException(UnsupportedGeometryStructureException::class);
(new Writer(new GeoJsonStrategy()))->convert($multiPolygon);
}

/** Preserve multiple holes, member order and decimal coordinates. */
public function testRingOrder(): void
{
$multiPolygon = new MultiPolygon([[[[0, 0], [8, 0], [8, 8], [0, 8], [0, 0]], [[1, 1], [1, 2], [2, 2], [2, 1], [1, 1]], [[4, 4], [4, 5], [5, 5], [5, 4], [4, 4]]], [[[10.5, 0], [12, 0], [10.5, 2], [10.5, 0]]]]);

static::assertJsonStringEqualsJsonString('{"type":"MultiPolygon","coordinates":[[[[0,0],[8,0],[8,8],[0,8],[0,0]],[[1,1],[1,2],[2,2],[2,1],[1,1]],[[4,4],[4,5],[5,5],[5,4],[4,4]]],[[[10.5,0],[12,0],[10.5,2],[10.5,0]]]]}', (new Writer(new GeoJsonStrategy()))->convert($multiPolygon));
static::assertJsonStringEqualsJsonString('{"type":"MultiPolygon","coordinates":[[[[0,0],[8,0],[8,8],[0,8],[0,0]],[[1,1],[1,2],[2,2],[2,1],[1,1]],[[4,4],[4,5],[5,5],[5,4],[4,4]]],[[[10.5,0],[12,0],[10.5,2],[10.5,0]]]]}', (new GeoJsonStrategy())->executeStrategy($multiPolygon));
}

/** Preserve member and position order with SRID omitted. */
public function testXy(): void
{
$multiPolygon = new MultiPolygon([[[[0, 0], [2, 0], [0, 2], [0, 0]]], [[[3, 0], [5, 0], [3, 2], [3, 0]]]], 4326);

static::assertJsonStringEqualsJsonString('{"type":"MultiPolygon","coordinates":[[[[0,0],[2,0],[0,2],[0,0]]],[[[3,0],[5,0],[3,2],[3,0]]]]}', (new Writer(new GeoJsonStrategy()))->convert($multiPolygon));
static::assertJsonStringEqualsJsonString('{"type":"MultiPolygon","coordinates":[[[[0,0],[2,0],[0,2],[0,0]]],[[[3,0],[5,0],[3,2],[3,0]]]]}', (new GeoJsonStrategy())->executeStrategy($multiPolygon));
}

/** Keep the singleton wrapper, Z and coordinates without reference metadata. */
public function testXyz(): void
{
$multiPolygon = new \LongitudeOne\SpatialTypes\Types\Dimension3z\Geography\MultiPolygon([[[[0, 0, 3], [2, 0, 3], [0, 2, 3], [0, 0, 3]]]], new SpatialReference(999999, 'CUSTOM'));

static::assertJsonStringEqualsJsonString('{"type":"MultiPolygon","coordinates":[[[[0,0,3],[2,0,3],[0,2,3],[0,0,3]]]]}', (new Writer(new GeoJsonStrategy()))->convert($multiPolygon));
static::assertJsonStringEqualsJsonString('{"type":"MultiPolygon","coordinates":[[[[0,0,3],[2,0,3],[0,2,3],[0,0,3]]]]}', (new GeoJsonStrategy())->executeStrategy($multiPolygon));
}
}
Loading
Loading