diff --git a/docs/immutability.md b/docs/immutability.md index 622bf50..0532978 100644 --- a/docs/immutability.md +++ b/docs/immutability.md @@ -102,8 +102,10 @@ 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 `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. +for point ownership, propagation and arc indexing. Mutable `CompoundCurve` classes also retain mutable curves and their points by +reference. See [mutable compound curves](instantiable-spatial-types.md#mutable-compound-curves) +for component editing and recursive context propagation. Other mutable aggregates +arrive in later Stories of Epic #38. Existing factories create the explicitly named immutable classes and expose immutable return contracts. diff --git a/docs/instantiable-spatial-types.md b/docs/instantiable-spatial-types.md index 462516e..cf70369 100644 --- a/docs/instantiable-spatial-types.md +++ b/docs/instantiable-spatial-types.md @@ -711,3 +711,69 @@ 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. + +## Mutable compound curves + +`Types\Dimension2\CompoundCurve`, `Types\Dimension3z\CompoundCurve`, +`Types\Dimension3m\CompoundCurve` and `Types\Dimension4zm\CompoundCurve` +implement `MutableCompoundCurveInterface`. Their constructor is +`__construct(array $curves = [], int|SpatialReference $srid = 0, +SpatialModelEnum $family = SpatialModelEnum::GEOMETRY)`. + +Only `MutableLineStringInterface` and `MutableCircularStringInterface` components +are accepted. Coordinate arrays, immutable curves and nested compound curves are +rejected without conversion. Components retain their own declared layout, +family and reference; compatibility and geometric continuity require explicit +validation. Incomplete and empty component sequences remain representable. + +| Operation | Behavior | +| --- | --- | +| `addCurve($curve)` | Append a component | +| `insertCurve($index, $curve)` | Insert before an index; the component count appends | +| `setCurve($index, $curve)` | Replace an existing component | +| `removeCurve($index)` | Remove an existing component and reindex | +| `setCurves($curves)` | Replace the entire ordered sequence atomically | +| `getCurve($index)` | Read an existing component | +| `getCurves()`, `getElements()` | Return the ordered component instances | + +All indices are zero-based. Negative indices and missing components raise +`OutOfBoundsException`; indexed access does not wrap. Invalid component arrays +raise `InvalidValueException`, while incompatible typed mutation arguments raise +PHP `TypeError`. Failures preserve membership and supplied components. +Successful mutations return the receiver as `static` for chaining. No `with…` +methods or dimension setter are exposed. + +```php +use LongitudeOne\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialTypes\Types\Dimension2\CircularString; +use LongitudeOne\SpatialTypes\Types\Dimension2\CompoundCurve; +use LongitudeOne\SpatialTypes\Types\Dimension2\LineString; +use LongitudeOne\SpatialTypes\Types\Dimension2\Point; + +$point = new Point(0, null, 4326); +$line = new LineString([$point], 4326); +$arc = new CircularString([[10, 0], [11, 1], [12, 0]], 4326); +$path = new CompoundCurve([$line], 4326); +$other = new CompoundCurve([$line], 4326); +$path->addCurve($arc)->insertCurve(1, $line)->removeCurve(1); + +assert($path->getCurve(0) === $line); +$point->setY(2); // Both paths now expose the changed point. +$path->setSrid(2154); // Updates both curves and all their points in place. +assert($other->getCurve(0)->getSrid() === 2154); +assert($other->getSrid() === 4326); +$path->setFamily(SpatialModelEnum::GEOGRAPHY, false); // Only this compound changes. +``` + +Construction, insertion and replacement retain the supplied instances without +cloning. Getters expose those instances; editing a component or its points is +visible to every owner. Modifying the returned PHP membership array itself does +not change the compound. + +`setSrid()`, `setSpatialReference()` and `setFamily()` accept a second boolean +`$propagate` argument, defaulting to `true`. Propagation updates existing +components and their points recursively, including instances shared by other +owners. Passing `false` updates only the receiver. No context setter recalculates +coordinates, changes the compound's spatial type or changes its layout. +`toArray()` preserves component boundaries and their defining point tuples; +circular components are never flattened or approximated. diff --git a/docs/spatial-reference-systems.md b/docs/spatial-reference-systems.md index caa4774..efcf021 100644 --- a/docs/spatial-reference-systems.md +++ b/docs/spatial-reference-systems.md @@ -47,3 +47,11 @@ $lambert93 = $lambert93->withCoordinates(Coordinates::xy(652469.0227, 6862035.25 The Lambert 93 ordinates in this example must be calculated by that external transformer; this library intentionally does not provide it. + +Mutable line strings and circular strings propagate declared-context updates to +contained points by default. Mutable compound curves recursively propagate to +all components and their points. Their `setSrid()`, `setSpatialReference()` and +`setFamily()` methods accept `$propagate = false` to change only the receiver. +Propagation changes shared instances, so their other owners observe the updated +member context while retaining their own aggregate context. Coordinates are +never recalculated. diff --git a/docs/type-class-hierarchy.md b/docs/type-class-hierarchy.md index 1b3224c..4271d80 100644 --- a/docs/type-class-hierarchy.md +++ b/docs/type-class-hierarchy.md @@ -72,3 +72,15 @@ 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. + +Mutable compound curves use a separate base: + +```text +AbstractSpatialType +└── AbstractCompoundCurve + └── Dimension{2,3m,3z,4zm}\CompoundCurve +``` + +`MutableCompoundCurveInterface` refines component getters to mutable line-string +or circular-string interfaces and exposes ordered membership editing. Immutable +compound curves retain their separate immutable base and copy contracts. diff --git a/lib/Interfaces/MutableCompoundCurveInterface.php b/lib/Interfaces/MutableCompoundCurveInterface.php index 29317c4..b45a885 100644 --- a/lib/Interfaces/MutableCompoundCurveInterface.php +++ b/lib/Interfaces/MutableCompoundCurveInterface.php @@ -16,7 +16,63 @@ namespace LongitudeOne\SpatialTypes\Interfaces; -/** Mutable compoundcurve contract; type-specific editing is introduced separately. */ +use LongitudeOne\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialTypes\Reference\SpatialReference; + +/** In-place ordered mutable line-string and circular-string components. */ interface MutableCompoundCurveInterface extends CompoundCurveInterface, MutableCurveInterface { + /** @param MutableCircularStringInterface|MutableLineStringInterface $curve Component retained without cloning */ + public function addCurve(MutableCircularStringInterface|MutableLineStringInterface $curve): static; + + /** @param int $index Existing zero-based component index */ + public function getCurve(int $index): MutableCircularStringInterface|MutableLineStringInterface; + + /** @return list */ + public function getCurves(): array; + + /** @return list */ + public function getElements(): array; + + /** Return the final component's endpoint, or null. */ + public function getEndPoint(): ?MutablePointInterface; + + /** Return the first component's start point, or null. */ + public function getStartPoint(): ?MutablePointInterface; + + /** + * @param int $index Zero-based insertion index, up to the component count + * @param MutableCircularStringInterface|MutableLineStringInterface $curve Component retained without cloning + */ + public function insertCurve(int $index, MutableCircularStringInterface|MutableLineStringInterface $curve): static; + + /** @param int $index Existing zero-based component index */ + public function removeCurve(int $index): static; + + /** + * @param int $index Existing zero-based component index + * @param MutableCircularStringInterface|MutableLineStringInterface $curve Component retained without cloning + */ + public function setCurve(int $index, MutableCircularStringInterface|MutableLineStringInterface $curve): static; + + /** @param array $curves Complete ordered replacement sequence */ + public function setCurves(array $curves): static; + + /** + * @param SpatialModelEnum $family Declared family + * @param bool $propagate Whether to update components and their points + */ + public function setFamily(SpatialModelEnum $family, bool $propagate = true): static; + + /** + * @param SpatialReference $reference Declared reference + * @param bool $propagate Whether to update components and their points + */ + public function setSpatialReference(SpatialReference $reference, bool $propagate = true): static; + + /** + * @param int $srid Declared reference identifier + * @param bool $propagate Whether to update components and their points + */ + public function setSrid(int $srid, bool $propagate = true): static; } diff --git a/lib/Types/AbstractCompoundCurve.php b/lib/Types/AbstractCompoundCurve.php new file mode 100644 index 0000000..812e30e --- /dev/null +++ b/lib/Types/AbstractCompoundCurve.php @@ -0,0 +1,222 @@ + 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\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialTypes\Exception\InvalidValueException; +use LongitudeOne\SpatialTypes\Exception\OutOfBoundsException; +use LongitudeOne\SpatialTypes\Interfaces\MutableCircularStringInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutableCompoundCurveInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutableLineStringInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutablePointInterface; +use LongitudeOne\SpatialTypes\Reference\SpatialReference; + +/** Mutable ordered curve storage without geometric validation. + * @internal + */ +abstract class AbstractCompoundCurve extends AbstractSpatialType implements MutableCompoundCurveInterface +{ + /** @var list */ + protected array $curves = []; + + protected SpatialModelEnum $family; + + /** + * @param array $curves Ordered mutable components + * @param int|SpatialReference $srid Declared reference + * @param SpatialModelEnum $family Declared family + */ + final public function __construct(array $curves = [], int|SpatialReference $srid = 0, SpatialModelEnum $family = SpatialModelEnum::GEOMETRY) + { + $this->family = $family; + $this->initializeSpatialReference($srid); + $this->setCurves($curves); + } + + /** @param MutableCircularStringInterface|MutableLineStringInterface $curve Component retained without cloning */ + public function addCurve(MutableCircularStringInterface|MutableLineStringInterface $curve): static + { + $this->curves[] = $curve; + + return $this; + } + + /** @param int $index Existing zero-based component index */ + public function getCurve(int $index): MutableCircularStringInterface|MutableLineStringInterface + { + $this->requireCurveIndex($index); + + return $this->curves[$index]; + } + + /** @return list */ + public function getCurves(): array + { + return $this->curves; + } + + /** @return list */ + public function getElements(): array + { + return $this->curves; + } + + /** Return the final component's endpoint, or null. */ + public function getEndPoint(): ?MutablePointInterface + { + return $this->isEmpty() ? null : $this->curves[count($this->curves) - 1]->getEndPoint(); + } + + /** Return the declared family. */ + public function getFamily(): SpatialModelEnum + { + return $this->family; + } + + /** Return the first component's start point, or null. */ + public function getStartPoint(): ?MutablePointInterface + { + return $this->isEmpty() ? null : $this->curves[0]->getStartPoint(); + } + + /** Return the fixed spatial type. */ + public function getType(): GeometryTypeEnum + { + return GeometryTypeEnum::COMPOUNDCURVE; + } + + /** + * @param int $index Zero-based insertion index, up to the component count + * @param MutableCircularStringInterface|MutableLineStringInterface $curve Component retained without cloning + */ + public function insertCurve(int $index, MutableCircularStringInterface|MutableLineStringInterface $curve): static + { + if ($index < 0 || $index > count($this->curves)) { + throw new OutOfBoundsException(sprintf('Curve index %d is outside the insertion range.', $index)); + } + array_splice($this->curves, $index, 0, [$curve]); + + return $this; + } + + /** Whether the first and final endpoints are present and equal. */ + public function isClosed(): bool + { + $start = $this->getStartPoint(); + $end = $this->getEndPoint(); + + return null !== $start && null !== $end && $start->equalsTo($end); + } + + /** Whether there are no components. */ + public function isEmpty(): bool + { + return [] === $this->curves; + } + + /** @param int $index Existing zero-based component index */ + public function removeCurve(int $index): static + { + $this->requireCurveIndex($index); + array_splice($this->curves, $index, 1); + + return $this; + } + + /** + * @param int $index Existing zero-based component index + * @param MutableCircularStringInterface|MutableLineStringInterface $curve Component retained without cloning + */ + public function setCurve(int $index, MutableCircularStringInterface|MutableLineStringInterface $curve): static + { + $this->requireCurveIndex($index); + array_splice($this->curves, $index, 1, [$curve]); + + return $this; + } + + /** @param array $curves Complete ordered replacement sequence */ + public function setCurves(array $curves): static + { + $checked = []; + foreach ($curves as $curve) { + if (!$curve instanceof MutableLineStringInterface && !$curve instanceof MutableCircularStringInterface) { + throw new InvalidValueException('A mutable compound curve requires mutable line-string or circular-string components.'); + } + $checked[] = $curve; + } + $this->curves = $checked; + + return $this; + } + + /** + * @param SpatialModelEnum $family Declared family + * @param bool $propagate Whether to update components and their points + */ + public function setFamily(SpatialModelEnum $family, bool $propagate = true): static + { + if ($propagate) { + foreach ($this->curves as $curve) { + $curve->setFamily($family); + } + } + $this->family = $family; + + return $this; + } + + /** + * @param SpatialReference $reference Declared reference + * @param bool $propagate Whether to update components and their points + */ + public function setSpatialReference(SpatialReference $reference, bool $propagate = true): static + { + if ($propagate) { + foreach ($this->curves as $curve) { + $curve->setSpatialReference($reference); + } + } + $this->spatialReference = $reference; + + return $this; + } + + /** + * @param int $srid Declared reference identifier + * @param bool $propagate Whether to update components and their points + */ + 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 (MutableCircularStringInterface|MutableLineStringInterface $curve): array => $curve->toArray(), $this->curves); + } + + /** @param int $index Existing zero-based component index */ + private function requireCurveIndex(int $index): void + { + if ($index < 0 || $index >= count($this->curves)) { + throw new OutOfBoundsException(sprintf('Curve index %d does not address an existing curve.', $index)); + } + } +} diff --git a/lib/Types/Dimension2/CompoundCurve.php b/lib/Types/Dimension2/CompoundCurve.php new file mode 100644 index 0000000..cb55cdb --- /dev/null +++ b/lib/Types/Dimension2/CompoundCurve.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\AbstractCompoundCurve; + +/** Mutable XY CompoundCurve with instance-level declared family. */ +final class CompoundCurve extends AbstractCompoundCurve +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XY; + } +} diff --git a/lib/Types/Dimension3m/CompoundCurve.php b/lib/Types/Dimension3m/CompoundCurve.php new file mode 100644 index 0000000..bc991eb --- /dev/null +++ b/lib/Types/Dimension3m/CompoundCurve.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\AbstractCompoundCurve; + +/** Mutable XYM CompoundCurve with instance-level declared family. */ +final class CompoundCurve extends AbstractCompoundCurve +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XYM; + } +} diff --git a/lib/Types/Dimension3z/CompoundCurve.php b/lib/Types/Dimension3z/CompoundCurve.php new file mode 100644 index 0000000..99cd419 --- /dev/null +++ b/lib/Types/Dimension3z/CompoundCurve.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\AbstractCompoundCurve; + +/** Mutable XYZ CompoundCurve with instance-level declared family. */ +final class CompoundCurve extends AbstractCompoundCurve +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XYZ; + } +} diff --git a/lib/Types/Dimension4zm/CompoundCurve.php b/lib/Types/Dimension4zm/CompoundCurve.php new file mode 100644 index 0000000..57dd1aa --- /dev/null +++ b/lib/Types/Dimension4zm/CompoundCurve.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\AbstractCompoundCurve; + +/** Mutable XYZM CompoundCurve with instance-level declared family. */ +final class CompoundCurve extends AbstractCompoundCurve +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XYZM; + } +} diff --git a/tests/Unit/Types/MutableCompoundCurveTest.php b/tests/Unit/Types/MutableCompoundCurveTest.php new file mode 100644 index 0000000..adda008 --- /dev/null +++ b/tests/Unit/Types/MutableCompoundCurveTest.php @@ -0,0 +1,592 @@ + 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\GeometryTypeEnum; +use LongitudeOne\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialTypes\Exception\InvalidValueException; +use LongitudeOne\SpatialTypes\Exception\OutOfBoundsException; +use LongitudeOne\SpatialTypes\Interfaces\MutableCompoundCurveInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutableSpatialInterface; +use LongitudeOne\SpatialTypes\Reference\SpatialReference; +use LongitudeOne\SpatialTypes\Types\Dimension2\CircularString; +use LongitudeOne\SpatialTypes\Types\Dimension2\CompoundCurve; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\LineStringImmutable; +use LongitudeOne\SpatialTypes\Types\Dimension2\LineString; +use LongitudeOne\SpatialTypes\Types\Dimension2\Point; +use PHPUnit\Framework\TestCase; + +/** Mutable compound membership, ownership and context. + * @internal + * + * @coversNothing + */ +class MutableCompoundCurveTest extends TestCase +{ + /** Invalid addressing leaves components unchanged. */ + public function testCountAccessIsAtomic(): void + { + $point = new Point(1, 2, 4326); + $curve = new LineString([$point], 4326); + $compound = new CompoundCurve([$curve], 2154); + $this->expectException(OutOfBoundsException::class); + + try { + $compound->getCurve(1); + } finally { + static::assertSame([$curve], $compound->getCurves()); + static::assertSame([[[1, 2]]], $compound->toArray()); + static::assertSame(4326, $point->getSrid()); + static::assertSame(4326, $curve->getSrid()); + static::assertSame(2154, $compound->getSrid()); + } + } + + /** Invalid addressing leaves components unchanged. */ + public function testCountRemovalIsAtomic(): void + { + $point = new Point(1, 2, 4326); + $curve = new LineString([$point], 4326); + $compound = new CompoundCurve([$curve], 2154); + $this->expectException(OutOfBoundsException::class); + + try { + $compound->removeCurve(1); + } finally { + static::assertSame([$curve], $compound->getCurves()); + static::assertSame([[[1, 2]]], $compound->toArray()); + static::assertSame(4326, $point->getSrid()); + static::assertSame(4326, $curve->getSrid()); + static::assertSame(2154, $compound->getSrid()); + } + } + + /** Invalid addressing leaves components unchanged. */ + public function testCountReplacementIsAtomic(): void + { + $point = new Point(1, 2, 4326); + $curve = new LineString([$point], 4326); + $compound = new CompoundCurve([$curve], 2154); + $this->expectException(OutOfBoundsException::class); + + try { + $compound->setCurve(1, $curve); + } finally { + static::assertSame([$curve], $compound->getCurves()); + static::assertSame([[[1, 2]]], $compound->toArray()); + static::assertSame(4326, $point->getSrid()); + static::assertSame(4326, $curve->getSrid()); + static::assertSame(2154, $compound->getSrid()); + } + } + + /** Functional context compatibility is left to explicit validation. */ + public function testDifferentComponentLayoutsRemainRepresentable(): void + { + $line = new LineString([[0, null]], 4326); + $arc = new \LongitudeOne\SpatialTypes\Types\Dimension3m\CircularString([[9, 8, null]], 2154, SpatialModelEnum::GEOGRAPHY); + $compound = new CompoundCurve([$line, $arc]); + static::assertSame([[[0, null]], [[9, 8, null]]], $compound->toArray()); + static::assertSame(CoordinateDimensionEnum::XY, $compound->getDimension()); + static::assertSame(CoordinateDimensionEnum::XYM, $arc->getDimension()); + static::assertSame(2154, $arc->getSrid()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $arc->getFamily()); + } + + /** Empty and incomplete components do not trigger geometric checks. */ + public function testEmptyAndIncompleteComponents(): void + { + $compound = new CompoundCurve(); + static::assertTrue($compound->isEmpty()); + static::assertNull($compound->getStartPoint()); + static::assertNull($compound->getEndPoint()); + static::assertFalse($compound->isClosed()); + $empty = new LineString([]); + $partial = new CircularString([[0, null]]); + $compound->setCurves([$empty, $partial]); + static::assertFalse($compound->isEmpty()); + static::assertNull($compound->getStartPoint()); + static::assertFalse($compound->isClosed()); + static::assertSame([[], [[0, null]]], $compound->toArray()); + $compound->setCurves([$partial, $empty]); + static::assertNull($compound->getEndPoint()); + static::assertFalse($compound->isClosed()); + $compound->setCurves([$partial]); + static::assertTrue($compound->isClosed()); + static::assertSame($compound, $compound->setCurves([])); + static::assertTrue($compound->isEmpty()); + } + + /** Empty indexed access has no existing component. */ + public function testEmptyIndexedAccess(): void + { + $compound = new CompoundCurve(); + $this->expectException(OutOfBoundsException::class); + + try { + $compound->getCurve(0); + } finally { + static::assertSame([], $compound->getCurves()); + } + } + + /** Context updates remain local. */ + public function testFamilyLocalOnly(): void + { + $initial = SpatialReference::epsg(4326); + $point = new Point(0, null, $initial); + $line = new LineString([$point], $initial); + $arc = new CircularString([$point], $initial); + $compound = new CompoundCurve([$line, $arc], $initial); + $owner = new CompoundCurve([$line], $initial); + static::assertSame($compound, $compound->setFamily(SpatialModelEnum::GEOGRAPHY, false)); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $compound->getFamily()); + static::assertSame(SpatialModelEnum::GEOMETRY, $line->getFamily()); + static::assertSame(SpatialModelEnum::GEOMETRY, $arc->getFamily()); + static::assertSame(SpatialModelEnum::GEOMETRY, $point->getFamily()); + static::assertSame(SpatialModelEnum::GEOMETRY, $owner->getCurve(0)->getFamily()); + static::assertSame(SpatialModelEnum::GEOMETRY, $owner->getFamily()); + static::assertSame($line, $compound->getCurve(0)); + static::assertSame($point, $arc->getPoint(0)); + static::assertSame([[[0, null]], [[0, null]]], $compound->toArray()); + } + + /** Context updates reach shared descendants recursively. */ + public function testFamilyPropagation(): void + { + $initial = SpatialReference::epsg(4326); + $point = new Point(0, null, $initial); + $line = new LineString([$point], $initial); + $arc = new CircularString([$point], $initial); + $compound = new CompoundCurve([$line, $arc], $initial); + $owner = new CompoundCurve([$line], $initial); + static::assertSame($compound, $compound->setFamily(SpatialModelEnum::GEOGRAPHY)); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $compound->getFamily()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $line->getFamily()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $arc->getFamily()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $point->getFamily()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $owner->getCurve(0)->getFamily()); + static::assertSame(SpatialModelEnum::GEOMETRY, $owner->getFamily()); + static::assertSame($line, $compound->getCurve(0)); + static::assertSame($point, $arc->getPoint(0)); + static::assertSame([[[0, null]], [[0, null]]], $compound->toArray()); + } + + /** Typed mutation rejects immutable components before modification. */ + public function testImmutableAdditionIsRejected(): void + { + $curve = new LineString([[0, null]], 4326); + $immutable = new LineStringImmutable([[3, 4]], 2154); + $compound = new CompoundCurve([$curve]); + $this->expectException(\TypeError::class); + + try { + (new \ReflectionMethod($compound, 'addCurve'))->invoke($compound, $immutable); + } finally { + static::assertSame([$curve], $compound->getCurves()); + static::assertSame([[[0, null]]], $compound->toArray()); + static::assertSame([[3, 4]], $immutable->toArray()); + } + } + + /** Unsupported component input is rejected atomically. */ + public function testImmutableConstructorIsRejected(): void + { + $curve = new LineString([[0, null]], 4326); + $immutable = new LineStringImmutable([[3, 4]], 2154); + $compound = new CompoundCurve([$curve], 4326); + $this->expectException(InvalidValueException::class); + + try { + (new \ReflectionClass(CompoundCurve::class))->newInstance([$immutable]); + } finally { + static::assertSame([$curve], $compound->getCurves()); + static::assertSame([[[0, null]]], $compound->toArray()); + static::assertSame([[3, 4]], $immutable->toArray()); + static::assertSame(2154, $immutable->getSrid()); + static::assertSame(4326, $curve->getSrid()); + } + } + + /** Typed mutation rejects immutable components before modification. */ + public function testImmutableInsertionIsRejected(): void + { + $curve = new LineString([[0, null]], 4326); + $immutable = new LineStringImmutable([[3, 4]], 2154); + $compound = new CompoundCurve([$curve]); + $this->expectException(\TypeError::class); + + try { + (new \ReflectionMethod($compound, 'insertCurve'))->invoke($compound, 0, $immutable); + } finally { + static::assertSame([$curve], $compound->getCurves()); + static::assertSame([[[0, null]]], $compound->toArray()); + static::assertSame([[3, 4]], $immutable->toArray()); + } + } + + /** Typed mutation rejects immutable components before modification. */ + public function testImmutableReplacementIsRejected(): void + { + $curve = new LineString([[0, null]], 4326); + $immutable = new LineStringImmutable([[3, 4]], 2154); + $compound = new CompoundCurve([$curve]); + $this->expectException(\TypeError::class); + + try { + (new \ReflectionMethod($compound, 'setCurve'))->invoke($compound, 0, $immutable); + } finally { + static::assertSame([$curve], $compound->getCurves()); + static::assertSame([[[0, null]]], $compound->toArray()); + static::assertSame([[3, 4]], $immutable->toArray()); + } + } + + /** Unsupported component input is rejected atomically. */ + public function testImmutableSequenceReplacementIsRejected(): void + { + $curve = new LineString([[0, null]], 4326); + $immutable = new LineStringImmutable([[3, 4]], 2154); + $compound = new CompoundCurve([$curve], 4326); + $this->expectException(InvalidValueException::class); + + try { + (new \ReflectionMethod($compound, 'setCurves'))->invoke($compound, [$curve, $immutable]); + } finally { + static::assertSame([$curve], $compound->getCurves()); + static::assertSame([[[0, null]]], $compound->toArray()); + static::assertSame([[3, 4]], $immutable->toArray()); + static::assertSame(2154, $immutable->getSrid()); + static::assertSame(4326, $curve->getSrid()); + } + } + + /** Invalid addressing leaves components unchanged. */ + public function testNegativeAccessIsAtomic(): void + { + $point = new Point(1, 2, 4326); + $curve = new LineString([$point], 4326); + $compound = new CompoundCurve([$curve], 2154); + $this->expectException(OutOfBoundsException::class); + + try { + $compound->getCurve(-1); + } finally { + static::assertSame([$curve], $compound->getCurves()); + static::assertSame([[[1, 2]]], $compound->toArray()); + static::assertSame(4326, $point->getSrid()); + static::assertSame(4326, $curve->getSrid()); + static::assertSame(2154, $compound->getSrid()); + } + } + + /** Invalid addressing leaves components unchanged. */ + public function testNegativeInsertionIsAtomic(): void + { + $point = new Point(1, 2, 4326); + $curve = new LineString([$point], 4326); + $compound = new CompoundCurve([$curve], 2154); + $this->expectException(OutOfBoundsException::class); + + try { + $compound->insertCurve(-1, $curve); + } finally { + static::assertSame([$curve], $compound->getCurves()); + static::assertSame([[[1, 2]]], $compound->toArray()); + static::assertSame(4326, $point->getSrid()); + static::assertSame(4326, $curve->getSrid()); + static::assertSame(2154, $compound->getSrid()); + } + } + + /** Invalid addressing leaves components unchanged. */ + public function testNegativeRemovalIsAtomic(): void + { + $point = new Point(1, 2, 4326); + $curve = new LineString([$point], 4326); + $compound = new CompoundCurve([$curve], 2154); + $this->expectException(OutOfBoundsException::class); + + try { + $compound->removeCurve(-1); + } finally { + static::assertSame([$curve], $compound->getCurves()); + static::assertSame([[[1, 2]]], $compound->toArray()); + static::assertSame(4326, $point->getSrid()); + static::assertSame(4326, $curve->getSrid()); + static::assertSame(2154, $compound->getSrid()); + } + } + + /** Invalid addressing leaves components unchanged. */ + public function testNegativeReplacementIsAtomic(): void + { + $point = new Point(1, 2, 4326); + $curve = new LineString([$point], 4326); + $compound = new CompoundCurve([$curve], 2154); + $this->expectException(OutOfBoundsException::class); + + try { + $compound->setCurve(-1, $curve); + } finally { + static::assertSame([$curve], $compound->getCurves()); + static::assertSame([[[1, 2]]], $compound->toArray()); + static::assertSame(4326, $point->getSrid()); + static::assertSame(4326, $curve->getSrid()); + static::assertSame(2154, $compound->getSrid()); + } + } + + /** Unsupported component input is rejected atomically. */ + public function testNestedCompoundIsRejected(): void + { + $curve = new LineString([[0, null]], 4326); + $immutable = new LineStringImmutable([[3, 4]], 2154); + $compound = new CompoundCurve([$curve], 4326); + $this->expectException(InvalidValueException::class); + + try { + (new \ReflectionMethod($compound, 'setCurves'))->invoke($compound, [$curve, new CompoundCurve()]); + } finally { + static::assertSame([$curve], $compound->getCurves()); + static::assertSame([[[0, null]]], $compound->toArray()); + static::assertSame([[3, 4]], $immutable->toArray()); + static::assertSame(2154, $immutable->getSrid()); + static::assertSame(4326, $curve->getSrid()); + } + } + + /** Membership retains component types and reference identity. */ + public function testOrderedMembershipAndSharedEdits(): void + { + $point = new Point(0, null, 4326); + $line = new LineString([$point], 4326); + $arc = new CircularString([[9, 8]], 2154); + $other = new LineString([[3, 4]]); + $compound = new CompoundCurve([$line]); + $owner = new CompoundCurve([$line]); + static::assertSame($compound, $compound->addCurve($arc)->insertCurve(1, $other)); + static::assertSame([$line, $other, $arc], $compound->getCurves()); + static::assertSame($compound, $compound->insertCurve(3, $line)); + static::assertSame($compound, $compound->setCurve(1, $arc)->removeCurve(2)); + static::assertSame([$line, $arc, $line], $compound->getElements()); + static::assertSame($arc, $compound->getCurve(1)); + static::assertSame($point, $compound->getStartPoint()); + static::assertSame($point, $compound->getEndPoint()); + $point->setY(7); + $line->addPoint(new Point(null, 5)); + static::assertSame([[[0, 7], [null, 5]]], $owner->toArray()); + static::assertSame([[[0, 7], [null, 5]], [[9, 8]], [[0, 7], [null, 5]]], $compound->toArray()); + static::assertSame($compound, $compound->setCurves([7 => $arc, 9 => $line])); + static::assertSame([$arc, $line], $compound->getCurves()); + $returned = $compound->getCurves(); + $returned[0] = $other; + static::assertSame($arc, $compound->getCurve(0)); + } + + /** Invalid addressing leaves components unchanged. */ + public function testOversizedInsertionIsAtomic(): void + { + $point = new Point(1, 2, 4326); + $curve = new LineString([$point], 4326); + $compound = new CompoundCurve([$curve], 2154); + $this->expectException(OutOfBoundsException::class); + + try { + $compound->insertCurve(2, $curve); + } finally { + static::assertSame([$curve], $compound->getCurves()); + static::assertSame([[[1, 2]]], $compound->toArray()); + static::assertSame(4326, $point->getSrid()); + static::assertSame(4326, $curve->getSrid()); + static::assertSame(2154, $compound->getSrid()); + } + } + + /** Mutable public contracts expose no copy or dimension mutation. */ + public function testPublicContracts(): void + { + $compound = new CompoundCurve(); + static::assertContains(MutableCompoundCurveInterface::class, class_implements($compound)); + static::assertContains(MutableSpatialInterface::class, class_implements($compound)); + static::assertSame(GeometryTypeEnum::COMPOUNDCURVE, $compound->getType()); + static::assertSame(CoordinateDimensionEnum::XY, $compound->getDimension()); + static::assertSame(SpatialModelEnum::GEOMETRY, $compound->getFamily()); + static::assertFalse((new \ReflectionClass($compound))->hasMethod('withSrid')); + static::assertFalse((new \ReflectionClass($compound))->hasMethod('withSpatialReference')); + static::assertFalse((new \ReflectionClass($compound))->hasMethod('withCurves')); + static::assertFalse((new \ReflectionClass($compound))->hasMethod('setDimension')); + static::assertSame('static', (string) (new \ReflectionMethod($compound, 'setCurves'))->getReturnType()); + } + + /** Context updates remain local. */ + public function testReferenceLocalOnly(): void + { + $initial = SpatialReference::epsg(4326); + $reference = SpatialReference::epsg(2154); + $point = new Point(0, null, $initial); + $line = new LineString([$point], $initial); + $arc = new CircularString([$point], $initial); + $compound = new CompoundCurve([$line, $arc], $initial); + $owner = new CompoundCurve([$line], $initial); + static::assertSame($compound, $compound->setSpatialReference($reference, false)); + static::assertSame($reference, $compound->getSpatialReference()); + static::assertSame($initial, $line->getSpatialReference()); + static::assertSame($initial, $arc->getSpatialReference()); + static::assertSame($initial, $point->getSpatialReference()); + static::assertSame($initial, $owner->getCurve(0)->getSpatialReference()); + static::assertSame($initial, $owner->getSpatialReference()); + static::assertSame($line, $compound->getCurve(0)); + static::assertSame($point, $arc->getPoint(0)); + static::assertSame([[[0, null]], [[0, null]]], $compound->toArray()); + } + + /** Context updates reach shared descendants recursively. */ + public function testReferencePropagation(): void + { + $initial = SpatialReference::epsg(4326); + $reference = SpatialReference::epsg(2154); + $point = new Point(0, null, $initial); + $line = new LineString([$point], $initial); + $arc = new CircularString([$point], $initial); + $compound = new CompoundCurve([$line, $arc], $initial); + $owner = new CompoundCurve([$line], $initial); + static::assertSame($compound, $compound->setSpatialReference($reference)); + static::assertSame($reference, $compound->getSpatialReference()); + static::assertSame($reference, $line->getSpatialReference()); + static::assertSame($reference, $arc->getSpatialReference()); + static::assertSame($reference, $point->getSpatialReference()); + static::assertSame($reference, $owner->getCurve(0)->getSpatialReference()); + static::assertSame($initial, $owner->getSpatialReference()); + static::assertSame($line, $compound->getCurve(0)); + static::assertSame($point, $arc->getPoint(0)); + static::assertSame([[[0, null]], [[0, null]]], $compound->toArray()); + } + + /** Context updates remain local. */ + public function testSridLocalOnly(): void + { + $initial = SpatialReference::epsg(4326); + $point = new Point(0, null, $initial); + $line = new LineString([$point], $initial); + $arc = new CircularString([$point], $initial); + $compound = new CompoundCurve([$line, $arc], $initial); + $owner = new CompoundCurve([$line], $initial); + static::assertSame($compound, $compound->setSrid(3857, false)); + static::assertSame(3857, $compound->getSrid()); + static::assertSame(4326, $line->getSrid()); + static::assertSame(4326, $arc->getSrid()); + static::assertSame(4326, $point->getSrid()); + static::assertSame(4326, $owner->getCurve(0)->getSrid()); + static::assertSame(4326, $owner->getSrid()); + static::assertSame($line, $compound->getCurve(0)); + static::assertSame($point, $arc->getPoint(0)); + static::assertSame([[[0, null]], [[0, null]]], $compound->toArray()); + } + + /** Context updates reach shared descendants recursively. */ + public function testSridPropagation(): void + { + $initial = SpatialReference::epsg(4326); + $point = new Point(0, null, $initial); + $line = new LineString([$point], $initial); + $arc = new CircularString([$point], $initial); + $compound = new CompoundCurve([$line, $arc], $initial); + $owner = new CompoundCurve([$line], $initial); + static::assertSame($compound, $compound->setSrid(3857)); + static::assertSame(3857, $compound->getSrid()); + static::assertSame(3857, $line->getSrid()); + static::assertSame(3857, $arc->getSrid()); + static::assertSame(3857, $point->getSrid()); + static::assertSame(3857, $owner->getCurve(0)->getSrid()); + static::assertSame(4326, $owner->getSrid()); + static::assertSame($line, $compound->getCurve(0)); + static::assertSame($point, $arc->getPoint(0)); + static::assertSame([[[0, null]], [[0, null]]], $compound->toArray()); + } + + /** Unsupported component input is rejected atomically. */ + public function testTupleConstructorIsRejected(): void + { + $curve = new LineString([[0, null]], 4326); + $immutable = new LineStringImmutable([[3, 4]], 2154); + $compound = new CompoundCurve([$curve], 4326); + $this->expectException(InvalidValueException::class); + + try { + (new \ReflectionClass(CompoundCurve::class))->newInstance([[[1, 2]]]); + } finally { + static::assertSame([$curve], $compound->getCurves()); + static::assertSame([[[0, null]]], $compound->toArray()); + static::assertSame([[3, 4]], $immutable->toArray()); + static::assertSame(2154, $immutable->getSrid()); + static::assertSame(4326, $curve->getSrid()); + } + } + + /** XYM layout preserves every supplied ordinate. */ + public function testXymLayout(): void + { + $line = new \LongitudeOne\SpatialTypes\Types\Dimension3m\LineString([[0, null, 9]], 4326); + $arc = new \LongitudeOne\SpatialTypes\Types\Dimension3m\CircularString([[0, null, 9]], 2154); + $compound = new \LongitudeOne\SpatialTypes\Types\Dimension3m\CompoundCurve([$line, $arc], 0, SpatialModelEnum::GEOGRAPHY); + static::assertSame(CoordinateDimensionEnum::XYM, $compound->getDimension()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $compound->getFamily()); + static::assertSame([[[0, null, 9]], [[0, null, 9]]], $compound->toArray()); + static::assertSame($compound, $compound->setCurve(0, $arc)->setSrid(3857)->setFamily(SpatialModelEnum::GEOMETRY)); + static::assertSame(CoordinateDimensionEnum::XYM, $compound->getDimension()); + static::assertSame(GeometryTypeEnum::COMPOUNDCURVE, $compound->getType()); + static::assertSame([[[0, null, 9]], [[0, null, 9]]], $compound->toArray()); + static::assertSame(4326, $line->getSrid()); + static::assertSame(3857, $arc->getPoint(0)->getSrid()); + } + + /** XYZ layout preserves every supplied ordinate. */ + public function testXyzLayout(): void + { + $line = new \LongitudeOne\SpatialTypes\Types\Dimension3z\LineString([[0, null, 8]], 4326); + $arc = new \LongitudeOne\SpatialTypes\Types\Dimension3z\CircularString([[0, null, 8]], 2154); + $compound = new \LongitudeOne\SpatialTypes\Types\Dimension3z\CompoundCurve([$line, $arc], 0, SpatialModelEnum::GEOGRAPHY); + static::assertSame(CoordinateDimensionEnum::XYZ, $compound->getDimension()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $compound->getFamily()); + static::assertSame([[[0, null, 8]], [[0, null, 8]]], $compound->toArray()); + static::assertSame($compound, $compound->setCurve(0, $arc)->setSrid(3857)->setFamily(SpatialModelEnum::GEOMETRY)); + static::assertSame(CoordinateDimensionEnum::XYZ, $compound->getDimension()); + static::assertSame(GeometryTypeEnum::COMPOUNDCURVE, $compound->getType()); + static::assertSame([[[0, null, 8]], [[0, null, 8]]], $compound->toArray()); + static::assertSame(4326, $line->getSrid()); + static::assertSame(3857, $arc->getPoint(0)->getSrid()); + } + + /** XYZM layout preserves every supplied ordinate. */ + public function testXyzmLayout(): void + { + $line = new \LongitudeOne\SpatialTypes\Types\Dimension4zm\LineString([[0, null, 8, 9]], 4326); + $arc = new \LongitudeOne\SpatialTypes\Types\Dimension4zm\CircularString([[0, null, 8, 9]], 2154); + $compound = new \LongitudeOne\SpatialTypes\Types\Dimension4zm\CompoundCurve([$line, $arc], 0, SpatialModelEnum::GEOGRAPHY); + static::assertSame(CoordinateDimensionEnum::XYZM, $compound->getDimension()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $compound->getFamily()); + static::assertSame([[[0, null, 8, 9]], [[0, null, 8, 9]]], $compound->toArray()); + static::assertSame($compound, $compound->setCurve(0, $arc)->setSrid(3857)->setFamily(SpatialModelEnum::GEOMETRY)); + static::assertSame(CoordinateDimensionEnum::XYZM, $compound->getDimension()); + static::assertSame(GeometryTypeEnum::COMPOUNDCURVE, $compound->getType()); + static::assertSame([[[0, null, 8, 9]], [[0, null, 8, 9]]], $compound->toArray()); + static::assertSame(4326, $line->getSrid()); + static::assertSame(3857, $arc->getPoint(0)->getSrid()); + } +}