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
32 changes: 27 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,30 @@ 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.

## CompoundCurve decoding

WKT and EWKT decode `COMPOUNDCURVE` into `CompoundCurveInterface` objects,
preserving each ordered linear or circular component without flattening or
linearizing it. XY, XYZ, XYM, XYZM and EMPTY are supported, including inside
geometry collections. Components share the containing curve's dimension.

```php
$curve = (new Decoder(new EwktDecoderStrategy()))
->decode('SRID=4326;COMPOUNDCURVE ((0 0,1 1),CIRCULARSTRING (1 1,2 0,3 1))');
// $curve->getCurve(0) is a LineStringInterface
// $curve->getCurve(1) is a CircularStringInterface
// $curve->getSrid() === 4326 (also applied to components and their points)
```

Linear components use parenthesized coordinates without a `LINESTRING` keyword;
circular components use `CIRCULARSTRING`. This follows ISO/IEC CD
13249-3:201x(E), the available 2009-01-16 Committee Draft, clause 5.1.57,
`<single curve text>`. Typed `LINESTRING` components are rejected in both dialects.
The existing WKT/EWKT dimension and SRID rules apply. Component continuity and
other structural invariants are validated by `spatial-types`; disconnected or
malformed curves raise the decoder's `InvalidArgumentException`, retaining the
original exception when domain construction fails.

## Recognized types awaiting implementation

