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
48 changes: 44 additions & 4 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 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 |
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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
Expand All @@ -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).
182 changes: 182 additions & 0 deletions lib/LongitudeOne/SpatialWriter/Strategy/GeoJson/GeometryEncoder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
<?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\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<mixed>}
*/
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;
}
}
95 changes: 2 additions & 93 deletions lib/LongitudeOne/SpatialWriter/Strategy/GeoJsonStrategy.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -50,99 +46,12 @@ 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);
} catch (\JsonException $exception) {
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()];
}
}
Loading
Loading