Skip to content

Commit b0fc887

Browse files
committed
feat(s1): Interval
1 parent 5c2a6c8 commit b0fc887

File tree

6 files changed

+1636
-5
lines changed

6 files changed

+1636
-5
lines changed

r1/Interval.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export class Interval {
9393

9494
/**
9595
* Returns the interval containing all points common to i and j.
96-
* @note Empty intervals do not need to be special-cased.
96+
* Empty intervals do not need to be special-cased.
9797
*/
9898
intersection(j: Interval): Interval {
9999
return new Interval(Math.max(this.lo, j.lo), Math.min(this.hi, j.hi))

s1/Interval.ts

Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
import { remainder } from '../r1/math'
2+
import { DBL_EPSILON, EPSILON } from './Interval_constants'
3+
import type { Angle } from './angle'
4+
5+
/**
6+
* An Interval represents a closed interval on a unit circle (also known as a 1-dimensional sphere).
7+
* It is capable of representing the empty interval (containing no points), the full interval (containing all points), and zero-length intervals (containing a single point).
8+
*
9+
* Points are represented by the angle they make with the positive x-axis in the range [-π, π].
10+
* An interval is represented by its lower and upper bounds (both inclusive, since the interval is closed).
11+
* The lower bound may be greater than the upper bound, in which case the interval is "inverted" (i.e. it passes through the point (-1, 0)).
12+
*
13+
* The point (-1, 0) has two valid representations, π and -π.
14+
* The normalized representation of this point is π, so that endpoints of normal intervals are in the range (-π, π].
15+
* We normalize the latter to the former in intervalFromEndpoints.
16+
* However, we take advantage of the point -π to construct two special intervals:
17+
*
18+
* The full interval is [-π, π]
19+
* The empty interval is [π, -π].
20+
*
21+
* Treat the exported fields as read-only.
22+
*/
23+
export class Interval {
24+
lo: number
25+
hi: number
26+
27+
constructor(lo: number, hi: number) {
28+
this.lo = lo
29+
this.hi = hi
30+
}
31+
32+
/**
33+
* Constructs a new interval from endpoints.
34+
* Both arguments must be in the range [-π,π].
35+
* This function allows inverted intervals to be created.
36+
* @category Constructors
37+
*/
38+
static fromEndpoints(lo: number, hi: number): Interval {
39+
const i = new Interval(lo, hi)
40+
if (lo === -Math.PI && hi !== Math.PI) i.lo = Math.PI
41+
if (hi === -Math.PI && lo !== Math.PI) i.hi = Math.PI
42+
return i
43+
}
44+
45+
/**
46+
* Returns the minimal interval containing the two given points.
47+
* Both arguments must be in [-π,π].
48+
* @category Constructors
49+
*/
50+
static fromPointPair(a: number, b: number): Interval {
51+
if (a === -Math.PI) a = Math.PI
52+
if (b === -Math.PI) b = Math.PI
53+
if (Interval.positiveDistance(a, b) <= Math.PI) return new Interval(a, b)
54+
return new Interval(b, a)
55+
}
56+
57+
/**
58+
* Returns an empty interval.
59+
* @category Constructors
60+
*/
61+
static emptyInterval(): Interval {
62+
return new Interval(Math.PI, -Math.PI)
63+
}
64+
65+
/**
66+
* Returns a full interval.
67+
* @category Constructors
68+
*/
69+
static fullInterval(): Interval {
70+
return new Interval(-Math.PI, Math.PI)
71+
}
72+
73+
/**
74+
* Reports whether the interval is valid.
75+
*/
76+
isValid(): boolean {
77+
return (
78+
Math.abs(this.lo) <= Math.PI &&
79+
Math.abs(this.hi) <= Math.PI &&
80+
!(this.lo === -Math.PI && this.hi !== Math.PI) &&
81+
!(this.hi === -Math.PI && this.lo !== Math.PI)
82+
)
83+
}
84+
85+
/**
86+
* Reports whether the interval is full.
87+
*/
88+
isFull(): boolean {
89+
return this.lo === -Math.PI && this.hi === Math.PI
90+
}
91+
92+
/**
93+
* Reports whether the interval is empty.
94+
*/
95+
isEmpty(): boolean {
96+
return this.lo === Math.PI && this.hi === -Math.PI
97+
}
98+
99+
/**
100+
* Reports whether the interval is inverted; that is, whether lo > hi.
101+
*/
102+
isInverted(): boolean {
103+
return this.lo > this.hi
104+
}
105+
106+
/**
107+
* Returns the interval with endpoints swapped.
108+
*/
109+
invert(): Interval {
110+
return new Interval(this.hi, this.lo)
111+
}
112+
113+
/**
114+
* Returns the midpoint of the interval.
115+
* It is undefined for full and empty intervals.
116+
*/
117+
center(): number {
118+
const c = 0.5 * (this.lo + this.hi)
119+
if (!this.isInverted()) return c
120+
if (c <= 0) return c + Math.PI
121+
return c - Math.PI
122+
}
123+
124+
/**
125+
* Returns the length of the interval.
126+
* The length of an empty interval is negative.
127+
*/
128+
length(): number {
129+
let l = this.hi - this.lo
130+
if (l >= 0) return l
131+
l += 2 * Math.PI
132+
if (l > 0) return l
133+
return -1
134+
}
135+
136+
/**
137+
* Assumes p ∈ (-π,π].
138+
*/
139+
fastContains(p: number): boolean {
140+
if (this.isInverted()) return (p >= this.lo || p <= this.hi) && !this.isEmpty()
141+
return p >= this.lo && p <= this.hi
142+
}
143+
144+
/**
145+
* Returns true iff the interval contains p.
146+
* Assumes p ∈ [-π,π].
147+
*/
148+
contains(p: number): boolean {
149+
if (p === -Math.PI) p = Math.PI
150+
return this.fastContains(p)
151+
}
152+
153+
/**
154+
* Returns true iff the interval contains oi.
155+
*/
156+
containsInterval(oi: Interval): boolean {
157+
if (this.isInverted()) {
158+
if (oi.isInverted()) return oi.lo >= this.lo && oi.hi <= this.hi
159+
return (oi.lo >= this.lo || oi.hi <= this.hi) && !this.isEmpty()
160+
}
161+
if (oi.isInverted()) return this.isFull() || oi.isEmpty()
162+
return oi.lo >= this.lo && oi.hi <= this.hi
163+
}
164+
165+
/**
166+
* Returns true iff the interior of the interval contains p.
167+
* Assumes p ∈ [-π,π].
168+
*/
169+
interiorContains(p: number): boolean {
170+
if (p === -Math.PI) p = Math.PI
171+
if (this.isInverted()) return p > this.lo || p < this.hi
172+
return (p > this.lo && p < this.hi) || this.isFull()
173+
}
174+
175+
/**
176+
* Returns true iff the interior of the interval contains oi.
177+
*/
178+
interiorContainsInterval(oi: Interval): boolean {
179+
if (this.isInverted()) {
180+
if (oi.isInverted()) return (oi.lo > this.lo && oi.hi < this.hi) || oi.isEmpty()
181+
return oi.lo > this.lo || oi.hi < this.hi
182+
}
183+
if (oi.isInverted()) return this.isFull() || oi.isEmpty()
184+
return (oi.lo > this.lo && oi.hi < this.hi) || this.isFull()
185+
}
186+
187+
/**
188+
* Returns true iff the interval contains any points in common with oi.
189+
*/
190+
intersects(oi: Interval): boolean {
191+
if (this.isEmpty() || oi.isEmpty()) return false
192+
if (this.isInverted()) return oi.isInverted() || oi.lo <= this.hi || oi.hi >= this.lo
193+
if (oi.isInverted()) return oi.lo <= this.hi || oi.hi >= this.lo
194+
return oi.lo <= this.hi && oi.hi >= this.lo
195+
}
196+
197+
/**
198+
* Returns true iff the interior of the interval contains any points in common with oi, including the latter's boundary.
199+
*/
200+
interiorIntersects(oi: Interval): boolean {
201+
if (this.isEmpty() || oi.isEmpty() || this.lo === this.hi) return false
202+
if (this.isInverted()) return oi.isInverted() || oi.lo < this.hi || oi.hi > this.lo
203+
if (oi.isInverted()) return oi.lo < this.hi || oi.hi > this.lo
204+
return (oi.lo < this.hi && oi.hi > this.lo) || this.isFull()
205+
}
206+
207+
/**
208+
* Compute distance from a to b in [0,2π], in a numerically stable way.
209+
*/
210+
static positiveDistance(a: number, b: number): number {
211+
const d = b - a
212+
if (d >= 0) return d
213+
return b + Math.PI - (a - Math.PI)
214+
}
215+
216+
/**
217+
* Returns the smallest interval that contains both the interval and oi.
218+
*/
219+
union(oi: Interval): Interval {
220+
if (oi.isEmpty()) return this
221+
if (this.fastContains(oi.lo)) {
222+
if (this.fastContains(oi.hi)) {
223+
if (this.containsInterval(oi)) return this
224+
return Interval.fullInterval()
225+
}
226+
return new Interval(this.lo, oi.hi)
227+
}
228+
if (this.fastContains(oi.hi)) return new Interval(oi.lo, this.hi)
229+
if (this.isEmpty() || oi.fastContains(this.lo)) return oi
230+
if (Interval.positiveDistance(oi.hi, this.lo) < Interval.positiveDistance(this.hi, oi.lo))
231+
return new Interval(oi.lo, this.hi)
232+
return new Interval(this.lo, oi.hi)
233+
}
234+
235+
/**
236+
* Returns the smallest interval that contains the intersection of the interval and oi.
237+
*/
238+
intersection(oi: Interval): Interval {
239+
if (oi.isEmpty()) return Interval.emptyInterval()
240+
if (this.fastContains(oi.lo)) {
241+
if (this.fastContains(oi.hi)) {
242+
if (oi.length() < this.length()) return oi
243+
return this
244+
}
245+
return new Interval(oi.lo, this.hi)
246+
}
247+
if (this.fastContains(oi.hi)) return new Interval(this.lo, oi.hi)
248+
if (oi.fastContains(this.lo)) return this
249+
return Interval.emptyInterval()
250+
}
251+
252+
/**
253+
* Returns the interval expanded by the minimum amount necessary such
254+
* that it contains the given point "p" (an angle in the range [-π, π]).
255+
*/
256+
addPoint(p: number): Interval {
257+
if (Math.abs(p) > Math.PI) return this
258+
if (p === -Math.PI) p = Math.PI
259+
if (this.fastContains(p)) return this
260+
if (this.isEmpty()) return new Interval(p, p)
261+
if (Interval.positiveDistance(p, this.lo) < Interval.positiveDistance(this.hi, p)) return new Interval(p, this.hi)
262+
return new Interval(this.lo, p)
263+
}
264+
265+
/**
266+
* Expanded returns an interval that has been expanded on each side by margin.
267+
* If margin is negative, then the function shrinks the interval on
268+
* each side by margin instead. The resulting interval may be empty or
269+
* full. Any expansion (positive or negative) of a full interval remains
270+
* full, and any expansion of an empty interval remains empty.
271+
*/
272+
expanded(margin: number): Interval {
273+
if (margin >= 0) {
274+
if (this.isEmpty()) return this
275+
if (this.length() + 2 * margin + 2 * DBL_EPSILON >= 2 * Math.PI) return Interval.fullInterval()
276+
} else {
277+
if (this.isFull()) return this
278+
if (this.length() + 2 * margin - 2 * DBL_EPSILON <= 0) return Interval.emptyInterval()
279+
}
280+
const result = Interval.fromEndpoints(
281+
remainder(this.lo - margin, 2 * Math.PI),
282+
remainder(this.hi + margin, 2 * Math.PI)
283+
)
284+
if (result.lo <= -Math.PI) result.lo = Math.PI
285+
return result
286+
}
287+
288+
/**
289+
* ApproxEqual reports whether this interval can be transformed into the given
290+
* interval by moving each endpoint by at most ε, without the
291+
* endpoints crossing (which would invert the interval). Empty and full
292+
* intervals are considered to start at an arbitrary point on the unit circle,
293+
* so any interval with (length <= 2*ε) matches the empty interval, and
294+
* any interval with (length >= 2*π - 2*ε) matches the full interval.
295+
*/
296+
approxEqual(other: Interval): boolean {
297+
if (this.isEmpty()) return other.length() <= 2 * EPSILON
298+
if (other.isEmpty()) return this.length() <= 2 * EPSILON
299+
if (this.isFull()) return other.length() >= 2 * (Math.PI - EPSILON)
300+
if (other.isFull()) return this.length() >= 2 * (Math.PI - EPSILON)
301+
return (
302+
Math.abs(remainder(other.lo - this.lo, 2 * Math.PI)) <= EPSILON &&
303+
Math.abs(remainder(other.hi - this.hi, 2 * Math.PI)) <= EPSILON &&
304+
Math.abs(this.length() - other.length()) <= 2 * EPSILON
305+
)
306+
}
307+
308+
toString(): string {
309+
return `[${this.lo.toFixed(7)}, ${this.hi.toFixed(7)}]`
310+
}
311+
312+
/**
313+
* Complement returns the complement of the interior of the interval. An interval and
314+
* its complement have the same boundary but do not share any interior
315+
* values. The complement operator is not a bijection, since the complement
316+
* of a singleton interval (containing a single value) is the same as the
317+
* complement of an empty interval.
318+
*/
319+
complement(): Interval {
320+
if (this.lo === this.hi) return Interval.fullInterval()
321+
return new Interval(this.hi, this.lo)
322+
}
323+
324+
/**
325+
* ComplementCenter returns the midpoint of the complement of the interval. For full and empty
326+
* intervals, the result is arbitrary. For a singleton interval (containing a
327+
* single point), the result is its antipodal point on S1.
328+
*/
329+
complementCenter(): number {
330+
if (this.lo !== this.hi) return this.complement().center()
331+
if (this.hi <= 0) return this.hi + Math.PI
332+
return this.hi - Math.PI
333+
}
334+
335+
/**
336+
* DirectedHausdorffDistance returns the Hausdorff distance to the given interval.
337+
* For two intervals i and y, this distance is defined by
338+
*
339+
* h(i, y) = max_{p in i} min_{q in y} d(p, q),
340+
*
341+
* where d(.,.) is measured along S1.
342+
*/
343+
directedHausdorffDistance(y: Interval): Angle {
344+
if (y.containsInterval(this)) return 0
345+
if (y.isEmpty()) return Math.PI
346+
const yComplementCenter = y.complementCenter()
347+
if (this.contains(yComplementCenter)) return Interval.positiveDistance(y.hi, yComplementCenter)
348+
349+
let hiHi = 0.0
350+
if (Interval.fromEndpoints(y.hi, yComplementCenter).contains(this.hi)) {
351+
hiHi = Interval.positiveDistance(y.hi, this.hi)
352+
}
353+
354+
let loLo = 0.0
355+
if (Interval.fromEndpoints(yComplementCenter, y.lo).contains(this.lo)) {
356+
loLo = Interval.positiveDistance(this.lo, y.lo)
357+
}
358+
359+
return Math.max(hiHi, loLo)
360+
}
361+
362+
/**
363+
* Project returns the closest point in the interval to the given point p.
364+
* The interval must be non-empty.
365+
*/
366+
project(p: number): number {
367+
if (p === -Math.PI) p = Math.PI
368+
if (this.fastContains(p)) return p
369+
const dlo = Interval.positiveDistance(p, this.lo)
370+
const dhi = Interval.positiveDistance(this.hi, p)
371+
if (dlo < dhi) return this.lo
372+
return this.hi
373+
}
374+
}

s1/Interval_constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
export const EPSILON = 1e-15
2-
export const DOUBLE_EPSILON = 2.220446049e-16
2+
export const DBL_EPSILON = 2.220446049e-16

0 commit comments

Comments
 (0)