diff --git a/README.md b/README.md index e36630e..282925d 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,18 @@ The shared WKT/EWKT lexer uses `SpatialTypeImplementationStatus` from keywords. `CIRCULARSTRING`, `COMPOUNDCURVE`, `CURVEPOLYGON`, `MULTICURVE`, `MULTISURFACE`, and `TIN` raise `NotYetImplementedException`. Non-instantiable enum types raise the decoder's `InvalidArgumentException`. Implemented types -continue to the existing parsers; implementation in the spatial model does not -by itself imply parser support (for example, `TRIANGLE` and `POLYHEDRALSURFACE`). +continue to the representation-specific parsers. EWKT supports `POINT`, +`LINESTRING`, `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`. + +EWKT triangles support XY, XYZ, XYM, and XYZM. Polyhedral surfaces require XYZ +or XYZM, including for EMPTY values; XY and XYM raise `InvalidArgumentException`, +not `NotYetImplementedException`. Surface construction validates triangle rings +and polyhedral patches using the spatial model constraints, reporting invalid +structures through the decoder's `InvalidArgumentException`. Root SRID, +coordinates, dimensions, and the geometry family are preserved recursively. Classification happens during lexical scanning, before grammar validation, including inside collections. A recognized unimplemented keyword therefore diff --git a/lib/Strategy/Wkt/Parser/MultiPolygonWktParser.php b/lib/Strategy/Wkt/Parser/MultiPolygonWktParser.php index 932f732..2b532c1 100644 --- a/lib/Strategy/Wkt/Parser/MultiPolygonWktParser.php +++ b/lib/Strategy/Wkt/Parser/MultiPolygonWktParser.php @@ -22,7 +22,7 @@ use LongitudeOne\SpatialDecoder\Strategy\Wkt\Factory\WktPolygonFactory; use LongitudeOne\SpatialDecoder\Strategy\Wkt\WktCoordinateReader; use LongitudeOne\SpatialDecoder\Strategy\Wkt\WktTokenCursor; -use LongitudeOne\SpatialTypes\Interfaces\SpatialInterface; +use LongitudeOne\SpatialTypes\Interfaces\MultiPolygonInterface; /** * Parses WKT multi-polygon representations. @@ -55,9 +55,9 @@ public function __construct( * * @param CoordinateDimensionEnum|null $inheritedDimension dimension inherited from a parent collection * - * @return SpatialInterface the decoded multi-polygon + * @return MultiPolygonInterface the decoded multi-polygon */ - public function parse(?CoordinateDimensionEnum $inheritedDimension = null): SpatialInterface + public function parse(?CoordinateDimensionEnum $inheritedDimension = null): MultiPolygonInterface { $dimension = $this->coordinateReader->consumeDimension($inheritedDimension); if ($this->cursor->isNextToken(Lexer::T_EMPTY)) { diff --git a/lib/Strategy/Wkt/Parser/PolygonWktParser.php b/lib/Strategy/Wkt/Parser/PolygonWktParser.php index 884a898..26e5d30 100644 --- a/lib/Strategy/Wkt/Parser/PolygonWktParser.php +++ b/lib/Strategy/Wkt/Parser/PolygonWktParser.php @@ -21,7 +21,7 @@ use LongitudeOne\SpatialDecoder\Strategy\Wkt\Factory\WktPolygonFactory; use LongitudeOne\SpatialDecoder\Strategy\Wkt\WktCoordinateReader; use LongitudeOne\SpatialDecoder\Strategy\Wkt\WktTokenCursor; -use LongitudeOne\SpatialTypes\Interfaces\SpatialInterface; +use LongitudeOne\SpatialTypes\Interfaces\PolygonInterface; /** * Parses WKT polygon representations. @@ -76,9 +76,9 @@ public function consumePolygon(?CoordinateDimensionEnum $dimension): array * * @param CoordinateDimensionEnum|null $inheritedDimension dimension inherited from a parent collection * - * @return SpatialInterface the decoded polygon + * @return PolygonInterface the decoded polygon */ - public function parse(?CoordinateDimensionEnum $inheritedDimension = null): SpatialInterface + public function parse(?CoordinateDimensionEnum $inheritedDimension = null): PolygonInterface { $dimension = $this->coordinateReader->consumeDimension($inheritedDimension); if ($this->cursor->isNextToken(Lexer::T_EMPTY)) { diff --git a/lib/Strategy/Wkt/Parser/PolyhedralSurfaceEwktParser.php b/lib/Strategy/Wkt/Parser/PolyhedralSurfaceEwktParser.php new file mode 100644 index 0000000..87fe145 --- /dev/null +++ b/lib/Strategy/Wkt/Parser/PolyhedralSurfaceEwktParser.php @@ -0,0 +1,61 @@ + 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\Exception\InvalidArgumentException; +use LongitudeOne\SpatialTypes\Exception\SpatialTypeExceptionInterface; +use LongitudeOne\SpatialTypes\Interfaces\PolyhedralSurfaceInterface; +use LongitudeOne\SpatialTypes\Types\Dimension3z\Geometry\PolyhedralSurface as PolyhedralSurfaceXYZ; +use LongitudeOne\SpatialTypes\Types\Dimension4zm\Geometry\PolyhedralSurface as PolyhedralSurfaceXYZM; + +/** + * Decode an EWKT polyhedral surface using the shared polygonal grammar. + * + * @internal + */ +final class PolyhedralSurfaceEwktParser implements WktGeometryParserInterface +{ + /** + * Construct the parser over the shared polygonal grammar. + * + * @param MultiPolygonWktParser $parser parser for the polygonal body + */ + public function __construct(private MultiPolygonWktParser $parser) + { + } + + /** + * Parse and validate the concrete surface before returning it. + * + * @param CoordinateDimensionEnum|null $inheritedDimension dimension inherited from a collection + */ + public function parse(?CoordinateDimensionEnum $inheritedDimension = null): PolyhedralSurfaceInterface + { + $geometry = $this->parser->parse($inheritedDimension); + + try { + return match ($geometry->getDimension()) { + CoordinateDimensionEnum::XYZ => new PolyhedralSurfaceXYZ($geometry->getElements()), + CoordinateDimensionEnum::XYZM => new PolyhedralSurfaceXYZM($geometry->getElements()), + default => throw new InvalidArgumentException('EWKT POLYHEDRALSURFACE requires a coordinate layout containing Z.'), + }; + } catch (SpatialTypeExceptionInterface $exception) { + throw new InvalidArgumentException($exception->getMessage(), 0, $exception); + } + } +} diff --git a/lib/Strategy/Wkt/Parser/TriangleEwktParser.php b/lib/Strategy/Wkt/Parser/TriangleEwktParser.php new file mode 100644 index 0000000..eccbdc2 --- /dev/null +++ b/lib/Strategy/Wkt/Parser/TriangleEwktParser.php @@ -0,0 +1,64 @@ + 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\Exception\InvalidArgumentException; +use LongitudeOne\SpatialTypes\Exception\SpatialTypeExceptionInterface; +use LongitudeOne\SpatialTypes\Interfaces\TriangleInterface; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\Triangle as TriangleXY; +use LongitudeOne\SpatialTypes\Types\Dimension3m\Geometry\Triangle as TriangleXYM; +use LongitudeOne\SpatialTypes\Types\Dimension3z\Geometry\Triangle as TriangleXYZ; +use LongitudeOne\SpatialTypes\Types\Dimension4zm\Geometry\Triangle as TriangleXYZM; + +/** + * Decode an EWKT triangle using the shared polygonal grammar. + * + * @internal + */ +final class TriangleEwktParser implements WktGeometryParserInterface +{ + /** + * Construct the parser over the shared polygonal grammar. + * + * @param PolygonWktParser $parser parser for the polygonal body + */ + public function __construct(private PolygonWktParser $parser) + { + } + + /** + * Parse and validate the concrete surface before returning it. + * + * @param CoordinateDimensionEnum|null $inheritedDimension dimension inherited from a collection + */ + public function parse(?CoordinateDimensionEnum $inheritedDimension = null): TriangleInterface + { + $geometry = $this->parser->parse($inheritedDimension); + + try { + return match ($geometry->getDimension()) { + CoordinateDimensionEnum::XY => new TriangleXY($geometry->getRings()), + CoordinateDimensionEnum::XYZ => new TriangleXYZ($geometry->getRings()), + CoordinateDimensionEnum::XYM => new TriangleXYM($geometry->getRings()), + CoordinateDimensionEnum::XYZM => new TriangleXYZM($geometry->getRings()), + }; + } catch (SpatialTypeExceptionInterface $exception) { + throw new InvalidArgumentException($exception->getMessage(), 0, $exception); + } + } +} diff --git a/lib/Strategy/Wkt/Parser/WktParserFactory.php b/lib/Strategy/Wkt/Parser/WktParserFactory.php index 1493b49..a566f0c 100644 --- a/lib/Strategy/Wkt/Parser/WktParserFactory.php +++ b/lib/Strategy/Wkt/Parser/WktParserFactory.php @@ -81,6 +81,14 @@ public function createFromCursor(WktTokenCursor $cursor, WktDialect $dialect = W new GeometryCollectionWktParser($cursor, $coordinateReader, new WktGeometryCollectionFactory(), $registry) ); + if (WktDialect::EWKT === $dialect) { + $registry->register(Lexer::T_TRIANGLE, new TriangleEwktParser(new PolygonWktParser($cursor, $coordinateReader, $polygonFactory))); + $registry->register( + Lexer::T_POLYHEDRALSURFACE, + new PolyhedralSurfaceEwktParser(new MultiPolygonWktParser($cursor, $coordinateReader, new WktMultiPolygonFactory($polygonFactory), $polygonFactory)) + ); + } + return new WktParser($cursor, $registry); } } diff --git a/tests/Unit/EwktGeometryCoverageTest.php b/tests/Unit/EwktGeometryCoverageTest.php new file mode 100644 index 0000000..6888000 --- /dev/null +++ b/tests/Unit/EwktGeometryCoverageTest.php @@ -0,0 +1,207 @@ + 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\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialDecoder\Exception\InvalidArgumentException; +use LongitudeOne\SpatialDecoder\Exception\NotYetImplementedException; +use LongitudeOne\SpatialDecoder\Strategy\EwktDecoderStrategy; +use LongitudeOne\SpatialDecoder\Strategy\WktDecoderStrategy; +use LongitudeOne\SpatialTypes\Interfaces\CollectionInterface; +use LongitudeOne\SpatialTypes\Interfaces\LineStringInterface; +use LongitudeOne\SpatialTypes\Interfaces\PolygonInterface; +use LongitudeOne\SpatialTypes\Interfaces\PolyhedralSurfaceInterface; +use LongitudeOne\SpatialTypes\Interfaces\SpatialInterface; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +/** + * @internal + * + * @covers \LongitudeOne\SpatialDecoder\Strategy\Wkt\Parser\TriangleEwktParser + * @covers \LongitudeOne\SpatialDecoder\Strategy\Wkt\Parser\PolyhedralSurfaceEwktParser + */ +class EwktGeometryCoverageTest extends TestCase +{ + /** @return iterable */ + public static function emptyGeometries(): iterable + { + $types = [GeometryTypeEnum::POINT, GeometryTypeEnum::LINESTRING, GeometryTypeEnum::POLYGON, + GeometryTypeEnum::TRIANGLE, GeometryTypeEnum::POLYHEDRALSURFACE, GeometryTypeEnum::MULTIPOINT, + GeometryTypeEnum::MULTILINESTRING, GeometryTypeEnum::MULTIPOLYGON, GeometryTypeEnum::GEOMETRYCOLLECTION]; + foreach ($types as $type) { + foreach (CoordinateDimensionEnum::cases() as $dimension) { + if (GeometryTypeEnum::POLYHEDRALSURFACE === $type && \in_array($dimension, [CoordinateDimensionEnum::XY, CoordinateDimensionEnum::XYM], true)) { + continue; + } + $marker = match ($dimension) { + CoordinateDimensionEnum::XY => '', CoordinateDimensionEnum::XYZ => ' Z', + CoordinateDimensionEnum::XYM => ' M', CoordinateDimensionEnum::XYZM => ' ZM', + }; + yield $type->name.$marker => [$type->name.$marker.' EMPTY', $type, $dimension]; + } + } + } + + /** @return iterable */ + public static function invalidGeometries(): iterable + { + yield 'triangle quadrilateral' => ['TRIANGLE ((0 0,4 0,4 4,0 4,0 0))']; + yield 'triangle hole' => ['TRIANGLE ((0 0,4 0,0 4,0 0),(1 1,2 1,1 2,1 1))']; + yield 'triangle open ring' => ['TRIANGLE ((0 0,4 0,0 4,1 1))']; + yield 'triangle mixed dimension' => ['TRIANGLE Z ((0 0 0,4 0 0,0 4,0 0 0))']; + yield 'surface XY' => ['POLYHEDRALSURFACE (((0 0,4 0,0 4,0 0)))']; + yield 'surface XY empty' => ['POLYHEDRALSURFACE EMPTY']; + yield 'surface M empty' => ['POLYHEDRALSURFACE M EMPTY']; + yield 'surface empty patch' => ['POLYHEDRALSURFACE Z (EMPTY)']; + yield 'surface nonplanar patch' => ['POLYHEDRALSURFACE Z (((0 0 0,4 0 0,4 4 1,0 4 0,0 0 0)))']; + yield 'surface mixed patches' => ['POLYHEDRALSURFACE Z (((0 0 0,4 0 0,0 4 0,0 0 0)),((0 0,4 0,0 4,0 0)))']; + yield 'surface missing parenthesis' => ['POLYHEDRALSURFACE Z ((0 0 0,4 0 0,0 4 0,0 0 0))']; + yield 'nested independent SRID' => ['GEOMETRYCOLLECTION Z (TRIANGLE EMPTY,SRID=3857;POLYHEDRALSURFACE EMPTY)']; + yield 'unknown' => ['UNKNOWN EMPTY']; + foreach (['GEOMETRY', 'CURVE', 'SURFACE', 'SOLID'] as $type) { + yield $type => [$type.' EMPTY']; + yield 'nested '.$type => ['GEOMETRYCOLLECTION ('.$type.' EMPTY)']; + } + } + + /** @return iterable */ + public static function unimplementedTypes(): iterable + { + foreach ([GeometryTypeEnum::CIRCULARSTRING, GeometryTypeEnum::COMPOUNDCURVE, + GeometryTypeEnum::CURVEPOLYGON, GeometryTypeEnum::MULTICURVE, + GeometryTypeEnum::MULTISURFACE, GeometryTypeEnum::TIN] as $type) { + yield $type->name => [$type]; + } + } + + /** + * @param string $input EWKT geometry + * @param GeometryTypeEnum $type expected type + * @param CoordinateDimensionEnum $dimension expected layout + */ + #[DataProvider('emptyGeometries')] + public function testEmptyGeometry(string $input, GeometryTypeEnum $type, CoordinateDimensionEnum $dimension): void + { + $geometry = (new EwktDecoderStrategy())->decode('SRID=4326;'.$input); + self::assertTrue($geometry->isEmpty()); + self::assertSame($type, $geometry->getType()); + self::assertSame($dimension, $geometry->getDimension()); + $this->assertMetadata($geometry); + } + + /** @param string $input invalid geometry */ + #[DataProvider('invalidGeometries')] + public function testInvalidGeometry(string $input): void + { + $this->expectException(InvalidArgumentException::class); + (new EwktDecoderStrategy())->decode('SRID=4326;'.$input); + } + + /** Measures survive both explicit and inferred XYZM surface decoding. */ + public function testMeasuredSurface(): void + { + foreach (['', ' ZM'] as $marker) { + $surface = (new EwktDecoderStrategy())->decode('SRID=4326;POLYHEDRALSURFACE'.$marker.' (((0 0 0 7,4 0 0 8,0 4 0 9,0 0 0 7)))'); + self::assertSame(CoordinateDimensionEnum::XYZM, $surface->getDimension()); + self::assertSame([[[[0, 0, 0, 7], [4, 0, 0, 8], [0, 4, 0, 9], [0, 0, 0, 7]]]], $surface->toArray()); + $this->assertMetadata($surface); + } + } + + /** Preserve inferred layouts, patches, nested members, and root references. */ + public function testNestedSurfaces(): void + { + $patch = [[0, 0, 0], [4, 0, 0], [0, 4, 0], [0, 0, 0]]; + $other = [[0, 0, 0], [0, 0, 4], [4, 0, 0], [0, 0, 0]]; + $surface = 'POLYHEDRALSURFACE (((0 0 0,4 0 0,0 4 0,0 0 0)),((0 0 0,0 0 4,4 0 0,0 0 0)))'; + $geometry = (new EwktDecoderStrategy())->decode('SRID=4326;GEOMETRYCOLLECTION ('.$surface.',GEOMETRYCOLLECTION (TRIANGLE ((0 0 0,4 0 0,0 4 0,0 0 0)),TRIANGLE EMPTY))'); + self::assertInstanceOf(CollectionInterface::class, $geometry); + self::assertSame(CoordinateDimensionEnum::XYZ, $geometry->getDimension()); + $elements = $geometry->getElements(); + self::assertSame(GeometryTypeEnum::POLYHEDRALSURFACE, $elements[0]->getType()); + self::assertSame([[$patch], [$other]], $elements[0]->toArray()); + self::assertSame([[$patch], []], $elements[1]->toArray()); + $this->assertMetadata($geometry); + } + + /** Preserve each triangle layout and the coordinates of its closed ring. */ + public function testTriangleLayouts(): void + { + foreach (['' => [0, 0], 'Z' => [0, 0, 2], 'M' => [0, 0, 7], 'ZM' => [0, 0, 2, 7]] as $marker => $origin) { + $right = $origin; + $right[0] = 4; + $top = $origin; + $top[1] = 4; + $ring = [$origin, $right, $top, $origin]; + $text = implode(',', array_map(static fn (array $point): string => implode(' ', $point), $ring)); + $triangle = (new EwktDecoderStrategy())->decode('SRID=4326;TRIANGLE'.$marker.' (('.$text.'))'); + self::assertSame(GeometryTypeEnum::TRIANGLE, $triangle->getType()); + self::assertSame([$ring], $triangle->toArray()); + self::assertSame(str_contains($marker, 'Z'), $triangle->hasZ()); + self::assertSame(str_contains($marker, 'M'), $triangle->hasM()); + $this->assertMetadata($triangle); + } + } + + /** @param GeometryTypeEnum $type recognized unimplemented type */ + #[DataProvider('unimplementedTypes')] + public function testUnimplementedType(GeometryTypeEnum $type): void + { + foreach ([$type->name.' EMPTY', 'GEOMETRYCOLLECTION (GEOMETRYCOLLECTION ('.$type->name.' EMPTY))'] as $input) { + try { + (new EwktDecoderStrategy())->decode($input); + self::fail('Expected an unimplemented geometry exception.'); + } catch (NotYetImplementedException $exception) { + self::assertSame($type, $exception->getSpatialType()); + self::assertStringContainsString($type->value, $exception->getMessage()); + } + } + } + + /** Standard WKT keeps its existing supported geometry set. */ + public function testWktSurfaceSupportIsUnchanged(): void + { + foreach (['TRIANGLE EMPTY', 'POLYHEDRALSURFACE Z EMPTY'] as $input) { + try { + (new WktDecoderStrategy())->decode($input); + self::fail('WKT must retain its existing unsupported-type behavior.'); + } catch (InvalidArgumentException $exception) { + self::assertStringContainsString('not supported', $exception->getMessage()); + } + } + } + + /** @param SpatialInterface $geometry geometry and descendants to inspect */ + private function assertMetadata(SpatialInterface $geometry): void + { + self::assertSame(4326, $geometry->getSrid()); + self::assertSame(SpatialModelEnum::GEOMETRY, $geometry->getFamily()); + $children = match (true) { + $geometry instanceof PolygonInterface => $geometry->getRings(), + $geometry instanceof LineStringInterface => $geometry->getPoints(), + $geometry instanceof PolyhedralSurfaceInterface => $geometry->getPatches(), + $geometry instanceof CollectionInterface => $geometry->getElements(), + default => [], + }; + foreach ($children as $child) { + $this->assertMetadata($child); + } + } +} diff --git a/tests/Unit/SpatialTypeImplementationTest.php b/tests/Unit/SpatialTypeImplementationTest.php index ba15efb..40842ba 100644 --- a/tests/Unit/SpatialTypeImplementationTest.php +++ b/tests/Unit/SpatialTypeImplementationTest.php @@ -37,7 +37,7 @@ public static function invalidInputs(): iterable { foreach (['GEOMETRY EMPTY', 'CURVE EMPTY', 'SURFACE EMPTY', 'SOLID EMPTY', 'UNKNOWN EMPTY', 'CIRCULARSTRINGUNKNOWN EMPTY', 'POINT (1)', 'POINT (1 @ 2)', - 'TRIANGLE EMPTY', 'POLYHEDRALSURFACE EMPTY'] as $input) { + 'TRIANGLE ((0 0,1 0,0 0))', 'POLYHEDRALSURFACE EMPTY'] as $input) { yield $input => [$input]; } }