diff --git a/README.md b/README.md index c756a5c..e2b1e4f 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,44 @@ 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 @@ -63,7 +101,7 @@ 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`. @@ -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`, diff --git a/composer.json b/composer.json index 083ca80..7a2bdab 100644 --- a/composer.json +++ b/composer.json @@ -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" diff --git a/lib/Strategy/Common/Lexer.php b/lib/Strategy/Common/Lexer.php index d4e063d..9d07846 100644 --- a/lib/Strategy/Common/Lexer.php +++ b/lib/Strategy/Common/Lexer.php @@ -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); } } diff --git a/lib/Strategy/Wkt/Factory/WktCircularStringFactory.php b/lib/Strategy/Wkt/Factory/WktCircularStringFactory.php new file mode 100644 index 0000000..ae2071d --- /dev/null +++ b/lib/Strategy/Wkt/Factory/WktCircularStringFactory.php @@ -0,0 +1,70 @@ + 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> $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); + } + } +} diff --git a/lib/Strategy/Wkt/Parser/CircularStringWktParser.php b/lib/Strategy/Wkt/Parser/CircularStringWktParser.php new file mode 100644 index 0000000..c2b5df1 --- /dev/null +++ b/lib/Strategy/Wkt/Parser/CircularStringWktParser.php @@ -0,0 +1,67 @@ + 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); + } +} diff --git a/lib/Strategy/Wkt/Parser/GeometryCollectionWktParser.php b/lib/Strategy/Wkt/Parser/GeometryCollectionWktParser.php index 858aab7..d11f0cd 100644 --- a/lib/Strategy/Wkt/Parser/GeometryCollectionWktParser.php +++ b/lib/Strategy/Wkt/Parser/GeometryCollectionWktParser.php @@ -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; @@ -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 ) { } @@ -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(); @@ -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.'); diff --git a/lib/Strategy/Wkt/Parser/WktParserFactory.php b/lib/Strategy/Wkt/Parser/WktParserFactory.php index a566f0c..9661c13 100644 --- a/lib/Strategy/Wkt/Parser/WktParserFactory.php +++ b/lib/Strategy/Wkt/Parser/WktParserFactory.php @@ -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; @@ -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)); @@ -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) { diff --git a/tests/Unit/CircularStringDecoderTest.php b/tests/Unit/CircularStringDecoderTest.php new file mode 100644 index 0000000..bc385ae --- /dev/null +++ b/tests/Unit/CircularStringDecoderTest.php @@ -0,0 +1,176 @@ + 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\Tests\Unit; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\Core\Enum\GeometryTypeEnum; +use LongitudeOne\SpatialDecoder\Decoder; +use LongitudeOne\SpatialDecoder\Exception\InvalidArgumentException; +use LongitudeOne\SpatialDecoder\Strategy\EwktDecoderStrategy; +use LongitudeOne\SpatialDecoder\Strategy\WktDecoderStrategy; +use LongitudeOne\SpatialTypes\Exception\SpatialTypeExceptionInterface; +use LongitudeOne\SpatialTypes\Interfaces\CircularStringInterface; +use LongitudeOne\SpatialTypes\Interfaces\CollectionInterface; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +/** @internal */ +class CircularStringDecoderTest extends TestCase +{ + /** @return iterable>, CoordinateDimensionEnum}> */ + public static function curves(): iterable + { + yield 'single arc XY' => ['CIRCULARSTRING (0 0,1 1,2 0)', [[0, 0], [1, 1], [2, 0]], CoordinateDimensionEnum::XY]; + yield 'multiple arcs' => ['CIRCULARSTRING (0 0,1 1,2 0,3 -1,4 0)', [[0, 0], [1, 1], [2, 0], [3, -1], [4, 0]], CoordinateDimensionEnum::XY]; + yield 'closed circle' => ['CIRCULARSTRING (1 0,-1 0,1 0)', [[1, 0], [-1, 0], [1, 0]], CoordinateDimensionEnum::XY]; + yield 'collinear' => ['CIRCULARSTRING (0 0,1 0,2 0)', [[0, 0], [1, 0], [2, 0]], CoordinateDimensionEnum::XY]; + yield 'XYZ' => ['CIRCULARSTRING Z (0 0 2,1 1 3,2 0 4)', [[0, 0, 2], [1, 1, 3], [2, 0, 4]], CoordinateDimensionEnum::XYZ]; + yield 'XYM' => ['CIRCULARSTRING M (0 0 2,1 1 3,2 0 4)', [[0, 0, 2], [1, 1, 3], [2, 0, 4]], CoordinateDimensionEnum::XYM]; + yield 'XYZM' => ['CIRCULARSTRING ZM (0 0 2 5,1 1 3 6,2 0 4 7)', [[0, 0, 2, 5], [1, 1, 3, 6], [2, 0, 4, 7]], CoordinateDimensionEnum::XYZM]; + yield 'case whitespace numeric syntax' => [" circularstring\n( -2.5 +0, 1e0 2.5, 3.5 -1 ) ", [[-2.5, 0], [1.0, 2.5], [3.5, -1]], CoordinateDimensionEnum::XY]; + foreach (['' => CoordinateDimensionEnum::XY, ' Z' => CoordinateDimensionEnum::XYZ, ' M' => CoordinateDimensionEnum::XYM, ' ZM' => CoordinateDimensionEnum::XYZM] as $marker => $dimension) { + yield 'empty '.$dimension->name => ['CIRCULARSTRING'.$marker.' EMPTY', [], $dimension]; + } + } + + /** @return iterable */ + public static function ewktDimensions(): iterable + { + yield 'inferred XYZ' => ['CIRCULARSTRING (0 0 2,1 1 3,2 0 4)', CoordinateDimensionEnum::XYZ]; + yield 'inferred XYZM' => ['CIRCULARSTRING (0 0 2 5,1 1 3 6,2 0 4 7)', CoordinateDimensionEnum::XYZM]; + yield 'compact Z' => ['CIRCULARSTRINGZ (0 0 2,1 1 3,2 0 4)', CoordinateDimensionEnum::XYZ]; + yield 'compact M' => ['CIRCULARSTRINGM (0 0 2,1 1 3,2 0 4)', CoordinateDimensionEnum::XYM]; + yield 'compact ZM' => ['CIRCULARSTRINGZM (0 0 2 5,1 1 3 6,2 0 4 7)', CoordinateDimensionEnum::XYZM]; + yield 'compact empty Z' => ['CIRCULARSTRINGZ EMPTY', CoordinateDimensionEnum::XYZ]; + yield 'compact empty M' => ['CIRCULARSTRINGM EMPTY', CoordinateDimensionEnum::XYM]; + yield 'compact empty ZM' => ['CIRCULARSTRINGZM EMPTY', CoordinateDimensionEnum::XYZM]; + } + + /** @return iterable */ + public static function invalidCurves(): iterable + { + yield 'collection dimension conflict' => ['GEOMETRYCOLLECTION Z (CIRCULARSTRING M EMPTY)']; + yield 'collection tuple mismatch' => ['GEOMETRYCOLLECTION Z (CIRCULARSTRING (0 0 1,1 1,2 0 3))']; + yield 'nested SRID' => ['GEOMETRYCOLLECTION (SRID=3857;CIRCULARSTRING EMPTY)']; + + foreach (['(0 0)', '(0 0,1 1)', '(0 0,1 1,2 0,3 1)', '(0 0,1 1,2 0,3 1,4 0,5 1)', + '(0 0,0 0,2 0)', '(0 0,2 0,2 0)', '(0 0,1 1,2 0,2 0,4 0)', + '()', '(EMPTY)', '(garbage)', '(0 0,1 1,2 0,)', '(0 0,1 1,2 0', + '((0 0,1 1,2 0))', '(0 0,1 1 2,2 0)', 'Z (0 0 1,1 1,2 0 3)', + 'M (0 0 1,1 1 2,2 0)', 'ZM (0 0 1 2,1 1 2 3,2 0 3)', + 'Z M EMPTY', 'EMPTY trailing', '(0 0,1e999 1,2 0)', '(0 0,1@1,2 0)'] as $body) { + yield $body => ['CIRCULARSTRING '.$body]; + } + } + + /** Verify nested curves inherit collection dimensions and the EWKT reference. */ + public function testCollectionInheritance(): void + { + foreach ([new WktDecoderStrategy(), new EwktDecoderStrategy()] as $strategy) { + $prefix = $strategy instanceof EwktDecoderStrategy ? 'SRID=4326;' : ''; + $collection = $strategy->decode($prefix.'GEOMETRYCOLLECTION M (CIRCULARSTRING (0 0 2,1 1 3,2 0 4),GEOMETRYCOLLECTION (CIRCULARSTRING EMPTY))'); + self::assertInstanceOf(CollectionInterface::class, $collection); + $curve = $collection->getElements()[0]; + self::assertInstanceOf(CircularStringInterface::class, $curve); + self::assertSame(CoordinateDimensionEnum::XYM, $curve->getDimension()); + self::assertSame([[0, 0, 2], [1, 1, 3], [2, 0, 4]], $curve->toArray()); + self::assertSame($collection->getSrid(), $curve->getSrid()); + $nested = $collection->getElements()[1]; + self::assertInstanceOf(CollectionInterface::class, $nested); + $empty = $nested->getElements()[0]; + self::assertInstanceOf(CircularStringInterface::class, $empty); + self::assertTrue($empty->isEmpty()); + self::assertSame(CoordinateDimensionEnum::XYM, $empty->getDimension()); + self::assertSame($collection->getSrid(), $empty->getSrid()); + } + } + + /** + * @param string $input EWKT representation + * @param CoordinateDimensionEnum $dimension expected layout + */ + #[DataProvider('ewktDimensions')] + public function testEwktDimensionExtensions(string $input, CoordinateDimensionEnum $dimension): void + { + foreach (['', 'SRID=3857;'] as $prefix) { + $curve = (new EwktDecoderStrategy())->decode($prefix.$input); + self::assertInstanceOf(CircularStringInterface::class, $curve); + self::assertSame($dimension, $curve->getDimension()); + self::assertSame('' === $prefix ? 0 : 3857, $curve->getSrid()); + } + + $this->expectException(InvalidArgumentException::class); + (new WktDecoderStrategy())->decode($input); + } + + /** + * @param string $input text representation + * @param list> $coordinates original control points + * @param CoordinateDimensionEnum $dimension expected coordinate layout + */ + #[DataProvider('curves')] + public function testPreservesCircularString(string $input, array $coordinates, CoordinateDimensionEnum $dimension): void + { + foreach ([new WktDecoderStrategy(), new EwktDecoderStrategy()] as $strategy) { + foreach ($strategy instanceof EwktDecoderStrategy ? ['', 'SRID=4326;'] : [''] as $prefix) { + $curve = (new Decoder($strategy))->decode($prefix.$input); + self::assertInstanceOf(CircularStringInterface::class, $curve); + self::assertSame(GeometryTypeEnum::CIRCULARSTRING, $curve->getType()); + self::assertSame($dimension, $curve->getDimension()); + self::assertSame($coordinates, $curve->toArray()); + self::assertSame([] === $coordinates, $curve->isEmpty()); + self::assertSame('' === $prefix ? 0 : 4326, $curve->getSrid()); + self::assertSame([] !== $coordinates && $coordinates[0] === $coordinates[\count($coordinates) - 1], $curve->isClosed()); + foreach ($curve->getPoints() as $point) { + self::assertSame($curve->getSrid(), $point->getSrid()); + } + } + } + } + + /** @param string $input malformed circular string */ + #[DataProvider('invalidCurves')] + public function testRejectsInvalidCurves(string $input): void + { + foreach ([new WktDecoderStrategy(), new EwktDecoderStrategy()] as $strategy) { + try { + $strategy->decode($strategy instanceof EwktDecoderStrategy ? 'SRID=4326;'.$input : $input); + self::fail('Invalid circular strings must fail through the decoder exception contract.'); + } catch (InvalidArgumentException $exception) { + self::assertNotSame('', $exception->getMessage()); + } + } + } + + /** Verify WKT continues to reject the EWKT reference prefix. */ + public function testWktRejectsSrid(): void + { + $this->expectException(InvalidArgumentException::class); + (new WktDecoderStrategy())->decode('SRID=4326;CIRCULARSTRING (0 0,1 1,2 0)'); + } + + /** Verify domain validation is translated without losing its cause. */ + public function testWrapsSpatialValidationFailure(): void + { + try { + (new WktDecoderStrategy())->decode('CIRCULARSTRING (0 0,1 1)'); + self::fail('An invalid control-point count must be rejected.'); + } catch (InvalidArgumentException $exception) { + self::assertInstanceOf(SpatialTypeExceptionInterface::class, $exception->getPrevious()); + } + } +} diff --git a/tests/Unit/CollectionDimensionDialectTest.php b/tests/Unit/CollectionDimensionDialectTest.php new file mode 100644 index 0000000..292c460 --- /dev/null +++ b/tests/Unit/CollectionDimensionDialectTest.php @@ -0,0 +1,129 @@ + 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\Tests\Unit; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialDecoder\Exception\InvalidArgumentException; +use LongitudeOne\SpatialDecoder\Strategy\EwktDecoderStrategy; +use LongitudeOne\SpatialDecoder\Strategy\WktDecoderStrategy; +use LongitudeOne\SpatialTypes\Interfaces\CircularStringInterface; +use LongitudeOne\SpatialTypes\Interfaces\CollectionInterface; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +/** @internal */ +class CollectionDimensionDialectTest extends TestCase +{ + /** @return iterable>}> */ + public static function dimensions(): iterable + { + yield 'Z' => ['Z', CoordinateDimensionEnum::XYZ, [[0, 0, 2], [1, 1, 3], [2, 0, 4]]]; + yield 'M' => ['M', CoordinateDimensionEnum::XYM, [[0, 0, 2], [1, 1, 3], [2, 0, 4]]]; + yield 'ZM' => ['ZM', CoordinateDimensionEnum::XYZM, [[0, 0, 2, 3], [1, 1, 3, 4], [2, 0, 4, 5]]]; + } + + /** @return iterable */ + public static function implicitWktMembers(): iterable + { + foreach (self::dimensions() as $name => [$marker, , $coordinates]) { + $body = self::coordinateBody($coordinates); + foreach (['CIRCULARSTRING '.$body, 'CIRCULARSTRING EMPTY', 'POINT (0 0'.('ZM' === $marker ? ' 2 3' : ' 2').')', 'POINT EMPTY'] as $member) { + foreach ([$member, 'GEOMETRYCOLLECTION ('.$member.')'] as $nested) { + yield $name.' sibling '.$nested => ['GEOMETRYCOLLECTION (POINT '.$marker.' EMPTY,'.$nested.')']; + yield $name.' reversed '.$nested => ['GEOMETRYCOLLECTION ('.$nested.',POINT '.$marker.' EMPTY)']; + } + } + } + } + + /** + * @param list> $coordinates control points + */ + private static function coordinateBody(array $coordinates): string + { + return '('.implode(',', array_map(static fn (array $point): string => implode(' ', $point), $coordinates)).')'; + } + + /** + * @param string $marker dimensional marker + * @param CoordinateDimensionEnum $dimension expected layout + * @param list> $coordinates expected control points + */ + #[DataProvider('dimensions')] + public function testEwktPreservesSiblingPropagation(string $marker, CoordinateDimensionEnum $dimension, array $coordinates): void + { + $body = self::coordinateBody($coordinates); + $firstMembers = ['POINT '.$marker.' EMPTY', 'CIRCULARSTRING '.$marker.' '.$body]; + if ('M' !== $marker) { + $firstMembers[] = 'CIRCULARSTRING '.$body; + } + foreach ($firstMembers as $first) { + $collection = (new EwktDecoderStrategy())->decode('SRID=4326;GEOMETRYCOLLECTION ('.$first.',GEOMETRYCOLLECTION (CIRCULARSTRING '.$body.',CIRCULARSTRING EMPTY))'); + self::assertInstanceOf(CollectionInterface::class, $collection); + $nested = $collection->getElements()[1]; + self::assertInstanceOf(CollectionInterface::class, $nested); + $curve = $nested->getElements()[0]; + $empty = $nested->getElements()[1]; + self::assertInstanceOf(CircularStringInterface::class, $curve); + self::assertInstanceOf(CircularStringInterface::class, $empty); + self::assertSame($dimension, $curve->getDimension()); + self::assertSame($coordinates, $curve->toArray()); + self::assertSame($dimension, $empty->getDimension()); + self::assertTrue($empty->isEmpty()); + self::assertSame(4326, $curve->getSrid()); + self::assertSame(4326, $empty->getSrid()); + } + } + + /** + * @param string $marker dimensional marker + * @param CoordinateDimensionEnum $dimension expected layout + * @param list> $coordinates expected control points + */ + #[DataProvider('dimensions')] + public function testExplicitAncestorAndMemberMarkers(string $marker, CoordinateDimensionEnum $dimension, array $coordinates): void + { + $body = self::coordinateBody($coordinates); + foreach ([new WktDecoderStrategy(), new EwktDecoderStrategy()] as $strategy) { + foreach ([ + 'GEOMETRYCOLLECTION '.$marker.' (POINT EMPTY,GEOMETRYCOLLECTION (CIRCULARSTRING '.$body.',CIRCULARSTRING EMPTY))', + 'GEOMETRYCOLLECTION (POINT '.$marker.' EMPTY,GEOMETRYCOLLECTION (CIRCULARSTRING '.$marker.' '.$body.',CIRCULARSTRING '.$marker.' EMPTY))', + ] as $input) { + $collection = $strategy->decode($input); + self::assertInstanceOf(CollectionInterface::class, $collection); + $nested = $collection->getElements()[1]; + self::assertInstanceOf(CollectionInterface::class, $nested); + $curve = $nested->getElements()[0]; + $empty = $nested->getElements()[1]; + self::assertInstanceOf(CircularStringInterface::class, $curve); + self::assertInstanceOf(CircularStringInterface::class, $empty); + self::assertSame($dimension, $curve->getDimension()); + self::assertSame($coordinates, $curve->toArray()); + self::assertSame($dimension, $empty->getDimension()); + self::assertTrue($empty->isEmpty()); + } + } + } + + /** @param string $input WKT lacking an explicit ancestor or member marker */ + #[DataProvider('implicitWktMembers')] + public function testWktRejectsSiblingDimension(string $input): void + { + $this->expectException(InvalidArgumentException::class); + (new WktDecoderStrategy())->decode($input); + } +} diff --git a/tests/Unit/EwktGeometryCoverageTest.php b/tests/Unit/EwktGeometryCoverageTest.php index 92df8d5..2e4417d 100644 --- a/tests/Unit/EwktGeometryCoverageTest.php +++ b/tests/Unit/EwktGeometryCoverageTest.php @@ -80,7 +80,7 @@ public static function invalidGeometries(): iterable /** @return iterable */ public static function unimplementedTypes(): iterable { - foreach ([GeometryTypeEnum::CIRCULARSTRING, GeometryTypeEnum::COMPOUNDCURVE, + foreach ([GeometryTypeEnum::COMPOUNDCURVE, GeometryTypeEnum::CURVEPOLYGON, GeometryTypeEnum::MULTICURVE, GeometryTypeEnum::MULTISURFACE, GeometryTypeEnum::TIN] as $type) { yield $type->name => [$type]; diff --git a/tests/Unit/NonInstantiableGeometryTypeTest.php b/tests/Unit/NonInstantiableGeometryTypeTest.php index 1d08186..e39f816 100644 --- a/tests/Unit/NonInstantiableGeometryTypeTest.php +++ b/tests/Unit/NonInstantiableGeometryTypeTest.php @@ -72,12 +72,12 @@ public function testStrategiesRejectAbstractTypesBeforeBodyValidation(GeometryTy public function testUnknownAndUnimplementedTypesRemainDistinct(): void { foreach ([new WktDecoderStrategy(), new EwktDecoderStrategy()] as $strategy) { - foreach (['UNKNOWN', 'CURVEUNKNOWN', 'GEOMETRYUNKNOWN', 'SURFACEUNKNOWN', 'SOLIDUNKNOWN', 'CIRCULARSTRING'] as $keyword) { + foreach (['UNKNOWN', 'CURVEUNKNOWN', 'GEOMETRYUNKNOWN', 'SURFACEUNKNOWN', 'SOLIDUNKNOWN', 'CURVEPOLYGON'] as $keyword) { try { $strategy->decode($keyword.' EMPTY'); self::fail('An unknown or unimplemented type must be rejected.'); } catch (DecoderExceptionInterface $exception) { - self::assertSame('CIRCULARSTRING' === $keyword ? NotYetImplementedException::class : InvalidArgumentException::class, $exception::class); + self::assertSame('CURVEPOLYGON' === $keyword ? NotYetImplementedException::class : InvalidArgumentException::class, $exception::class); } } } diff --git a/tests/Unit/SpatialTypeImplementationTest.php b/tests/Unit/SpatialTypeImplementationTest.php index fb466d9..49642b6 100644 --- a/tests/Unit/SpatialTypeImplementationTest.php +++ b/tests/Unit/SpatialTypeImplementationTest.php @@ -44,7 +44,7 @@ public static function invalidInputs(): iterable /** @return iterable */ public static function missingTypes(): iterable { - foreach ([GeometryTypeEnum::CIRCULARSTRING, GeometryTypeEnum::COMPOUNDCURVE, + foreach ([GeometryTypeEnum::COMPOUNDCURVE, GeometryTypeEnum::CURVEPOLYGON, GeometryTypeEnum::MULTICURVE, GeometryTypeEnum::MULTISURFACE, GeometryTypeEnum::TIN] as $type) { yield $type->name => [$type]; @@ -54,7 +54,7 @@ public static function missingTypes(): iterable /** Test all implemented geometry keywords remain available to parsers. */ public function testImplementedTypesRemainLexerTokens(): void { - foreach ([GeometryTypeEnum::POINT, GeometryTypeEnum::LINESTRING, GeometryTypeEnum::POLYGON, + foreach ([GeometryTypeEnum::POINT, GeometryTypeEnum::LINESTRING, GeometryTypeEnum::CIRCULARSTRING, GeometryTypeEnum::POLYGON, GeometryTypeEnum::TRIANGLE, GeometryTypeEnum::POLYHEDRALSURFACE, GeometryTypeEnum::MULTIPOINT, GeometryTypeEnum::MULTILINESTRING, GeometryTypeEnum::MULTIPOLYGON, GeometryTypeEnum::GEOMETRYCOLLECTION] as $type) { @@ -86,7 +86,7 @@ public function testMissingImplementationIsReportedBeforeBodyValidation(): void { $this->expectException(NotYetImplementedException::class); - (new WktDecoderStrategy())->decode('CIRCULARSTRING (garbage)'); + (new WktDecoderStrategy())->decode('CURVEPOLYGON (garbage)'); } /**