From 34b35ba6febd877a5f64b2bd2a0501abcc16bb72 Mon Sep 17 00:00:00 2001 From: Alexandre Tranchant Date: Thu, 29 Oct 2026 15:00:16 +0100 Subject: [PATCH 1/2] feat(types): edit mutable line strings and circular arcs --- docs/immutability.md | 18 +- docs/instantiable-spatial-types.md | 88 ++++ docs/type-class-hierarchy.md | 19 + lib/Interfaces/CircularStringInterface.php | 2 +- lib/Interfaces/LineStringInterface.php | 2 +- .../MutableCircularStringInterface.php | 76 +++- lib/Interfaces/MutableLineStringInterface.php | 60 ++- lib/Interfaces/MutablePointInterface.php | 3 + lib/Types/AbstractCircularString.php | 77 ++++ lib/Types/AbstractLineString.php | 46 +++ lib/Types/AbstractPointSequence.php | 179 ++++++++ lib/Types/Dimension2/CircularString.php | 30 ++ lib/Types/Dimension2/LineString.php | 30 ++ lib/Types/Dimension3m/CircularString.php | 30 ++ lib/Types/Dimension3m/LineString.php | 30 ++ lib/Types/Dimension3z/CircularString.php | 30 ++ lib/Types/Dimension3z/LineString.php | 30 ++ lib/Types/Dimension4zm/CircularString.php | 30 ++ lib/Types/Dimension4zm/LineString.php | 30 ++ lib/Types/MutablePointHydrator.php | 44 ++ lib/Types/PointSequenceEditingTrait.php | 80 ++++ .../Unit/Types/MutableCircularStringTest.php | 384 ++++++++++++++++++ tests/Unit/Types/MutableLineStringTest.php | 359 ++++++++++++++++ .../Types/MutablePointSequenceContextTest.php | 305 ++++++++++++++ 24 files changed, 1976 insertions(+), 6 deletions(-) create mode 100644 lib/Types/AbstractCircularString.php create mode 100644 lib/Types/AbstractLineString.php create mode 100644 lib/Types/AbstractPointSequence.php create mode 100644 lib/Types/Dimension2/CircularString.php create mode 100644 lib/Types/Dimension2/LineString.php create mode 100644 lib/Types/Dimension3m/CircularString.php create mode 100644 lib/Types/Dimension3m/LineString.php create mode 100644 lib/Types/Dimension3z/CircularString.php create mode 100644 lib/Types/Dimension3z/LineString.php create mode 100644 lib/Types/Dimension4zm/CircularString.php create mode 100644 lib/Types/Dimension4zm/LineString.php create mode 100644 lib/Types/MutablePointHydrator.php create mode 100644 lib/Types/PointSequenceEditingTrait.php create mode 100644 tests/Unit/Types/MutableCircularStringTest.php create mode 100644 tests/Unit/Types/MutableLineStringTest.php create mode 100644 tests/Unit/Types/MutablePointSequenceContextTest.php diff --git a/docs/immutability.md b/docs/immutability.md index e7fd810..622bf50 100644 --- a/docs/immutability.md +++ b/docs/immutability.md @@ -100,7 +100,10 @@ and dimension; `withSpatialReference()` and `withSrid()` preserve them. Mutable `Point` classes are available directly under each dimension namespace. See [mutable points](instantiable-spatial-types.md#mutable-points) for constructors -and in-place operations. Mutable aggregates arrive in later Stories of Epic #38. +and in-place operations. Mutable `LineString` and `CircularString` classes are also available directly under +each dimension namespace. See [mutable paths](instantiable-spatial-types.md#mutable-line-strings-and-circular-strings) +for point ownership, propagation and arc indexing. Other mutable aggregates arrive +in later Stories of Epic #38. Existing factories create the explicitly named immutable classes and expose immutable return contracts. @@ -202,7 +205,6 @@ creates a deep copy whose descendants all receive the requested reference. See [Instantiable spatial types](instantiable-spatial-types.md#immutability-contract) for the complete current mutability contract. - Circular strings also provide `withArrayOfCoordinates()` without functional validity checks. Reference replacements preserve defining coordinates and copy the contained points. Editing an endpoint does not automatically edit the @@ -226,3 +228,15 @@ $partial = $paris->withCoordinates(Coordinates::xy(0, null)); assert([0, null] === $partial->toArray()); assert(!$partial->isEmpty()); ``` + +### Mutable path ownership + +Mutable line strings and circular strings reject immutable point components +without conversion. Supplied mutable points are retained by reference on +construction, insertion and replacement; getters expose those same instances. +Mutating a shared point is observable through every path containing it. + +Their declared-context setters accept a second `$propagate` argument, defaulting +to `true`. Propagation edits all contained points in place, so other paths sharing +those points observe the updated point metadata. Passing `false` updates only the +receiving path. Ordinates never change during a declared-context update. diff --git a/docs/instantiable-spatial-types.md b/docs/instantiable-spatial-types.md index 093bc85..462516e 100644 --- a/docs/instantiable-spatial-types.md +++ b/docs/instantiable-spatial-types.md @@ -623,3 +623,91 @@ Committee Draft: clauses 4.2.7 and 7.4.1 (component types, continuity and empty values), and 7.4.6–7.4.7 (endpoints, including `null` for EMPTY). The library's point equality semantics apply to endpoint predicates; continuity is not enforced. This feature adds no serialization, decoding, linearization or factory entry points. + +## Mutable line strings and circular strings + +`Types\Dimension2`, `Types\Dimension3z`, `Types\Dimension3m` and +`Types\Dimension4zm` each provide `LineString` and `CircularString`. They implement +`MutableLineStringInterface` and `MutableCircularStringInterface` respectively. +Their constructor is `__construct(array $points, int|SpatialReference $srid = 0, +SpatialModelEnum $family = SpatialModelEnum::GEOMETRY)`. The coordinate layout and +spatial type remain fixed; the declared family is instance state. + +Supply mutable point objects or indexed coordinate tuples in the selected layout. +Tuples create mutable points using the line's declared family and reference, +including the existing X/Y coordinate-string parser. Numeric and null Z/M values +follow the corresponding point constructor. Immutable point objects are rejected +without conversion. Supplied mutable points keep their identity, ordinates, +family and reference; construction and membership edits do not synchronize them. +Functional compatibility and geometric validity remain consumer concerns. + +| Operation | Effect | +| --- | --- | +| `addPoint($point)` | Append the supplied mutable point | +| `insertPoint($index, $point)` | Insert before the zero-based index; the point count appends | +| `setPoint($index, $point)` | Replace an existing point | +| `removePoint($index)` | Remove an existing point and reindex the remaining points | +| `setArrayOfCoordinates($tuples)` | Replace all points with newly constructed mutable points | +| `getPoint($index)` | Return the same mutable point instance at an existing index | +| `getPoints()` / `getElements()` | Return an ordered array of the same mutable point instances | + +Indexed access, replacement and removal require an existing non-negative index; +invalid addressing throws `OutOfBoundsException`. Unlike the immutable variants, +mutable paths do not wrap indices or accept negative indices. Failed membership, +coordinate or arc edits preserve the path and all referenced points. All mutation +methods return the receiver with return type `static`; there are no `with…` +methods or dimension setter. + +```php +use LongitudeOne\SpatialTypes\Types\Dimension2\LineString; +use LongitudeOne\SpatialTypes\Types\Dimension2\Point; + +$shared = new Point(0, null, 4326); +$first = new LineString([$shared], 4326); +$second = new LineString([$shared], 4326); +$first->addPoint(new Point(2, 3))->insertPoint(1, new Point(1, null)); +$shared->setY(4); // Both lines now expose [0, 4] for their first point. +$first->setSrid(2154); // Updates the shared point, also observed through $second. +$first->setSrid(3857, false); // Updates only $first's declared reference. +``` + +`setSrid($srid, $propagate = true)`, +`setSpatialReference($reference, $propagate = true)` and +`setFamily($family, $propagate = true)` change the path's declared context. +By default they also update every contained point, including shared instances in +other paths. Passing `false` changes only the receiver. None of these operations +transforms ordinates or updates the metadata of another containing path. +Returned point arrays cannot change membership, but their mutable points remain shared. + +### Circular arc indexing + +General point editing is available even for EMPTY or incomplete circular paths. +`setOrigin($point)` inserts the first point into an empty circular string or +replaces only its existing first point. `appendArc($passingPoint, +$destinationPoint)` appends two points after an origin followed by zero or more +complete pairs. Without an origin or after an incomplete pair, it throws +`InvalidValueException` without mutation. + +| Arc index | Origin point index | Passing point index | Destination point index | +| --- | --- | --- | --- | +| `0` | `0` | `1` | `2` | +| `1` | `2` | `3` | `4` | +| `i` | `2 * i` | `2 * i + 1` | `2 * i + 2` | + +```php +use LongitudeOne\SpatialTypes\Types\Dimension2\CircularString; + +$path = new CircularString([]); +$path->setOrigin(new Point(0, 0)) + ->appendArc(new Point(1, 1), new Point(2, 0)) + ->appendArc(new Point(3, -1), new Point(4, 0)); +$path->replaceArc(0, new Point(1, 2), new Point(2, 1)); +// Point 0 is retained; points 1 and 2 are replaced. +// Point 2 is also the origin of arc 1, whose points 3 and 4 are retained. +``` + +`replaceArc()` requires all three positions of the addressed arc to exist and +throws `OutOfBoundsException` otherwise. Arc helpers check only this sequence +structure. They do not validate circle geometry, coordinate completeness or +continuity, close the path, repair points, or enforce geometric minimum counts. +Null ordinates, EMPTY points and incomplete point sequences remain representable. diff --git a/docs/type-class-hierarchy.md b/docs/type-class-hierarchy.md index a25c06f..1b3224c 100644 --- a/docs/type-class-hierarchy.md +++ b/docs/type-class-hierarchy.md @@ -53,3 +53,22 @@ storage; they are not line strings and do not inherit point-set simplicity checks. Compound curves retain ordered `ImmutableCurveInterface` components and require curve interfaces and point tuples with numeric or null ordinates; functional compatibility and endpoint continuity are not checked. + +Mutable point-defined paths extend the read-only spatial base independently of +immutable implementations: + +```text +AbstractSpatialType +└── AbstractPointSequence + ├── AbstractLineString + │ └── Dimension{2,3m,3z,4zm}\LineString + └── AbstractCircularString + └── Dimension{2,3m,3z,4zm}\CircularString +``` + +`PointSequenceEditingTrait` shares ordered membership editing between the two +mutable path bases. These paths contain only `MutablePointInterface` instances; +coordinate tuples create mutable points, and immutable components are rejected. +Their interfaces refine the common read methods to return mutable points and +expose type-specific mutation, including optional context propagation. The common +interfaces remain read-only, and mutable paths expose no copy operations. diff --git a/lib/Interfaces/CircularStringInterface.php b/lib/Interfaces/CircularStringInterface.php index 36256d3..bdf0a19 100644 --- a/lib/Interfaces/CircularStringInterface.php +++ b/lib/Interfaces/CircularStringInterface.php @@ -35,7 +35,7 @@ public function getElements(): array; /** * Return a point from the circular string. * - * @param int $index index of the point. -1 is the last point. -2 is the penultimate point, etc. + * @param int $index index of the point; immutable variants wrap negative indices, mutable variants require an existing zero-based index */ public function getPoint(int $index): PointInterface; diff --git a/lib/Interfaces/LineStringInterface.php b/lib/Interfaces/LineStringInterface.php index 104a3a4..b80db89 100644 --- a/lib/Interfaces/LineStringInterface.php +++ b/lib/Interfaces/LineStringInterface.php @@ -39,7 +39,7 @@ public function getElements(): array; /** * Return a point from the line string. * - * @param int $index index of the point. -1 is the last point. -2 is the penultimate point, etc. + * @param int $index index of the point; immutable variants wrap negative indices, mutable variants require an existing zero-based index */ public function getPoint(int $index): PointInterface; diff --git a/lib/Interfaces/MutableCircularStringInterface.php b/lib/Interfaces/MutableCircularStringInterface.php index e80f673..e415399 100644 --- a/lib/Interfaces/MutableCircularStringInterface.php +++ b/lib/Interfaces/MutableCircularStringInterface.php @@ -16,7 +16,81 @@ namespace LongitudeOne\SpatialTypes\Interfaces; -/** Mutable circularstring contract; type-specific editing is introduced separately. */ +use LongitudeOne\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialTypes\Reference\SpatialReference; + +/** In-place ordered point editing; immutable components are rejected. */ interface MutableCircularStringInterface extends CircularStringInterface, MutableCurveInterface { + /** @param MutablePointInterface $point Point retained without cloning */ + public function addPoint(MutablePointInterface $point): static; + + /** + * @param MutablePointInterface $passingPoint Intermediate point retained without cloning + * @param MutablePointInterface $destinationPoint End point retained without cloning + */ + public function appendArc(MutablePointInterface $passingPoint, MutablePointInterface $destinationPoint): static; + + /** @return MutablePointInterface[] */ + public function getElements(): array; + + /** Return the last point, or null for EMPTY. */ + public function getEndPoint(): ?MutablePointInterface; + + /** @param int $index Existing zero-based point index */ + public function getPoint(int $index): MutablePointInterface; + + /** @return MutablePointInterface[] */ + public function getPoints(): array; + + /** Return the first point, or null for EMPTY. */ + public function getStartPoint(): ?MutablePointInterface; + + /** + * @param int $index Zero-based insertion index, up to the point count + * @param MutablePointInterface $point Point retained without cloning + */ + public function insertPoint(int $index, MutablePointInterface $point): static; + + /** @param int $index Existing zero-based point index */ + public function removePoint(int $index): static; + + /** + * @param int $index Zero-based arc index; all three defining points must exist + * @param MutablePointInterface $passingPoint Replacement intermediate point + * @param MutablePointInterface $destinationPoint Replacement shared endpoint + */ + public function replaceArc(int $index, MutablePointInterface $passingPoint, MutablePointInterface $destinationPoint): static; + + /** + * @param array $coordinates Replacement tuples + */ + public function setArrayOfCoordinates(array $coordinates): static; + + /** + * @param SpatialModelEnum $family Declared family + * @param bool $propagate Whether to update every contained point + */ + public function setFamily(SpatialModelEnum $family, bool $propagate = true): static; + + /** @param MutablePointInterface $point First point retained without cloning */ + public function setOrigin(MutablePointInterface $point): static; + + /** + * @param int $index Existing zero-based point index + * @param MutablePointInterface $point Point retained without cloning + */ + public function setPoint(int $index, MutablePointInterface $point): static; + + /** + * @param SpatialReference $reference Declared reference + * @param bool $propagate Whether to update every contained point + */ + public function setSpatialReference(SpatialReference $reference, bool $propagate = true): static; + + /** + * @param int $srid Declared reference identifier + * @param bool $propagate Whether to update every contained point + */ + public function setSrid(int $srid, bool $propagate = true): static; } diff --git a/lib/Interfaces/MutableLineStringInterface.php b/lib/Interfaces/MutableLineStringInterface.php index 8a2a7f9..504b4e3 100644 --- a/lib/Interfaces/MutableLineStringInterface.php +++ b/lib/Interfaces/MutableLineStringInterface.php @@ -16,7 +16,65 @@ namespace LongitudeOne\SpatialTypes\Interfaces; -/** Mutable linestring contract; type-specific editing is introduced separately. */ +use LongitudeOne\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialTypes\Reference\SpatialReference; + +/** In-place ordered point editing; immutable components are rejected. */ interface MutableLineStringInterface extends LineStringInterface, MutableCurveInterface { + /** @param MutablePointInterface $point Point retained without cloning */ + public function addPoint(MutablePointInterface $point): static; + + /** @return MutablePointInterface[] */ + public function getElements(): array; + + /** Return the last point, or null for EMPTY. */ + public function getEndPoint(): ?MutablePointInterface; + + /** @param int $index Existing zero-based point index */ + public function getPoint(int $index): MutablePointInterface; + + /** @return MutablePointInterface[] */ + public function getPoints(): array; + + /** Return the first point, or null for EMPTY. */ + public function getStartPoint(): ?MutablePointInterface; + + /** + * @param int $index Zero-based insertion index, up to the point count + * @param MutablePointInterface $point Point retained without cloning + */ + public function insertPoint(int $index, MutablePointInterface $point): static; + + /** @param int $index Existing zero-based point index */ + public function removePoint(int $index): static; + + /** + * @param array $coordinates Replacement tuples + */ + public function setArrayOfCoordinates(array $coordinates): static; + + /** + * @param SpatialModelEnum $family Declared family + * @param bool $propagate Whether to update every contained point + */ + public function setFamily(SpatialModelEnum $family, bool $propagate = true): static; + + /** + * @param int $index Existing zero-based point index + * @param MutablePointInterface $point Point retained without cloning + */ + public function setPoint(int $index, MutablePointInterface $point): static; + + /** + * @param SpatialReference $reference Declared reference + * @param bool $propagate Whether to update every contained point + */ + public function setSpatialReference(SpatialReference $reference, bool $propagate = true): static; + + /** + * @param int $srid Declared reference identifier + * @param bool $propagate Whether to update every contained point + */ + public function setSrid(int $srid, bool $propagate = true): static; } diff --git a/lib/Interfaces/MutablePointInterface.php b/lib/Interfaces/MutablePointInterface.php index 8b16835..69b80db 100644 --- a/lib/Interfaces/MutablePointInterface.php +++ b/lib/Interfaces/MutablePointInterface.php @@ -30,4 +30,7 @@ public function setY(float|int|string|null $y): static; /** @param null|float|int $z Elevation ordinate; the layout must contain Z */ public function setZ(float|int|null $z): static; + + /** @return (null|float|int)[] */ + public function toArray(): array; } diff --git a/lib/Types/AbstractCircularString.php b/lib/Types/AbstractCircularString.php new file mode 100644 index 0000000..eb18ef0 --- /dev/null +++ b/lib/Types/AbstractCircularString.php @@ -0,0 +1,77 @@ + 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\SpatialTypes\Types; + +use LongitudeOne\Core\Enum\GeometryTypeEnum; +use LongitudeOne\SpatialTypes\Exception\InvalidValueException; +use LongitudeOne\SpatialTypes\Exception\OutOfBoundsException; +use LongitudeOne\SpatialTypes\Interfaces\MutableCircularStringInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutablePointInterface; + +/** Mutable circular point sequences and structural arc editing. + * @internal + */ +abstract class AbstractCircularString extends AbstractPointSequence implements MutableCircularStringInterface +{ + use PointSequenceEditingTrait; + + /** + * @param MutablePointInterface $passingPoint Intermediate point + * @param MutablePointInterface $destinationPoint End point + */ + public function appendArc(MutablePointInterface $passingPoint, MutablePointInterface $destinationPoint): static + { + if (0 === count($this->points) % 2) { + throw new InvalidValueException('Appending an arc requires an origin followed by complete point pairs.'); + } + + $this->points[] = $passingPoint; + $this->points[] = $destinationPoint; + + return $this; + } + + /** Return the fixed spatial type. */ + public function getType(): GeometryTypeEnum + { + return GeometryTypeEnum::CIRCULARSTRING; + } + + /** + * @param int $index Zero-based arc index + * @param MutablePointInterface $passingPoint Replacement intermediate point + * @param MutablePointInterface $destinationPoint Replacement shared endpoint + */ + public function replaceArc(int $index, MutablePointInterface $passingPoint, MutablePointInterface $destinationPoint): static + { + if ($index < 0 || $index >= intdiv(max(0, count($this->points) - 1), 2)) { + throw new OutOfBoundsException(sprintf('Arc index %d does not address three existing points.', $index)); + } + + array_splice($this->points, 2 * $index + 1, 2, [$passingPoint, $destinationPoint]); + + return $this; + } + + /** @param MutablePointInterface $point Origin retained without cloning */ + public function setOrigin(MutablePointInterface $point): static + { + $this->points[0] = $point; + + return $this; + } +} diff --git a/lib/Types/AbstractLineString.php b/lib/Types/AbstractLineString.php new file mode 100644 index 0000000..cc79771 --- /dev/null +++ b/lib/Types/AbstractLineString.php @@ -0,0 +1,46 @@ + 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\SpatialTypes\Types; + +use LongitudeOne\Core\Enum\GeometryTypeEnum; +use LongitudeOne\SpatialTypes\Interfaces\MutableLineStringInterface; + +/** Mutable line-string operations without geometric validation. + * @internal + */ +abstract class AbstractLineString extends AbstractPointSequence implements MutableLineStringInterface +{ + use PointSequenceEditingTrait; + + /** Return the fixed spatial type. */ + public function getType(): GeometryTypeEnum + { + return GeometryTypeEnum::LINESTRING; + } + + /** Whether a line has equal endpoints. */ + public function isClosed(): bool + { + return $this->isLine() && parent::isClosed(); + } + + /** Whether a line has at least two defining points. */ + public function isLine(): bool + { + return count($this->points) >= 2; + } +} diff --git a/lib/Types/AbstractPointSequence.php b/lib/Types/AbstractPointSequence.php new file mode 100644 index 0000000..7363ad9 --- /dev/null +++ b/lib/Types/AbstractPointSequence.php @@ -0,0 +1,179 @@ + 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\SpatialTypes\Types; + +use LongitudeOne\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialTypes\Exception\InvalidValueException; +use LongitudeOne\SpatialTypes\Exception\OutOfBoundsException; +use LongitudeOne\SpatialTypes\Factory\SpatialContext; +use LongitudeOne\SpatialTypes\Interfaces\MutableCurveInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutablePointInterface; +use LongitudeOne\SpatialTypes\Reference\SpatialReference; + +/** Shared mutable ordered-point storage, reads and declared context. + * @internal + */ +abstract class AbstractPointSequence extends AbstractSpatialType implements MutableCurveInterface +{ + protected SpatialModelEnum $family; + + /** @var list */ + protected array $points = []; + + /** + * @param array $points Mutable points or tuples + * @param int|SpatialReference $srid Declared reference + * @param SpatialModelEnum $family Declared family + */ + final public function __construct(array $points, int|SpatialReference $srid = 0, SpatialModelEnum $family = SpatialModelEnum::GEOMETRY) + { + $this->family = $family; + $this->initializeSpatialReference($srid); + $this->points = $this->hydratePoints($points); + } + + /** @return MutablePointInterface[] */ + public function getElements(): array + { + return $this->points; + } + + /** Return the last defining point, or null for EMPTY. */ + public function getEndPoint(): ?MutablePointInterface + { + return $this->isEmpty() ? null : $this->points[count($this->points) - 1]; + } + + /** Return the declared family. */ + public function getFamily(): SpatialModelEnum + { + return $this->family; + } + + /** @param int $index Existing zero-based point index */ + public function getPoint(int $index): MutablePointInterface + { + $this->requirePointIndex($index); + + return $this->points[$index]; + } + + /** @return MutablePointInterface[] */ + public function getPoints(): array + { + return $this->points; + } + + /** Return the first defining point, or null for EMPTY. */ + public function getStartPoint(): ?MutablePointInterface + { + return $this->points[0] ?? null; + } + + /** Whether this sequence has equal endpoints. */ + public function isClosed(): bool + { + return !$this->isEmpty() && $this->points[0]->equalsTo($this->points[count($this->points) - 1]); + } + + /** Whether there are no defining points. */ + public function isEmpty(): bool + { + return [] === $this->points; + } + + /** + * @param SpatialModelEnum $family Declared family + * @param bool $propagate Whether to update every contained point + */ + public function setFamily(SpatialModelEnum $family, bool $propagate = true): static + { + if ($propagate) { + foreach ($this->points as $point) { + $point->setFamily($family); + } + } + + $this->family = $family; + + return $this; + } + + /** + * @param SpatialReference $reference Declared reference + * @param bool $propagate Whether to update every contained point + */ + public function setSpatialReference(SpatialReference $reference, bool $propagate = true): static + { + if ($propagate) { + foreach ($this->points as $point) { + $point->setSpatialReference($reference); + } + } + + $this->spatialReference = $reference; + + return $this; + } + + /** + * @param int $srid Declared reference identifier + * @param bool $propagate Whether to update every contained point + */ + public function setSrid(int $srid, bool $propagate = true): static + { + return $this->setSpatialReference(SpatialReference::fromSrid($srid), $propagate); + } + + /** @return (null|float|int)[][] */ + public function toArray(): array + { + return array_map(static fn (MutablePointInterface $point): array => $point->toArray(), $this->points); + } + + /** + * @param array $points Mutable points or tuples + * + * @return list + */ + final protected function hydratePoints(array $points): array + { + $hydrated = []; + $context = new SpatialContext($this->getSpatialReference(), $this->family, $this->getDimension()); + foreach ($points as $point) { + if (is_array($point)) { + $point = MutablePointHydrator::hydrate($point, $context); + } + + if (!$point instanceof MutablePointInterface) { + throw new InvalidValueException('Mutable point sequences accept only mutable points or coordinate tuples.'); + } + + $hydrated[] = $point; + } + + return $hydrated; + } + + /** @param int $index Existing zero-based point index */ + final protected function requirePointIndex(int $index): void + { + if ($index < 0 || $index >= count($this->points)) { + throw new OutOfBoundsException(sprintf('Point index %d does not address an existing point.', $index)); + } + } +} diff --git a/lib/Types/Dimension2/CircularString.php b/lib/Types/Dimension2/CircularString.php new file mode 100644 index 0000000..80fabb1 --- /dev/null +++ b/lib/Types/Dimension2/CircularString.php @@ -0,0 +1,30 @@ + 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\SpatialTypes\Types\Dimension2; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialTypes\Types\AbstractCircularString; + +/** Mutable XY CircularString with instance-level declared family. */ +final class CircularString extends AbstractCircularString +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XY; + } +} diff --git a/lib/Types/Dimension2/LineString.php b/lib/Types/Dimension2/LineString.php new file mode 100644 index 0000000..fc9312d --- /dev/null +++ b/lib/Types/Dimension2/LineString.php @@ -0,0 +1,30 @@ + 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\SpatialTypes\Types\Dimension2; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialTypes\Types\AbstractLineString; + +/** Mutable XY LineString with instance-level declared family. */ +final class LineString extends AbstractLineString +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XY; + } +} diff --git a/lib/Types/Dimension3m/CircularString.php b/lib/Types/Dimension3m/CircularString.php new file mode 100644 index 0000000..af68165 --- /dev/null +++ b/lib/Types/Dimension3m/CircularString.php @@ -0,0 +1,30 @@ + 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\SpatialTypes\Types\Dimension3m; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialTypes\Types\AbstractCircularString; + +/** Mutable XYM CircularString with instance-level declared family. */ +final class CircularString extends AbstractCircularString +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XYM; + } +} diff --git a/lib/Types/Dimension3m/LineString.php b/lib/Types/Dimension3m/LineString.php new file mode 100644 index 0000000..a90f859 --- /dev/null +++ b/lib/Types/Dimension3m/LineString.php @@ -0,0 +1,30 @@ + 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\SpatialTypes\Types\Dimension3m; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialTypes\Types\AbstractLineString; + +/** Mutable XYM LineString with instance-level declared family. */ +final class LineString extends AbstractLineString +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XYM; + } +} diff --git a/lib/Types/Dimension3z/CircularString.php b/lib/Types/Dimension3z/CircularString.php new file mode 100644 index 0000000..362d562 --- /dev/null +++ b/lib/Types/Dimension3z/CircularString.php @@ -0,0 +1,30 @@ + 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\SpatialTypes\Types\Dimension3z; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialTypes\Types\AbstractCircularString; + +/** Mutable XYZ CircularString with instance-level declared family. */ +final class CircularString extends AbstractCircularString +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XYZ; + } +} diff --git a/lib/Types/Dimension3z/LineString.php b/lib/Types/Dimension3z/LineString.php new file mode 100644 index 0000000..4edd058 --- /dev/null +++ b/lib/Types/Dimension3z/LineString.php @@ -0,0 +1,30 @@ + 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\SpatialTypes\Types\Dimension3z; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialTypes\Types\AbstractLineString; + +/** Mutable XYZ LineString with instance-level declared family. */ +final class LineString extends AbstractLineString +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XYZ; + } +} diff --git a/lib/Types/Dimension4zm/CircularString.php b/lib/Types/Dimension4zm/CircularString.php new file mode 100644 index 0000000..7a376d4 --- /dev/null +++ b/lib/Types/Dimension4zm/CircularString.php @@ -0,0 +1,30 @@ + 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\SpatialTypes\Types\Dimension4zm; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialTypes\Types\AbstractCircularString; + +/** Mutable XYZM CircularString with instance-level declared family. */ +final class CircularString extends AbstractCircularString +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XYZM; + } +} diff --git a/lib/Types/Dimension4zm/LineString.php b/lib/Types/Dimension4zm/LineString.php new file mode 100644 index 0000000..607f788 --- /dev/null +++ b/lib/Types/Dimension4zm/LineString.php @@ -0,0 +1,30 @@ + 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\SpatialTypes\Types\Dimension4zm; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialTypes\Types\AbstractLineString; + +/** Mutable XYZM LineString with instance-level declared family. */ +final class LineString extends AbstractLineString +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XYZM; + } +} diff --git a/lib/Types/MutablePointHydrator.php b/lib/Types/MutablePointHydrator.php new file mode 100644 index 0000000..c78c577 --- /dev/null +++ b/lib/Types/MutablePointHydrator.php @@ -0,0 +1,44 @@ + 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\SpatialTypes\Types; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialTypes\Factory\Hydrator\CoordinatesHydrator; +use LongitudeOne\SpatialTypes\Factory\SpatialContext; +use LongitudeOne\SpatialTypes\Interfaces\MutablePointInterface; + +/** Tuple hydration for mutable point sequences. + * @internal + */ +final class MutablePointHydrator +{ + /** + * @param array{0: null|float|int|string, 1: null|float|int|string, 2 ?: null|float|int, 3 ?: null|float|int} $tuple Point tuple + * @param SpatialContext $context Declared sequence context + */ + public static function hydrate(array $tuple, SpatialContext $context): MutablePointInterface + { + $coordinates = (new CoordinatesHydrator())->hydrate($tuple, $context); + + return match ($context->dimension) { + CoordinateDimensionEnum::XY => new Dimension2\Point($coordinates->x, $coordinates->y, $context->reference, $context->family), + CoordinateDimensionEnum::XYZ => new Dimension3z\Point($coordinates->x, $coordinates->y, $coordinates->z, $context->reference, $context->family), + CoordinateDimensionEnum::XYM => new Dimension3m\Point($coordinates->x, $coordinates->y, $coordinates->m, $context->reference, $context->family), + CoordinateDimensionEnum::XYZM => new Dimension4zm\Point($coordinates->x, $coordinates->y, $coordinates->z, $coordinates->m, $context->reference, $context->family), + }; + } +} diff --git a/lib/Types/PointSequenceEditingTrait.php b/lib/Types/PointSequenceEditingTrait.php new file mode 100644 index 0000000..64d021a --- /dev/null +++ b/lib/Types/PointSequenceEditingTrait.php @@ -0,0 +1,80 @@ + 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\SpatialTypes\Types; + +use LongitudeOne\SpatialTypes\Exception\OutOfBoundsException; +use LongitudeOne\SpatialTypes\Interfaces\MutablePointInterface; + +/** Atomic membership editing shared by mutable linear and circular paths. + * @internal + */ +trait PointSequenceEditingTrait +{ + /** @param MutablePointInterface $point Point retained without cloning */ + public function addPoint(MutablePointInterface $point): static + { + $this->points[] = $point; + + return $this; + } + + /** + * @param int $index Zero-based insertion index, up to the point count + * @param MutablePointInterface $point Point retained without cloning + */ + public function insertPoint(int $index, MutablePointInterface $point): static + { + if ($index < 0 || $index > count($this->points)) { + throw new OutOfBoundsException(sprintf('Point index %d is outside the insertion range.', $index)); + } + + array_splice($this->points, $index, 0, [$point]); + + return $this; + } + + /** @param int $index Existing zero-based point index */ + public function removePoint(int $index): static + { + $this->requirePointIndex($index); + array_splice($this->points, $index, 1); + + return $this; + } + + /** + * @param array $coordinates Replacement tuples + */ + public function setArrayOfCoordinates(array $coordinates): static + { + $this->points = $this->hydratePoints($coordinates); + + return $this; + } + + /** + * @param int $index Existing zero-based point index + * @param MutablePointInterface $point Point retained without cloning + */ + public function setPoint(int $index, MutablePointInterface $point): static + { + $this->requirePointIndex($index); + array_splice($this->points, $index, 1, [$point]); + + return $this; + } +} diff --git a/tests/Unit/Types/MutableCircularStringTest.php b/tests/Unit/Types/MutableCircularStringTest.php new file mode 100644 index 0000000..87d1635 --- /dev/null +++ b/tests/Unit/Types/MutableCircularStringTest.php @@ -0,0 +1,384 @@ + 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\SpatialTypes\Tests\Unit\Types; + +use LongitudeOne\Core\Enum\GeometryTypeEnum; +use LongitudeOne\SpatialTypes\Exception\InvalidValueException; +use LongitudeOne\SpatialTypes\Exception\MissingValueException; +use LongitudeOne\SpatialTypes\Exception\OutOfBoundsException; +use LongitudeOne\SpatialTypes\Interfaces\ImmutableSpatialInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutableCircularStringInterface; +use LongitudeOne\SpatialTypes\Types\Dimension2\CircularString; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\PointImmutable; +use LongitudeOne\SpatialTypes\Types\Dimension2\Point; +use PHPUnit\Framework\TestCase; + +/** Mutable arc structure, shared endpoints and incomplete sequences. + * @internal + * + * @coversNothing + */ +class MutableCircularStringTest extends TestCase +{ + /** Appending after an incomplete pair leaves the sequence unchanged. */ + public function testAppendAfterIncompletePairIsAtomic(): void + { + $origin = new Point(0, 0); + $existing = new Point(1, 2); + $passing = new Point(3, 4); + $destination = new Point(5, 6); + $curve = new CircularString([$origin, $existing]); + $this->expectException(InvalidValueException::class); + + try { + $curve->appendArc($passing, $destination); + } finally { + static::assertSame([$origin, $existing], $curve->getPoints()); + static::assertSame([[0, 0], [1, 2]], $curve->toArray()); + static::assertSame([3, 4], $passing->toArray()); + static::assertSame([5, 6], $destination->toArray()); + } + } + + /** AppendArc rejects an immutable input before any mutation. */ + public function testAppendArcRejectsImmutableInputAtomically(): void + { + $origin = new Point(0, 0); + $passing = new Point(1, 1); + $destination = new Point(2, 0); + $mutable = new Point(3, 4); + $immutable = new PointImmutable(5, 6); + $curve = new CircularString([$origin, $passing, $destination]); + $this->expectException(\TypeError::class); + + try { + (new \ReflectionMethod($curve, 'appendArc'))->invoke($curve, $mutable, $immutable); + } finally { + static::assertSame([$origin, $passing, $destination], $curve->getPoints()); + static::assertSame([[0, 0], [1, 1], [2, 0]], $curve->toArray()); + static::assertSame([3, 4], $mutable->toArray()); + static::assertSame([5, 6], $immutable->toArray()); + } + } + + /** Appending without an origin fails without editing supplied points. */ + public function testAppendWithoutOriginIsAtomic(): void + { + $passing = new Point(1, 2, 4326); + $destination = new Point(3, 4, 4326); + $curve = new CircularString([], 2154); + $this->expectException(InvalidValueException::class); + + try { + $curve->appendArc($passing, $destination); + } finally { + static::assertSame([], $curve->getPoints()); + static::assertSame([1, 2], $passing->toArray()); + static::assertSame([3, 4], $destination->toArray()); + static::assertSame(4326, $passing->getSrid()); + static::assertSame(4326, $destination->getSrid()); + } + } + + /** Arc edits preserve point identity and the addressed origin. */ + public function testArcEditsPreserveIdentityAndSharedEndpoints(): void + { + $origin = new Point(0, null); + $passing = new Point(1, 2); + $destination = new Point(3, 4); + $nextPassing = new Point(5, 6); + $nextDestination = new Point(7, 8); + $curve = new CircularString([]); + static::assertSame($curve, $curve->setOrigin($origin)->appendArc($passing, $destination)->appendArc($nextPassing, $nextDestination)); + static::assertSame([$origin, $passing, $destination, $nextPassing, $nextDestination], $curve->getPoints()); + $replacementPassing = new Point(9, null); + $replacementDestination = new Point(null, 10); + static::assertSame($curve, $curve->replaceArc(0, $replacementPassing, $replacementDestination)); + static::assertSame($origin, $curve->getPoint(0)); + static::assertSame($replacementDestination, $curve->getPoint(2)); + static::assertSame([$origin, $replacementPassing, $replacementDestination, $nextPassing, $nextDestination], $curve->getElements()); + static::assertSame($curve, $curve->replaceArc(1, $passing, $destination)); + static::assertSame($replacementDestination, $curve->getPoint(2)); + static::assertSame([$origin, $replacementPassing, $replacementDestination, $passing, $destination], $curve->getPoints()); + static::assertSame([3, 4], $destination->toArray()); + static::assertSame(GeometryTypeEnum::CIRCULARSTRING, $curve->getType()); + } + + /** Closure reads current shared point values without repairing endpoints. */ + public function testClosureObservesSharedPointChanges(): void + { + $origin = new Point(0, 0); + $destination = new Point(0, 0); + $curve = new CircularString([$origin, new Point(1, 1), $destination]); + static::assertTrue($curve->isClosed()); + $destination->setY(2); + static::assertFalse($curve->isClosed()); + static::assertSame([0, 0], $origin->toArray()); + static::assertSame([[0, 0], [1, 1], [0, 2]], $curve->toArray()); + } + + /** Constructor rejects immutable points without conversion. */ + public function testConstructorRejectsImmutablePoints(): void + { + $point = new PointImmutable(1, 2); + $this->expectException(InvalidValueException::class); + + try { + (new \ReflectionClass(CircularString::class))->newInstance([$point]); + } finally { + static::assertSame([1, 2], $point->toArray()); + } + } + + /** EmptyArc addressing fails without mutation. */ + public function testEmptyArcIsAtomic(): void + { + $origin = new Point(0, 0); + $passing = new Point(1, 2); + $destination = new Point(3, 4); + $replacementPassing = new Point(5, 6); + $replacementDestination = new Point(7, 8); + $curve = new CircularString([]); + $this->expectException(OutOfBoundsException::class); + + try { + $curve->replaceArc(0, $replacementPassing, $replacementDestination); + } finally { + static::assertSame([], $curve->getPoints()); + static::assertSame([0, 0], $origin->toArray()); + static::assertSame([1, 2], $passing->toArray()); + static::assertSame([3, 4], $destination->toArray()); + static::assertSame([5, 6], $replacementPassing->toArray()); + static::assertSame([7, 8], $replacementDestination->toArray()); + } + } + + /** General editing can construct and preserve incomplete sequences. */ + public function testGeneralEditingAllowsIncompleteSequences(): void + { + $origin = new Point(0, 0); + $point = new Point(null, null); + $replacement = new Point(3, 4); + $curve = new CircularString([]); + static::assertTrue($curve->isEmpty()); + static::assertNull($curve->getStartPoint()); + static::assertNull($curve->getEndPoint()); + static::assertFalse($curve->isClosed()); + static::assertSame($curve, $curve->addPoint($origin)->insertPoint(1, $point)->setPoint(0, $replacement)); + static::assertSame([$replacement, $point], $curve->getPoints()); + static::assertSame([[3, 4], []], $curve->toArray()); + static::assertSame($curve, $curve->removePoint(0)); + static::assertSame([$point], $curve->getPoints()); + static::assertSame($curve, $curve->setArrayOfCoordinates([[0, null], [2, 3]])); + static::assertSame([[0, null], [2, 3]], $curve->toArray()); + static::assertFalse($curve->isClosed()); + static::assertSame($curve, $curve->setArrayOfCoordinates([])); + static::assertTrue($curve->isEmpty()); + } + + /** IncompleteArc addressing fails without mutation. */ + public function testIncompleteArcIsAtomic(): void + { + $origin = new Point(0, 0); + $passing = new Point(1, 2); + $destination = new Point(3, 4); + $replacementPassing = new Point(5, 6); + $replacementDestination = new Point(7, 8); + $curve = new CircularString([$origin, $passing]); + $this->expectException(OutOfBoundsException::class); + + try { + $curve->replaceArc(0, $replacementPassing, $replacementDestination); + } finally { + static::assertSame([$origin, $passing], $curve->getPoints()); + static::assertSame([0, 0], $origin->toArray()); + static::assertSame([1, 2], $passing->toArray()); + static::assertSame([3, 4], $destination->toArray()); + static::assertSame([5, 6], $replacementPassing->toArray()); + static::assertSame([7, 8], $replacementDestination->toArray()); + } + } + + /** Replacing all coordinates rejects later malformed tuples atomically. */ + public function testMalformedTupleReplacementIsAtomic(): void + { + $point = new Point(0, null, 4326); + $curve = new CircularString([$point], 4326); + $this->expectException(MissingValueException::class); + + try { + (new \ReflectionMethod($curve, 'setArrayOfCoordinates'))->invoke($curve, [[1, 2], [3]]); + } finally { + static::assertSame([$point], $curve->getPoints()); + static::assertSame([[0, null]], $curve->toArray()); + static::assertSame(4326, $point->getSrid()); + } + } + + /** MissingArc addressing fails without mutation. */ + public function testMissingArcIsAtomic(): void + { + $origin = new Point(0, 0); + $passing = new Point(1, 2); + $destination = new Point(3, 4); + $replacementPassing = new Point(5, 6); + $replacementDestination = new Point(7, 8); + $curve = new CircularString([$origin, $passing, $destination]); + $this->expectException(OutOfBoundsException::class); + + try { + $curve->replaceArc(1, $replacementPassing, $replacementDestination); + } finally { + static::assertSame([$origin, $passing, $destination], $curve->getPoints()); + static::assertSame([0, 0], $origin->toArray()); + static::assertSame([1, 2], $passing->toArray()); + static::assertSame([3, 4], $destination->toArray()); + static::assertSame([5, 6], $replacementPassing->toArray()); + static::assertSame([7, 8], $replacementDestination->toArray()); + } + } + + /** Mutable circular contracts exclude immutable operations. */ + public function testMutableCircularContractExcludesCopyOperations(): void + { + $curve = new CircularString([]); + static::assertContains(MutableCircularStringInterface::class, class_implements($curve)); + static::assertNotContains(ImmutableSpatialInterface::class, class_implements($curve)); + static::assertFalse((new \ReflectionClass($curve))->hasMethod('withPoint')); + static::assertFalse((new \ReflectionClass($curve))->hasMethod('withArrayOfCoordinates')); + static::assertFalse((new \ReflectionClass($curve))->hasMethod('withSrid')); + static::assertFalse((new \ReflectionClass($curve))->hasMethod('withSpatialReference')); + static::assertFalse((new \ReflectionClass($curve))->hasMethod('setDimension')); + } + + /** NegativeArc addressing fails without mutation. */ + public function testNegativeArcIsAtomic(): void + { + $origin = new Point(0, 0); + $passing = new Point(1, 2); + $destination = new Point(3, 4); + $replacementPassing = new Point(5, 6); + $replacementDestination = new Point(7, 8); + $curve = new CircularString([$origin, $passing, $destination]); + $this->expectException(OutOfBoundsException::class); + + try { + $curve->replaceArc(-1, $replacementPassing, $replacementDestination); + } finally { + static::assertSame([$origin, $passing, $destination], $curve->getPoints()); + static::assertSame([0, 0], $origin->toArray()); + static::assertSame([1, 2], $passing->toArray()); + static::assertSame([3, 4], $destination->toArray()); + static::assertSame([5, 6], $replacementPassing->toArray()); + static::assertSame([7, 8], $replacementDestination->toArray()); + } + } + + /** Negative access is rejected without wrapping. */ + public function testNegativePointAccessDoesNotWrap(): void + { + $point = new Point(1, 2); + $curve = new CircularString([$point]); + $this->expectException(OutOfBoundsException::class); + + try { + $curve->getPoint(-1); + } finally { + static::assertSame([$point], $curve->getPoints()); + } + } + + /** Origin rejects an immutable input before any mutation. */ + public function testOriginRejectsImmutableInputAtomically(): void + { + $origin = new Point(0, 0); + $passing = new Point(1, 1); + $destination = new Point(2, 0); + $mutable = new Point(3, 4); + $immutable = new PointImmutable(5, 6); + $curve = new CircularString([$origin, $passing, $destination]); + $this->expectException(\TypeError::class); + + try { + (new \ReflectionMethod($curve, 'setOrigin'))->invoke($curve, $immutable); + } finally { + static::assertSame([$origin, $passing, $destination], $curve->getPoints()); + static::assertSame([[0, 0], [1, 1], [2, 0]], $curve->toArray()); + static::assertSame([3, 4], $mutable->toArray()); + static::assertSame([5, 6], $immutable->toArray()); + } + } + + /** Setting an origin changes only the first point. */ + public function testOriginReplacementChangesOnlyFirstPoint(): void + { + $origin = new Point(0, 0); + $passing = new Point(1, 1); + $destination = new Point(2, 0); + $replacement = new Point(null, 3); + $curve = new CircularString([$origin, $passing, $destination]); + static::assertSame($curve, $curve->setOrigin($replacement)); + static::assertSame([$replacement, $passing, $destination], $curve->getPoints()); + static::assertSame([0, 0], $origin->toArray()); + static::assertSame($replacement, $curve->getStartPoint()); + static::assertSame($destination, $curve->getEndPoint()); + } + + /** OverflowArc addressing fails without mutation. */ + public function testOverflowArcIsAtomic(): void + { + $origin = new Point(0, 0); + $passing = new Point(1, 2); + $destination = new Point(3, 4); + $replacementPassing = new Point(5, 6); + $replacementDestination = new Point(7, 8); + $curve = new CircularString([$origin, $passing, $destination]); + $this->expectException(OutOfBoundsException::class); + + try { + $curve->replaceArc(9223372036854775807, $replacementPassing, $replacementDestination); + } finally { + static::assertSame([$origin, $passing, $destination], $curve->getPoints()); + static::assertSame([0, 0], $origin->toArray()); + static::assertSame([1, 2], $passing->toArray()); + static::assertSame([3, 4], $destination->toArray()); + static::assertSame([5, 6], $replacementPassing->toArray()); + static::assertSame([7, 8], $replacementDestination->toArray()); + } + } + + /** ReplaceArc rejects an immutable input before any mutation. */ + public function testReplaceArcRejectsImmutableInputAtomically(): void + { + $origin = new Point(0, 0); + $passing = new Point(1, 1); + $destination = new Point(2, 0); + $mutable = new Point(3, 4); + $immutable = new PointImmutable(5, 6); + $curve = new CircularString([$origin, $passing, $destination]); + $this->expectException(\TypeError::class); + + try { + (new \ReflectionMethod($curve, 'replaceArc'))->invoke($curve, 0, $mutable, $immutable); + } finally { + static::assertSame([$origin, $passing, $destination], $curve->getPoints()); + static::assertSame([[0, 0], [1, 1], [2, 0]], $curve->toArray()); + static::assertSame([3, 4], $mutable->toArray()); + static::assertSame([5, 6], $immutable->toArray()); + } + } +} diff --git a/tests/Unit/Types/MutableLineStringTest.php b/tests/Unit/Types/MutableLineStringTest.php new file mode 100644 index 0000000..7d476c5 --- /dev/null +++ b/tests/Unit/Types/MutableLineStringTest.php @@ -0,0 +1,359 @@ + 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\SpatialTypes\Tests\Unit\Types; + +use LongitudeOne\Core\Enum\GeometryTypeEnum; +use LongitudeOne\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialTypes\Exception\InvalidDimensionException; +use LongitudeOne\SpatialTypes\Exception\InvalidValueException; +use LongitudeOne\SpatialTypes\Exception\OutOfBoundsException; +use LongitudeOne\SpatialTypes\Interfaces\ImmutableSpatialInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutableLineStringInterface; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\PointImmutable; +use LongitudeOne\SpatialTypes\Types\Dimension2\LineString; +use LongitudeOne\SpatialTypes\Types\Dimension2\Point; +use PHPUnit\Framework\TestCase; + +/** Mutable linear membership and ownership. + * @internal + * + * @coversNothing + */ +class MutableLineStringTest extends TestCase +{ + /** Addition rejects immutable components atomically. */ + public function testAdditionRejectsImmutableComponents(): void + { + $point = new Point(1, 2); + $line = new LineString([$point]); + $this->expectException(\TypeError::class); + + try { + (new \ReflectionMethod($line, 'addPoint'))->invoke($line, new PointImmutable(3, 4)); + } finally { + static::assertSame([$point], $line->getPoints()); + } + } + + /** Constructor rejects immutable components without modifying them. */ + public function testConstructorRejectsImmutableComponents(): void + { + $point = new PointImmutable(1, 2, 4326); + $this->expectException(InvalidValueException::class); + + try { + (new \ReflectionClass(LineString::class))->newInstance([$point]); + } finally { + static::assertSame([1, 2], $point->toArray()); + static::assertSame(4326, $point->getSrid()); + } + } + + /** A later parsing failure cannot replace earlier membership. */ + public function testCoordinateReplacementParsingFailureIsAtomic(): void + { + $point = new Point(1, 2, 4326); + $line = new LineString([$point], 4326); + $this->expectException(InvalidValueException::class); + + try { + $line->setArrayOfCoordinates([[3, 4], ['not a coordinate', 5]]); + } finally { + static::assertSame([$point], $line->getPoints()); + static::assertSame([[1, 2]], $line->toArray()); + static::assertSame(4326, $line->getSrid()); + static::assertSame(4326, $point->getSrid()); + } + } + + /** Empty and incomplete paths remain editable without closing or repair. */ + public function testEmptyAndIncompletePathsRemainEditable(): void + { + $line = new LineString([]); + static::assertTrue($line->isEmpty()); + static::assertNull($line->getStartPoint()); + static::assertNull($line->getEndPoint()); + static::assertFalse($line->isClosed()); + static::assertFalse($line->isLine()); + $line->addPoint(new Point(0, null)); + static::assertFalse($line->isEmpty()); + static::assertFalse($line->isClosed()); + static::assertFalse($line->isLine()); + $line->addPoint(new Point(0, null)); + static::assertTrue($line->isLine()); + static::assertTrue($line->isClosed()); + $line->setPoint(1, new Point(1, 2)); + static::assertFalse($line->isClosed()); + static::assertSame([[0, null], [1, 2]], $line->toArray()); + static::assertSame($line, $line->setArrayOfCoordinates([])); + static::assertTrue($line->isEmpty()); + } + + /** Extra coordinates are rejected without mutation. */ + public function testExtraTupleCoordinateIsRejectedAtomically(): void + { + $point = new Point(1, 2); + $line = new LineString([$point]); + $this->expectException(InvalidDimensionException::class); + + try { + $line->setArrayOfCoordinates([[3, 4], [5, 6, 7]]); + } finally { + static::assertSame([$point], $line->getPoints()); + static::assertSame([[1, 2]], $line->toArray()); + } + } + + /** Insertion rejects immutable components atomically. */ + public function testInsertionRejectsImmutableComponents(): void + { + $point = new Point(1, 2); + $line = new LineString([$point]); + $immutable = new PointImmutable(3, 4); + $this->expectException(\TypeError::class); + + try { + (new \ReflectionMethod($line, 'insertPoint'))->invoke($line, 0, $immutable); + } finally { + static::assertSame([$point], $line->getPoints()); + static::assertSame([1, 2], $point->toArray()); + static::assertSame([3, 4], $immutable->toArray()); + } + } + + /** Membership edits preserve order and identity. */ + public function testMembershipEditsPreserveOrderAndIdentity(): void + { + $first = new Point(0, null); + $middle = new Point(2, 3); + $last = new Point(4, 5); + $replacement = new Point(null, 6); + $line = new LineString([]); + static::assertSame($line, $line->addPoint($first)->insertPoint(1, $last)->insertPoint(1, $middle)); + static::assertSame([$first, $middle, $last], $line->getPoints()); + static::assertSame($first, $line->getStartPoint()); + static::assertSame($last, $line->getEndPoint()); + static::assertSame($middle, $line->getPoint(1)); + static::assertSame($line, $line->setPoint(1, $replacement)->removePoint(0)); + static::assertSame([$replacement, $last], $line->getElements()); + static::assertSame([[null, 6], [4, 5]], $line->toArray()); + static::assertSame($line, $line->setArrayOfCoordinates([[0, null], [null, null]])); + static::assertSame([[0, null], []], $line->toArray()); + static::assertInstanceOf(Point::class, $line->getPoint(0)); + static::assertSame([0, null], $first->toArray()); + static::assertSame([null, 6], $replacement->toArray()); + } + + /** Typed contracts expose neither immutable operations nor dimension changes. */ + public function testMutableContractsExcludeCopyAndDimensionSetters(): void + { + $line = new LineString([]); + static::assertContains(MutableLineStringInterface::class, class_implements($line)); + static::assertNotContains(ImmutableSpatialInterface::class, class_implements($line)); + static::assertFalse((new \ReflectionClass($line))->hasMethod('withPoint')); + static::assertFalse((new \ReflectionClass($line))->hasMethod('withArrayOfCoordinates')); + static::assertFalse((new \ReflectionClass($line))->hasMethod('withSrid')); + static::assertFalse((new \ReflectionClass($line))->hasMethod('withSpatialReference')); + static::assertFalse((new \ReflectionClass($line))->hasMethod('setDimension')); + } + + /** NegativeAccess fails without changing membership or referenced points. */ + public function testNegativeAccessIsAtomic(): void + { + $point = new Point(1, 2, 4326); + $line = new LineString([$point], 4326); + $this->expectException(OutOfBoundsException::class); + + try { + $line->getPoint(-1); + } finally { + static::assertSame([$point], $line->getPoints()); + static::assertSame([[1, 2]], $line->toArray()); + static::assertSame(4326, $point->getSrid()); + } + } + + /** NegativeInsertion fails without changing membership or referenced points. */ + public function testNegativeInsertionIsAtomic(): void + { + $point = new Point(1, 2, 4326); + $line = new LineString([$point], 4326); + $this->expectException(OutOfBoundsException::class); + + try { + $line->insertPoint(-1, new Point(3, 4)); + } finally { + static::assertSame([$point], $line->getPoints()); + static::assertSame([[1, 2]], $line->toArray()); + static::assertSame(4326, $point->getSrid()); + } + } + + /** NegativeRemoval fails without changing membership or referenced points. */ + public function testNegativeRemovalIsAtomic(): void + { + $point = new Point(1, 2, 4326); + $line = new LineString([$point], 4326); + $this->expectException(OutOfBoundsException::class); + + try { + $line->removePoint(-1); + } finally { + static::assertSame([$point], $line->getPoints()); + static::assertSame([[1, 2]], $line->toArray()); + static::assertSame(4326, $point->getSrid()); + } + } + + /** NegativeReplacement fails without changing membership or referenced points. */ + public function testNegativeReplacementIsAtomic(): void + { + $point = new Point(1, 2, 4326); + $line = new LineString([$point], 4326); + $this->expectException(OutOfBoundsException::class); + + try { + $line->setPoint(-1, new Point(3, 4)); + } finally { + static::assertSame([$point], $line->getPoints()); + static::assertSame([[1, 2]], $line->toArray()); + static::assertSame(4326, $point->getSrid()); + } + } + + /** OverflowAccess fails without changing membership or referenced points. */ + public function testOverflowAccessIsAtomic(): void + { + $point = new Point(1, 2, 4326); + $line = new LineString([$point], 4326); + $this->expectException(OutOfBoundsException::class); + + try { + $line->getPoint(1); + } finally { + static::assertSame([$point], $line->getPoints()); + static::assertSame([[1, 2]], $line->toArray()); + static::assertSame(4326, $point->getSrid()); + } + } + + /** OverflowInsertion fails without changing membership or referenced points. */ + public function testOverflowInsertionIsAtomic(): void + { + $point = new Point(1, 2, 4326); + $line = new LineString([$point], 4326); + $this->expectException(OutOfBoundsException::class); + + try { + $line->insertPoint(2, new Point(3, 4)); + } finally { + static::assertSame([$point], $line->getPoints()); + static::assertSame([[1, 2]], $line->toArray()); + static::assertSame(4326, $point->getSrid()); + } + } + + /** OverflowRemoval fails without changing membership or referenced points. */ + public function testOverflowRemovalIsAtomic(): void + { + $point = new Point(1, 2, 4326); + $line = new LineString([$point], 4326); + $this->expectException(OutOfBoundsException::class); + + try { + $line->removePoint(1); + } finally { + static::assertSame([$point], $line->getPoints()); + static::assertSame([[1, 2]], $line->toArray()); + static::assertSame(4326, $point->getSrid()); + } + } + + /** OverflowReplacement fails without changing membership or referenced points. */ + public function testOverflowReplacementIsAtomic(): void + { + $point = new Point(1, 2, 4326); + $line = new LineString([$point], 4326); + $this->expectException(OutOfBoundsException::class); + + try { + $line->setPoint(1, new Point(3, 4)); + } finally { + static::assertSame([$point], $line->getPoints()); + static::assertSame([[1, 2]], $line->toArray()); + static::assertSame(4326, $point->getSrid()); + } + } + + /** Replacement rejects immutable components atomically. */ + public function testReplacementRejectsImmutableComponents(): void + { + $point = new Point(1, 2); + $line = new LineString([$point]); + $this->expectException(\TypeError::class); + + try { + (new \ReflectionMethod($line, 'setPoint'))->invoke($line, 0, new PointImmutable(3, 4)); + } finally { + static::assertSame([$point], $line->getPoints()); + static::assertSame([1, 2], $point->toArray()); + } + } + + /** Shared points are retained without rewriting their initial context. */ + public function testSharedPointsRetainIdentityAndInitialContext(): void + { + $point = new Point(1, 2, 4326, SpatialModelEnum::GEOGRAPHY); + $first = new LineString([$point], 2154); + $second = new LineString([$point]); + static::assertSame($point, $first->getPoint(0)); + static::assertSame($point, $second->getPoint(0)); + static::assertSame(4326, $point->getSrid()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $point->getFamily()); + $point->setX(null)->setY(0); + static::assertSame([[null, 0]], $first->toArray()); + static::assertSame([[null, 0]], $second->toArray()); + $points = $first->getPoints(); + $points[] = new Point(7, 8); + static::assertSame([$point], $first->getPoints()); + } + + /** Technical input cannot partially replace coordinate content. */ + public function testTechnicalTupleInputFailureIsAtomic(): void + { + $point = new Point(1, 2); + $line = new LineString([$point]); + $this->expectException(\TypeError::class); + + try { + (new \ReflectionMethod($line, 'setArrayOfCoordinates'))->invoke($line, [[3, 4], [[], 5]]); + } finally { + static::assertSame([$point], $line->getPoints()); + static::assertSame([[1, 2]], $line->toArray()); + } + } + + /** Tuple parsing uses the mutable point constructor. */ + public function testTupleParsingUsesPointConstructor(): void + { + $line = new LineString([["2°30'E", "48°30'N"]], 4326, SpatialModelEnum::GEOGRAPHY); + static::assertSame([[2.5, 48.5]], $line->toArray()); + static::assertSame(4326, $line->getPoint(0)->getSrid()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $line->getPoint(0)->getFamily()); + static::assertSame(GeometryTypeEnum::LINESTRING, $line->getType()); + } +} diff --git a/tests/Unit/Types/MutablePointSequenceContextTest.php b/tests/Unit/Types/MutablePointSequenceContextTest.php new file mode 100644 index 0000000..ac15e09 --- /dev/null +++ b/tests/Unit/Types/MutablePointSequenceContextTest.php @@ -0,0 +1,305 @@ + 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\SpatialTypes\Tests\Unit\Types; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialTypes\Reference\SpatialReference; +use LongitudeOne\SpatialTypes\Types\Dimension2\CircularString; +use LongitudeOne\SpatialTypes\Types\Dimension2\LineString; +use LongitudeOne\SpatialTypes\Types\Dimension2\Point; +use PHPUnit\Framework\TestCase; + +/** Coordinate layouts and declared-context propagation. + * @internal + * + * @coversNothing + */ +class MutablePointSequenceContextTest extends TestCase +{ + /** CircularString setFamily propagates by default through shared references. */ + public function testCircularStringFamilyPropagationAndLocalSetting(): void + { + $point = new Point(0, null, 4326); + $other = new Point(3, 4, 4326); + $line = new CircularString([$point, $other], 4326); + $shared = new CircularString([$point], 4326); + static::assertSame($line, $line->setFamily(SpatialModelEnum::GEOGRAPHY, false)); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $line->getFamily()); + static::assertEquals(SpatialModelEnum::GEOMETRY, $point->getFamily()); + static::assertEquals(SpatialModelEnum::GEOMETRY, $other->getFamily()); + static::assertSame($line, $line->setFamily(SpatialModelEnum::GEOGRAPHY)); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $point->getFamily()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $other->getFamily()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $shared->getPoint(0)->getFamily()); + static::assertEquals(SpatialModelEnum::GEOMETRY, $shared->getFamily()); + static::assertSame([$point, $other], $line->getPoints()); + static::assertSame([[0, null], [3, 4]], $line->toArray()); + static::assertSame([[0, null]], $shared->toArray()); + } + + /** CircularString setSpatialReference propagates by default through shared references. */ + public function testCircularStringSpatialReferencePropagationAndLocalSetting(): void + { + $reference = SpatialReference::epsg(2154); + $point = new Point(0, null, SpatialReference::epsg(4326)); + $other = new Point(3, 4, SpatialReference::epsg(4326)); + $line = new CircularString([$point, $other], SpatialReference::epsg(4326)); + $shared = new CircularString([$point], 4326); + static::assertSame($line, $line->setSpatialReference($reference, false)); + static::assertSame($reference, $line->getSpatialReference()); + static::assertEquals(SpatialReference::epsg(4326), $point->getSpatialReference()); + static::assertEquals(SpatialReference::epsg(4326), $other->getSpatialReference()); + static::assertSame($line, $line->setSpatialReference($reference)); + static::assertSame($reference, $point->getSpatialReference()); + static::assertSame($reference, $other->getSpatialReference()); + static::assertSame($reference, $shared->getPoint(0)->getSpatialReference()); + static::assertEquals(SpatialReference::fromSrid(4326), $shared->getSpatialReference()); + static::assertSame([$point, $other], $line->getPoints()); + static::assertSame([[0, null], [3, 4]], $line->toArray()); + static::assertSame([[0, null]], $shared->toArray()); + } + + /** CircularString setSrid propagates by default through shared references. */ + public function testCircularStringSridPropagationAndLocalSetting(): void + { + $point = new Point(0, null, 4326); + $other = new Point(3, 4, 4326); + $line = new CircularString([$point, $other], 4326); + $shared = new CircularString([$point], 4326); + static::assertSame($line, $line->setSrid(2154, false)); + static::assertSame(2154, $line->getSrid()); + static::assertEquals(4326, $point->getSrid()); + static::assertEquals(4326, $other->getSrid()); + static::assertSame($line, $line->setSrid(2154)); + static::assertSame(2154, $point->getSrid()); + static::assertSame(2154, $other->getSrid()); + static::assertSame(2154, $shared->getPoint(0)->getSrid()); + static::assertEquals(4326, $shared->getSrid()); + static::assertSame([$point, $other], $line->getPoints()); + static::assertSame([[0, null], [3, 4]], $line->toArray()); + static::assertSame([[0, null]], $shared->toArray()); + } + + /** CircularString XY preserves supplied nullable ordinates and family. */ + public function testCircularStringXyCoordinatesAndFamily(): void + { + $line = new CircularString([[0, null]], 4326, SpatialModelEnum::GEOGRAPHY); + static::assertSame([[0, null]], $line->toArray()); + static::assertInstanceOf(Point::class, $line->getPoint(0)); + static::assertSame(CoordinateDimensionEnum::XY, $line->getDimension()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $line->getFamily()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $line->getPoint(0)->getFamily()); + static::assertSame(4326, $line->getPoint(0)->getSrid()); + static::assertSame($line, $line->setArrayOfCoordinates([[0, null]])); + static::assertSame([[0, null]], $line->toArray()); + $point = new Point(0, null); + static::assertSame($line, $line->addPoint($point)->setPoint(0, $point)->insertPoint(2, $point)->removePoint(1)); + static::assertSame([$point, $point], $line->getPoints()); + static::assertSame([[0, null], [0, null]], $line->toArray()); + } + + /** CircularString XYM preserves supplied nullable ordinates and family. */ + public function testCircularStringXymCoordinatesAndFamily(): void + { + $line = new \LongitudeOne\SpatialTypes\Types\Dimension3m\CircularString([[0, null, 4]], 4326, SpatialModelEnum::GEOGRAPHY); + static::assertSame([[0, null, 4]], $line->toArray()); + static::assertInstanceOf(\LongitudeOne\SpatialTypes\Types\Dimension3m\Point::class, $line->getPoint(0)); + static::assertSame(CoordinateDimensionEnum::XYM, $line->getDimension()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $line->getFamily()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $line->getPoint(0)->getFamily()); + static::assertSame(4326, $line->getPoint(0)->getSrid()); + static::assertSame($line, $line->setArrayOfCoordinates([[0, null, 4]])); + static::assertSame([[0, null, 4]], $line->toArray()); + $point = new \LongitudeOne\SpatialTypes\Types\Dimension3m\Point(0, null, 4); + static::assertSame($line, $line->addPoint($point)->setPoint(0, $point)->insertPoint(2, $point)->removePoint(1)); + static::assertSame([$point, $point], $line->getPoints()); + static::assertSame([[0, null, 4], [0, null, 4]], $line->toArray()); + } + + /** CircularString XYZ preserves supplied nullable ordinates and family. */ + public function testCircularStringXyzCoordinatesAndFamily(): void + { + $line = new \LongitudeOne\SpatialTypes\Types\Dimension3z\CircularString([[0, null, 3]], 4326, SpatialModelEnum::GEOGRAPHY); + static::assertSame([[0, null, 3]], $line->toArray()); + static::assertInstanceOf(\LongitudeOne\SpatialTypes\Types\Dimension3z\Point::class, $line->getPoint(0)); + static::assertSame(CoordinateDimensionEnum::XYZ, $line->getDimension()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $line->getFamily()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $line->getPoint(0)->getFamily()); + static::assertSame(4326, $line->getPoint(0)->getSrid()); + static::assertSame($line, $line->setArrayOfCoordinates([[0, null, 3]])); + static::assertSame([[0, null, 3]], $line->toArray()); + $point = new \LongitudeOne\SpatialTypes\Types\Dimension3z\Point(0, null, 3); + static::assertSame($line, $line->addPoint($point)->setPoint(0, $point)->insertPoint(2, $point)->removePoint(1)); + static::assertSame([$point, $point], $line->getPoints()); + static::assertSame([[0, null, 3], [0, null, 3]], $line->toArray()); + } + + /** CircularString XYZM preserves supplied nullable ordinates and family. */ + public function testCircularStringXyzmCoordinatesAndFamily(): void + { + $line = new \LongitudeOne\SpatialTypes\Types\Dimension4zm\CircularString([[0, null, 3, 4]], 4326, SpatialModelEnum::GEOGRAPHY); + static::assertSame([[0, null, 3, 4]], $line->toArray()); + static::assertInstanceOf(\LongitudeOne\SpatialTypes\Types\Dimension4zm\Point::class, $line->getPoint(0)); + static::assertSame(CoordinateDimensionEnum::XYZM, $line->getDimension()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $line->getFamily()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $line->getPoint(0)->getFamily()); + static::assertSame(4326, $line->getPoint(0)->getSrid()); + static::assertSame($line, $line->setArrayOfCoordinates([[0, null, 3, 4]])); + static::assertSame([[0, null, 3, 4]], $line->toArray()); + $point = new \LongitudeOne\SpatialTypes\Types\Dimension4zm\Point(0, null, 3, 4); + static::assertSame($line, $line->addPoint($point)->setPoint(0, $point)->insertPoint(2, $point)->removePoint(1)); + static::assertSame([$point, $point], $line->getPoints()); + static::assertSame([[0, null, 3, 4], [0, null, 3, 4]], $line->toArray()); + } + + /** LineString setFamily propagates by default through shared references. */ + public function testLineStringFamilyPropagationAndLocalSetting(): void + { + $point = new Point(0, null, 4326); + $other = new Point(3, 4, 4326); + $line = new LineString([$point, $other], 4326); + $shared = new LineString([$point], 4326); + static::assertSame($line, $line->setFamily(SpatialModelEnum::GEOGRAPHY, false)); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $line->getFamily()); + static::assertEquals(SpatialModelEnum::GEOMETRY, $point->getFamily()); + static::assertEquals(SpatialModelEnum::GEOMETRY, $other->getFamily()); + static::assertSame($line, $line->setFamily(SpatialModelEnum::GEOGRAPHY)); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $point->getFamily()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $other->getFamily()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $shared->getPoint(0)->getFamily()); + static::assertEquals(SpatialModelEnum::GEOMETRY, $shared->getFamily()); + static::assertSame([$point, $other], $line->getPoints()); + static::assertSame([[0, null], [3, 4]], $line->toArray()); + static::assertSame([[0, null]], $shared->toArray()); + } + + /** LineString setSpatialReference propagates by default through shared references. */ + public function testLineStringSpatialReferencePropagationAndLocalSetting(): void + { + $reference = SpatialReference::epsg(2154); + $point = new Point(0, null, SpatialReference::epsg(4326)); + $other = new Point(3, 4, SpatialReference::epsg(4326)); + $line = new LineString([$point, $other], SpatialReference::epsg(4326)); + $shared = new LineString([$point], 4326); + static::assertSame($line, $line->setSpatialReference($reference, false)); + static::assertSame($reference, $line->getSpatialReference()); + static::assertEquals(SpatialReference::epsg(4326), $point->getSpatialReference()); + static::assertEquals(SpatialReference::epsg(4326), $other->getSpatialReference()); + static::assertSame($line, $line->setSpatialReference($reference)); + static::assertSame($reference, $point->getSpatialReference()); + static::assertSame($reference, $other->getSpatialReference()); + static::assertSame($reference, $shared->getPoint(0)->getSpatialReference()); + static::assertEquals(SpatialReference::fromSrid(4326), $shared->getSpatialReference()); + static::assertSame([$point, $other], $line->getPoints()); + static::assertSame([[0, null], [3, 4]], $line->toArray()); + static::assertSame([[0, null]], $shared->toArray()); + } + + /** LineString setSrid propagates by default through shared references. */ + public function testLineStringSridPropagationAndLocalSetting(): void + { + $point = new Point(0, null, 4326); + $other = new Point(3, 4, 4326); + $line = new LineString([$point, $other], 4326); + $shared = new LineString([$point], 4326); + static::assertSame($line, $line->setSrid(2154, false)); + static::assertSame(2154, $line->getSrid()); + static::assertEquals(4326, $point->getSrid()); + static::assertEquals(4326, $other->getSrid()); + static::assertSame($line, $line->setSrid(2154)); + static::assertSame(2154, $point->getSrid()); + static::assertSame(2154, $other->getSrid()); + static::assertSame(2154, $shared->getPoint(0)->getSrid()); + static::assertEquals(4326, $shared->getSrid()); + static::assertSame([$point, $other], $line->getPoints()); + static::assertSame([[0, null], [3, 4]], $line->toArray()); + static::assertSame([[0, null]], $shared->toArray()); + } + + /** LineString XY preserves supplied nullable ordinates and family. */ + public function testLineStringXyCoordinatesAndFamily(): void + { + $line = new LineString([[0, null]], 4326, SpatialModelEnum::GEOGRAPHY); + static::assertSame([[0, null]], $line->toArray()); + static::assertInstanceOf(Point::class, $line->getPoint(0)); + static::assertSame(CoordinateDimensionEnum::XY, $line->getDimension()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $line->getFamily()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $line->getPoint(0)->getFamily()); + static::assertSame(4326, $line->getPoint(0)->getSrid()); + static::assertSame($line, $line->setArrayOfCoordinates([[0, null]])); + static::assertSame([[0, null]], $line->toArray()); + $point = new Point(0, null); + static::assertSame($line, $line->addPoint($point)->setPoint(0, $point)->insertPoint(2, $point)->removePoint(1)); + static::assertSame([$point, $point], $line->getPoints()); + static::assertSame([[0, null], [0, null]], $line->toArray()); + } + + /** LineString XYM preserves supplied nullable ordinates and family. */ + public function testLineStringXymCoordinatesAndFamily(): void + { + $line = new \LongitudeOne\SpatialTypes\Types\Dimension3m\LineString([[0, null, 4]], 4326, SpatialModelEnum::GEOGRAPHY); + static::assertSame([[0, null, 4]], $line->toArray()); + static::assertInstanceOf(\LongitudeOne\SpatialTypes\Types\Dimension3m\Point::class, $line->getPoint(0)); + static::assertSame(CoordinateDimensionEnum::XYM, $line->getDimension()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $line->getFamily()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $line->getPoint(0)->getFamily()); + static::assertSame(4326, $line->getPoint(0)->getSrid()); + static::assertSame($line, $line->setArrayOfCoordinates([[0, null, 4]])); + static::assertSame([[0, null, 4]], $line->toArray()); + $point = new \LongitudeOne\SpatialTypes\Types\Dimension3m\Point(0, null, 4); + static::assertSame($line, $line->addPoint($point)->setPoint(0, $point)->insertPoint(2, $point)->removePoint(1)); + static::assertSame([$point, $point], $line->getPoints()); + static::assertSame([[0, null, 4], [0, null, 4]], $line->toArray()); + } + + /** LineString XYZ preserves supplied nullable ordinates and family. */ + public function testLineStringXyzCoordinatesAndFamily(): void + { + $line = new \LongitudeOne\SpatialTypes\Types\Dimension3z\LineString([[0, null, 3]], 4326, SpatialModelEnum::GEOGRAPHY); + static::assertSame([[0, null, 3]], $line->toArray()); + static::assertInstanceOf(\LongitudeOne\SpatialTypes\Types\Dimension3z\Point::class, $line->getPoint(0)); + static::assertSame(CoordinateDimensionEnum::XYZ, $line->getDimension()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $line->getFamily()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $line->getPoint(0)->getFamily()); + static::assertSame(4326, $line->getPoint(0)->getSrid()); + static::assertSame($line, $line->setArrayOfCoordinates([[0, null, 3]])); + static::assertSame([[0, null, 3]], $line->toArray()); + $point = new \LongitudeOne\SpatialTypes\Types\Dimension3z\Point(0, null, 3); + static::assertSame($line, $line->addPoint($point)->setPoint(0, $point)->insertPoint(2, $point)->removePoint(1)); + static::assertSame([$point, $point], $line->getPoints()); + static::assertSame([[0, null, 3], [0, null, 3]], $line->toArray()); + } + + /** LineString XYZM preserves supplied nullable ordinates and family. */ + public function testLineStringXyzmCoordinatesAndFamily(): void + { + $line = new \LongitudeOne\SpatialTypes\Types\Dimension4zm\LineString([[0, null, 3, 4]], 4326, SpatialModelEnum::GEOGRAPHY); + static::assertSame([[0, null, 3, 4]], $line->toArray()); + static::assertInstanceOf(\LongitudeOne\SpatialTypes\Types\Dimension4zm\Point::class, $line->getPoint(0)); + static::assertSame(CoordinateDimensionEnum::XYZM, $line->getDimension()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $line->getFamily()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $line->getPoint(0)->getFamily()); + static::assertSame(4326, $line->getPoint(0)->getSrid()); + static::assertSame($line, $line->setArrayOfCoordinates([[0, null, 3, 4]])); + static::assertSame([[0, null, 3, 4]], $line->toArray()); + $point = new \LongitudeOne\SpatialTypes\Types\Dimension4zm\Point(0, null, 3, 4); + static::assertSame($line, $line->addPoint($point)->setPoint(0, $point)->insertPoint(2, $point)->removePoint(1)); + static::assertSame([$point, $point], $line->getPoints()); + static::assertSame([[0, null, 3, 4], [0, null, 3, 4]], $line->toArray()); + } +} From 27ac6d4378223724d52e178ff1195e0f1c3a813a Mon Sep 17 00:00:00 2001 From: Alexandre Tranchant Date: Thu, 29 Oct 2026 15:02:11 +0100 Subject: [PATCH 2/2] fix(types): distinguish mutable lines from closed paths --- lib/Types/AbstractLineString.php | 6 +++--- tests/Unit/Types/MutableLineStringTest.php | 12 ++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/lib/Types/AbstractLineString.php b/lib/Types/AbstractLineString.php index cc79771..ad1df5d 100644 --- a/lib/Types/AbstractLineString.php +++ b/lib/Types/AbstractLineString.php @@ -35,12 +35,12 @@ public function getType(): GeometryTypeEnum /** Whether a line has equal endpoints. */ public function isClosed(): bool { - return $this->isLine() && parent::isClosed(); + return count($this->points) >= 2 && parent::isClosed(); } - /** Whether a line has at least two defining points. */ + /** Whether this path has exactly two defining points. */ public function isLine(): bool { - return count($this->points) >= 2; + return 2 === count($this->points); } } diff --git a/tests/Unit/Types/MutableLineStringTest.php b/tests/Unit/Types/MutableLineStringTest.php index 7d476c5..9563ecb 100644 --- a/tests/Unit/Types/MutableLineStringTest.php +++ b/tests/Unit/Types/MutableLineStringTest.php @@ -347,6 +347,18 @@ public function testTechnicalTupleInputFailureIsAtomic(): void } } + /** A three-point path is not a line even when its endpoints agree. */ + public function testThreePointClosureDoesNotImplyLine(): void + { + $path = new LineString([[0, 0], [1, 1], [2, 0]]); + static::assertFalse($path->isLine()); + static::assertFalse($path->isClosed()); + static::assertSame($path, $path->setPoint(2, new Point(0, 0))); + static::assertFalse($path->isLine()); + static::assertTrue($path->isClosed()); + static::assertSame([[0, 0], [1, 1], [0, 0]], $path->toArray()); + } + /** Tuple parsing uses the mutable point constructor. */ public function testTupleParsingUsesPointConstructor(): void {