diff --git a/index.ts b/index.ts index e3a4d39..9477ab2 100644 --- a/index.ts +++ b/index.ts @@ -1,2 +1,3 @@ +export * as r1 from './r1/_index' export * as r2 from './r2/_index' export * as s2 from './s2/_index' diff --git a/r1/Interval.ts b/r1/Interval.ts new file mode 100644 index 0000000..166cc77 --- /dev/null +++ b/r1/Interval.ts @@ -0,0 +1,172 @@ +/** + * Interval represents a closed interval on ℝ. + * Zero-length intervals (where lo == hi) represent single points. + * If lo > hi then the interval is empty. + */ +export class Interval { + lo: number = 0.0 + hi: number = 0.0 + + /** + * Returns a new Interval. + * @category Constructors + */ + constructor(lo: number, hi: number) { + this.lo = lo + this.hi = hi + } + + /** + * Reports whether the interval is empty. + */ + isEmpty(): boolean { + return this.lo > this.hi + } + + /** + * Returns true iff the interval contains the same points as oi. + */ + equal(oi: Interval): boolean { + return (this.lo == oi.lo && this.hi == oi.hi) || (this.isEmpty() && oi.isEmpty()) + } + + /** + * Returns the midpoint of the interval. + * Behaviour is undefined for empty intervals. + */ + center(): number { + return 0.5 * (this.lo + this.hi) + } + + /** + * Returns the length of the interval. + * The length of an empty interval is negative. + */ + length(): number { + return this.hi - this.lo + } + + /** + * Returns true iff the interval contains p. + */ + contains(p: number): boolean { + return this.lo <= p && p <= this.hi + } + + /** + * Returns true iff the interval contains oi. + */ + containsInterval(oi: Interval): boolean { + if (oi.isEmpty()) return true + return this.lo <= oi.lo && oi.hi <= this.hi + } + + /** + * Returns true iff the interval strictly contains p. + */ + interiorContains(p: number): boolean { + return this.lo < p && p < this.hi + } + + /** + * Returns true iff the interval strictly contains oi. + */ + interiorContainsInterval(oi: Interval): boolean { + if (oi.isEmpty()) return true + return this.lo < oi.lo && oi.hi < this.hi + } + + /** + * Returns true iff the interval contains any points in common with oi. + */ + intersects(oi: Interval): boolean { + if (this.lo <= oi.lo) return oi.lo <= this.hi && oi.lo <= oi.hi // oi.lo ∈ i and oi is not empty + return this.lo <= oi.hi && this.lo <= this.hi // i.lo ∈ oi and i is not empty + } + + /** + * Returns true iff the interior of the interval contains any points in common with oi, including the latter's boundary. + */ + interiorIntersects(oi: Interval): boolean { + return oi.lo < this.hi && this.lo < oi.hi && this.lo < this.hi && oi.lo <= oi.hi + } + + /** + * Returns the interval containing all points common to i and j. + * @note Empty intervals do not need to be special-cased. + */ + intersection(j: Interval): Interval { + return new Interval(Math.max(this.lo, j.lo), Math.min(this.hi, j.hi)) + } + + /** + * Returns the smallest interval that contains this interval and the given interval. + */ + union(other: Interval): Interval { + if (this.isEmpty()) return other + if (other.isEmpty()) return this + return new Interval(Math.min(this.lo, other.lo), Math.max(this.hi, other.hi)) + } + + /** + * Returns the interval expanded so that it contains the given point. + */ + addPoint(p: number): Interval { + if (this.isEmpty()) return new Interval(p, p) + if (p < this.lo) return new Interval(p, this.hi) + if (p > this.hi) return new Interval(this.lo, p) + return this + } + + /** + * Returns the closest point in the interval to the given point p. + * The interval must be non-empty. + */ + clampPoint(p: number): number { + return Math.max(this.lo, Math.min(this.hi, p)) + } + + /** + * Returns an interval that has been expanded on each side by margin. + * If margin is negative, then the function shrinks the interval on each side by margin instead. + * The resulting interval may be empty. + * Any expansion of an empty interval remains empty. + */ + expanded(margin: number): Interval { + if (this.isEmpty()) return this + return new Interval(this.lo - margin, this.hi + margin) + } + + /** + * Truncates {lo, hi} floats to n digits of precision. + */ + trunc(n: number = 15): Interval { + const p = Number(`1e${n}`) + const trunc = (dim: number) => Math.round(dim * p) / p + return new Interval(trunc(this.lo), trunc(this.hi)) + } + + /** + * Generates a human readable string. + */ + toString(): string { + const t = this.trunc(7) + return `[${t.lo.toFixed(7)}, ${t.hi.toFixed(7)}]` + } + + /** + * Returns an empty interval. + * @category Constructors + */ + static empty(): Interval { + return new Interval(1, 0) + } + + /** + * Returns an interval representing a single point. + * @category Constructors + */ + static fromPoint(p: number): Interval { + return new Interval(p, p) + } +} diff --git a/r1/Interval_test.ts b/r1/Interval_test.ts new file mode 100644 index 0000000..1204126 --- /dev/null +++ b/r1/Interval_test.ts @@ -0,0 +1,125 @@ +import test from 'node:test' +import { ok, equal } from 'node:assert/strict' +import { Interval } from './Interval' + +// Some standard intervals for use throughout the tests. +const UNIT = new Interval(0, 1) +const NEG_UNIT = new Interval(-1, 0) +const HALF = new Interval(0.5, 0.5) +const EMPTY = Interval.empty() + +test('isEmpty', t => { + ok(!UNIT.isEmpty(), 'should not be empty') + ok(!NEG_UNIT.isEmpty(), 'should not be empty') + ok(!HALF.isEmpty(), 'should not be empty') + ok(EMPTY.isEmpty(), 'should not empty') +}) + +test('center', t => { + equal(UNIT.center(), 0.5) + equal(NEG_UNIT.center(), -0.5) + equal(HALF.center(), 0.5) +}) + +test('length', t => { + equal(UNIT.length(), 1) + equal(NEG_UNIT.length(), 1) + equal(HALF.length(), 0) +}) + +test('contains', t => { + ok(UNIT.contains(0.5)) + ok(UNIT.interiorContains(0.5)) + + ok(UNIT.contains(0)) + ok(!UNIT.interiorContains(0)) + + ok(UNIT.contains(1)) + ok(!UNIT.interiorContains(1)) +}) + +test('operations', t => { + ok(EMPTY.containsInterval(EMPTY)) + ok(EMPTY.interiorContainsInterval(EMPTY)) + ok(!EMPTY.intersects(EMPTY)) + ok(!EMPTY.interiorIntersects(EMPTY)) + + ok(!EMPTY.containsInterval(UNIT)) + ok(!EMPTY.interiorContainsInterval(UNIT)) + ok(!EMPTY.intersects(UNIT)) + ok(!EMPTY.interiorIntersects(UNIT)) + + ok(UNIT.containsInterval(HALF)) + ok(UNIT.interiorContainsInterval(HALF)) + ok(UNIT.intersects(HALF)) + ok(UNIT.interiorIntersects(HALF)) + + ok(UNIT.containsInterval(UNIT)) + ok(!UNIT.interiorContainsInterval(UNIT)) + ok(UNIT.intersects(UNIT)) + ok(UNIT.interiorIntersects(UNIT)) + + ok(UNIT.containsInterval(EMPTY)) + ok(UNIT.interiorContainsInterval(EMPTY)) + ok(!UNIT.intersects(EMPTY)) + ok(!UNIT.interiorIntersects(EMPTY)) + + ok(!UNIT.containsInterval(NEG_UNIT)) + ok(!UNIT.interiorContainsInterval(NEG_UNIT)) + ok(UNIT.intersects(NEG_UNIT)) + ok(!UNIT.interiorIntersects(NEG_UNIT)) + + const i = new Interval(0, 0.5) + ok(UNIT.containsInterval(i)) + ok(!UNIT.interiorContainsInterval(i)) + ok(UNIT.intersects(i)) + ok(UNIT.interiorIntersects(i)) + + ok(!HALF.containsInterval(i)) + ok(!HALF.interiorContainsInterval(i)) + ok(HALF.intersects(i)) + ok(!HALF.interiorIntersects(i)) +}) + +test('intersection', t => { + ok(UNIT.intersection(HALF).equal(HALF)) + ok(UNIT.intersection(NEG_UNIT).equal(new Interval(0, 0))) + ok(NEG_UNIT.intersection(HALF).equal(EMPTY)) + ok(UNIT.intersection(EMPTY).equal(EMPTY)) + ok(EMPTY.intersection(UNIT).equal(EMPTY)) +}) + +test('union', t => { + ok(new Interval(99, 100).union(EMPTY).equal(new Interval(99, 100))) + ok(EMPTY.union(new Interval(99, 100)).equal(new Interval(99, 100))) + ok(new Interval(5, 3).union(new Interval(0, -2)).equal(EMPTY)) + ok(new Interval(0, -2).union(new Interval(5, 3)).equal(EMPTY)) + ok(UNIT.union(UNIT).equal(UNIT)) + ok(UNIT.union(NEG_UNIT).equal(new Interval(-1, 1))) + ok(NEG_UNIT.union(UNIT).equal(new Interval(-1, 1))) + ok(HALF.union(UNIT).equal(UNIT)) +}) + +test('addPoint', t => { + ok(EMPTY.addPoint(5).equal(new Interval(5, 5))) + ok(new Interval(5, 5).addPoint(-1).equal(new Interval(-1, 5))) + ok(new Interval(-1, 5).addPoint(0).equal(new Interval(-1, 5))) + ok(new Interval(-1, 5).addPoint(6).equal(new Interval(-1, 6))) +}) + +test('clampPoint', t => { + equal(new Interval(0.1, 0.4).clampPoint(0.3), 0.3) + equal(new Interval(0.1, 0.4).clampPoint(-7.0), 0.1) + equal(new Interval(0.1, 0.4).clampPoint(0.6), 0.4) +}) + +test('expanded', t => { + ok(EMPTY.expanded(0.45).equal(EMPTY)) + ok(UNIT.expanded(0.5).equal(new Interval(-0.5, 1.5))) + ok(UNIT.expanded(-0.5).equal(new Interval(0.5, 0.5))) + ok(UNIT.expanded(-0.51).equal(EMPTY)) +}) + +test('toString', t => { + equal(new Interval(2, 4.5).toString(), '[2.0000000, 4.5000000]') +}) diff --git a/r1/_index.ts b/r1/_index.ts new file mode 100644 index 0000000..6430173 --- /dev/null +++ b/r1/_index.ts @@ -0,0 +1,5 @@ +/** + * Module r1 implements types and functions for working with geometry in ℝ¹. + * @module r1 + */ +export { Interval } from './Interval'