`LongitudeOne\SpatialDecoder\Exception\NotYetImplementedException` is available
Expand All @@ -114,13 +138,11 @@ The shared WKT/EWKT lexer uses `SpatialTypeImplementationStatus` from
keywords. `CURVEPOLYGON`, `MULTICURVE`,
`MULTISURFACE`, and `TIN` raise `NotYetImplementedException`. Non-instantiable
enum types raise `NonInstantiableGeometryTypeException`. Implemented types
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`,
continue to the representation-specific parsers. EWKT supports `POINT`,
`LINESTRING`, `CIRCULARSTRING`, `COMPOUNDCURVE`, `POLYGON`, `TRIANGLE`, `POLYHEDRALSURFACE`, `MULTIPOINT`,
`MULTILINESTRING`, `MULTIPOLYGON`, and `GEOMETRYCOLLECTION`, including nested
collections and supported EMPTY values. Standard WKT retains its existing
geometry set, adds `CIRCULARSTRING`, and does not decode `TRIANGLE` or `POLYHEDRALSURFACE`.
geometry set, adds `CIRCULARSTRING` and `COMPOUNDCURVE`, 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
3 changes: 1 addition & 2 deletions lib/Strategy/Common/Lexer.php
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,7 @@ private function getWordType(string $value): int
throw new NonInstantiableGeometryTypeException($geometryType);
}

// Compound curves are available in the model but still await decoder support.
if (GeometryTypeEnum::COMPOUNDCURVE === $geometryType || !SpatialTypeImplementationStatus::isFullyImplemented($geometryType)) {
if (!SpatialTypeImplementationStatus::isFullyImplemented($geometryType)) {
throw new NotYetImplementedException($geometryType);
}
}
Expand Down
55 changes: 55 additions & 0 deletions lib/Strategy/Wkt/Factory/WktCompoundCurveFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?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\CompoundCurveInterface;
use LongitudeOne\SpatialTypes\Interfaces\CurveInterface;
use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\CompoundCurve as CompoundCurve2D;
use LongitudeOne\SpatialTypes\Types\Dimension3m\Geometry\CompoundCurve as CompoundCurve3DM;
use LongitudeOne\SpatialTypes\Types\Dimension3z\Geometry\CompoundCurve as CompoundCurve3DZ;
use LongitudeOne\SpatialTypes\Types\Dimension4zm\Geometry\CompoundCurve as CompoundCurve4D;

/**
* Creates dimension-specific WKT compound curves.
*
* @internal
*/
final class WktCompoundCurveFactory
{
/**
* Create a compound curve from its dimension and ordered components.
*
* @param CoordinateDimensionEnum $dimension coordinate dimension of the compound curve
* @param CurveInterface[] $curves ordered line-string and circular-string components
*/
public function createCompoundCurve(CoordinateDimensionEnum $dimension, array $curves): CompoundCurveInterface
{
try {
return match ($dimension) {
CoordinateDimensionEnum::XY => new CompoundCurve2D($curves),
CoordinateDimensionEnum::XYZ => new CompoundCurve3DZ($curves),
CoordinateDimensionEnum::XYM => new CompoundCurve3DM($curves),
CoordinateDimensionEnum::XYZM => new CompoundCurve4D($curves),
};
} catch (SpatialTypeExceptionInterface $exception) {
throw new InvalidArgumentException($exception->getMessage(), 0, $exception);
}
}
}
130 changes: 130 additions & 0 deletions lib/Strategy/Wkt/Parser/CompoundCurveWktParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<?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\Core\Enum\GeometryTypeEnum;
use LongitudeOne\SpatialDecoder\Strategy\Common\Lexer;
use LongitudeOne\SpatialDecoder\Strategy\Wkt\Factory\WktCompoundCurveFactory;
use LongitudeOne\SpatialDecoder\Strategy\Wkt\Factory\WktLineStringFactory;
use LongitudeOne\SpatialDecoder\Strategy\Wkt\WktCoordinateReader;
use LongitudeOne\SpatialDecoder\Strategy\Wkt\WktTokenCursor;
use LongitudeOne\SpatialTypes\Interfaces\CircularStringInterface;
use LongitudeOne\SpatialTypes\Interfaces\CurveInterface;
use LongitudeOne\SpatialTypes\Interfaces\SpatialInterface;

/**
* Parses WKT compound-curve representations.
*
* @internal
*/
final class CompoundCurveWktParser implements WktGeometryParserInterface
{
/**
* Construct a compound-curve parser.
*
* @param WktTokenCursor $cursor lexer cursor for the WKT input
* @param WktCoordinateReader $coordinateReader reader for coordinate values and dimensions
* @param WktLineStringFactory $lineStringFactory factory for linear components
* @param WktCompoundCurveFactory $factory factory for the decoded compound curve
* @param WktGeometryParserDispatcherInterface $geometryParser dispatcher for typed curve components
*/
public function __construct(
private WktTokenCursor $cursor,
private WktCoordinateReader $coordinateReader,
private WktLineStringFactory $lineStringFactory,
private WktCompoundCurveFactory $factory,
private WktGeometryParserDispatcherInterface $geometryParser
) {
}

/**
* Parse a compound-curve representation.
*
* @param CoordinateDimensionEnum|null $inheritedDimension dimension inherited from a parent collection
*
* @return SpatialInterface the decoded compound curve
*/
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->createCompoundCurve($dimension ?? CoordinateDimensionEnum::XY, []);
}

$this->cursor->expectSymbol('(');
$curves = [];
do {
$curve = $this->parseComponent($dimension);
$dimension ??= $curve->getDimension();
$curves[] = $curve;

$hasNextCurve = $this->cursor->isNextToken(Lexer::T_COMMA);
if ($hasNextCurve) {
$this->cursor->moveNext();
}
} while ($hasNextCurve);
$this->cursor->expectSymbol(')');

return $this->factory->createCompoundCurve($dimension, $curves);
}

/**
* Parse a parenthesized linear component or a typed curve component.
*
* @param CoordinateDimensionEnum|null $dimension compound-curve dimension established by its parent or first component
*
* @return CurveInterface the decoded component
*/
private function parseComponent(?CoordinateDimensionEnum $dimension): CurveInterface
{
if ($this->cursor->isNextToken(Lexer::T_OPEN_PARENTHESIS)) {
[$dimension, $coordinates] = $this->coordinateReader->consumeCoordinateSequence($dimension);

return $this->lineStringFactory->createLineString($dimension, $coordinates);
}

return $this->parseTypedComponent($dimension);
}

/**
* Parse and validate a typed circular-string component.
*
* @param CoordinateDimensionEnum|null $dimension compound-curve dimension established by its parent or first component
*
* @return CurveInterface the decoded component
*/
private function parseTypedComponent(?CoordinateDimensionEnum $dimension): CurveInterface
{
$token = $this->cursor->currentToken();
if (null === $token || Lexer::T_CIRCULARSTRING !== $token->type) {
throw $this->cursor->createInvalidInputException('A compound curve component must be a line string or circular string.');
}

$curve = $this->geometryParser->parseNext($dimension);
if (null !== $dimension && $curve->getDimension() !== $dimension) {
throw $this->cursor->createInvalidInputException('Compound-curve components must use the compound-curve coordinate dimension.');
}
if (GeometryTypeEnum::CIRCULARSTRING === $curve->getType() && $curve instanceof CircularStringInterface) {
return $curve;
}

throw $this->cursor->createInvalidInputException('A compound curve component must be a line string or circular string.');
}
}
5 changes: 5 additions & 0 deletions lib/Strategy/Wkt/Parser/WktParserFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,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\WktCompoundCurveFactory;
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 @@ -74,6 +75,10 @@ public function createFromCursor(WktTokenCursor $cursor, WktDialect $dialect = W
Lexer::T_CIRCULARSTRING,
new CircularStringWktParser($cursor, $coordinateReader, new WktCircularStringFactory($pointFactory))
);
$registry->register(
Lexer::T_COMPOUNDCURVE,
new CompoundCurveWktParser($cursor, $coordinateReader, $lineStringFactory, new WktCompoundCurveFactory(), $registry)
);
$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 Down
Loading
Loading