added boolean xor and "slice"

This commit is contained in:
Joby Elliott 2024-07-29 14:07:27 -06:00
parent 0980e5e68c
commit 1ab8c481a2
2 changed files with 282 additions and 0 deletions

View file

@ -151,6 +151,88 @@ abstract class AbstractRange
}
}
/**
* Perform a boolean XOR operation on this range and another, returning an
* array of all areas that are covered by either range but not both. If the
* ranges do not overlap, this array will contain both ranges separately.
* Separate objects must be returned in ascending order.
* @param static $other
* @return static[]
*/
public function booleanXor(AbstractRange $other): array
{
// if the ranges are equal, return an empty array
if ($this->equals($other)) return [];
// if the ranges are adjacent return a single range
if ($this->adjacentLeftOf($other)) return [new static($this->start(), $other->end())];
if ($this->adjacentRightOf($other)) return [new static($other->start(), $this->end())];
// if the ranges do not overlap, return both ranges
if (!$this->intersects($other)) {
if ($this->extendsBefore($other)) {
return [new static($this->start(), $this->end()), new static($other->start(), $other->end())];
} else {
return [new static($other->start(), $other->end()), new static($this->start(), $this->end())];
}
}
// otherwise get the maximum bounds minus wherever these intersect
$range = new static(
$this->extendsBefore($other) ? $this->start() : $other->start(),
$this->extendsAfter($other) ? $this->end() : $other->end()
);
if ($intersect = $this->booleanAnd($other)) {
return $range->booleanNot($intersect);
} else {
return [$range];
}
}
/**
* Find all areas that are covered by both this range and another, sliced
* into up to three different ranges along the boundaries other boolean
* operations would break on. Areas will be returned in ascending order, and
* some information about the relationships between the ranges can be inferred
* from the number fo ranges returned here:
* - 1 range: the entered ranges are equal
* - 2 ranges: the entered ranges are adjacent, disjoint, or overlap with a shared boundary
* - 3 ranges: the entered ranges overlap with space on each end
* @param static $other
* @return static[]
*/
public function booleanSlice(AbstractRange $other): array
{
// if the ranges are equal, return a single range
if ($this->equals($other)) return [new static($this->start(), $this->end())];
// if the ranges are adjacent, return two ranges
if ($this->adjacentLeftOf($other)) return [new static($this->start(), $this->end()), new static($other->start(), $other->end())];
if ($this->adjacentRightOf($other)) return [new static($other->start(), $other->end()), new static($this->start(), $this->end())];
// if the ranges do not overlap, return two ranges
if (!$this->intersects($other)) {
if ($this->extendsBefore($other)) {
return [new static($this->start(), $this->end()), new static($other->start(), $other->end())];
} else {
return [new static($other->start(), $other->end()), new static($this->start(), $this->end())];
}
}
// otherwise get the maximum bounds minus wherever these intersect
$overall_range = new static(
$this->extendsBefore($other) ? $this->start() : $other->start(),
$this->extendsAfter($other) ? $this->end() : $other->end()
);
$intersection = $this->booleanAnd($other);
$xor = $overall_range->booleanNot($intersection);
if (count($xor) == 2) {
return [$xor[0], $intersection, $xor[1]];
} elseif (count($xor) == 1) {
if ($intersection->extendsBefore($xor[0])) {
return [$intersection, $xor[0]];
} else {
return [$xor[0], $intersection];
}
}
// throw an exception if we get in an unexpected state
throw new RuntimeException(sprintf("Unexpected state (%s,%s) (%s,%s)", $this->start, $this->end, $other->start, $other->end));
}
/**
* Perform a boolean NOT operation on this range and another, returning an
* array of all areas that are covered by this range but not the other. If

View file

@ -955,6 +955,206 @@ class IntegerRangeTest extends TestCase
);
}
public function testBooleanXor()
{
// fully bounded
$this->assertEquals(
[
'same' => '',
'unbounded' => 'null,9;21,null',
'intersecting open end left' => '9,9;21,null',
'intersecting open end right' => '10,10;21,null',
'intersecting open end center' => '21,null',
'intersecting open start left' => 'null,9;20,20',
'intersecting open start right' => 'null,9;21,21',
'intersecting open start center' => 'null,9',
'disjoint open start' => 'null,8;10,20',
'disjoint open end' => '10,20;22,null',
'disjoint left' => '-2,8;10,20',
'disjoint right' => '10,20;22,32',
'adjacent open start' => 'null,20',
'adjacent open end' => '10,null',
'adjacent left' => '-1,20',
'adjacent right' => '10,31',
'contained' => '10,10;20,20',
'contained same start' => '20,20',
'contained same end' => '10,10',
'containing' => '9,9;21,21',
'containing unbounded start' => 'null,9;21,21',
'containing unbounded end' => '9,9;21,null',
'containing same start' => '21,21',
'containing same end' => '9,9',
'containing same start unbounded end' => '21,null',
'containing same end unbounded start' => 'null,9',
],
$this->scenarioResults(
new IntegerRange(10, 20),
'booleanXor',
)
);
// open start
$this->assertEquals(
[
'same' => '',
'unbounded' => '21,null',
'intersecting open start left' => '20,20',
'intersecting open start right' => '21,21',
'intersecting bounded start left' => 'null,8;20,20',
'intersecting bounded start right' => 'null,10;21,21',
'intersecting bounded start center' => 'null,9',
'intersecting open end same' => 'null,19;21,null',
'intersecting open end left' => 'null,18;21,null',
'intersecting bounded end same' => 'null,19;21,30',
'intersecting bounded end left' => 'null,18;21,29',
'adjacent open end' => 'null,null',
'adjacent bounded end' => 'null,30',
'disjoint open end' => 'null,20;22,null',
'disjoint bounded end' => 'null,20;22,32',
],
$this->scenarioResults(
new IntegerRange(null, 20),
'booleanXor',
)
);
// open end
$this->assertEquals(
[
'same' => '',
'unbounded' => 'null,9',
'intersecting open end left' => '9,9',
'intersecting open end right' => '10,10',
'intersecting bounded end left' => '9,9;20,null',
'intersecting bounded end right' => '10,10;22,null',
'intersecting bounded end center' => '21,null',
'intersecting open start same' => 'null,9;11,null',
'intersecting open start right' => 'null,9;12,null',
'intersecting bounded start same' => '0,9;11,null',
'intersecting bounded start right' => '1,9;12,null',
'adjacent open start' => 'null,null',
'adjacent bounded start' => '0,null',
'disjoint open start' => 'null,8;10,null',
'disjoint bounded start' => '-2,8;10,null',
],
$this->scenarioResults(
new IntegerRange(10, null),
'booleanXor',
)
);
// fully unbounded
$this->assertEquals(
[
'same' => '',
'open start' => '21,null',
'open end' => 'null,9',
'bounded' => 'null,9;21,null',
],
$this->scenarioResults(
new IntegerRange(null, null),
'booleanXor',
)
);
}
public function testBooleanSlice()
{
// fully bounded
$this->assertEquals(
[
'same' => '10,20',
'unbounded' => 'null,9;10,20;21,null',
'intersecting open end left' => '9,9;10,20;21,null',
'intersecting open end right' => '10,10;11,20;21,null',
'intersecting open end center' => '10,20;21,null',
'intersecting open start left' => 'null,9;10,19;20,20',
'intersecting open start right' => 'null,9;10,20;21,21',
'intersecting open start center' => 'null,9;10,20',
'disjoint open start' => 'null,8;10,20',
'disjoint open end' => '10,20;22,null',
'disjoint left' => '-2,8;10,20',
'disjoint right' => '10,20;22,32',
'adjacent open start' => 'null,9;10,20',
'adjacent open end' => '10,20;21,null',
'adjacent left' => '-1,9;10,20',
'adjacent right' => '10,20;21,31',
'contained' => '10,10;11,19;20,20',
'contained same start' => '10,19;20,20',
'contained same end' => '10,10;11,20',
'containing' => '9,9;10,20;21,21',
'containing unbounded start' => 'null,9;10,20;21,21',
'containing unbounded end' => '9,9;10,20;21,null',
'containing same start' => '10,20;21,21',
'containing same end' => '9,9;10,20',
'containing same start unbounded end' => '10,20;21,null',
'containing same end unbounded start' => 'null,9;10,20',
],
$this->scenarioResults(
new IntegerRange(10, 20),
'booleanSlice',
)
);
// open start
$this->assertEquals(
[
'same' => 'null,20',
'unbounded' => 'null,20;21,null',
'intersecting open start left' => 'null,19;20,20',
'intersecting open start right' => 'null,20;21,21',
'intersecting bounded start left' => 'null,8;9,19;20,20',
'intersecting bounded start right' => 'null,10;11,20;21,21',
'intersecting bounded start center' => 'null,9;10,20',
'intersecting open end same' => 'null,19;20,20;21,null',
'intersecting open end left' => 'null,18;19,20;21,null',
'intersecting bounded end same' => 'null,19;20,20;21,30',
'intersecting bounded end left' => 'null,18;19,20;21,29',
'adjacent open end' => 'null,20;21,null',
'adjacent bounded end' => 'null,20;21,30',
'disjoint open end' => 'null,20;22,null',
'disjoint bounded end' => 'null,20;22,32',
],
$this->scenarioResults(
new IntegerRange(null, 20),
'booleanSlice',
)
);
// open end
$this->assertEquals(
[
'same' => '10,null',
'unbounded' => 'null,9;10,null',
'intersecting open end left' => '9,9;10,null',
'intersecting open end right' => '10,10;11,null',
'intersecting bounded end left' => '9,9;10,19;20,null',
'intersecting bounded end right' => '10,10;11,21;22,null',
'intersecting bounded end center' => '10,20;21,null',
'intersecting open start same' => 'null,9;10,10;11,null',
'intersecting open start right' => 'null,9;10,11;12,null',
'intersecting bounded start same' => '0,9;10,10;11,null',
'intersecting bounded start right' => '1,9;10,11;12,null',
'adjacent open start' => 'null,9;10,null',
'adjacent bounded start' => '0,9;10,null',
'disjoint open start' => 'null,8;10,null',
'disjoint bounded start' => '-2,8;10,null',
],
$this->scenarioResults(
new IntegerRange(10, null),
'booleanSlice',
)
);
// fully unbounded
$this->assertEquals(
[
'same' => 'null,null',
'open start' => 'null,20;21,null',
'open end' => 'null,9;10,null',
'bounded' => 'null,9;10,20;21,null',
],
$this->scenarioResults(
new IntegerRange(null, null),
'booleanSlice',
)
);
}
protected function createScenarios(IntegerRange $range): array
{
if (is_null($range->start()) && is_null($range->end())) {