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
52 changes: 46 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,52 @@ geometry type or parser extension. It preserves supported SRIDs without applying
PostGIS-specific SRID normalization. The [PostGIS grammar](https://github.com/postgis/postgis/blob/master/liblwgeom/lwin_wkt_parse.y)
places its optional SRID at the root; EWKT is a vendor extension, not OGC WKT.

## CircularString decoding

WKT and EWKT decode `CIRCULARSTRING` into dimension-specific
`CircularStringInterface` objects. The ordered control points are preserved
without linearizing the curve. XY, XYZ, XYM, XYZM and EMPTY are supported,
including inside geometry collections.

```php
$curve = (new Decoder(new EwktDecoderStrategy()))
->decode('SRID=4326;CIRCULARSTRING (0 0,1 1,2 0,3 -1,4 0)');
// $curve->toArray() === [[0, 0], [1, 1], [2, 0], [3, -1], [4, 0]]
// $curve->getSrid() === 4326
```

The existing dialect rules apply: WKT requires separated dimension markers
for non-XY coordinates and rejects SRID prefixes; EWKT also accepts compact
markers and infers XYZ/XYZM from unmarked coordinates. EMPTY preserves its
explicit or inherited dimension, defaulting to XY otherwise.

In WKT collections, a member may inherit a dimension marker from an explicitly
marked ancestor collection, but never from a preceding sibling. This also
applies to nested collections and EMPTY members. For example,
`GEOMETRYCOLLECTION (POINT Z EMPTY,CIRCULARSTRING (0 0 2,1 1 3,2 0 4))`
is rejected by WKT; declare `GEOMETRYCOLLECTION Z` or mark the curve `Z`.
EWKT retains its existing dimension inference and collection propagation.

Non-empty curves require an odd number of at least three control points.
Closed circles and collinear arcs are accepted. Structural validation is
provided by `spatial-types`; invalid input raises the decoder's
`InvalidArgumentException`, retaining the original exception when domain
construction fails.

Circular-string WKT syntax is based on ISO/IEC CD 13249-3:201x(E), the available
2009-01-16 Committee Draft, clause 5.1.57 (grammar and Description 1(e)).
The spatial model implements the curve contract in clause 7.3.1, Description
rules 4–12. This SQL/MM geometry is distinct from the OGC Simple Features
geometry subset; EWKT remains a vendor extension.

## Recognized types awaiting implementation

`LongitudeOne\SpatialDecoder\Exception\NotYetImplementedException` is available
for strategies that recognize a valid spatial type whose spatial object cannot
yet be instantiated. Construct it with the recognized `GeometryTypeEnum` value:

```php
throw new \LongitudeOne\SpatialDecoder\Exception\NotYetImplementedException(\LongitudeOne\Core\Enum\GeometryTypeEnum::CIRCULARSTRING);
throw new \LongitudeOne\SpatialDecoder\Exception\NotYetImplementedException(\LongitudeOne\Core\Enum\GeometryTypeEnum::CURVEPOLYGON);
```

The exception implements `DecoderExceptionInterface` and extends `\RuntimeException`.
Expand All @@ -72,15 +110,17 @@ identifies that type and states that decoding is not yet implemented. It is
independent of the input format.

The shared WKT/EWKT lexer uses `SpatialTypeImplementationStatus` from
`longitude-one/spatial-types` **0.0.1-alpha.4** to classify recognized geometry
keywords. `CIRCULARSTRING`, `COMPOUNDCURVE`, `CURVEPOLYGON`, `MULTICURVE`,
`longitude-one/spatial-types` **1.0.0-beta** to classify recognized geometry
keywords. `CURVEPOLYGON`, `MULTICURVE`,
`MULTISURFACE`, and `TIN` raise `NotYetImplementedException`. Non-instantiable
enum types raise `NonInstantiableGeometryTypeException`. Implemented types
continue to the representation-specific parsers. EWKT supports `POINT`,
`LINESTRING`, `POLYGON`, `TRIANGLE`, `POLYHEDRALSURFACE`, `MULTIPOINT`,
continue to the representation-specific parsers. `COMPOUNDCURVE` still raises
`NotYetImplementedException` because decoder support is not implemented, even
though the spatial model now provides it. EWKT supports `POINT`,
`LINESTRING`, `CIRCULARSTRING`, `POLYGON`, `TRIANGLE`, `POLYHEDRALSURFACE`, `MULTIPOINT`,
`MULTILINESTRING`, `MULTIPOLYGON`, and `GEOMETRYCOLLECTION`, including nested
collections and supported EMPTY values. Standard WKT retains its existing
geometry set and does not decode `TRIANGLE` or `POLYHEDRALSURFACE`.
geometry set, adds `CIRCULARSTRING`, and does not decode `TRIANGLE` or `POLYHEDRALSURFACE`.

EWKT triangles support XY, XYZ, XYM, and XYZM. Polyhedral surfaces require XYZ
or XYZM, including for EMPTY values; XY and XYM raise `InvalidArgumentException`,
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"require": {
"php": "^8.4",
"doctrine/lexer": "^3.0.2",
"longitude-one/spatial-types": "0.0.1-alpha.4"
"longitude-one/spatial-types": "1.0.0-beta"
},
"require-dev": {
"phpunit/phpunit": "^13.0"
Expand Down
3 changes: 2 additions & 1 deletion lib/Strategy/Common/Lexer.php
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,8 @@ private function getWordType(string $value): int
throw new NonInstantiableGeometryTypeException($geometryType);
}

if (!SpatialTypeImplementationStatus::isFullyImplemented($geometryType)) {
// Compound curves are available in the model but still await decoder support.
if (GeometryTypeEnum::COMPOUNDCURVE === $geometryType || !SpatialTypeImplementationStatus::isFullyImplemented($geometryType)) {
throw new NotYetImplementedException($geometryType);
}
}
Expand Down
70 changes: 70 additions & 0 deletions lib/Strategy/Wkt/Factory/WktCircularStringFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php
/**
* This file is part of the spatial-decoder project.
*
* PHP 8.4 | 8.5
*
* Copyright Alexandre Tranchant <alexandre.tranchant@gmail.com> 2026
* Copyright Longitude One 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\SpatialDecoder\Strategy\Wkt\Factory;

use LongitudeOne\Core\Enum\CoordinateDimensionEnum;
use LongitudeOne\SpatialDecoder\Exception\InvalidArgumentException;
use LongitudeOne\SpatialTypes\Exception\SpatialTypeExceptionInterface;
use LongitudeOne\SpatialTypes\Interfaces\CircularStringInterface;
use LongitudeOne\SpatialTypes\Interfaces\PointInterface;
use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\CircularString as CircularString2D;
use LongitudeOne\SpatialTypes\Types\Dimension3m\Geometry\CircularString as CircularString3DM;
use LongitudeOne\SpatialTypes\Types\Dimension3z\Geometry\CircularString as CircularString3DZ;
use LongitudeOne\SpatialTypes\Types\Dimension4zm\Geometry\CircularString as CircularString4D;

/**
* Creates dimension-specific WKT circular strings.
*
* @internal
*/
final class WktCircularStringFactory
{
/**
* Construct a circular-string factory.
*
* @param WktPointFactory $pointFactory factory used to create control points
*/
public function __construct(private WktPointFactory $pointFactory)
{
}

/**
* Create a circular string from its dimension and coordinate sequence.
*
* @param CoordinateDimensionEnum $dimension coordinate dimension of the circular string
* @param list<list<float|int>> $coordinates ordered circular-string coordinates
*/
public function createCircularString(CoordinateDimensionEnum $dimension, array $coordinates): CircularStringInterface
{
try {
// Build dimension-specific points before assembling the circular string.
$points = array_map(
fn (array $ordinates): PointInterface => $this->pointFactory->createPoint($dimension, $ordinates),
$coordinates
);

return match ($dimension) {
CoordinateDimensionEnum::XY => new CircularString2D($points),
CoordinateDimensionEnum::XYZ => new CircularString3DZ($points),
CoordinateDimensionEnum::XYM => new CircularString3DM($points),
CoordinateDimensionEnum::XYZM => new CircularString4D($points),
};
} catch (SpatialTypeExceptionInterface $exception) {
throw new InvalidArgumentException($exception->getMessage(), 0, $exception);
}
}
}
67 changes: 67 additions & 0 deletions lib/Strategy/Wkt/Parser/CircularStringWktParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php
/**
* This file is part of the spatial-decoder project.
*
* PHP 8.4 | 8.5
*
* Copyright Alexandre Tranchant <alexandre.tranchant@gmail.com> 2026
* Copyright Longitude One 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\SpatialDecoder\Strategy\Wkt\Parser;

use LongitudeOne\Core\Enum\CoordinateDimensionEnum;
use LongitudeOne\SpatialDecoder\Strategy\Common\Lexer;
use LongitudeOne\SpatialDecoder\Strategy\Wkt\Factory\WktCircularStringFactory;
use LongitudeOne\SpatialDecoder\Strategy\Wkt\WktCoordinateReader;
use LongitudeOne\SpatialDecoder\Strategy\Wkt\WktTokenCursor;
use LongitudeOne\SpatialTypes\Interfaces\SpatialInterface;

/**
* Parses WKT circular-string representations.
*
* @internal
*/
final class CircularStringWktParser implements WktGeometryParserInterface
{
/**
* Construct a circular-string parser.
*
* @param WktTokenCursor $cursor lexer cursor for the WKT input
* @param WktCoordinateReader $coordinateReader reader for coordinate values and dimensions
* @param WktCircularStringFactory $factory factory for the decoded circular string
*/
public function __construct(
private WktTokenCursor $cursor,
private WktCoordinateReader $coordinateReader,
private WktCircularStringFactory $factory
) {
}

/**
* Parse a circular-string representation.
*
* @param CoordinateDimensionEnum|null $inheritedDimension dimension inherited from a parent collection
*
* @return SpatialInterface the decoded circular string
*/
public function parse(?CoordinateDimensionEnum $inheritedDimension = null): SpatialInterface
{
$dimension = $this->coordinateReader->consumeDimension($inheritedDimension);
if ($this->cursor->isNextToken(Lexer::T_EMPTY)) {
$this->cursor->moveNext();

return $this->factory->createCircularString($dimension ?? CoordinateDimensionEnum::XY, []);
}

[$dimension, $coordinates] = $this->coordinateReader->consumeCoordinateSequence($dimension);

return $this->factory->createCircularString($dimension, $coordinates);
}
}
9 changes: 7 additions & 2 deletions lib/Strategy/Wkt/Parser/GeometryCollectionWktParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use LongitudeOne\SpatialDecoder\Strategy\Common\Lexer;
use LongitudeOne\SpatialDecoder\Strategy\Wkt\Factory\WktGeometryCollectionFactory;
use LongitudeOne\SpatialDecoder\Strategy\Wkt\WktCoordinateReader;
use LongitudeOne\SpatialDecoder\Strategy\Wkt\WktDialect;
use LongitudeOne\SpatialDecoder\Strategy\Wkt\WktTokenCursor;
use LongitudeOne\SpatialTypes\Interfaces\SpatialInterface;

Expand All @@ -37,12 +38,14 @@ final class GeometryCollectionWktParser implements WktGeometryParserInterface
* @param WktCoordinateReader $coordinateReader reader for coordinate values and dimensions
* @param WktGeometryCollectionFactory $factory factory for the decoded collection
* @param WktGeometryParserDispatcherInterface $geometryParser recursive geometry dispatcher
* @param WktDialect $dialect dimensional syntax accepted by the representation
*/
public function __construct(
private WktTokenCursor $cursor,
private WktCoordinateReader $coordinateReader,
private WktGeometryCollectionFactory $factory,
private WktGeometryParserDispatcherInterface $geometryParser
private WktGeometryParserDispatcherInterface $geometryParser,
private WktDialect $dialect = WktDialect::WKT
) {
}

Expand All @@ -56,6 +59,7 @@ public function __construct(
public function parse(?CoordinateDimensionEnum $inheritedDimension = null): SpatialInterface
{
$dimension = $this->coordinateReader->consumeDimension($inheritedDimension);
$declaredDimension = $dimension;
if ($this->cursor->isNextToken(Lexer::T_EMPTY)) {
$this->cursor->moveNext();

Expand All @@ -65,7 +69,8 @@ public function parse(?CoordinateDimensionEnum $inheritedDimension = null): Spat
$elements = [];
$this->cursor->expectSymbol('(');
do {
$element = $this->geometryParser->parseNext($dimension);
// A sibling's effective dimension is not an ancestor's WKT marker.
$element = $this->geometryParser->parseNext(WktDialect::EWKT === $this->dialect ? $dimension : $declaredDimension);
$elementDimension = $this->dimensionOf($element);
if (null !== $dimension && $dimension !== $elementDimension) {
throw $this->cursor->createInvalidInputException('WKT geometry-collection members must use one coordinate dimension.');
Expand Down
7 changes: 6 additions & 1 deletion lib/Strategy/Wkt/Parser/WktParserFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

use LongitudeOne\SpatialDecoder\Exception\InvalidArgumentException;
use LongitudeOne\SpatialDecoder\Strategy\Common\Lexer;
use LongitudeOne\SpatialDecoder\Strategy\Wkt\Factory\WktCircularStringFactory;
use LongitudeOne\SpatialDecoder\Strategy\Wkt\Factory\WktGeometryCollectionFactory;
use LongitudeOne\SpatialDecoder\Strategy\Wkt\Factory\WktLineStringFactory;
use LongitudeOne\SpatialDecoder\Strategy\Wkt\Factory\WktMultiLineStringFactory;
Expand Down Expand Up @@ -69,6 +70,10 @@ public function createFromCursor(WktTokenCursor $cursor, WktDialect $dialect = W

$registry->register(Lexer::T_POINT, new PointWktParser($cursor, $coordinateReader, $pointFactory));
$registry->register(Lexer::T_LINESTRING, new LineStringWktParser($cursor, $coordinateReader, $lineStringFactory));
$registry->register(
Lexer::T_CIRCULARSTRING,
new CircularStringWktParser($cursor, $coordinateReader, new WktCircularStringFactory($pointFactory))
);
$registry->register(Lexer::T_MULTIPOINT, new MultiPointWktParser($cursor, $coordinateReader, new WktMultiPointFactory($pointFactory)));
$registry->register(Lexer::T_MULTILINESTRING, new MultiLineStringWktParser($cursor, $coordinateReader, new WktMultiLineStringFactory($lineStringFactory)));
$registry->register(Lexer::T_POLYGON, new PolygonWktParser($cursor, $coordinateReader, $polygonFactory));
Expand All @@ -78,7 +83,7 @@ public function createFromCursor(WktTokenCursor $cursor, WktDialect $dialect = W
);
$registry->register(
Lexer::T_GEOMETRYCOLLECTION,
new GeometryCollectionWktParser($cursor, $coordinateReader, new WktGeometryCollectionFactory(), $registry)
new GeometryCollectionWktParser($cursor, $coordinateReader, new WktGeometryCollectionFactory(), $registry, $dialect)
);

if (WktDialect::EWKT === $dialect) {
Expand Down
Loading
Loading