├── .c8rc
├── .github
└── workflows
│ ├── _test.yml
│ ├── github_pages.yml
│ ├── npm_publish.yml
│ ├── pull_request.yml
│ └── push.yml
├── .gitignore
├── .npmignore
├── .prettierrc
├── LICENSE
├── README.md
├── geojson
├── RegionCoverer.ts
├── RegionCoverer_test.ts
├── _index.ts
├── geometry.ts
├── geometry_test.ts
├── linestring.ts
├── loop.ts
├── point.ts
├── polygon.ts
├── position.ts
├── rect.ts
└── testing.ts
├── index.ts
├── package.json
├── r1
├── Interval.ts
├── Interval_test.ts
├── _index.ts
├── math.ts
└── math_test.ts
├── r2
├── Point.ts
├── Point_test.ts
├── Rect.ts
├── Rect_test.ts
└── _index.ts
├── r3
├── PreciseVector.ts
├── PreciseVector_test.ts
├── Vector.ts
├── Vector_test.ts
└── _index.ts
├── s1
├── Interval.ts
├── Interval_constants.ts
├── Interval_test.ts
├── _index.ts
├── angle.ts
├── angle_constants.ts
├── angle_test.ts
├── chordangle.ts
├── chordangle_constants.ts
└── chordangle_test.ts
├── s2
├── Cap.ts
├── Cap_test.ts
├── Cell.ts
├── CellUnion.ts
├── CellUnion_test.ts
├── Cell_test.ts
├── ContainsPointQuery.ts
├── ContainsPointQuery_test.ts
├── ContainsVertexQuery.ts
├── ContainsVertexQuery_test.ts
├── CrossingEdgeQuery.ts
├── CrossingEdgeQuery_test.ts
├── EdgeCrosser.ts
├── EdgeCrosser_test.ts
├── EdgeVectorShape.ts
├── EdgeVectorShape_test.ts
├── LatLng.ts
├── LatLng_test.ts
├── LaxLoop.ts
├── LaxLoop_test.ts
├── LaxPolygon.ts
├── LaxPolygon_test.ts
├── LaxPolyline.ts
├── LaxPolyline_test.ts
├── Loop.ts
├── Loop_test.ts
├── Metric.ts
├── Metric_constants.ts
├── Metric_test.ts
├── PaddedCell.ts
├── PaddedCell_test.ts
├── Point.ts
├── PointVector.ts
├── PointVector_test.ts
├── Polygon.ts
├── Polygon_test.ts
├── Polyline.ts
├── Polyline_test.ts
├── Rect.ts
├── RectBounder.ts
├── RectBounder_test.ts
├── Rect_test.ts
├── Region.ts
├── RegionCoverer.ts
├── RegionCoverer_test.ts
├── Shape.ts
├── ShapeIndex.ts
├── ShapeIndexCell.ts
├── ShapeIndexClippedShape.ts
├── ShapeIndexIterator.ts
├── ShapeIndexRegion.ts
├── ShapeIndexRegion_test.ts
├── ShapeIndexTracker.ts
├── ShapeIndex_test.ts
├── _index.ts
├── cellid.ts
├── cellid_constants.ts
├── cellid_extra_test.ts
├── cellid_test.ts
├── centroids.ts
├── centroids_test.ts
├── edge_clipping.ts
├── edge_clipping_test.ts
├── edge_crossings.ts
├── edge_crossings_test.ts
├── edge_distances.ts
├── edge_distances_test.ts
├── lookupIJ.ts
├── matrix3x3.ts
├── matrix3x3_test.ts
├── point_measures.ts
├── point_measures_test.ts
├── predicates.ts
├── predicates_test.ts
├── shapeutil.ts
├── stuv.ts
├── stuv_test.ts
├── testing.ts
├── testing_pseudo.ts
├── testing_textformat.ts
├── util.ts
├── wedge_relations.ts
└── wedge_relations_test.ts
├── tsconfig.json
└── typedoc.json
/.c8rc:
--------------------------------------------------------------------------------
1 | {
2 | "all": true,
3 | "include": ["**/*.ts"],
4 | "exclude": ["node_modules/**", "**/*.d.ts", "**/*_test.ts", "**/index.ts", "**/_*"],
5 | "reporter": ["lcov", "text"]
6 | }
7 |
--------------------------------------------------------------------------------
/.github/workflows/_test.yml:
--------------------------------------------------------------------------------
1 | name: Unit Tests
2 | on: workflow_call
3 | jobs:
4 | unit-tests:
5 | runs-on: '${{ matrix.os }}'
6 | timeout-minutes: 10
7 | strategy:
8 | matrix:
9 | os:
10 | - 'ubuntu-22.04'
11 | node-version:
12 | - 22.x
13 | - 20.x
14 | - 18.x
15 | steps:
16 | - uses: actions/checkout@v4
17 | - name: 'Install node.js ${{ matrix.node-version }}'
18 | uses: actions/setup-node@v3
19 | with:
20 | node-version: '${{ matrix.node-version }}'
21 | - name: Run unit tests
22 | run: |
23 | npm install
24 | npm test
25 |
--------------------------------------------------------------------------------
/.github/workflows/github_pages.yml:
--------------------------------------------------------------------------------
1 | name: Publish Documentation
2 |
3 | on:
4 | # Runs on pushes targeting the default branch
5 | push:
6 | branches: ['main']
7 |
8 | # Allows you to run this workflow manually from the Actions tab
9 | workflow_dispatch:
10 |
11 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
12 | permissions:
13 | contents: read
14 | pages: write
15 | id-token: write
16 |
17 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
18 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
19 | concurrency:
20 | group: 'pages'
21 | cancel-in-progress: false
22 |
23 | jobs:
24 | # Build job
25 | build:
26 | name: Build Documentation
27 | runs-on: ubuntu-latest
28 | steps:
29 | - name: Checkout
30 | uses: actions/checkout@v4
31 | - name: Setup Pages
32 | uses: actions/configure-pages@v5
33 | - name: Install Node
34 | with:
35 | node-version: 'lts/*'
36 | uses: actions/setup-node@v4
37 | - name: Install Dependencies
38 | run: npm install
39 | - name: Build Documentation
40 | run: npm run docs
41 | - name: Upload Artifacts
42 | uses: actions/upload-pages-artifact@v3
43 | with:
44 | path: 'docs'
45 |
46 | # Deployment job
47 | deploy:
48 | name: Deploy Documentation
49 | environment:
50 | name: Publish Documentation
51 | url: ${{ steps.deployment.outputs.page_url }}
52 | runs-on: ubuntu-latest
53 | needs: build
54 | steps:
55 | - name: Deploy to GitHub Pages
56 | id: deployment
57 | uses: actions/deploy-pages@v4
58 |
--------------------------------------------------------------------------------
/.github/workflows/npm_publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish NPM Module
2 | on:
3 | push:
4 | branches:
5 | - main
6 |
7 | permissions:
8 | contents: read # for checkout
9 |
10 | jobs:
11 | unit-tests:
12 | uses: ./.github/workflows/_test.yml
13 | release:
14 | name: Semantic Release
15 | environment:
16 | name: Publish NPM Module
17 | url: https://www.npmjs.com/package/s2js
18 | runs-on: ubuntu-latest
19 | needs: unit-tests
20 | permissions:
21 | contents: write # to be able to publish a GitHub release
22 | issues: write # to be able to comment on released issues
23 | pull-requests: write # to be able to comment on released pull requests
24 | id-token: write # to enable use of OIDC for npm provenance
25 | steps:
26 | - name: Checkout
27 | uses: actions/checkout@v3
28 | with:
29 | fetch-depth: 0
30 | - name: Install Node
31 | uses: actions/setup-node@v3
32 | with:
33 | node-version: 'lts/*'
34 | - name: Install Dependencies
35 | run: npm install
36 | - name: Ensure docs are valid
37 | run: npm run docs
38 | - name: Transpile to Javascript
39 | run: npm run build
40 | - name: Publish
41 | env:
42 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
43 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
44 | run: npx semantic-release
45 |
--------------------------------------------------------------------------------
/.github/workflows/pull_request.yml:
--------------------------------------------------------------------------------
1 | name: Pull Request
2 | on: pull_request
3 | jobs:
4 | unit-tests:
5 | # only run this job for forks
6 | if: github.event.pull_request.head.repo.full_name != github.repository
7 | uses: ./.github/workflows/_test.yml
8 |
--------------------------------------------------------------------------------
/.github/workflows/push.yml:
--------------------------------------------------------------------------------
1 | name: Push
2 | on: push
3 | jobs:
4 | unit-tests:
5 | uses: ./.github/workflows/_test.yml
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | docs
4 | coverage
5 | package-lock.json
6 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | **/*
2 | !dist/**/*
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "singleQuote": true,
4 | "printWidth": 120,
5 | "trailingComma": "none"
6 | }
7 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # s2js
2 |
3 | s2js is a Javascript port of the s2 spherical geometry library.
4 |
5 | | [github](https://github.com/missinglink/s2js) | [npm](https://www.npmjs.com/package/s2js) | [documentation](https://missinglink.github.io/s2js) | [demo](https://bdon.github.io/s2js-demos/) |
6 |
7 | ### Installation
8 |
9 | ```bash
10 | npm install s2js
11 | ```
12 |
13 | ### Usage
14 |
15 | The library is available as both ESM & CJS modules:
16 |
17 | **ESM**
18 |
19 | ```js
20 | import { s2 } from 's2js'
21 | ```
22 |
23 | **CJS**
24 |
25 | ```js
26 | const { s2 } = require('s2js')
27 | ```
28 |
29 | **CDN**
30 |
31 | ```html
32 |
35 | ```
36 |
37 | ### GeoJSON support
38 |
39 | The supplementary `geojson` module provides convenience functions for working with GeoJSON data in S2:
40 |
41 | ```js
42 | import { geojson } from 's2js'
43 |
44 | const s2Polyline = geojson.fromGeoJSON({
45 | type: 'LineString',
46 | coordinates: [
47 | [102.0, 0.0],
48 | [103.0, 1.0],
49 | [104.0, 0.0],
50 | [105.0, 1.0]
51 | ]
52 | })
53 | ```
54 |
55 | The `RegionCoverer` supports all geometry types including multi-geometries:
56 |
57 | ```js
58 | const coverer = new geojson.RegionCoverer({ maxCells: 30 })
59 |
60 | const union = coverer.covering({
61 | type: 'Polygon',
62 | coordinates: [
63 | [
64 | [100.0, 0.0],
65 | [101.0, 0.0],
66 | [101.0, 1.0],
67 | [100.0, 1.0],
68 | [100.0, 0.0]
69 | ]
70 | ]
71 | })
72 | ```
73 |
74 | ### Contributing
75 |
76 | If you'd like to contribute a module please open an Issue to discuss.
77 |
78 | ### Copyright
79 |
80 | © 2024 Peter Johnson <github:missinglink>
81 |
82 | This source code is published under the Apache-2.0 license.
83 |
--------------------------------------------------------------------------------
/geojson/RegionCoverer.ts:
--------------------------------------------------------------------------------
1 | import type * as geojson from 'geojson'
2 | import { MAX_LEVEL } from '../s2/cellid_constants'
3 | import { CellUnion } from '../s2/CellUnion'
4 | import { fromGeoJSON } from './geometry'
5 | import { Polyline } from '../s2/Polyline'
6 | import { Polygon } from '../s2/Polygon'
7 | import { Rect } from '../s2/Rect'
8 | import type { Region } from '../s2/Region'
9 | import type { RegionCovererOptions as S2RegionCovererOptions } from '../s2/RegionCoverer'
10 | import { RegionCoverer as S2RegionCoverer } from '../s2/RegionCoverer'
11 | import * as cellid from '../s2/cellid'
12 |
13 | /**
14 | * RegionCovererOptions allows the RegionCoverer to be configured.
15 | */
16 | export interface RegionCovererOptions extends S2RegionCovererOptions {
17 | /**
18 | * the maximum desired number of cells for each member of a multi-member geometry in the approximation.
19 | * @default Math.max(Math.floor(maxCells / 10), 8)
20 | */
21 | memberMaxCells?: number
22 |
23 | /**
24 | * the maximum size the approximation may reach before a compaction is triggered.
25 | * used to avoid OOM errors.
26 | * @default 65536
27 | */
28 | compactAt?: number
29 |
30 | /**
31 | * the maximum area of a shape to be considered for fast covering.
32 | * used to speed up covering small shapes.
33 | * area values are between 0 and 4*Pi.
34 | * @default 1e-6
35 | */
36 | smallAreaEpsilon?: number
37 | }
38 |
39 | /**
40 | * RegionCoverer allows arbitrary GeoJSON geometries to be approximated as unions of cells (CellUnion).
41 | *
42 | * Typical usage:
43 | *
44 | * feature = loadGeoJSON()
45 | * rc = new RegionCoverer({ maxCells: 256, memberMaxCells: 64 })
46 | * covering = rc.covering(feature.geometry)
47 | *
48 | * @beta unstable API
49 | */
50 | export class RegionCoverer {
51 | private coverer: S2RegionCoverer
52 | private memberCoverer: S2RegionCoverer
53 | private compactAt: number
54 | private smallAreaEpsilon: number
55 |
56 | /**
57 | * Returns a new RegionCoverer with the appropriate defaults.
58 | *
59 | * @param options - RegionCoverer options
60 | *
61 | * @category Constructors
62 | */
63 | constructor({
64 | minLevel = 0,
65 | maxLevel = MAX_LEVEL,
66 | levelMod = 1,
67 | maxCells = 8,
68 | memberMaxCells = Math.max(Math.floor(maxCells / 10), 8),
69 | compactAt = 65536,
70 | smallAreaEpsilon = 1e-6
71 | }: RegionCovererOptions = {}) {
72 | this.coverer = new S2RegionCoverer({ minLevel, maxLevel, levelMod, maxCells })
73 | this.memberCoverer = new S2RegionCoverer({ minLevel, maxLevel, levelMod, maxCells: memberMaxCells })
74 | this.compactAt = compactAt
75 | this.smallAreaEpsilon = smallAreaEpsilon
76 | }
77 |
78 | /** Computes the covering of a multi-member geometry (ie. MultiPoint, MultiLineString, MultiPolygon). */
79 | private mutliMemberCovering(shapes: Region[]): CellUnion {
80 | // sort shapes from largest to smallest
81 | shapes.sort((a: Region, b: Region): number => RegionCoverer.area(b) - RegionCoverer.area(a))
82 |
83 | let union = new CellUnion()
84 | shapes.forEach((shape: Region) => {
85 | const area = RegionCoverer.area(shape)
86 | const isPolygon = shape instanceof Polygon
87 |
88 | // discard zero-area polygons
89 | if (isPolygon && area <= 0) return
90 |
91 | // optionally elect to use a fast covering method for small areas
92 | const fast = union.length >= this.memberCoverer.maxCells && area < this.smallAreaEpsilon
93 | const cov = fast ? this.memberCoverer.fastCovering(shape) : this.memberCoverer.covering(shape)
94 |
95 | // discard errorneous members which cover the entire planet
96 | if (!RegionCoverer.validCovering(shape, cov)) return
97 |
98 | // append covering to union
99 | union = CellUnion.fromUnion(union, cov)
100 |
101 | // force compact large coverings to avoid OOM errors
102 | if (union.length >= this.compactAt) union = this.coverer.covering(union)
103 | })
104 |
105 | // reduce the global covering to maxCells
106 | return this.coverer.covering(union)
107 | }
108 |
109 | /** Returns a CellUnion that covers the given GeoJSON geometry and satisfies the various restrictions. */
110 | covering(geometry: geojson.Geometry): CellUnion {
111 | const shape = fromGeoJSON(geometry)
112 | if (Array.isArray(shape)) return this.mutliMemberCovering(shape as Region[])
113 |
114 | // discard zero-area polygons
115 | if (shape instanceof Polygon && RegionCoverer.area(shape) <= 0) return new CellUnion()
116 |
117 | // discard errorneous shapes which cover the entire planet
118 | const cov = this.coverer.covering(shape)
119 | if (!RegionCoverer.validCovering(shape, cov)) return new CellUnion()
120 |
121 | return cov
122 | }
123 |
124 | /** Computes the area of a shape */
125 | private static area(shape: Region): number {
126 | if (shape instanceof Polygon) return shape.area()
127 | if (shape instanceof Polyline) shape.capBound().area()
128 | if (shape instanceof Rect) shape.capBound().area()
129 | return 0
130 | }
131 |
132 | /** Attempts to detect invalid geometries which produce global coverings */
133 | private static validCovering(shape: Region, covering: CellUnion): boolean {
134 | if (covering.length !== 6 || !covering.every(cellid.isFace)) return true
135 |
136 | // compare the polygon covering with a covering of the outer ring as a linestring
137 | if (shape instanceof Polygon) {
138 | const union = new Polyline(shape.loop(0).vertices).cellUnionBound()
139 | return union.length === 6 && union.every(cellid.isFace)
140 | }
141 |
142 | // area is too small to have a global covering
143 | return this.area(shape) < Math.PI * 2
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/geojson/_index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Module geojson implements types and functions for working with GeoJSON.
3 | * @module geojson
4 | */
5 | export { Encodable, Decodable } from './geometry'
6 | export { toGeoJSON, fromGeoJSON } from './geometry'
7 |
8 | export { RegionCovererOptions } from './RegionCoverer'
9 | export { RegionCoverer } from './RegionCoverer'
10 |
--------------------------------------------------------------------------------
/geojson/geometry.ts:
--------------------------------------------------------------------------------
1 | import type * as geojson from 'geojson'
2 | import { Point } from '../s2/Point'
3 | import { Polyline } from '../s2/Polyline'
4 | import { Polygon } from '../s2/Polygon'
5 | import { Cell } from '../s2/Cell'
6 | import { Rect } from '../s2/Rect'
7 | import * as point from './point'
8 | import * as linestring from './linestring'
9 | import * as polygon from './polygon'
10 | import * as rect from './rect'
11 |
12 | export type Decodable = Point | Polyline | Polygon | Rect | Point[] | Polyline[] | Polygon[]
13 | export type Encodable = bigint | Cell | Decodable
14 |
15 | /**
16 | * Returns a geojson Geometry given a s2 shape(s).
17 | * @category Constructors
18 | */
19 | export const toGeoJSON = (shape: Encodable): geojson.Geometry => {
20 | if (typeof shape === 'bigint') shape = Cell.fromCellID(shape)
21 | if (shape instanceof Cell) shape = Polygon.fromCell(shape)
22 |
23 | if (shape instanceof Point) return point.marshal(shape)
24 | if (shape instanceof Polyline) return linestring.marshal(shape)
25 | if (shape instanceof Polygon) return polygon.marshal(shape)
26 | if (shape instanceof Rect) return rect.marshal(shape)
27 |
28 | if (Array.isArray(shape) && shape.length) {
29 | if (shape.every((g: any) => g instanceof Point)) return point.marshalMulti(shape as Point[])
30 | if (shape.every((g: any) => g instanceof Polyline)) return linestring.marshalMulti(shape as Polyline[])
31 | if (shape.every((g: any) => g instanceof Polygon)) return polygon.marshalMulti(shape as Polygon[])
32 | }
33 |
34 | throw new Error(`unsupported: ${shape?.constructor?.name || typeof shape}`)
35 | }
36 |
37 | /**
38 | * Constructs s2 shape(s) given a geojson geometry.
39 | * @category Constructors
40 | */
41 | export const fromGeoJSON = (geometry: geojson.Geometry): Decodable => {
42 | const t = geometry?.type
43 |
44 | if (t === 'Point') return point.unmarshal(geometry as geojson.Point)
45 | if (t === 'LineString') return linestring.unmarshal(geometry as geojson.LineString)
46 | if (t === 'Polygon') {
47 | if (rect.valid(geometry as geojson.Polygon)) return rect.unmarshal(geometry as geojson.Polygon)
48 | return polygon.unmarshal(geometry as geojson.Polygon)
49 | }
50 |
51 | if (t === 'MultiPoint') return point.unmarshalMulti(geometry as geojson.MultiPoint)
52 | if (t === 'MultiLineString') return linestring.unmarshalMulti(geometry as geojson.MultiLineString)
53 | if (t === 'MultiPolygon') return polygon.unmarshalMulti(geometry as geojson.MultiPolygon)
54 |
55 | throw new Error(`unsupported: ${t || 'UnknownGeometryType'}`)
56 | }
57 |
--------------------------------------------------------------------------------
/geojson/geometry_test.ts:
--------------------------------------------------------------------------------
1 | import type * as geojson from 'geojson'
2 | import { test, describe } from 'node:test'
3 | import { ok } from 'node:assert/strict'
4 | import { approxEqual } from './testing'
5 | import * as geometry from './geometry'
6 |
7 | describe('geojson', () => {
8 | test('point', (t) => {
9 | const points: geojson.Point[] = [
10 | { type: 'Point', coordinates: [0, 0] },
11 | { type: 'Point', coordinates: [-180, -90] },
12 | { type: 'Point', coordinates: [180, 90] },
13 | { type: 'Point', coordinates: [102.0, 0.5] }
14 | ]
15 |
16 | points.forEach((point) => {
17 | const decoded = geometry.fromGeoJSON(point)
18 | const encoded = geometry.toGeoJSON(decoded)
19 | ok(approxEqual(encoded, point), JSON.stringify(point) + ' -> ' + JSON.stringify(encoded))
20 | })
21 | })
22 |
23 | test('linestring', (t) => {
24 | const linestrings: geojson.LineString[] = [
25 | {
26 | type: 'LineString',
27 | coordinates: [
28 | [0, 0],
29 | [2, 2],
30 | [-180, -90],
31 | [180, 90]
32 | ]
33 | },
34 | {
35 | type: 'LineString',
36 | coordinates: [
37 | [1, 1],
38 | [2, 2],
39 | [3, 3],
40 | [4, 4],
41 | [5, 5]
42 | ]
43 | },
44 | {
45 | type: 'LineString',
46 | coordinates: [
47 | [102.0, 0.0],
48 | [103.0, 1.0],
49 | [104.0, 0.0],
50 | [105.0, 1.0]
51 | ]
52 | }
53 | ]
54 |
55 | linestrings.forEach((linestring) => {
56 | const decoded = geometry.fromGeoJSON(linestring)
57 | const encoded = geometry.toGeoJSON(decoded)
58 | ok(approxEqual(encoded, linestring), JSON.stringify(linestring) + ' -> ' + JSON.stringify(encoded))
59 | })
60 | })
61 |
62 | test('polygon', (t) => {
63 | const polygons: geojson.Polygon[] = [
64 | {
65 | type: 'Polygon',
66 | coordinates: [
67 | [
68 | [0, 0],
69 | [2, 2],
70 | [0, 0]
71 | ]
72 | ]
73 | },
74 | {
75 | type: 'Polygon',
76 | coordinates: [
77 | [
78 | [100.0, 0.0],
79 | [101.0, 0.0],
80 | [101.0, 1.0],
81 | [100.0, 1.0],
82 | [100.0, 0.0]
83 | ]
84 | ]
85 | }
86 | ]
87 |
88 | polygons.forEach((polygon) => {
89 | const decoded = geometry.fromGeoJSON(polygon)
90 | const encoded = geometry.toGeoJSON(decoded)
91 | ok(approxEqual(encoded, polygon), JSON.stringify(polygon) + ' -> ' + JSON.stringify(encoded))
92 | })
93 | })
94 | })
95 |
--------------------------------------------------------------------------------
/geojson/linestring.ts:
--------------------------------------------------------------------------------
1 | import type * as geojson from 'geojson'
2 | import * as position from './position'
3 | import { Polyline } from '../s2/Polyline'
4 |
5 | /**
6 | * Returns a geojson LineString geometry given an s2 Polyline.
7 | * @category Constructors
8 | */
9 | export const marshal = (polyline: Polyline): geojson.LineString => {
10 | return {
11 | type: 'LineString',
12 | coordinates: polyline.points.map(position.marshal)
13 | }
14 | }
15 |
16 | /**
17 | * Constructs an s2 Polyline given a geojson LineString geometry.
18 | * @category Constructors
19 | */
20 | export const unmarshal = (geometry: geojson.LineString): Polyline => {
21 | return new Polyline(geometry.coordinates.map(position.unmarshal))
22 | }
23 |
24 | /**
25 | * Returns a geojson MultiLineString geometry given s2 Polylines.
26 | * @category Constructors
27 | */
28 | export const marshalMulti = (polylines: Polyline[]): geojson.MultiLineString => {
29 | return {
30 | type: 'MultiLineString',
31 | coordinates: polylines.map((polyline) => polyline.points.map(position.marshal))
32 | }
33 | }
34 |
35 | /**
36 | * Constructs s2 Polylines given a geojson MultiLineString geometry.
37 | * @category Constructors
38 | */
39 | export const unmarshalMulti = (geometry: geojson.MultiLineString): Polyline[] => {
40 | return geometry.coordinates.map((polyline) => new Polyline(polyline.map(position.unmarshal)))
41 | }
42 |
--------------------------------------------------------------------------------
/geojson/loop.ts:
--------------------------------------------------------------------------------
1 | import type * as geojson from 'geojson'
2 | import * as position from './position'
3 | import { Loop } from '../s2/Loop'
4 |
5 | /**
6 | * Returns a geojson Polygon ring given an s2 Loop & ordinal.
7 | * @category Constructors
8 | */
9 | export const marshal = (loop: Loop, ordinal: number): geojson.Position[] => {
10 | const ring = loop.vertices.map(position.marshal)
11 | if (ordinal > 0) ring.reverse() // outer ring remains CCW, inner rings become CW
12 | if (ring.length) ring.push(ring[0]) // add matching start/end points
13 | return ring
14 | }
15 |
16 | /**
17 | * Constructs an s2 Loop given a geojson Polygon ring.
18 | * @category Constructors
19 | *
20 | * Handles differences between GeoJSON and S2:
21 | * - GeoJSON rings are oriented CCW for the exterior and CW for holes, in S2 all loops are oriented CCW.
22 | * - GeoJSON rings duplicate the start/end points, in S2 they do not.
23 | *
24 | * S2 Loops require the following properties be met:
25 | * - Loops are not allowed to have any duplicate vertices (whether adjacent or not).
26 | * - Non-adjacent edges are not allowed to intersect, and furthermore edges of length 180 degrees are not allowed (i.e., adjacent vertices cannot be antipodal).
27 | * - Loops must have at least 3 vertices.
28 | */
29 | export const unmarshal = (ring: geojson.Position[]): Loop => {
30 | if (ring.length < 3) return new Loop([])
31 |
32 | ring = ring.slice() // make a copy to avoid mutating input
33 | if (clockwise(ring)) ring.reverse() // all rings must be CCW
34 | if (position.equal(ring.at(0)!, ring.at(-1)!)) ring.length -= 1 // remove matching start/end points
35 |
36 | // Loops are not allowed to have duplicate vertices (whether adjacent or not)
37 | if (containsDuplicateVertices(ring)) {
38 | // adjacent duplicates are fixable
39 | ring = removeAdjacentDuplicateVertices(ring, 0)
40 | if (ring.length < 3) return new Loop([])
41 |
42 | // non-adjacent duplicates are not fixable
43 | if (containsDuplicateVertices(ring)) return new Loop([])
44 | }
45 |
46 | return new Loop(ring.map(position.unmarshal))
47 | }
48 |
49 | /**
50 | * Removes *adjacent* duplicate (and near-duplicate) vertices from ring.
51 | */
52 | export const removeAdjacentDuplicateVertices = (ring: geojson.Position[], epsilon = 1e-8): geojson.Position[] => {
53 | return ring.filter((p, i) => !i || !position.equal(ring.at(i - 1)!, p, epsilon))
54 | }
55 |
56 | /**
57 | * Returns true IFF ring contains duplicate vertices at any position.
58 | */
59 | export const containsDuplicateVertices = (ring: geojson.Position[]): boolean => {
60 | return new Set(ring.map((c) => `${c[0]}|${c[1]}`)).size !== ring.length
61 | }
62 |
63 | /**
64 | * Returns true IFF ring is oriented Clockwise.
65 | */
66 | export const clockwise = (ring: geojson.Position[]): boolean => {
67 | let sum = 0
68 | for (let i = 1; i < ring.length; i++) {
69 | sum += (ring[i][0] - ring[i - 1][0]) * (ring[i][1] + ring[i - 1][1])
70 | }
71 | return sum > 0
72 | }
73 |
--------------------------------------------------------------------------------
/geojson/point.ts:
--------------------------------------------------------------------------------
1 | import type * as geojson from 'geojson'
2 | import * as position from './position'
3 | import { Point } from '../s2/Point'
4 |
5 | /**
6 | * Returns a geojson Point geometry given an s2 Point.
7 | * @category Constructors
8 | */
9 | export const marshal = (point: Point): geojson.Point => {
10 | return {
11 | type: 'Point',
12 | coordinates: position.marshal(point)
13 | }
14 | }
15 |
16 | /**
17 | * Constructs an s2 Point given a geojson Point geometry.
18 | * @category Constructors
19 | */
20 | export const unmarshal = (geometry: geojson.Point): Point => {
21 | return position.unmarshal(geometry.coordinates)
22 | }
23 |
24 | /**
25 | * Returns a geojson MultiPoint geometry given s2 Points.
26 | * @category Constructors
27 | */
28 | export const marshalMulti = (points: Point[]): geojson.MultiPoint => {
29 | return {
30 | type: 'MultiPoint',
31 | coordinates: points.map(position.marshal)
32 | }
33 | }
34 |
35 | /**
36 | * Constructs s2 Points given a geojson MultiPoint geometry.
37 | * @category Constructors
38 | */
39 | export const unmarshalMulti = (geometry: geojson.MultiPoint): Point[] => {
40 | return geometry.coordinates.map(position.unmarshal)
41 | }
42 |
--------------------------------------------------------------------------------
/geojson/polygon.ts:
--------------------------------------------------------------------------------
1 | import type * as geojson from 'geojson'
2 | import * as loop from './loop'
3 | import { Polygon } from '../s2/Polygon'
4 |
5 | /**
6 | * Returns a geojson Polygon geometry given an s2 Polygon.
7 | * @category Constructors
8 | */
9 | export const marshal = (polygon: Polygon): geojson.Polygon => {
10 | return {
11 | type: 'Polygon',
12 | coordinates: polygon.loops.map(loop.marshal)
13 | }
14 | }
15 |
16 | /**
17 | * Constructs an s2 Polygon given a geojson Polygon geometry.
18 | * @category Constructors
19 | */
20 | export const unmarshal = (geometry: geojson.Polygon): Polygon => {
21 | return new Polygon(geometry.coordinates.map(loop.unmarshal))
22 | }
23 |
24 | /**
25 | * Returns a geojson MultiPolygon geometry given s2 Polygons.
26 | * @category Constructors
27 | */
28 | export const marshalMulti = (polygons: Polygon[]): geojson.MultiPolygon => {
29 | return {
30 | type: 'MultiPolygon',
31 | coordinates: polygons.map((polygon) => polygon.loops.map(loop.marshal))
32 | }
33 | }
34 |
35 | /**
36 | * Constructs s2 Polygons given a geojson MultiPolygon geometry.
37 | * @category Constructors
38 | */
39 | export const unmarshalMulti = (geometry: geojson.MultiPolygon): Polygon[] => {
40 | return geometry.coordinates.map((coords) => new Polygon(coords.map(loop.unmarshal)))
41 | }
42 |
--------------------------------------------------------------------------------
/geojson/position.ts:
--------------------------------------------------------------------------------
1 | import type * as geojson from 'geojson'
2 | import * as angle from '../s1/angle'
3 | import { Point } from '../s2/Point'
4 | import { LatLng } from '../s2/LatLng'
5 |
6 | /**
7 | * Returns a geojson Position given an s2 Point.
8 | * @category Constructors
9 | */
10 | export const marshal = (point: Point): geojson.Position => {
11 | const ll = LatLng.fromPoint(point)
12 | return [angle.degrees(ll.lng), angle.degrees(ll.lat)]
13 | }
14 |
15 | /**
16 | * Constructs an s2 Point given a geojson Position.
17 | * @category Constructors
18 | */
19 | export const unmarshal = (position: geojson.Position): Point => {
20 | return Point.fromLatLng(LatLng.fromDegrees(position[1], position[0]))
21 | }
22 |
23 | /**
24 | * Returns true IFF the two positions are equal.
25 | */
26 | export const equal = (a: geojson.Position, b: geojson.Position, epsilon = 0) => {
27 | if (epsilon == 0) return a[0] === b[0] && a[1] === b[1]
28 | return Math.abs(a[0] - b[0]) <= epsilon && Math.abs(a[1] - b[1]) <= epsilon
29 | }
30 |
--------------------------------------------------------------------------------
/geojson/rect.ts:
--------------------------------------------------------------------------------
1 | import type * as geojson from 'geojson'
2 | import { Rect } from '../s2/Rect'
3 | import { Interval as R1Interval } from '../r1/Interval'
4 | import { Interval as S1Interval } from '../s1/Interval'
5 | import { Point } from '../s2/Point'
6 | import { Loop } from '../s2/Loop'
7 | import { Polygon } from '../s2/Polygon'
8 | import * as polygon from './polygon'
9 | import { DEGREE } from '../s1/angle_constants'
10 |
11 | /**
12 | * Returns a geojson Polygon geometry given an s2 Rect.
13 | * @category Constructors
14 | */
15 | export const marshal = (rect: Rect): geojson.Polygon => {
16 | const loop = new Loop(Array.from({ length: 4 }, (_, i) => Point.fromLatLng(rect.vertex(i))))
17 | return polygon.marshal(Polygon.fromOrientedLoops([loop]))
18 | }
19 |
20 | /**
21 | * Constructs an s2 Rect given a geojson Polygon geometry.
22 | * @category Constructors
23 | */
24 | export const unmarshal = (geometry: geojson.Polygon): Rect => {
25 | const ring = geometry.coordinates[0]
26 | const lngLo = Math.min(ring[0][0], ring[2][0])
27 | const lngHi = Math.max(ring[0][0], ring[2][0])
28 | const latLo = Math.min(ring[0][1], ring[2][1])
29 | const latHi = Math.max(ring[0][1], ring[2][1])
30 |
31 | return new Rect(
32 | new R1Interval(latLo * DEGREE, latHi * DEGREE),
33 | S1Interval.fromEndpoints(lngLo * DEGREE, lngHi * DEGREE)
34 | )
35 | }
36 |
37 | /**
38 | * Returns true iff the geojson Polygon represents a valid Rect.
39 | * @category Constructors
40 | */
41 | export const valid = (geometry: geojson.Polygon): boolean => {
42 | if (geometry?.type !== 'Polygon') return false
43 | if (geometry?.coordinates.length !== 1) return false
44 | const ring = geometry.coordinates[0]
45 | if (ring.length !== 5) return false
46 | if (!pointsEqual(ring[0], ring[4])) return false
47 | if (!lngEqual(ring[0], ring[3])) return false
48 | if (!lngEqual(ring[1], ring[2])) return false
49 | if (!latEqual(ring[0], ring[1])) return false
50 | if (!latEqual(ring[2], ring[3])) return false
51 | return true
52 | }
53 |
54 | const lngEqual = (a: geojson.Position, b: geojson.Position) => a[0] === b[0]
55 | const latEqual = (a: geojson.Position, b: geojson.Position) => a[1] === b[1]
56 | const pointsEqual = (a: geojson.Position, b: geojson.Position) => lngEqual(a, b) && latEqual(a, b)
57 |
--------------------------------------------------------------------------------
/geojson/testing.ts:
--------------------------------------------------------------------------------
1 | import type * as geojson from 'geojson'
2 | import * as position from './position'
3 |
4 | // default distance threshold for approx equality
5 | const EPSILON = 1e-13
6 |
7 | export const approxEqual = (a: geojson.Geometry, b: geojson.Geometry, epsilon = EPSILON) => {
8 | if (a?.type !== b?.type) return false
9 | switch (a.type) {
10 | case 'Point': {
11 | const aa = a as geojson.Point
12 | const bb = b as geojson.Point
13 | return position.equal(aa.coordinates, bb.coordinates, epsilon)
14 | }
15 |
16 | case 'LineString': {
17 | const aa = a as geojson.LineString
18 | const bb = b as geojson.LineString
19 | if (aa.coordinates.length !== bb.coordinates.length) return false
20 | return aa.coordinates.every((c, i) => position.equal(c, bb.coordinates[i], epsilon))
21 | }
22 |
23 | case 'Polygon': {
24 | const aa = a as geojson.Polygon
25 | const bb = b as geojson.Polygon
26 | if (aa.coordinates.length !== bb.coordinates.length) return false
27 | return aa.coordinates.every((r, ri) => {
28 | if (r.length !== bb.coordinates[ri].length) return false
29 | return r.every((c, ci) => position.equal(c, bb.coordinates[ri][ci], epsilon))
30 | })
31 | }
32 |
33 | default:
34 | throw new Error(`unsupported geometry type: ${a.type}`)
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Javascript port of s2 geometry library.
3 | *
4 | * @see: https://github.com/missinglink/s2js
5 | *
6 | * @module s2js
7 | */
8 | export * as r1 from './r1/_index'
9 | export * as r2 from './r2/_index'
10 | export * as r3 from './r3/_index'
11 | export * as s1 from './s1/_index'
12 | export * as s2 from './s2/_index'
13 | export * as geojson from './geojson/_index'
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "s2js",
3 | "description": "javascript port of s2 geometry",
4 | "version": "0.0.0-development",
5 | "author": "Peter Johnson",
6 | "license": "Apache-2.0",
7 | "repository": "github:missinglink/s2js",
8 | "main": "./dist/index.js",
9 | "types": "./dist/index.d.ts",
10 | "module": "./dist/s2js.esm.js",
11 | "scripts": {
12 | "build": "npx tsdx build --entry index.ts",
13 | "docs": "npx typedoc",
14 | "test": "node --import tsx --test [^_]**/[^_]*_test.ts",
15 | "coverage": "npx c8 npm test",
16 | "lint": "npx prettier --check .",
17 | "format": "npx prettier --write .",
18 | "pre-commit": "npx lint-staged"
19 | },
20 | "devDependencies": {
21 | "@types/geojson": "^7946.0.14",
22 | "@types/node": "^20.14.11",
23 | "tsx": "^4.16.2"
24 | },
25 | "dependencies": {
26 | "bigfloat": "^0.1.1"
27 | },
28 | "lint-staged": {
29 | "*.{ts,md}": "prettier --write"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/r1/Interval.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Interval represents a closed interval on ℝ.
3 | * Zero-length intervals (where lo == hi) represent single points.
4 | * If lo > hi then the interval is empty.
5 | */
6 | export class Interval {
7 | lo: number = 0.0
8 | hi: number = 0.0
9 |
10 | /**
11 | * Returns a new Interval.
12 | * @category Constructors
13 | */
14 | constructor(lo: number, hi: number) {
15 | this.lo = lo
16 | this.hi = hi
17 | }
18 |
19 | /**
20 | * Reports whether the interval is empty.
21 | */
22 | isEmpty(): boolean {
23 | return this.lo > this.hi
24 | }
25 |
26 | /**
27 | * Returns true iff the interval contains the same points as oi.
28 | */
29 | equals(oi: Interval): boolean {
30 | return (this.lo == oi.lo && this.hi == oi.hi) || (this.isEmpty() && oi.isEmpty())
31 | }
32 |
33 | /**
34 | * Returns the midpoint of the interval.
35 | * Behaviour is undefined for empty intervals.
36 | */
37 | center(): number {
38 | return 0.5 * (this.lo + this.hi)
39 | }
40 |
41 | /**
42 | * Returns the length of the interval.
43 | * The length of an empty interval is negative.
44 | */
45 | length(): number {
46 | return this.hi - this.lo
47 | }
48 |
49 | /**
50 | * Returns true iff the interval contains p.
51 | */
52 | contains(p: number): boolean {
53 | return this.lo <= p && p <= this.hi
54 | }
55 |
56 | /**
57 | * Returns true iff the interval contains oi.
58 | */
59 | containsInterval(oi: Interval): boolean {
60 | if (oi.isEmpty()) return true
61 | return this.lo <= oi.lo && oi.hi <= this.hi
62 | }
63 |
64 | /**
65 | * Returns true iff the interval strictly contains p.
66 | */
67 | interiorContains(p: number): boolean {
68 | return this.lo < p && p < this.hi
69 | }
70 |
71 | /**
72 | * Returns true iff the interval strictly contains oi.
73 | */
74 | interiorContainsInterval(oi: Interval): boolean {
75 | if (oi.isEmpty()) return true
76 | return this.lo < oi.lo && oi.hi < this.hi
77 | }
78 |
79 | /**
80 | * Returns true iff the interval contains any points in common with oi.
81 | */
82 | intersects(oi: Interval): boolean {
83 | if (this.lo <= oi.lo) return oi.lo <= this.hi && oi.lo <= oi.hi // oi.lo ∈ i and oi is not empty
84 | return this.lo <= oi.hi && this.lo <= this.hi // i.lo ∈ oi and i is not empty
85 | }
86 |
87 | /**
88 | * Returns true iff the interior of the interval contains any points in common with oi, including the latter's boundary.
89 | */
90 | interiorIntersects(oi: Interval): boolean {
91 | return oi.lo < this.hi && this.lo < oi.hi && this.lo < this.hi && oi.lo <= oi.hi
92 | }
93 |
94 | /**
95 | * Returns the interval containing all points common to i and j.
96 | * Empty intervals do not need to be special-cased.
97 | */
98 | intersection(j: Interval): Interval {
99 | return new Interval(Math.max(this.lo, j.lo), Math.min(this.hi, j.hi))
100 | }
101 |
102 | /**
103 | * Returns the smallest interval that contains this interval and the given interval.
104 | */
105 | union(oi: Interval): Interval {
106 | if (this.isEmpty()) return oi
107 | if (oi.isEmpty()) return this
108 | return new Interval(Math.min(this.lo, oi.lo), Math.max(this.hi, oi.hi))
109 | }
110 |
111 | /**
112 | * Returns the interval expanded so that it contains the given point.
113 | */
114 | addPoint(p: number): Interval {
115 | if (this.isEmpty()) return new Interval(p, p)
116 | if (p < this.lo) return new Interval(p, this.hi)
117 | if (p > this.hi) return new Interval(this.lo, p)
118 | return this
119 | }
120 |
121 | /**
122 | * Returns the closest point in the interval to the given point p.
123 | * The interval must be non-empty.
124 | */
125 | clampPoint(p: number): number {
126 | return Math.max(this.lo, Math.min(this.hi, p))
127 | }
128 |
129 | /**
130 | * Returns an interval that has been expanded on each side by margin.
131 | * If margin is negative, then the function shrinks the interval on each side by margin instead.
132 | * The resulting interval may be empty.
133 | * Any expansion of an empty interval remains empty.
134 | */
135 | expanded(margin: number): Interval {
136 | if (this.isEmpty()) return this
137 | return new Interval(this.lo - margin, this.hi + margin)
138 | }
139 |
140 | /**
141 | * Reports whether the interval can be transformed into the given interval by moving each endpoint a small distance.
142 | * The empty interval is considered to be positioned arbitrarily on the real line, so any interval with a small enough length will match the empty interval.
143 | */
144 | approxEqual(oi: Interval, epsilon = 1e-15): boolean {
145 | if (this.isEmpty()) return oi.length() <= 2 * epsilon
146 | if (oi.isEmpty()) return this.length() <= 2 * epsilon
147 | return Math.abs(oi.lo - this.lo) <= epsilon && Math.abs(oi.hi - this.hi) <= epsilon
148 | }
149 |
150 | /**
151 | * Returns the Hausdorff distance to the given interval. For two intervals x and y, this distance is defined as:
152 | * h(x, y) = max_{p in x} min_{q in y} d(p, q).
153 | */
154 | directedHausdorffDistance(oi: Interval): number {
155 | if (this.isEmpty()) return 0
156 | if (oi.isEmpty()) return Infinity
157 | return Math.max(0, Math.max(this.hi - oi.hi, oi.lo - this.lo))
158 | }
159 |
160 | /**
161 | * Truncates {lo, hi} floats to n digits of precision.
162 | */
163 | trunc(n: number = 15): Interval {
164 | const p = Number(`1e${n}`)
165 | const trunc = (dim: number) => Math.round(dim * p) / p
166 | return new Interval(trunc(this.lo), trunc(this.hi))
167 | }
168 |
169 | /**
170 | * Generates a human readable string.
171 | */
172 | toString(): string {
173 | const t = this.trunc(7)
174 | return `[${t.lo.toFixed(7)}, ${t.hi.toFixed(7)}]`
175 | }
176 |
177 | /**
178 | * Returns an empty interval.
179 | * @category Constructors
180 | */
181 | static empty(): Interval {
182 | return new Interval(1, 0)
183 | }
184 |
185 | /**
186 | * Returns an interval representing a single point.
187 | * @category Constructors
188 | */
189 | static fromPoint(p: number): Interval {
190 | return new Interval(p, p)
191 | }
192 | }
193 |
--------------------------------------------------------------------------------
/r1/Interval_test.ts:
--------------------------------------------------------------------------------
1 | import { test, describe } from 'node:test'
2 | import { ok, equal } from 'node:assert/strict'
3 | import { Interval } from './Interval'
4 |
5 | // Some standard intervals for use throughout the tests.
6 | const UNIT = new Interval(0, 1)
7 | const NEG_UNIT = new Interval(-1, 0)
8 | const HALF = new Interval(0.5, 0.5)
9 | const EMPTY = Interval.empty()
10 |
11 | describe('r1.Interval', () => {
12 | test('isEmpty', (t) => {
13 | ok(!UNIT.isEmpty(), 'should not be empty')
14 | ok(!NEG_UNIT.isEmpty(), 'should not be empty')
15 | ok(!HALF.isEmpty(), 'should not be empty')
16 | ok(EMPTY.isEmpty(), 'should not empty')
17 | })
18 |
19 | test('center', (t) => {
20 | equal(UNIT.center(), 0.5)
21 | equal(NEG_UNIT.center(), -0.5)
22 | equal(HALF.center(), 0.5)
23 | })
24 |
25 | test('length', (t) => {
26 | equal(UNIT.length(), 1)
27 | equal(NEG_UNIT.length(), 1)
28 | equal(HALF.length(), 0)
29 | })
30 |
31 | test('contains', (t) => {
32 | ok(UNIT.contains(0.5))
33 | ok(UNIT.interiorContains(0.5))
34 |
35 | ok(UNIT.contains(0))
36 | ok(!UNIT.interiorContains(0))
37 |
38 | ok(UNIT.contains(1))
39 | ok(!UNIT.interiorContains(1))
40 | })
41 |
42 | test('operations', (t) => {
43 | ok(EMPTY.containsInterval(EMPTY))
44 | ok(EMPTY.interiorContainsInterval(EMPTY))
45 | ok(!EMPTY.intersects(EMPTY))
46 | ok(!EMPTY.interiorIntersects(EMPTY))
47 |
48 | ok(!EMPTY.containsInterval(UNIT))
49 | ok(!EMPTY.interiorContainsInterval(UNIT))
50 | ok(!EMPTY.intersects(UNIT))
51 | ok(!EMPTY.interiorIntersects(UNIT))
52 |
53 | ok(UNIT.containsInterval(HALF))
54 | ok(UNIT.interiorContainsInterval(HALF))
55 | ok(UNIT.intersects(HALF))
56 | ok(UNIT.interiorIntersects(HALF))
57 |
58 | ok(UNIT.containsInterval(UNIT))
59 | ok(!UNIT.interiorContainsInterval(UNIT))
60 | ok(UNIT.intersects(UNIT))
61 | ok(UNIT.interiorIntersects(UNIT))
62 |
63 | ok(UNIT.containsInterval(EMPTY))
64 | ok(UNIT.interiorContainsInterval(EMPTY))
65 | ok(!UNIT.intersects(EMPTY))
66 | ok(!UNIT.interiorIntersects(EMPTY))
67 |
68 | ok(!UNIT.containsInterval(NEG_UNIT))
69 | ok(!UNIT.interiorContainsInterval(NEG_UNIT))
70 | ok(UNIT.intersects(NEG_UNIT))
71 | ok(!UNIT.interiorIntersects(NEG_UNIT))
72 |
73 | const i = new Interval(0, 0.5)
74 | ok(UNIT.containsInterval(i))
75 | ok(!UNIT.interiorContainsInterval(i))
76 | ok(UNIT.intersects(i))
77 | ok(UNIT.interiorIntersects(i))
78 |
79 | ok(!HALF.containsInterval(i))
80 | ok(!HALF.interiorContainsInterval(i))
81 | ok(HALF.intersects(i))
82 | ok(!HALF.interiorIntersects(i))
83 | })
84 |
85 | test('intersection', (t) => {
86 | ok(UNIT.intersection(HALF).equals(HALF))
87 | ok(UNIT.intersection(NEG_UNIT).equals(new Interval(0, 0)))
88 | ok(NEG_UNIT.intersection(HALF).equals(EMPTY))
89 | ok(UNIT.intersection(EMPTY).equals(EMPTY))
90 | ok(EMPTY.intersection(UNIT).equals(EMPTY))
91 | })
92 |
93 | test('union', (t) => {
94 | ok(new Interval(99, 100).union(EMPTY).equals(new Interval(99, 100)))
95 | ok(EMPTY.union(new Interval(99, 100)).equals(new Interval(99, 100)))
96 | ok(new Interval(5, 3).union(new Interval(0, -2)).equals(EMPTY))
97 | ok(new Interval(0, -2).union(new Interval(5, 3)).equals(EMPTY))
98 | ok(UNIT.union(UNIT).equals(UNIT))
99 | ok(UNIT.union(NEG_UNIT).equals(new Interval(-1, 1)))
100 | ok(NEG_UNIT.union(UNIT).equals(new Interval(-1, 1)))
101 | ok(HALF.union(UNIT).equals(UNIT))
102 | })
103 |
104 | test('addPoint', (t) => {
105 | ok(EMPTY.addPoint(5).equals(new Interval(5, 5)))
106 | ok(new Interval(5, 5).addPoint(-1).equals(new Interval(-1, 5)))
107 | ok(new Interval(-1, 5).addPoint(0).equals(new Interval(-1, 5)))
108 | ok(new Interval(-1, 5).addPoint(6).equals(new Interval(-1, 6)))
109 | })
110 |
111 | test('clampPoint', (t) => {
112 | equal(new Interval(0.1, 0.4).clampPoint(0.3), 0.3)
113 | equal(new Interval(0.1, 0.4).clampPoint(-7.0), 0.1)
114 | equal(new Interval(0.1, 0.4).clampPoint(0.6), 0.4)
115 | })
116 |
117 | test('expanded', (t) => {
118 | ok(EMPTY.expanded(0.45).equals(EMPTY))
119 | ok(UNIT.expanded(0.5).equals(new Interval(-0.5, 1.5)))
120 | ok(UNIT.expanded(-0.5).equals(new Interval(0.5, 0.5)))
121 | ok(UNIT.expanded(-0.51).equals(EMPTY))
122 | })
123 |
124 | test('approxEqual', (t) => {
125 | // Choose two values lo and hi such that it's okay to shift an endpoint by
126 | // kLo (i.e., the resulting interval is equivalent) but not by kHi.
127 | const lo = 4 * 2.220446049250313e-16 // < max_error default
128 | const hi = 6 * 2.220446049250313e-16 // > max_error default
129 |
130 | // Empty intervals.
131 | ok(EMPTY.approxEqual(EMPTY))
132 | ok(new Interval(0, 0).approxEqual(EMPTY))
133 | ok(EMPTY.approxEqual(new Interval(0, 0)))
134 | ok(new Interval(1, 1).approxEqual(EMPTY))
135 | ok(EMPTY.approxEqual(new Interval(1, 1)))
136 | ok(!EMPTY.approxEqual(new Interval(0, 1)))
137 | ok(EMPTY.approxEqual(new Interval(1, 1 + 2 * lo)))
138 | ok(!EMPTY.approxEqual(new Interval(1, 1 + 2 * hi)))
139 |
140 | // Singleton intervals.
141 | ok(new Interval(1, 1).approxEqual(new Interval(1, 1)))
142 | ok(new Interval(1, 1).approxEqual(new Interval(1 - lo, 1 - lo)))
143 | ok(new Interval(1, 1).approxEqual(new Interval(1 + lo, 1 + lo)))
144 | ok(!new Interval(1, 1).approxEqual(new Interval(1 - hi, 1)))
145 | ok(!new Interval(1, 1).approxEqual(new Interval(1, 1 + hi)))
146 | ok(new Interval(1, 1).approxEqual(new Interval(1 - lo, 1 + lo)))
147 | ok(!new Interval(0, 0).approxEqual(new Interval(1, 1)))
148 |
149 | // Other intervals.
150 | ok(new Interval(1 - lo, 2 + lo).approxEqual(new Interval(1, 2)))
151 | ok(new Interval(1 + lo, 2 - lo).approxEqual(new Interval(1, 2)))
152 | ok(!new Interval(1 - hi, 2 + lo).approxEqual(new Interval(1, 2)))
153 | ok(!new Interval(1 + hi, 2 - lo).approxEqual(new Interval(1, 2)))
154 | ok(!new Interval(1 - lo, 2 + hi).approxEqual(new Interval(1, 2)))
155 | ok(!new Interval(1 + lo, 2 - hi).approxEqual(new Interval(1, 2)))
156 | })
157 |
158 | test('directedHausdorffDistance', (t) => {
159 | equal(EMPTY.directedHausdorffDistance(EMPTY), 0)
160 | equal(UNIT.directedHausdorffDistance(EMPTY), Infinity)
161 | equal(new Interval(1, 1).directedHausdorffDistance(new Interval(1, 1)), 0)
162 | equal(new Interval(1, 3).directedHausdorffDistance(new Interval(1, 1)), 2)
163 | equal(new Interval(1, 1).directedHausdorffDistance(new Interval(3, 5)), 2)
164 | })
165 |
166 | test('toString', (t) => {
167 | equal(new Interval(2, 4.5).toString(), '[2.0000000, 4.5000000]')
168 | })
169 | })
170 |
--------------------------------------------------------------------------------
/r1/_index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Module r1 implements types and functions for working with geometry in ℝ¹.
3 | * @module r1
4 | */
5 | export { Interval } from './Interval'
6 |
--------------------------------------------------------------------------------
/r1/math.ts:
--------------------------------------------------------------------------------
1 | /** Computes the IEEE 754 floating-point remainder of x / y. */
2 | export const remainder = (x: number, y: number): number => {
3 | if (isNaN(x) || isNaN(y) || !isFinite(x) || y === 0) return NaN
4 |
5 | const quotient = x / y
6 | let n = Math.round(quotient)
7 |
8 | // When quotient is exactly halfway between two integers, round to the nearest even integer
9 | if (Math.abs(quotient - n) === 0.5) n = 2 * Math.round(quotient / 2)
10 |
11 | const rem = x - n * y
12 | return !rem ? Math.sign(x) * 0 : rem
13 | }
14 |
15 | /** Returns the next representable floating-point value after x towards y. */
16 | export const nextAfter = (x: number, y: number): number => {
17 | if (isNaN(x) || isNaN(y)) return NaN
18 | if (x === y) return y
19 | if (x === 0) return y > 0 ? Number.MIN_VALUE : -Number.MIN_VALUE
20 |
21 | const buffer = new ArrayBuffer(8)
22 | const view = new DataView(buffer)
23 |
24 | view.setFloat64(0, x, true)
25 | let bits = view.getBigUint64(0, true)
26 |
27 | if (x > 0 === y > x) bits += 1n
28 | else bits -= 1n
29 |
30 | view.setBigUint64(0, bits, true)
31 | return view.getFloat64(0, true)
32 | }
33 |
34 | /** Returns true IFF a is within epsilon distance of b. */
35 | export const float64Near = (a: number, b: number, epsilon: number = 1e-14) => Math.abs(a - b) <= epsilon
36 |
37 | /**
38 | * Returns the offset of the lest significant bit set.
39 | * Offset 0 corresponds to the rightmost bit, 63 is the leftmost bit.
40 | * If none of the rightmost 64 bits are set then 64 is returned.
41 | */
42 | export const findLSBSetNonZero64 = (i: bigint): number => {
43 | const lsb = i & -i & 0xffffffffffffffffn
44 | if (lsb === 0n) return 64
45 | const lo = Math.clz32(Number(lsb & 0xffffffffn))
46 | if (lo < 32) return 31 - lo
47 | const hi = Math.clz32(Number((lsb >> 32n) & 0xffffffffn))
48 | return 63 - hi
49 | }
50 |
51 | /**
52 | * Returns the offset of the most significant bit set.
53 | * Offset 0 corresponds to the rightmost bit, 63 is the leftmost bit.
54 | * If none of the rightmost 64 bits are set then 64 is returned.
55 | */
56 | export const findMSBSetNonZero64 = (i: bigint): number => {
57 | const msb = i & 0xffffffffffffffffn
58 | if (msb === 0n) return 64
59 | const hi = Math.clz32(Number((msb >> 32n) & 0xffffffffn))
60 | if (hi < 32) return 63 - hi
61 | const lo = Math.clz32(Number(msb & 0xffffffffn))
62 | return 31 - lo
63 | }
64 |
65 | /**
66 | * Returns the result of multiplying `frac` by 2 raised to the power of `exp`.
67 | */
68 | export const ldexp = (frac: number, exp: number): number => {
69 | return frac * Math.pow(2, exp)
70 | }
71 |
72 | /**
73 | * Returns the binary exponent of the absolute value of x.
74 | * This is the exponent of the value when expressed as a normalized
75 | * floating-point number.
76 | *
77 | * - For a normal positive floating-point number x, Ilogb returns floor(log2(x)).
78 | * - For zero, it returns -Infinity.
79 | * - For infinity, it returns Infinity.
80 | * - For NaN, it returns NaN.
81 | */
82 | export const ilogb = (x: number): number => {
83 | if (isNaN(x)) return NaN
84 | if (x === 0) return -Infinity
85 | if (!isFinite(x)) return Infinity
86 |
87 | x = Math.abs(x)
88 |
89 | if (x < Number.MIN_VALUE) return -1074 // Special case for denormalized numbers
90 | return Math.floor(Math.log2(x))
91 | }
92 |
--------------------------------------------------------------------------------
/r1/math_test.ts:
--------------------------------------------------------------------------------
1 | import { test, describe } from 'node:test'
2 | import { equal, ok } from 'node:assert/strict'
3 | import { remainder, nextAfter, float64Near, findLSBSetNonZero64, ldexp, ilogb, findMSBSetNonZero64 } from './math'
4 |
5 | describe('r1.math', () => {
6 | test('remainder', (t) => {
7 | equal(remainder(5.1, 2), -0.9000000000000004)
8 | equal(remainder(5.5, 2), -0.5)
9 | equal(remainder(-5.5, 2), 0.5)
10 | equal(remainder(5, 2.5), 0)
11 | equal(remainder(5.1, 0), NaN)
12 | equal(remainder(Infinity, 2), NaN)
13 | equal(remainder(5, NaN), NaN)
14 | equal(remainder(0, 2), 0)
15 | equal(remainder(5, 2), 1)
16 | equal(remainder(-5, 2), -1)
17 | })
18 |
19 | test('nextAfter', (t) => {
20 | equal(nextAfter(0, 1), 5e-324)
21 | equal(nextAfter(0, -1), -5e-324)
22 | equal(nextAfter(1, 2), 1.0000000000000002)
23 | equal(nextAfter(1, 0), 0.9999999999999999)
24 | equal(nextAfter(1, 1), 1)
25 | equal(nextAfter(Number.MAX_VALUE, Infinity), Infinity)
26 | equal(nextAfter(-Number.MAX_VALUE, -Infinity), -Infinity)
27 | equal(nextAfter(Number.NaN, 1), NaN)
28 | equal(nextAfter(1, Number.NaN), NaN)
29 | })
30 |
31 | test('float64Near', (t) => {
32 | ok(float64Near(0, 0, 0))
33 | ok(float64Near(1e-10, 1e-10 * 2, 1e-10))
34 | ok(!float64Near(1e-10, 1e-9, 1e-10))
35 | ok(!float64Near(1e-5, 1e-4, 1e-5 / 10))
36 | })
37 |
38 | test('findLSBSetNonZero64', (t) => {
39 | equal(findLSBSetNonZero64(0b0000000000000000000000000000000000000000000000000000000000000001n), 0)
40 | equal(findLSBSetNonZero64(0b0000000000000000000000000000000000000000000000000000000000000010n), 1)
41 | equal(findLSBSetNonZero64(0b0000000000000000000000000000000000000000000000000000000000000100n), 2)
42 | equal(findLSBSetNonZero64(0b0000000000000000000000000000000000000000000000000000000000001000n), 3)
43 | equal(findLSBSetNonZero64(0b0000000000000000000000000000000000000000100000000000000000000000n), 23)
44 | equal(findLSBSetNonZero64(0b0000000000000000000000000000000000000001000000000000000000000000n), 24)
45 | equal(findLSBSetNonZero64(0b0000000000000000000000000000000000000010000000000000000000000000n), 25)
46 | equal(findLSBSetNonZero64(0b0000000000000000000000000100000000000000000000000000000000000000n), 38)
47 | equal(findLSBSetNonZero64(0b0000000000000000000000001000000000000000000000000000000000000000n), 39)
48 | equal(findLSBSetNonZero64(0b0000000000000000000000010000000000000000000000000000000000000000n), 40)
49 | equal(findLSBSetNonZero64(0b0000000010000000000000000000000000000000000000000000000000000000n), 55)
50 | equal(findLSBSetNonZero64(0b0000000100000000000000000000000000000000000000000000000000000000n), 56)
51 | equal(findLSBSetNonZero64(0b0000001000000000000000000000000000000000000000000000000000000000n), 57)
52 | equal(findLSBSetNonZero64(0b1000000000000000000000000000000000000000000000000000000000000000n), 63)
53 | equal(findLSBSetNonZero64(0b0000000000000000000000000000000000000000000000000000000000000000n), 64)
54 | equal(findLSBSetNonZero64(0b1000000000000000000000000000000000000000000000000000000000000000000n), 64)
55 | })
56 |
57 | test('findMSBSetNonZero64', (t) => {
58 | equal(findMSBSetNonZero64(0b0000000000000000000000000000000000000000000000000000000000000001n), 0)
59 | equal(findMSBSetNonZero64(0b0000000000000000000000000000000000000000000000000000000000000010n), 1)
60 | equal(findMSBSetNonZero64(0b0000000000000000000000000000000000000000000000000000000000000100n), 2)
61 | equal(findMSBSetNonZero64(0b0000000000000000000000000000000000000000000000000000000000001000n), 3)
62 | equal(findMSBSetNonZero64(0b0000000000000000000000000000000000000000100000000000000000000000n), 23)
63 | equal(findMSBSetNonZero64(0b0000000000000000000000000000000000000001000000000000000000000000n), 24)
64 | equal(findMSBSetNonZero64(0b0000000000000000000000000000000000000010000000000000000000000000n), 25)
65 | equal(findMSBSetNonZero64(0b0000000000000000000000000100000000000000000000000000000000000000n), 38)
66 | equal(findMSBSetNonZero64(0b0000000000000000000000001000000000000000000000000000000000000000n), 39)
67 | equal(findMSBSetNonZero64(0b0000000000000000000000010000000000000000000000000000000000000000n), 40)
68 | equal(findMSBSetNonZero64(0b0000000010000000000000000000000000000000000000000000000000000000n), 55)
69 | equal(findMSBSetNonZero64(0b0000000100000000000000000000000000000000000000000000000000000000n), 56)
70 | equal(findMSBSetNonZero64(0b0000001000000000000000000000000000000000000000000000000000000000n), 57)
71 | equal(findMSBSetNonZero64(0b1000000000000000000000000000000000000000000000000000000000000000n), 63)
72 | equal(findMSBSetNonZero64(0b0000000000000000000000000000000000000000000000000000000000000000n), 64)
73 | equal(findMSBSetNonZero64(0b1000000000000000000000000000000000000000000000000000000000000000000n), 64)
74 | })
75 |
76 | test('ldexp', () => {
77 | // Test with positive exponent
78 | equal(ldexp(1.5, 2), 6)
79 | equal(ldexp(0.75, 1), 1.5)
80 |
81 | // Test with negative exponent
82 | equal(ldexp(1.5, -2), 0.375)
83 | equal(ldexp(4, -1), 2)
84 |
85 | // Test with zero exponent
86 | equal(ldexp(1.5, 0), 1.5)
87 | equal(ldexp(0.5, 0), 0.5)
88 |
89 | // Test with zero fraction
90 | equal(ldexp(0, 10), 0)
91 | equal(ldexp(0, -10), 0)
92 |
93 | // Test with large exponent
94 | equal(ldexp(1, 20), 1048576)
95 | equal(ldexp(2, 10), 2048)
96 |
97 | // Test with small fraction
98 | equal(ldexp(0.125, 3), 1)
99 | equal(ldexp(0.25, -2), 0.0625)
100 | })
101 |
102 | test('ilogb', () => {
103 | // Test with positive numbers
104 | equal(ilogb(1), 0)
105 | equal(ilogb(2), 1)
106 | equal(ilogb(4), 2)
107 | equal(ilogb(8), 3)
108 |
109 | // Test with numbers less than 1
110 | equal(ilogb(0.5), -1)
111 | equal(ilogb(0.25), -2)
112 | equal(ilogb(0.125), -3)
113 |
114 | // Test with large numbers
115 | equal(ilogb(1024), 10)
116 | equal(ilogb(2048), 11)
117 |
118 | // Test with numbers close to zero
119 | equal(ilogb(Number.MIN_VALUE), -1074) // Smallest positive number
120 |
121 | // Test with zero
122 | equal(ilogb(0), -Infinity)
123 |
124 | // Test with Infinity
125 | equal(ilogb(Infinity), Infinity)
126 | equal(ilogb(-Infinity), Infinity)
127 |
128 | // Test with NaN
129 | ok(isNaN(ilogb(NaN)))
130 | })
131 | })
132 |
--------------------------------------------------------------------------------
/r2/Point.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Point represents a point in ℝ².
3 | */
4 | export class Point {
5 | x: number = 0.0
6 | y: number = 0.0
7 |
8 | /**
9 | * Returns a new Point.
10 | * @category Constructors
11 | */
12 | constructor(x: number = 0.0, y: number = 0.0) {
13 | this.x = x
14 | this.y = y
15 | }
16 |
17 | /** Returns the sum of p and op. */
18 | add(op: Point): Point {
19 | return new Point(this.x + op.x, this.y + op.y)
20 | }
21 |
22 | /** Returns the difference of p and op. */
23 | sub(op: Point): Point {
24 | return new Point(this.x - op.x, this.y - op.y)
25 | }
26 |
27 | /** Returns the scalar product of p and m. */
28 | mul(m: number): Point {
29 | return new Point(this.x * m, this.y * m)
30 | }
31 |
32 | /** Returns a counterclockwise orthogonal point with the same norm. */
33 | ortho(): Point {
34 | return new Point(-this.y, this.x)
35 | }
36 |
37 | /** Returns the dot product between p and op. */
38 | dot(op: Point): number {
39 | return this.x * op.x + this.y * op.y
40 | }
41 |
42 | /** Returns the cross product of p and op. */
43 | cross(op: Point): number {
44 | return this.x * op.y - this.y * op.x
45 | }
46 |
47 | /** Returns the vector's norm. */
48 | norm(): number {
49 | return Math.hypot(this.x, this.y)
50 | }
51 |
52 | /** Returns a unit point in the same direction as p. */
53 | normalize(): Point {
54 | if (this.x == 0.0 && this.y == 0.0) return this
55 | return this.mul(1 / this.norm())
56 | }
57 |
58 | /** Truncates {x, y} floats to n digits of precision. */
59 | trunc(n: number = 15): Point {
60 | const p = Number(`1e${n}`)
61 | const trunc = (dim: number) => Math.round(dim * p) / p
62 | return new Point(trunc(this.x), trunc(this.y))
63 | }
64 |
65 | /**
66 | * Reports whether this point equals another point.
67 | */
68 | equals(op: Point): boolean {
69 | return this.x === op.x && this.y === op.y
70 | }
71 |
72 | /** Generates a human readable string. */
73 | toString(): string {
74 | const t = this.trunc(12)
75 | return `(${t.x.toFixed(12)}, ${t.y.toFixed(12)})`
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/r2/Point_test.ts:
--------------------------------------------------------------------------------
1 | import { test, describe } from 'node:test'
2 | import { equal, deepEqual } from 'node:assert/strict'
3 | import { Point } from './Point'
4 |
5 | export const MAX_FLOAT32 = Math.pow(2, 127) * (2 - 1 / Math.pow(2, 23))
6 |
7 | describe('r2.Point', () => {
8 | test('add', (t) => {
9 | deepEqual(new Point(0, 0).add(new Point(0, 0)), new Point(0, 0))
10 | deepEqual(new Point(0, 1).add(new Point(0, 0)), new Point(0, 1))
11 | deepEqual(new Point(1, 1).add(new Point(4, 3)), new Point(5, 4))
12 | deepEqual(new Point(-4, 7).add(new Point(1, 5)), new Point(-3, 12))
13 | })
14 |
15 | test('sub', (t) => {
16 | deepEqual(new Point(0, 0).sub(new Point(0, 0)), new Point(0, 0))
17 | deepEqual(new Point(0, 1).sub(new Point(0, 0)), new Point(0, 1))
18 | deepEqual(new Point(1, 1).sub(new Point(4, 3)), new Point(-3, -2))
19 | deepEqual(new Point(-4, 7).sub(new Point(1, 5)), new Point(-5, 2))
20 | })
21 |
22 | test('mul', (t) => {
23 | deepEqual(new Point(0, 0).mul(0), new Point(0, 0))
24 | deepEqual(new Point(0, 1).mul(1), new Point(0, 1))
25 | deepEqual(new Point(1, 1).mul(5), new Point(5, 5))
26 | })
27 |
28 | test('ortho', (t) => {
29 | deepEqual(new Point(0, 0).ortho(), new Point(-0, 0))
30 | deepEqual(new Point(0, 1).ortho(), new Point(-1, 0))
31 | deepEqual(new Point(1, 1).ortho(), new Point(-1, 1))
32 | deepEqual(new Point(-4, 7).ortho(), new Point(-7, -4))
33 | deepEqual(new Point(1, Math.sqrt(3)).ortho(), new Point(-Math.sqrt(3), 1))
34 | })
35 |
36 | test('dot', (t) => {
37 | equal(new Point(0, 0).dot(new Point(0, 0)), 0)
38 | equal(new Point(0, 1).dot(new Point(0, 0)), 0)
39 | equal(new Point(1, 1).dot(new Point(4, 3)), 7)
40 | equal(new Point(-4, 7).dot(new Point(1, 5)), 31)
41 | })
42 |
43 | test('cross', (t) => {
44 | equal(new Point(0, 0).cross(new Point(0, 0)), 0)
45 | equal(new Point(0, 1).cross(new Point(0, 0)), 0)
46 | equal(new Point(1, 1).cross(new Point(-1, -1)), 0)
47 | equal(new Point(1, 1).cross(new Point(4, 3)), -1)
48 | equal(new Point(1, 5).cross(new Point(-2, 3)), 13)
49 | })
50 |
51 | test('norm', (t) => {
52 | equal(new Point(0, 0).norm(), 0)
53 | equal(new Point(0, 1).norm(), 1)
54 | equal(new Point(-1, 0).norm(), 1)
55 | equal(new Point(3, 4).norm(), 5)
56 | equal(new Point(3, -4).norm(), 5)
57 | equal(new Point(2, 2).norm(), 2 * Math.sqrt(2))
58 | equal(new Point(1, Math.sqrt(3)).norm(), 2)
59 | equal(new Point(29, 29 * Math.sqrt(3)).norm(), 29 * 2 + 0.00000000000001)
60 | equal(new Point(1, 1e15).norm(), 1e15)
61 | equal(new Point(1e14, MAX_FLOAT32 - 1).norm(), MAX_FLOAT32)
62 | })
63 |
64 | test('normalize', (t) => {
65 | deepEqual(new Point().normalize(), new Point(0, 0))
66 | deepEqual(new Point(0, 0).normalize(), new Point(0, 0))
67 | deepEqual(new Point(0, 1).normalize(), new Point(0, 1))
68 | deepEqual(new Point(-1, 0).normalize(), new Point(-1, 0))
69 | deepEqual(new Point(3, 4).normalize().trunc(), new Point(0.6, 0.8))
70 | deepEqual(new Point(3, -4).normalize().trunc(), new Point(0.6, -0.8))
71 | deepEqual(new Point(2, 2).normalize().trunc(), new Point(Math.sqrt(2) / 2, Math.sqrt(2) / 2).trunc())
72 | deepEqual(new Point(7, 7 * Math.sqrt(3)).normalize().trunc(), new Point(0.5, Math.sqrt(3) / 2).trunc())
73 | deepEqual(new Point(1e21, 1e21 * Math.sqrt(3)).normalize().trunc(), new Point(0.5, Math.sqrt(3) / 2).trunc())
74 | deepEqual(new Point(1, 1e16).normalize().trunc(), new Point(0, 1))
75 | deepEqual(new Point(1e4, MAX_FLOAT32 - 1).normalize().trunc(), new Point(0, 1))
76 | })
77 |
78 | test('trunc', (t) => {
79 | deepEqual(new Point().trunc(), new Point(0, 0))
80 | deepEqual(new Point(0.0000000000000001, 0.0000000000000001).trunc(), new Point(0, 0))
81 | deepEqual(new Point(0.00000000001, 0.00000000001).trunc(10), new Point(0, 0))
82 | })
83 |
84 | test('toString', (t) => {
85 | equal(new Point().toString(), '(0.000000000000, 0.000000000000)')
86 | equal(new Point(0.0000000000000001, 0.0000000000000001).toString(), '(0.000000000000, 0.000000000000)')
87 | equal(new Point(-1, 1).toString(), '(-1.000000000000, 1.000000000000)')
88 | equal(new Point(-1.123456789123456789, 1).toString(), '(-1.123456789123, 1.000000000000)')
89 | })
90 | })
91 |
--------------------------------------------------------------------------------
/r2/_index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Module r2 implements types and functions for working with geometry in ℝ².
3 | * @module r2
4 | */
5 | export { Point } from './Point'
6 | export { Rect } from './Rect'
7 |
--------------------------------------------------------------------------------
/r3/PreciseVector.ts:
--------------------------------------------------------------------------------
1 | import { BigFloat32 as BigFloat } from 'bigfloat'
2 | import type { Axis } from './Vector'
3 | import { Vector } from './Vector'
4 | export { BigFloat }
5 |
6 | /**
7 | * Represents a point in ℝ³ using high-precision values.
8 | * Note that this is NOT a complete implementation because there are some operations that Vector supports that are not feasible with arbitrary precision math.
9 | */
10 | export class PreciseVector {
11 | x: BigFloat
12 | y: BigFloat
13 | z: BigFloat
14 |
15 | /**
16 | * Returns a new PreciseVector.
17 | * @category Constructors
18 | */
19 | constructor(x: BigFloat, y: BigFloat, z: BigFloat) {
20 | this.x = x
21 | this.y = y
22 | this.z = z
23 | }
24 |
25 | /**
26 | * Creates a high precision vector from the given Vector.
27 | * @category Constructors
28 | */
29 | static fromVector(v: Vector): PreciseVector {
30 | return new PreciseVector(new BigFloat(v.x), new BigFloat(v.y), new BigFloat(v.z))
31 | }
32 |
33 | /**
34 | * Converts this precise vector to a Vector.
35 | */
36 | vector(): Vector {
37 | return new Vector(this.x.valueOf(), this.y.valueOf(), this.z.valueOf()).normalize()
38 | }
39 |
40 | /**
41 | * Reports whether this vector and another precise vector are equal.
42 | */
43 | equals(ov: PreciseVector): boolean {
44 | return this.x.cmp(ov.x) === 0 && this.y.cmp(ov.y) === 0 && this.z.cmp(ov.z) === 0
45 | }
46 |
47 | /**
48 | * Returns a string representation of the vector.
49 | */
50 | toString(): string {
51 | return `(${this.x.toString()}, ${this.y.toString()}, ${this.z.toString()})`
52 | }
53 |
54 | /**
55 | * Returns the square of the norm.
56 | */
57 | norm2(): BigFloat {
58 | return this.dot(this)
59 | }
60 |
61 | /**
62 | * Reports whether this vector is of unit length.
63 | */
64 | isUnit(): boolean {
65 | return this.norm2().cmp(new BigFloat(1)) === 0
66 | }
67 |
68 | /**
69 | * Returns the vector with nonnegative components.
70 | */
71 | abs(): PreciseVector {
72 | const x = this.x.mul(this.x.getSign())
73 | const y = this.y.mul(this.y.getSign())
74 | const z = this.z.mul(this.z.getSign())
75 | return new PreciseVector(x, y, z)
76 | }
77 |
78 | /**
79 | * Returns the standard vector sum of this vector and another.
80 | */
81 | add(ov: PreciseVector): PreciseVector {
82 | return new PreciseVector(this.x.add(ov.x), this.y.add(ov.y), this.z.add(ov.z))
83 | }
84 |
85 | /**
86 | * Returns the standard vector difference of this vector and another.
87 | */
88 | sub(ov: PreciseVector): PreciseVector {
89 | return new PreciseVector(this.x.sub(ov.x), this.y.sub(ov.y), this.z.sub(ov.z))
90 | }
91 |
92 | /**
93 | * Returns the standard scalar product of this vector and a BigFloat.
94 | */
95 | mul(f: BigFloat): PreciseVector {
96 | return new PreciseVector(this.x.mul(f), this.y.mul(f), this.z.mul(f))
97 | }
98 |
99 | /**
100 | * Returns the standard scalar product of this vector and a float.
101 | */
102 | mulByFloat64(f: number): PreciseVector {
103 | return this.mul(new BigFloat(f))
104 | }
105 |
106 | /**
107 | * Returns the standard dot product of this vector and another.
108 | */
109 | dot(ov: PreciseVector): BigFloat {
110 | return this.x.mul(ov.x).add(this.y.mul(ov.y).add(this.z.mul(ov.z)))
111 | }
112 |
113 | /**
114 | * Returns the standard cross product of this vector and another.
115 | */
116 | cross(ov: PreciseVector): PreciseVector {
117 | return new PreciseVector(
118 | this.y.mul(ov.z).sub(this.z.mul(ov.y)),
119 | this.z.mul(ov.x).sub(this.x.mul(ov.z)),
120 | this.x.mul(ov.y).sub(this.y.mul(ov.x))
121 | )
122 | }
123 |
124 | /**
125 | * Returns the axis that represents the largest component in this vector.
126 | */
127 | largestComponent(): Axis {
128 | const t = this.abs()
129 | if (t.x.cmp(t.y) > 0) {
130 | if (t.x.cmp(t.z) > 0) return Vector.X_AXIS
131 | return Vector.Z_AXIS
132 | }
133 | if (t.y.cmp(t.z) > 0) return Vector.Y_AXIS
134 | return Vector.Z_AXIS
135 | }
136 |
137 | /**
138 | * Returns the axis that represents the smallest component in this vector.
139 | */
140 | smallestComponent(): Axis {
141 | const t = this.abs()
142 | if (t.x.cmp(t.y) < 0) {
143 | if (t.x.cmp(t.z) < 0) return Vector.X_AXIS
144 | return Vector.Z_AXIS
145 | }
146 | if (t.y.cmp(t.z) < 0) return Vector.Y_AXIS
147 | return Vector.Z_AXIS
148 | }
149 |
150 | /**
151 | * Reports if this vector is exactly 0 efficiently.
152 | */
153 | isZero(): boolean {
154 | return this.x.isZero() && this.y.isZero() && this.z.isZero()
155 | }
156 | }
157 |
--------------------------------------------------------------------------------
/r3/Vector.ts:
--------------------------------------------------------------------------------
1 | import type { Angle } from '../s1/angle'
2 | export type Axis = number
3 |
4 | /**
5 | * Vector represents a point in ℝ³
6 | */
7 | export class Vector {
8 | x: number = 0.0
9 | y: number = 0.0
10 | z: number = 0.0
11 |
12 | /**
13 | * Returns a new Vector.
14 | * @category Constructors
15 | */
16 | constructor(x: number, y: number, z: number) {
17 | this.x = x
18 | this.y = y
19 | this.z = z
20 | }
21 |
22 | /** Reports whether v and ov are equal within a small epsilon. */
23 | approxEqual(ov: Vector): boolean {
24 | const epsilon = 1e-16
25 | return Math.abs(this.x - ov.x) < epsilon && Math.abs(this.y - ov.y) < epsilon && Math.abs(this.z - ov.z) < epsilon
26 | }
27 |
28 | /**
29 | * Generates a human readable string.
30 | */
31 | toString(): string {
32 | return `(${this.x.toFixed(24)}, ${this.y.toFixed(24)}, ${this.z.toFixed(24)})`
33 | }
34 |
35 | // Returns the vector's norm.
36 | norm(): number {
37 | return Math.sqrt(this.dot(this))
38 | }
39 |
40 | /** Returns the square of the norm. */
41 | norm2(): number {
42 | return this.dot(this)
43 | }
44 |
45 | /** Returns a unit vector in the same direction as v. */
46 | normalize(): Vector {
47 | const n2 = this.norm2()
48 | if (n2 == 0) return new Vector(0, 0, 0)
49 | return this.mul(1 / Math.sqrt(n2))
50 | }
51 |
52 | /** Returns whether this vector is of approximately unit length. */
53 | isUnit(): boolean {
54 | const epsilon = 5e-14
55 | return Math.abs(this.norm2() - 1) <= epsilon
56 | }
57 |
58 | /** Returns the vector with nonnegative components. */
59 | abs(): Vector {
60 | return new Vector(Math.abs(this.x), Math.abs(this.y), Math.abs(this.z))
61 | }
62 |
63 | /** Returns the standard vector sum of v and ov. */
64 | add(ov: Vector): Vector {
65 | return new Vector(this.x + ov.x, this.y + ov.y, this.z + ov.z)
66 | }
67 |
68 | /** Returns the standard vector difference of v and ov. */
69 | sub(ov: Vector): Vector {
70 | return new Vector(this.x - ov.x, this.y - ov.y, this.z - ov.z)
71 | }
72 |
73 | /** Returns the standard scalar product of v and m. */
74 | mul(m: number): Vector {
75 | return new Vector(m * this.x, m * this.y, m * this.z)
76 | }
77 |
78 | /** Returns the standard dot product of v and ov. */
79 | dot(ov: Vector): number {
80 | return this.x * ov.x + this.y * ov.y + this.z * ov.z || 0
81 | }
82 |
83 | /** Returns the standard cross product of v and ov. */
84 | cross(ov: Vector): Vector {
85 | return new Vector(this.y * ov.z - this.z * ov.y, this.z * ov.x - this.x * ov.z, this.x * ov.y - this.y * ov.x)
86 | }
87 |
88 | /** Returns the Euclidean distance between v and ov. */
89 | distance(ov: Vector): number {
90 | return this.sub(ov).norm()
91 | }
92 |
93 | /** Returns the angle between v and ov. */
94 | angle(ov: Vector): Angle {
95 | return Math.atan2(this.cross(ov).norm(), this.dot(ov))
96 | }
97 |
98 | /**
99 | * Returns a unit vector that is orthogonal to v.
100 | * ortho(-v) = -ortho(v) for all v.
101 | */
102 | ortho(): Vector {
103 | const ov = new Vector(0, 0, 0)
104 | const lc = this.largestComponent()
105 | if (lc === Vector.X_AXIS) ov.z = 1
106 | else if (lc === Vector.Y_AXIS) ov.x = 1
107 | else ov.y = 1
108 | return this.cross(ov).normalize()
109 | }
110 |
111 | /** Returns the Axis that represents the largest component in this vector. */
112 | largestComponent(): Axis {
113 | const t = this.abs()
114 | if (t.x > t.y) {
115 | if (t.x > t.z) return Vector.X_AXIS
116 | return Vector.Z_AXIS
117 | }
118 | if (t.y > t.z) return Vector.Y_AXIS
119 | return Vector.Z_AXIS
120 | }
121 |
122 | /** Returns the Axis that represents the smallest component in this vector. */
123 | smallestComponent(): Axis {
124 | const t = this.abs()
125 | if (t.x < t.y) {
126 | if (t.x < t.z) return Vector.X_AXIS
127 | return Vector.Z_AXIS
128 | }
129 | if (t.y < t.z) return Vector.Y_AXIS
130 | return Vector.Z_AXIS
131 | }
132 |
133 | /**
134 | * Reports whether this Vector equals another Vector.
135 | */
136 | equals(ov: Vector): boolean {
137 | return this.x == ov.x && this.y == ov.y && this.z == ov.z
138 | }
139 |
140 | /**
141 | * Compares v and ov lexicographically and returns:
142 | *
143 | * -1 if v < ov
144 | * 0 if v == ov
145 | * +1 if v > ov
146 | *
147 | * This method is based on C++'s std::lexicographical_compare. Two entities
148 | * are compared element by element with the given operator. The first mismatch
149 | * defines which is less (or greater) than the other. If both have equivalent
150 | * values they are lexicographically equal.
151 | */
152 | cmp(ov: Vector): number {
153 | if (this.x < ov.x) return -1
154 | if (this.x > ov.x) return 1
155 |
156 | // First elements were the same, try the next.
157 | if (this.y < ov.y) return -1
158 | if (this.y > ov.y) return 1
159 |
160 | // Second elements were the same return the final compare.
161 | if (this.z < ov.z) return -1
162 | if (this.z > ov.z) return 1
163 |
164 | // Both are equal
165 | return 0
166 | }
167 |
168 | /**
169 | * @categoryDescription Axis
170 | * The three axes of ℝ³.
171 | */
172 |
173 | /**
174 | * X Axis
175 | * @category Axis
176 | */
177 | static X_AXIS: Axis = 0
178 |
179 | /**
180 | * Y Axis
181 | * @category Axis
182 | */
183 | static Y_AXIS: Axis = 1
184 |
185 | /**
186 | * Z Axis
187 | * @category Axis
188 | */
189 | static Z_AXIS: Axis = 2
190 | }
191 |
--------------------------------------------------------------------------------
/r3/_index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Module r3 implements types and functions for working with geometry in ℝ³
3 | * @module r3
4 | */
5 | export { Vector } from './Vector'
6 | export { PreciseVector } from './PreciseVector'
7 |
--------------------------------------------------------------------------------
/s1/Interval_constants.ts:
--------------------------------------------------------------------------------
1 | export const EPSILON = 1e-15
2 | export const DBL_EPSILON = 2.220446049e-16
3 |
--------------------------------------------------------------------------------
/s1/_index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Module s1 implements types and functions for working with geometry in S¹ (circular geometry).
3 | * @module s1
4 | */
5 | export * as angle from './angle'
6 | export * as chordangle from './chordangle'
7 | export { Interval } from './Interval'
8 |
--------------------------------------------------------------------------------
/s1/angle.ts:
--------------------------------------------------------------------------------
1 | import { DEGREE } from './angle_constants'
2 | import { remainder } from '../r1/math'
3 |
4 | export type Angle = number
5 |
6 | /**
7 | * Angle represents a 1D angle. The internal representation is a double precision value in radians, so conversion to and from radians is exact.
8 | * Conversions between E5, E6, E7, and Degrees are not always exact.
9 | *
10 | * For example, Degrees(3.1) is different from E6(3100000) or E7(31000000).
11 | *
12 | * The following conversions between degrees and radians are exact:
13 | *
14 | * Degree*180 == Radian*Math.PI
15 | * Degree*(180/n) == Radian*(Math.PI/n) for n == 0..8
16 | *
17 | * These identities hold when the arguments are scaled up or down by any power of 2. Some similar identities are also true, for example,
18 | *
19 | * Degree*60 == Radian*(Math.PI/3)
20 | *
21 | * But be aware that this type of identity does not hold in general.
22 | * For example:
23 | *
24 | * Degree*3 != Radian*(Math.PI/60)
25 | *
26 | * Similarly, the conversion to radians means that (Angle(x)*Degree).Degrees() does not always equal x.
27 | * For example:
28 | *
29 | * (Angle(45*n)*Degree).Degrees() == 45*n for n == 0..8
30 | *
31 | * but
32 | *
33 | * (60*Degree).Degrees() != 60
34 | *
35 | * When testing for equality, you should allow for numerical errors (ApproxEqual)
36 | * or convert to discrete E5/E6/E7 values first.
37 | *
38 | * @module angle
39 | */
40 |
41 | /**
42 | * Returns the angle in radians.
43 | */
44 | export const radians = (a: Angle): number => a
45 |
46 | /**
47 | * Returns the angle in degrees.
48 | */
49 | export const degrees = (a: Angle): number => a / DEGREE || 0
50 |
51 | /**
52 | * Returns the value rounded to nearest as an int32.
53 | * This does not match C++ exactly for the case of x.5.
54 | */
55 | export const round = (a: Angle): number => Math.round(a) || 0
56 |
57 | /** Returns an angle larger than any finite angle. */
58 | export const infAngle = (): Angle => Infinity
59 |
60 | /** Reports whether this Angle is infinite. */
61 | export const isInf = (a: Angle): boolean => a == Infinity
62 |
63 | /** Returns the angle in hundred thousandths of degrees. */
64 | export const e5 = (a: Angle): number => round(degrees(a) * 1e5)
65 |
66 | /** Returns the angle in millionths of degrees. */
67 | export const e6 = (a: Angle): number => round(degrees(a) * 1e6)
68 |
69 | /** Returns the angle in ten millionths of degrees. */
70 | export const e7 = (a: Angle): number => round(degrees(a) * 1e7)
71 |
72 | /** Returns the absolute value of the angle. */
73 | export const abs = (a: Angle): Angle => Math.abs(a)
74 |
75 | /** Returns an equivalent angle in (-π, π]. */
76 | export const normalized = (a: Angle): Angle => {
77 | let rad = remainder(a, 2 * Math.PI)
78 | if (rad <= -Math.PI) rad = Math.PI
79 | return rad || 0
80 | }
81 |
82 | /**
83 | * Generates a human readable string.
84 | */
85 | export const toString = (a: Angle): string => degrees(a).toFixed(7)
86 |
87 | /** Reports whether the two angles are the same up to a small tolerance. */
88 | export const approxEqual = (a: Angle, oa: Angle, epsilon = 1e-15): boolean => Math.abs(a - oa) <= epsilon
89 |
--------------------------------------------------------------------------------
/s1/angle_constants.ts:
--------------------------------------------------------------------------------
1 | import type { Angle } from './angle'
2 |
3 | // angle units.
4 | export const DEGREE: Angle = Math.PI / 180
5 | export const E5: Angle = 1e-5 * DEGREE
6 | export const E6: Angle = 1e-6 * DEGREE
7 | export const E7: Angle = 1e-7 * DEGREE
8 |
--------------------------------------------------------------------------------
/s1/angle_test.ts:
--------------------------------------------------------------------------------
1 | import { test, describe } from 'node:test'
2 | import { equal, ok } from 'node:assert/strict'
3 | import * as angle from './angle'
4 | import { DEGREE, E5, E6, E7 } from './angle_constants'
5 |
6 | describe('s1.angle', () => {
7 | test('empty', (t) => {
8 | equal(0, angle.radians(0))
9 | })
10 |
11 | test('PI radians exactly 180 degrees', (t) => {
12 | equal(angle.radians(Math.PI), Math.PI, '(π * Radian).Radians() was %v, want π')
13 | equal(angle.degrees(Math.PI), 180, '(π * Radian).Degrees() was %v, want 180')
14 | equal(angle.radians(180 * DEGREE), Math.PI, '(180 * Degree).Radians() was %v, want π')
15 | equal(angle.degrees(180 * DEGREE), 180, '(180 * Degree).Degrees() was %v, want 180')
16 |
17 | equal(angle.degrees(Math.PI / 2), 90, '(π/2 * Radian).Degrees() was %v, want 90')
18 |
19 | // Check negative angles.
20 | equal(angle.degrees(-Math.PI / 2), -90, '(-π/2 * Radian).Degrees() was %v, want -90')
21 | equal(angle.radians(-45 * DEGREE), -Math.PI / 4, '(-45 * Degree).Radians() was %v, want -π/4')
22 |
23 | // zero(s)
24 | equal(angle.degrees(0), 0, 'positive zero')
25 | equal(angle.degrees(-0), 0, 'negative zero')
26 | })
27 |
28 | test('E5/E6/E7 representation', (t) => {
29 | ok(Math.abs(angle.radians(-45 * DEGREE) - angle.radians(-4500000 * E5)) <= 1e-15)
30 | equal(angle.radians(-60 * DEGREE), angle.radians(-60000000 * E6))
31 | equal(angle.radians(-75 * DEGREE), angle.radians(-750000000 * E7))
32 |
33 | equal(-17256123, angle.e5(-172.56123 * DEGREE))
34 | equal(12345678, angle.e6(12.345678 * DEGREE))
35 | equal(-123456789, angle.e7(-12.3456789 * DEGREE))
36 |
37 | equal(angle.e5(0.500000001 * 1e-5 * DEGREE), 1)
38 | equal(angle.e6(0.500000001 * 1e-6 * DEGREE), 1)
39 | equal(angle.e7(0.500000001 * 1e-7 * DEGREE), 1)
40 |
41 | equal(angle.e5(-0.500000001 * 1e-5 * DEGREE), -1)
42 | equal(angle.e6(-0.500000001 * 1e-6 * DEGREE), -1)
43 | equal(angle.e7(-0.500000001 * 1e-7 * DEGREE), -1)
44 |
45 | equal(angle.e5(0.499999999 * 1e-5 * DEGREE), 0)
46 | equal(angle.e6(0.499999999 * 1e-6 * DEGREE), 0)
47 | equal(angle.e7(0.499999999 * 1e-7 * DEGREE), 0)
48 |
49 | equal(angle.e5(-0.499999999 * 1e-5 * DEGREE), 0)
50 | equal(angle.e6(-0.499999999 * 1e-6 * DEGREE), 0)
51 | equal(angle.e7(-0.499999999 * 1e-7 * DEGREE), 0)
52 | })
53 |
54 | test('normalize correctly canonicalizes angles', (t) => {
55 | equal(angle.normalized(360 * DEGREE), 0 * DEGREE)
56 | equal(angle.normalized(-90 * DEGREE), -90 * DEGREE)
57 | equal(angle.normalized(-180 * DEGREE), 180 * DEGREE)
58 | equal(angle.normalized(180 * DEGREE), 180 * DEGREE)
59 | equal(angle.normalized(540 * DEGREE), 180 * DEGREE)
60 | equal(angle.normalized(-270 * DEGREE), 90 * DEGREE)
61 | equal(angle.normalized(900 * DEGREE), 180 * DEGREE)
62 | equal(angle.normalized(-900 * DEGREE), 180 * DEGREE)
63 | equal(angle.normalized(Math.PI), Math.PI)
64 | equal(angle.normalized(-Math.PI), Math.PI)
65 | equal(angle.normalized(Math.PI * 2), 0)
66 | equal(angle.normalized(-Math.PI * 2), 0)
67 | equal(angle.normalized(Math.PI + 0.1), -Math.PI + 0.1)
68 | equal(angle.normalized(-Math.PI - 0.1), Math.PI - 0.1)
69 | equal(angle.normalized(0), 0)
70 | equal(angle.normalized(-0), 0)
71 | })
72 |
73 | test('toString', (t) => {
74 | equal(angle.toString(180 * DEGREE), '180.0000000')
75 | })
76 |
77 | test('degrees vs. radians', (t) => {
78 | // This test tests the exactness of specific values between degrees and radians.
79 | for (let k = -8; k <= 8; k++) {
80 | equal(45 * k * DEGREE, (k * Math.PI) / 4)
81 | equal(angle.degrees(45 * k * DEGREE), 45 * k)
82 | }
83 |
84 | for (let k = 0; k < 30; k++) {
85 | const m = 1 << k
86 | equal((180 / m) * DEGREE, Math.PI / (1 * m))
87 | equal((60 / m) * DEGREE, Math.PI / (3 * m))
88 | equal((36 / m) * DEGREE, Math.PI / (5 * m))
89 | equal((20 / m) * DEGREE, Math.PI / (9 * m))
90 | equal((4 / m) * DEGREE, Math.PI / (45 * m))
91 | }
92 |
93 | // We also spot check a non-identity.
94 | // @missinglink: this fails for epsilon=1e-15
95 | ok(angle.approxEqual(angle.degrees(60 * DEGREE), 60, 1e-14))
96 | })
97 | })
98 |
--------------------------------------------------------------------------------
/s1/chordangle_constants.ts:
--------------------------------------------------------------------------------
1 | import type { ChordAngle } from './chordangle'
2 |
3 | /**
4 | * Represents a zero angle.
5 | */
6 | export const ZERO_CHORDANGLE: ChordAngle = 0
7 |
8 | /**
9 | * Represents a chord angle smaller than the zero angle.
10 | * The only valid operations on a NEGATIVE_CHORDANGLE are comparisons,
11 | * Angle conversions, and Successor/Predecessor.
12 | */
13 | export const NEGATIVE_CHORDANGLE: ChordAngle = -1
14 |
15 | /** Represents a chord angle of 90 degrees (a "right angle"). */
16 | export const RIGHT_CHORDANGLE: ChordAngle = 2
17 |
18 | /**
19 | * Represents a chord angle of 180 degrees (a "straight angle").
20 | * This is the maximum finite chord angle.
21 | */
22 | export const STRAIGHT_CHORDANGLE: ChordAngle = 4
23 |
24 | /** The square of the maximum length allowed in a ChordAngle. */
25 | export const MAX_LENGTH2 = 4.0
26 |
--------------------------------------------------------------------------------
/s2/ContainsPointQuery.ts:
--------------------------------------------------------------------------------
1 | import { CROSS, DO_NOT_CROSS, MAYBE_CROSS, vertexCrossing } from './edge_crossings'
2 | import { EdgeCrosser } from './EdgeCrosser'
3 | import { Point } from './Point'
4 | import { NilShape, Shape } from './Shape'
5 | import { ShapeIndex } from './ShapeIndex'
6 | import { ShapeIndexClippedShape } from './ShapeIndexClippedShape'
7 | import { ShapeIndexIterator } from './ShapeIndexIterator'
8 |
9 | /**
10 | * VertexModel defines whether shapes are considered to contain their vertices.
11 | * Note that these definitions differ from the ones used by BooleanOperation.
12 | *
13 | * Note that points other than vertices are never contained by polylines.
14 | * If you want need this behavior, use ClosestEdgeQuery's IsDistanceLess
15 | * with a suitable distance threshold instead.
16 | */
17 | export type VertexModel = number
18 |
19 | /** VERTEX_MODEL_OPEN means no shapes contain their vertices (not even points). */
20 | export const VERTEX_MODEL_OPEN: VertexModel = 0
21 |
22 | /**
23 | * VERTEX_MODEL_SEMI_OPEN means that polygon point containment is defined
24 | * such that if several polygons tile the region around a vertex, then
25 | * exactly one of those polygons contains that vertex.
26 | */
27 | export const VERTEX_MODEL_SEMI_OPEN: VertexModel = 1
28 |
29 | /**
30 | * VERTEX_MODEL_CLOSED means all shapes contain their vertices (including
31 | * points and polylines).
32 | */
33 | export const VERTEX_MODEL_CLOSED: VertexModel = 2
34 |
35 | /**
36 | * ContainsPointQuery determines whether one or more shapes in a ShapeIndex
37 | * contain a given Point. The ShapeIndex may contain any number of points,
38 | * polylines, and/or polygons (possibly overlapping). Shape boundaries may be
39 | * modeled as Open, SemiOpen, or Closed (this affects whether or not shapes are
40 | * considered to contain their vertices).
41 | *
42 | * This type is not safe for concurrent use.
43 | *
44 | * However, note that if you need to do a large number of point containment
45 | * tests, it is more efficient to re-use the query rather than creating a new
46 | * one each time.
47 | */
48 | export class ContainsPointQuery {
49 | model: VertexModel
50 | index: ShapeIndex
51 | iter: ShapeIndexIterator
52 |
53 | /**
54 | * Returns a new ContainsPointQuery.
55 | * @category Constructors
56 | */
57 | constructor(index: ShapeIndex, model: VertexModel) {
58 | this.index = index
59 | this.model = model
60 | this.iter = index.iterator()
61 | }
62 |
63 | /** Reports whether any shape in the queries index contains the point p under the queries vertex model (Open, SemiOpen, or Closed). */
64 | contains(p: Point): boolean {
65 | if (!this.iter.locatePoint(p)) return false
66 |
67 | const cell = this.iter.indexCell()
68 | for (const clipped of cell.shapes) {
69 | if (this._shapeContains(clipped, this.iter.center(), p)) return true
70 | }
71 |
72 | return false
73 | }
74 |
75 | /** Reports whether the clippedShape from the iterator's center position contains the given point. */
76 | private _shapeContains(clipped: ShapeIndexClippedShape, center: Point, p: Point): boolean {
77 | let inside = clipped.containsCenter
78 | const numEdges = clipped.numEdges()
79 | if (numEdges <= 0) return inside
80 |
81 | const shape = this.index.shape(clipped.shapeID)
82 | if (shape.dimension() !== 2) {
83 | // Points and polylines can be ignored unless the vertex model is Closed.
84 | if (this.model !== VERTEX_MODEL_CLOSED) return false
85 |
86 | // Otherwise, the point is contained if and only if it matches a vertex.
87 | for (const edgeID of clipped.edges) {
88 | const edge = shape.edge(edgeID)
89 | if (edge.v0.equals(p) || edge.v1.equals(p)) return true
90 | }
91 |
92 | return false
93 | }
94 |
95 | // Test containment by drawing a line segment from the cell center to the given point
96 | // and counting edge crossings.
97 | let crosser = new EdgeCrosser(center, p)
98 | for (const edgeID of clipped.edges) {
99 | const edge = shape.edge(edgeID)
100 |
101 | let sign = crosser.crossingSign(edge.v0, edge.v1)
102 | if (sign === DO_NOT_CROSS) continue
103 |
104 | if (sign === MAYBE_CROSS) {
105 | // For the Open and Closed models, check whether p is a vertex.
106 | if (this.model !== VERTEX_MODEL_SEMI_OPEN && (edge.v0.equals(p) || edge.v1.equals(p))) {
107 | return this.model === VERTEX_MODEL_CLOSED
108 | }
109 |
110 | if (vertexCrossing(crosser.a, crosser.b, edge.v0, edge.v1)) sign = CROSS
111 | else sign = DO_NOT_CROSS
112 | }
113 | inside = inside !== (sign === CROSS)
114 | }
115 |
116 | return inside
117 | }
118 |
119 | /** Reports whether the given shape contains the point under this queries vertex model (Open, SemiOpen, or Closed). This requires the shape belongs to this queries index. */
120 | shapeContains(shape: Shape, p: Point): boolean {
121 | if (!shape || shape instanceof NilShape) return false
122 | if (!this.iter.locatePoint(p)) return false
123 |
124 | const iCell = this.iter.indexCell()
125 | const clipped = iCell.findByShapeID(this.index.idForShape(shape))
126 | if (!clipped) return false
127 |
128 | return this._shapeContains(clipped, this.iter.center(), p)
129 | }
130 |
131 | /**
132 | * A type of function that can be called against shapes in an index.
133 | */
134 | shapeVisitorFunc(_shape: Shape): boolean {
135 | return true
136 | }
137 |
138 | /**
139 | * Visits all shapes in the given index that contain the
140 | * given point p, terminating early if the given visitor function returns false,
141 | * in which case visitContainingShapes returns false. Each shape is
142 | * visited at most once.
143 | */
144 | visitContainingShapes(p: Point, f: (shape: Shape) => boolean): boolean {
145 | if (!this.iter.locatePoint(p)) return true
146 |
147 | const cell = this.iter.indexCell()
148 | for (const clipped of cell.shapes) {
149 | if (this._shapeContains(clipped, this.iter.center(), p) && !f(this.index.shape(clipped.shapeID))) return false
150 | }
151 |
152 | return true
153 | }
154 |
155 | /** Returns a slice of all shapes that contain the given point. */
156 | containingShapes(p: Point): Shape[] {
157 | const shapes: Shape[] = []
158 | this.visitContainingShapes(p, (shape: Shape) => {
159 | shapes.push(shape)
160 | return true
161 | })
162 | return shapes
163 | }
164 | }
165 |
--------------------------------------------------------------------------------
/s2/ContainsPointQuery_test.ts:
--------------------------------------------------------------------------------
1 | import { test, describe } from 'node:test'
2 | import { equal, ok, deepEqual } from 'node:assert/strict'
3 | import { makeShapeIndex, parsePoint } from './testing_textformat'
4 | import { Cap } from './Cap'
5 | import { kmToAngle, randomFloat64, randomPoint, samplePointFromCap } from './testing'
6 | import { Shape } from './Shape'
7 | import { Loop } from './Loop'
8 | import { ShapeIndex } from './ShapeIndex'
9 | import {
10 | ContainsPointQuery,
11 | VERTEX_MODEL_CLOSED,
12 | VERTEX_MODEL_OPEN,
13 | VERTEX_MODEL_SEMI_OPEN
14 | } from './ContainsPointQuery'
15 |
16 | describe('s2.ContainsPointQuery', () => {
17 | test('VERTEX_MODEL_OPEN', () => {
18 | const index = makeShapeIndex('0:0 # -1:1, 1:1 # 0:5, 0:7, 2:6')
19 | const q = new ContainsPointQuery(index, VERTEX_MODEL_OPEN)
20 |
21 | const tests = [
22 | { pt: parsePoint('0:0'), want: false },
23 | { pt: parsePoint('-1:1'), want: false },
24 | { pt: parsePoint('1:1'), want: false },
25 | { pt: parsePoint('0:2'), want: false },
26 | { pt: parsePoint('0:3'), want: false },
27 | { pt: parsePoint('0:5'), want: false },
28 | { pt: parsePoint('0:7'), want: false },
29 | { pt: parsePoint('2:6'), want: false },
30 | { pt: parsePoint('1:6'), want: true },
31 | { pt: parsePoint('10:10'), want: false }
32 | ]
33 |
34 | for (const test of tests) {
35 | const got = q.contains(test.pt)
36 | equal(got, test.want, `query.contains(${test.pt}) = ${got}, want ${test.want}`)
37 | }
38 |
39 | equal(q.shapeContains(index.shape(1), parsePoint('1:6')), false, 'query.shapeContains(...) = true, want false')
40 | equal(q.shapeContains(index.shape(2), parsePoint('1:6')), true, 'query.shapeContains(...) = false, want true')
41 | equal(q.shapeContains(index.shape(2), parsePoint('0:5')), false, 'query.shapeContains(...) = true, want false')
42 | equal(q.shapeContains(index.shape(2), parsePoint('0:7')), false, 'query.shapeContains(...) = true, want false')
43 | })
44 |
45 | test('VERTEX_MODEL_SEMI_OPEN', () => {
46 | const index = makeShapeIndex('0:0 # -1:1, 1:1 # 0:5, 0:7, 2:6')
47 | const q = new ContainsPointQuery(index, VERTEX_MODEL_SEMI_OPEN)
48 |
49 | const tests = [
50 | { pt: parsePoint('0:0'), want: false },
51 | { pt: parsePoint('-1:1'), want: false },
52 | { pt: parsePoint('1:1'), want: false },
53 | { pt: parsePoint('0:2'), want: false },
54 | { pt: parsePoint('0:5'), want: false },
55 | { pt: parsePoint('0:7'), want: true },
56 | { pt: parsePoint('2:6'), want: false },
57 | { pt: parsePoint('1:6'), want: true },
58 | { pt: parsePoint('10:10'), want: false }
59 | ]
60 |
61 | for (const test of tests) {
62 | const got = q.contains(test.pt)
63 | equal(got, test.want, `query.contains(${test.pt}) = ${got}, want ${test.want}`)
64 | }
65 |
66 | equal(q.shapeContains(index.shape(1), parsePoint('1:6')), false, 'query.shapeContains(...) = true, want false')
67 | equal(q.shapeContains(index.shape(2), parsePoint('1:6')), true, 'query.shapeContains(...) = false, want true')
68 | equal(q.shapeContains(index.shape(2), parsePoint('0:5')), false, 'query.shapeContains(...) = true, want false')
69 | equal(q.shapeContains(index.shape(2), parsePoint('0:7')), true, 'query.shapeContains(...) = false, want true')
70 | })
71 |
72 | test('VERTEX_MODEL_CLOSED', () => {
73 | const index = makeShapeIndex('0:0 # -1:1, 1:1 # 0:5, 0:7, 2:6')
74 | const q = new ContainsPointQuery(index, VERTEX_MODEL_CLOSED)
75 |
76 | const tests = [
77 | { pt: parsePoint('0:0'), want: true },
78 | { pt: parsePoint('-1:1'), want: true },
79 | { pt: parsePoint('1:1'), want: true },
80 | { pt: parsePoint('0:2'), want: false },
81 | { pt: parsePoint('0:5'), want: true },
82 | { pt: parsePoint('0:7'), want: true },
83 | { pt: parsePoint('2:6'), want: true },
84 | { pt: parsePoint('1:6'), want: true },
85 | { pt: parsePoint('10:10'), want: false }
86 | ]
87 |
88 | for (const test of tests) {
89 | const got = q.contains(test.pt)
90 | equal(got, test.want, `query.contains(${test.pt}) = ${got}, want ${test.want}`)
91 | }
92 |
93 | equal(q.shapeContains(index.shape(1), parsePoint('1:6')), false, 'query.shapeContains(...) = true, want false')
94 | equal(q.shapeContains(index.shape(2), parsePoint('1:6')), true, 'query.shapeContains(...) = false, want true')
95 | equal(q.shapeContains(index.shape(2), parsePoint('0:5')), true, 'query.shapeContains(...) = false, want true')
96 | equal(q.shapeContains(index.shape(2), parsePoint('0:7')), true, 'query.shapeContains(...) = false, want true')
97 | })
98 |
99 | test('containingShapes', () => {
100 | const NUM_VERTICES_PER_LOOP = 10
101 | const MAX_LOOP_RADIUS = kmToAngle(10)
102 | const centerCap = Cap.fromCenterAngle(randomPoint(), MAX_LOOP_RADIUS)
103 | const index = new ShapeIndex()
104 |
105 | for (let i = 0; i < 100; i++) {
106 | index.add(
107 | Loop.regularLoop(samplePointFromCap(centerCap), randomFloat64() * MAX_LOOP_RADIUS, NUM_VERTICES_PER_LOOP)
108 | )
109 | }
110 |
111 | const query = new ContainsPointQuery(index, VERTEX_MODEL_SEMI_OPEN)
112 |
113 | for (let i = 0; i < 100; i++) {
114 | const p = samplePointFromCap(centerCap)
115 | const want: Shape[] = []
116 |
117 | for (let j = 0; j < index.shapes.size; j++) {
118 | const shape = index.shape(j)
119 | const loop = shape as Loop
120 | if (loop.containsPoint(p)) {
121 | ok(
122 | query.shapeContains(shape, p),
123 | `index.shape(${j}).containsPoint(${p}) = true, but query.shapeContains(${p}) = false`
124 | )
125 | want.push(shape)
126 | } else {
127 | ok(
128 | !query.shapeContains(shape, p),
129 | `query.shapeContains(shape, ${p}) = true, but the original loop does not contain the point.`
130 | )
131 | }
132 | }
133 |
134 | const got = query.containingShapes(p)
135 | deepEqual(got, want, `${i} query.containingShapes(${p}) = ${got}, want ${want}`)
136 | }
137 | })
138 | })
139 |
--------------------------------------------------------------------------------
/s2/ContainsVertexQuery.ts:
--------------------------------------------------------------------------------
1 | import { Point } from './Point'
2 |
3 | // Encode points as strings for use as the Map keys
4 | const encode = (p: Point) => `${p.x}:${p.y}:${p.z}`
5 | const decode = (s: string) => {
6 | const [x, y, z] = s.split(':').map(parseFloat)
7 | return new Point(x, y, z)
8 | }
9 |
10 | /**
11 | * ContainsVertexQuery is used to track the edges entering and leaving the
12 | * given vertex of a Polygon in order to be able to determine if the point is
13 | * contained by the Polygon.
14 | *
15 | * Point containment is defined according to the semi-open boundary model
16 | * which means that if several polygons tile the region around a vertex,
17 | * then exactly one of those polygons contains that vertex.
18 | */
19 | export class ContainsVertexQuery {
20 | target: Point
21 | edgeMap: Map
22 |
23 | /**
24 | * Creates a new query for the given vertex whose containment will be determined.
25 | * @category Constructors
26 | */
27 | constructor(target: Point) {
28 | this.target = target
29 | this.edgeMap = new Map()
30 | }
31 |
32 | /**
33 | * Adds the edge between target and v with the given direction.
34 | * (+1 = outgoing, -1 = incoming, 0 = degenerate).
35 | */
36 | addEdge(v: Point, direction: number) {
37 | const k = encode(v)
38 | this.edgeMap.set(k, (this.edgeMap.get(k) || 0) + direction)
39 | }
40 |
41 | /**
42 | * Reports a +1 if the target vertex is contained, -1 if it is
43 | * not contained, and 0 if the incident edges consisted of matched sibling pairs.
44 | */
45 | containsVertex(): number {
46 | // Find the unmatched edge that is immediately clockwise from Ortho(P).
47 | const refDir = this.target.referenceDir()
48 |
49 | let bestPoint = refDir
50 | let bestDir = 0
51 |
52 | for (const [k, v] of this.edgeMap) {
53 | if (v === 0) continue // This is a "matched" edge.
54 | const p = decode(k)
55 | if (Point.orderedCCW(refDir, bestPoint, p, this.target)) {
56 | bestPoint = p
57 | bestDir = v
58 | }
59 | }
60 |
61 | return bestDir
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/s2/ContainsVertexQuery_test.ts:
--------------------------------------------------------------------------------
1 | import { test, describe } from 'node:test'
2 | import { equal } from 'node:assert/strict'
3 | import { ContainsVertexQuery } from './ContainsVertexQuery'
4 | import { parsePoint } from './testing_textformat'
5 |
6 | describe('s2.ContainsVertexQuery', () => {
7 | test('undetermined', () => {
8 | const q = new ContainsVertexQuery(parsePoint('1:2'))
9 | q.addEdge(parsePoint('3:4'), 1)
10 | q.addEdge(parsePoint('3:4'), -1)
11 | const result = q.containsVertex()
12 | equal(result, 0, `ContainsVertex() = ${result}, want 0 for vertex with undetermined containment`)
13 | })
14 |
15 | test('contained with duplicates', () => {
16 | const q = new ContainsVertexQuery(parsePoint('0:0'))
17 | q.addEdge(parsePoint('3:-3'), -1)
18 | q.addEdge(parsePoint('1:-5'), 1)
19 | q.addEdge(parsePoint('2:-4'), 1)
20 | q.addEdge(parsePoint('1:-5'), -1)
21 | const result = q.containsVertex()
22 | equal(result, 1, `ContainsVertex() = ${result}, want 1 for vertex that is contained`)
23 | })
24 |
25 | test('not contained with duplicates', () => {
26 | const q = new ContainsVertexQuery(parsePoint('1:1'))
27 | q.addEdge(parsePoint('1:-5'), 1)
28 | q.addEdge(parsePoint('2:-4'), -1)
29 | q.addEdge(parsePoint('3:-3'), 1)
30 | q.addEdge(parsePoint('1:-5'), -1)
31 | const result = q.containsVertex()
32 | equal(result, -1, `ContainsVertex() = ${result}, want -1 for vertex that is not contained`)
33 | })
34 |
35 | // test('matches loop containment', () => {
36 | // const loop = RegularLoop(parsePoint('89:-179'), 10 * DEGREE, 1000)
37 | // for (let i = 1; i <= loop.numVertices(); i++) {
38 | // const q = new ContainsVertexQuery(loop.vertex(i))
39 | // q.addEdge(loop.vertex(i - 1), -1)
40 | // q.addEdge(loop.vertex(i + 1), 1)
41 | // const result = q.containsVertex() > 0
42 | // const expected = loop.containsPoint(loop.vertex(i))
43 | // equal(
44 | // result,
45 | // expected,
46 | // `ContainsVertex() = ${result}, loop.containsPoint(${loop.vertex(i)}) = ${expected}, should be the same`
47 | // )
48 | // }
49 | // })
50 | })
51 |
--------------------------------------------------------------------------------
/s2/EdgeCrosser_test.ts:
--------------------------------------------------------------------------------
1 | import { test } from 'node:test'
2 | import { strict as assert } from 'node:assert'
3 | import { nextAfter } from '../r1/math'
4 | import { Point } from './Point'
5 | import { CROSS, Crossing, DO_NOT_CROSS, MAYBE_CROSS } from './edge_crossings'
6 | import { EdgeCrosser } from './EdgeCrosser'
7 |
8 | test('crossings', () => {
9 | const NA1 = nextAfter(1, 0)
10 | const NA2 = nextAfter(1, 2)
11 |
12 | const tests = [
13 | {
14 | msg: 'two regular edges that cross',
15 | a: new Point(1, 2, 1),
16 | b: new Point(1, -3, 0.5),
17 | c: new Point(1, -0.5, -3),
18 | d: new Point(0.1, 0.5, 3),
19 | robust: CROSS,
20 | edgeOrVertex: true
21 | },
22 | {
23 | msg: 'two regular edges that intersect antipodal points',
24 | a: new Point(1, 2, 1),
25 | b: new Point(1, -3, 0.5),
26 | c: new Point(-1, 0.5, 3),
27 | d: new Point(-0.1, -0.5, -3),
28 | robust: DO_NOT_CROSS,
29 | edgeOrVertex: false
30 | },
31 | {
32 | msg: 'two edges on the same great circle that start at antipodal points',
33 | a: new Point(0, 0, -1),
34 | b: new Point(0, 1, 0),
35 | c: new Point(0, 0, 1),
36 | d: new Point(0, 1, 1),
37 | robust: DO_NOT_CROSS,
38 | edgeOrVertex: false
39 | },
40 | {
41 | msg: 'two edges that cross where one vertex is the OriginPoint',
42 | a: new Point(1, 0, 0),
43 | b: Point.originPoint(),
44 | c: new Point(1, -0.1, 1),
45 | d: new Point(1, 1, -0.1),
46 | robust: CROSS,
47 | edgeOrVertex: true
48 | },
49 | {
50 | msg: 'two edges that intersect antipodal points where one vertex is the OriginPoint',
51 | a: new Point(1, 0, 0),
52 | b: Point.originPoint(),
53 | c: new Point(1, 0.1, -1),
54 | d: new Point(1, 1, -0.1),
55 | robust: DO_NOT_CROSS,
56 | edgeOrVertex: false
57 | },
58 | {
59 | msg: 'two edges that cross antipodal points',
60 | a: new Point(1, 0, 0),
61 | b: new Point(0, 1, 0),
62 | c: new Point(0, 0, -1),
63 | d: new Point(-1, -1, 1),
64 | robust: DO_NOT_CROSS,
65 | edgeOrVertex: false
66 | },
67 | {
68 | msg: 'two edges that share an endpoint',
69 | a: new Point(2, 3, 4),
70 | b: new Point(-1, 2, 5),
71 | c: new Point(7, -2, 3),
72 | d: new Point(2, 3, 4),
73 | robust: MAYBE_CROSS,
74 | edgeOrVertex: false
75 | },
76 | {
77 | msg: 'two edges that barely cross near the middle of one edge',
78 | a: new Point(1, 1, 1),
79 | b: new Point(1, NA1, -1),
80 | c: new Point(11, -12, -1),
81 | d: new Point(10, 10, 1),
82 | robust: CROSS,
83 | edgeOrVertex: true
84 | },
85 | {
86 | msg: 'two edges that barely cross near the middle separated by a distance of about 1e-15',
87 | a: new Point(1, 1, 1),
88 | b: new Point(1, NA2, -1),
89 | c: new Point(1, -1, 0),
90 | d: new Point(1, 1, 0),
91 | robust: DO_NOT_CROSS,
92 | edgeOrVertex: false
93 | },
94 | {
95 | msg: 'two edges that barely cross each other near the end of both edges',
96 | a: new Point(0, 0, 1),
97 | b: new Point(2, -1e-323, 1),
98 | c: new Point(1, -1, 1),
99 | d: new Point(1e-323, 0, 1),
100 | robust: CROSS,
101 | edgeOrVertex: true
102 | },
103 | {
104 | msg: 'two edges that barely cross each other near the end separated by a distance of about 1e-640',
105 | a: new Point(0, 0, 1),
106 | b: new Point(2, 1e-323, 1),
107 | c: new Point(1, -1, 1),
108 | d: new Point(1e-323, 0, 1),
109 | robust: DO_NOT_CROSS,
110 | edgeOrVertex: false
111 | },
112 | {
113 | msg: 'two edges that barely cross each other near the middle of one edge',
114 | a: new Point(1, -1e-323, -1e-323),
115 | b: new Point(1e-323, 1, 1e-323),
116 | c: new Point(1, -1, 1e-323),
117 | d: new Point(1, 1, 0),
118 | robust: CROSS,
119 | edgeOrVertex: true
120 | },
121 | {
122 | msg: 'two edges that barely cross each other near the middle separated by a distance of about 1e-640',
123 | a: new Point(1, 1e-323, -1e-323),
124 | b: new Point(-1e-323, 1, 1e-323),
125 | c: new Point(1, -1, 1e-323),
126 | d: new Point(1, 1, 0),
127 | robust: DO_NOT_CROSS,
128 | edgeOrVertex: false
129 | }
130 | ]
131 |
132 | tests.forEach((test) => {
133 | const a = Point.fromVector(test.a.vector.normalize())
134 | const b = Point.fromVector(test.b.vector.normalize())
135 | const c = Point.fromVector(test.c.vector.normalize())
136 | const d = Point.fromVector(test.d.vector.normalize())
137 |
138 | testCrossing(test.msg, a, b, c, d, test.robust, test.edgeOrVertex)
139 | testCrossing(test.msg, b, a, c, d, test.robust, test.edgeOrVertex)
140 | testCrossing(test.msg, a, b, d, c, test.robust, test.edgeOrVertex)
141 | testCrossing(test.msg, b, a, d, c, test.robust, test.edgeOrVertex)
142 |
143 | // test degenerate cases
144 | testCrossing(test.msg, a, a, c, d, DO_NOT_CROSS, false)
145 | testCrossing(test.msg, a, b, c, c, DO_NOT_CROSS, false)
146 | testCrossing(test.msg, a, a, c, c, DO_NOT_CROSS, false)
147 |
148 | testCrossing(test.msg, a, b, a, b, MAYBE_CROSS, true)
149 | testCrossing(test.msg, c, d, a, b, test.robust, test.edgeOrVertex !== (test.robust === MAYBE_CROSS))
150 | })
151 | })
152 |
153 | function testCrossing(msg: string, a: Point, b: Point, c: Point, d: Point, robust: Crossing, edgeOrVertex: boolean) {
154 | if (a.equals(c) || a.equals(d) || b.equals(c) || b.equals(d)) {
155 | robust = MAYBE_CROSS
156 | }
157 |
158 | const input = `${msg}: a: ${a}, b: ${b}, c: ${c}, d: ${d}`
159 |
160 | const crosser = EdgeCrosser.newChainEdgeCrosser(a, b, c)
161 | assert.equal(crosser.chainCrossingSign(d), robust, `${input}, ChainCrossingSign(d)`)
162 | assert.equal(crosser.chainCrossingSign(c), robust, `${input}, ChainCrossingSign(c)`)
163 | assert.equal(crosser.crossingSign(d, c), robust, `${input}, CrossingSign(d, c)`)
164 | assert.equal(crosser.crossingSign(c, d), robust, `${input}, CrossingSign(c, d)`)
165 |
166 | crosser.restartAt(c)
167 | assert.equal(crosser.edgeOrVertexChainCrossing(d), edgeOrVertex, `${input}, EdgeOrVertexChainCrossing(d)`)
168 | assert.equal(crosser.edgeOrVertexChainCrossing(c), edgeOrVertex, `${input}, EdgeOrVertexChainCrossing(c)`)
169 | assert.equal(crosser.edgeOrVertexCrossing(d, c), edgeOrVertex, `${input}, EdgeOrVertexCrossing(d, c)`)
170 | assert.equal(crosser.edgeOrVertexCrossing(c, d), edgeOrVertex, `${input}, EdgeOrVertexCrossing(c, d)`)
171 | }
172 |
--------------------------------------------------------------------------------
/s2/EdgeVectorShape.ts:
--------------------------------------------------------------------------------
1 | import { Point } from './Point'
2 | import {
3 | Chain,
4 | ChainPosition,
5 | defaultShapeIsEmpty,
6 | defaultShapeIsFull,
7 | Edge,
8 | originReferencePoint,
9 | ReferencePoint,
10 | TypeTag,
11 | TypeTagNone
12 | } from './Shape'
13 |
14 | /**
15 | * EdgeVectorShape is a class representing an arbitrary set of edges.
16 | * It is used for testing, but it can also be useful if you have, say,
17 | * a collection of polylines and don't care about memory efficiency (since
18 | * this type would store most of the vertices twice).
19 | */
20 | export class EdgeVectorShape {
21 | edges: Edge[] = []
22 |
23 | /**
24 | * Returns a new EdgeVectorShape.
25 | * @category Constructors
26 | */
27 | constructor(edges: Edge[] = []) {
28 | this.edges = edges
29 | }
30 |
31 | /**
32 | * Returns an EdgeVectorShape of length 1 from the given points.
33 | * @category Constructors
34 | */
35 | static fromPoints(a: Point, b: Point): EdgeVectorShape {
36 | return new EdgeVectorShape([new Edge(a, b)])
37 | }
38 |
39 | /**
40 | * Adds the given edge to the shape.
41 | */
42 | add(a: Point, b: Point) {
43 | this.edges.push(new Edge(a, b))
44 | }
45 |
46 | /**
47 | * Returns the number of edges in the shape.
48 | */
49 | numEdges(): number {
50 | return this.edges.length
51 | }
52 |
53 | /**
54 | * Returns the edge at the given index.
55 | */
56 | edge(id: number): Edge {
57 | return this.edges[id]
58 | }
59 |
60 | /**
61 | * Returns the reference point for the shape.
62 | */
63 | referencePoint(): ReferencePoint {
64 | return originReferencePoint(false)
65 | }
66 |
67 | /**
68 | * Returns the number of chains in the shape.
69 | */
70 | numChains(): number {
71 | return this.edges.length
72 | }
73 |
74 | /**
75 | * Returns the chain at the given index.
76 | */
77 | chain(chainID: number): Chain {
78 | return { start: chainID, length: 1 }
79 | }
80 |
81 | /**
82 | * Returns the edge in the given chain at the given offset.
83 | */
84 | chainEdge(chainID: number, _offset: number): Edge {
85 | return this.edges[chainID]
86 | }
87 |
88 | /**
89 | * Returns the position of the given edge within its chain.
90 | */
91 | chainPosition(edgeID: number): ChainPosition {
92 | return { chainID: edgeID, offset: 0 }
93 | }
94 |
95 | /**
96 | * Returns true if the shape is empty.
97 | */
98 | isEmpty(): boolean {
99 | return defaultShapeIsEmpty(this)
100 | }
101 |
102 | /**
103 | * Returns true if the shape is full.
104 | */
105 | isFull(): boolean {
106 | return defaultShapeIsFull(this)
107 | }
108 |
109 | /**
110 | * Returns the dimension of the shape.
111 | */
112 | dimension(): number {
113 | return 1
114 | }
115 |
116 | /**
117 | * Returns the type tag of the shape.
118 | */
119 | typeTag(): TypeTag {
120 | return TypeTagNone
121 | }
122 |
123 | /**
124 | * Private interface enforcement method.
125 | */
126 | privateInterface() {}
127 | }
128 |
--------------------------------------------------------------------------------
/s2/EdgeVectorShape_test.ts:
--------------------------------------------------------------------------------
1 | import { test, describe } from 'node:test'
2 | import { equal, deepEqual } from 'node:assert/strict'
3 | import { EdgeVectorShape } from './EdgeVectorShape'
4 | import { Point } from './Point'
5 |
6 | describe('s2.EdgeVectorShape', () => {
7 | test('empty', () => {
8 | const shape = new EdgeVectorShape()
9 |
10 | equal(shape.numEdges(), 0)
11 | equal(shape.numChains(), 0)
12 | equal(shape.dimension(), 1)
13 | equal(shape.isEmpty(), true)
14 | equal(shape.isFull(), false)
15 | equal(shape.referencePoint().contained, false)
16 | })
17 |
18 | test('singleton constructor', () => {
19 | const a = Point.fromCoords(1, 0, 0)
20 | const b = Point.fromCoords(0, 1, 0)
21 |
22 | const shape = EdgeVectorShape.fromPoints(a, b)
23 |
24 | equal(shape.numEdges(), 1)
25 | equal(shape.numChains(), 1)
26 |
27 | const edge = shape.edge(0)
28 | deepEqual(edge.v0, a)
29 | deepEqual(edge.v1, b)
30 | equal(shape.isEmpty(), false)
31 | equal(shape.isFull(), false)
32 | })
33 | })
34 |
--------------------------------------------------------------------------------
/s2/LatLng.ts:
--------------------------------------------------------------------------------
1 | import type { Angle } from '../s1/angle'
2 | import { DEGREE } from '../s1/angle_constants'
3 | import * as angle from '../s1/angle'
4 | import { remainder } from '../r1/math'
5 | import { Point } from './Point'
6 |
7 | const NORTH_POLE_LAT: Angle = Math.PI / 2
8 | const SOUTH_POLE_LAT: Angle = -NORTH_POLE_LAT
9 |
10 | /**
11 | * Represents a point on the unit sphere as a pair of angles.
12 | */
13 | export class LatLng {
14 | lat: Angle
15 | lng: Angle
16 |
17 | /**
18 | * Returns a new LatLng.
19 | * @category Constructors
20 | */
21 | constructor(lat: Angle, lng: Angle) {
22 | this.lat = lat
23 | this.lng = lng
24 | }
25 |
26 | /**
27 | * Returns a LatLng for the coordinates given in degrees.
28 | * @category Constructors
29 | */
30 | static fromDegrees(lat: number, lng: number): LatLng {
31 | return new LatLng(lat * DEGREE, lng * DEGREE)
32 | }
33 |
34 | /**
35 | * Returns the latitude of the given point.
36 | */
37 | static latitude(p: Point): number {
38 | const v = p.vector
39 | return Math.atan2(v.z, Math.sqrt(v.x * v.x + v.y * v.y))
40 | }
41 |
42 | /**
43 | * Returns the longitude of the given point.
44 | */
45 | static longitude(p: Point): number {
46 | return Math.atan2(p.vector.y, p.vector.x)
47 | }
48 |
49 | /**
50 | * Returns a LatLng for a given Point.
51 | * @category Constructors
52 | */
53 | static fromPoint(p: Point): LatLng {
54 | return new LatLng(this.latitude(p), this.longitude(p))
55 | }
56 |
57 | /**
58 | * Returns true if and only if the LatLng is normalized,
59 | * with lat ∈ [-π/2,π/2] and lng ∈ [-π,π].
60 | */
61 | isValid(): boolean {
62 | return Math.abs(this.lat) <= Math.PI / 2 && Math.abs(this.lng) <= Math.PI
63 | }
64 |
65 | /**
66 | * Returns the normalized version of the LatLng,
67 | * with lat clamped to [-π/2,π/2] and lng wrapped in [-π,π].
68 | */
69 | normalized(): LatLng {
70 | let lat = this.lat
71 | if (lat > NORTH_POLE_LAT) lat = NORTH_POLE_LAT
72 | else if (lat < SOUTH_POLE_LAT) lat = SOUTH_POLE_LAT
73 | const lng = remainder(this.lng, 2 * Math.PI)
74 | return new LatLng(lat, lng)
75 | }
76 |
77 | /**
78 | * Returns the string representation of the LatLng.
79 | */
80 | toString(): string {
81 | return `[${angle.toString(this.lat)}, ${angle.toString(this.lng)}]`
82 | }
83 |
84 | /**
85 | * Returns the angle between two LatLngs.
86 | */
87 | distance(oll: LatLng): number {
88 | const dlat = Math.sin(0.5 * (oll.lat - this.lat))
89 | const dlng = Math.sin(0.5 * (oll.lng - this.lng))
90 | const x = dlat * dlat + dlng * dlng * Math.cos(this.lat) * Math.cos(oll.lat)
91 | return 2 * Math.atan2(Math.sqrt(x), Math.sqrt(Math.max(0, 1 - x)))
92 | }
93 |
94 | /**
95 | * Reports whether the latitude and longitude of the two LatLngs
96 | * are the same up to a small tolerance.
97 | */
98 | approxEqual(oll: LatLng): boolean {
99 | return angle.approxEqual(this.lat, oll.lat) && angle.approxEqual(this.lng, oll.lng)
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/s2/LatLng_test.ts:
--------------------------------------------------------------------------------
1 | import { test, describe } from 'node:test'
2 | import { equal, ok } from 'node:assert/strict'
3 | import { LatLng } from './LatLng'
4 | import { Point } from './Point'
5 | import * as angle from '../s1/angle'
6 | import { EPSILON } from './predicates'
7 |
8 | describe('s2.LatLng', () => {
9 | test('normalized', (t) => {
10 | const tests = [
11 | {
12 | desc: 'Valid lat/lng',
13 | pos: LatLng.fromDegrees(21.8275043, 151.1979675),
14 | want: LatLng.fromDegrees(21.8275043, 151.1979675)
15 | },
16 | {
17 | desc: 'Valid lat/lng in the West',
18 | pos: LatLng.fromDegrees(21.8275043, -151.1979675),
19 | want: LatLng.fromDegrees(21.8275043, -151.1979675)
20 | },
21 | {
22 | desc: 'Beyond the North pole',
23 | pos: LatLng.fromDegrees(95, 151.1979675),
24 | want: LatLng.fromDegrees(90, 151.1979675)
25 | },
26 | {
27 | desc: 'Beyond the South pole',
28 | pos: LatLng.fromDegrees(-95, 151.1979675),
29 | want: LatLng.fromDegrees(-90, 151.1979675)
30 | },
31 | {
32 | desc: 'At the date line (from East)',
33 | pos: LatLng.fromDegrees(21.8275043, 180),
34 | want: LatLng.fromDegrees(21.8275043, 180)
35 | },
36 | {
37 | desc: 'At the date line (from West)',
38 | pos: LatLng.fromDegrees(21.8275043, -180),
39 | want: LatLng.fromDegrees(21.8275043, -180)
40 | },
41 | {
42 | desc: 'Across the date line going East',
43 | pos: LatLng.fromDegrees(21.8275043, 181.0012),
44 | want: LatLng.fromDegrees(21.8275043, -178.9988)
45 | },
46 | {
47 | desc: 'Across the date line going West',
48 | pos: LatLng.fromDegrees(21.8275043, -181.0012),
49 | want: LatLng.fromDegrees(21.8275043, 178.9988)
50 | },
51 | {
52 | desc: 'All wrong',
53 | pos: LatLng.fromDegrees(256, 256),
54 | want: LatLng.fromDegrees(90, -104)
55 | }
56 | ]
57 |
58 | tests.forEach((test) => {
59 | const got = test.pos.normalized()
60 | ok(got.isValid(), `${test.desc}: A LatLng should be valid after normalization but isn't: ${got}`)
61 | ok(got.distance(test.want) <= 1e-13, `${test.desc}: ${test.pos}.normalized() = ${got}, want ${test.want}`)
62 | })
63 | })
64 |
65 | test('toString', (t) => {
66 | const expected = '[1.4142136, -2.2360680]'
67 | const s = LatLng.fromDegrees(Math.SQRT2, -Math.sqrt(5)).toString()
68 | equal(s, expected)
69 | })
70 |
71 | test('conversion', (t) => {
72 | const tests = [
73 | { lat: 0, lng: 0, x: 1, y: 0, z: 0 },
74 | { lat: 90, lng: 0, x: 6.12323e-17, y: 0, z: 1 },
75 | { lat: -90, lng: 0, x: 6.12323e-17, y: 0, z: -1 },
76 | { lat: 0, lng: 180, x: -1, y: 1.22465e-16, z: 0 },
77 | { lat: 0, lng: -180, x: -1, y: -1.22465e-16, z: 0 },
78 | { lat: 90, lng: 180, x: -6.12323e-17, y: 7.4988e-33, z: 1 },
79 | { lat: 90, lng: -180, x: -6.12323e-17, y: -7.4988e-33, z: 1 },
80 | { lat: -90, lng: 180, x: -6.12323e-17, y: 7.4988e-33, z: -1 },
81 | { lat: -90, lng: -180, x: -6.12323e-17, y: -7.4988e-33, z: -1 },
82 | {
83 | lat: -81.82750430354997,
84 | lng: 151.19796752929685,
85 | x: -0.12456788151479525,
86 | y: 0.0684875268284729,
87 | z: -0.989844584550441
88 | }
89 | ]
90 |
91 | tests.forEach((test) => {
92 | const ll = LatLng.fromDegrees(test.lat, test.lng)
93 | const p = Point.fromLatLng(ll)
94 | const want = Point.fromCoords(test.x, test.y, test.z)
95 | ok(p.approxEqual(want))
96 | const ll2 = LatLng.fromPoint(p)
97 | const isPolar = test.lat === 90 || test.lat === -90
98 | equal(angle.degrees(ll2.lat), test.lat)
99 | if (!isPolar) {
100 | equal(angle.degrees(ll2.lng), test.lng)
101 | }
102 | })
103 | })
104 |
105 | test('distance', (t) => {
106 | const tests = [
107 | { lat1: 90, lng1: 0, lat2: 90, lng2: 0, want: 0, tolerance: 0 },
108 | { lat1: -37, lng1: 25, lat2: -66, lng2: -155, want: 77, tolerance: 1e-13 },
109 | { lat1: 0, lng1: 165, lat2: 0, lng2: -80, want: 115, tolerance: 1e-13 },
110 | { lat1: 47, lng1: -127, lat2: -47, lng2: 53, want: 180, tolerance: 2e-6 }
111 | ]
112 |
113 | tests.forEach((test) => {
114 | const ll1 = LatLng.fromDegrees(test.lat1, test.lng1)
115 | const ll2 = LatLng.fromDegrees(test.lat2, test.lng2)
116 | const d = angle.degrees(ll1.distance(ll2))
117 | ok(Math.abs(d - test.want) <= test.tolerance)
118 | })
119 | })
120 |
121 | test('approxEqual', (t) => {
122 | const ε = EPSILON / 10
123 |
124 | const tests = [
125 | { a: LatLng.fromDegrees(30, 50), b: LatLng.fromDegrees(30, 50 + ε), want: true },
126 | { a: LatLng.fromDegrees(30, 50), b: LatLng.fromDegrees(30 - ε, 50), want: true },
127 | { a: LatLng.fromDegrees(1, 5), b: LatLng.fromDegrees(2, 3), want: false }
128 | ]
129 |
130 | tests.forEach((test) => {
131 | const got = test.a.approxEqual(test.b)
132 | equal(got, test.want)
133 | })
134 | })
135 | })
136 |
--------------------------------------------------------------------------------
/s2/LaxLoop.ts:
--------------------------------------------------------------------------------
1 | import { Loop } from './Loop'
2 | import { Point } from './Point'
3 | import type { ReferencePoint, Shape, TypeTag } from './Shape'
4 | import { Chain, ChainPosition, defaultShapeIsEmpty, defaultShapeIsFull, Edge, TypeTagNone } from './Shape'
5 | import { referencePointForShape } from './shapeutil'
6 |
7 | // LaxLoop represents a closed loop of edges surrounding an interior
8 | // region. It is similar to Loop except that this class allows
9 | // duplicate vertices and edges. Loops may have any number of vertices,
10 | // including 0, 1, or 2. (A one-vertex loop defines a degenerate edge
11 | // consisting of a single point.)
12 | //
13 | // Note that LaxLoop is faster to initialize and more compact than
14 | // Loop, but does not support the same operations as Loop.
15 | export class LaxLoop implements Shape {
16 | numVertices: number = 0
17 | vertices: Point[] = []
18 |
19 | /**
20 | * Creates a LaxLoop from the given points.
21 | * @category Constructors
22 | */
23 | static fromPoints(vertices: Point[]): LaxLoop {
24 | const l = new LaxLoop()
25 | l.numVertices = vertices.length
26 | l.vertices = vertices.slice() // Create a shallow copy of the vertices array
27 | return l
28 | }
29 |
30 | /**
31 | * Creates a LaxLoop from the given Loop, copying its points.
32 | * @category Constructors
33 | */
34 | static fromLoop(loop: Loop): LaxLoop {
35 | if (loop.isFull()) throw new Error('FullLoops are not yet supported')
36 | if (loop.isEmpty()) return new LaxLoop()
37 |
38 | const l = new LaxLoop()
39 | l.numVertices = loop.vertices.length
40 | l.vertices = loop.vertices.slice() // Create a shallow copy of the loop's vertices array
41 | return l
42 | }
43 |
44 | vertex(i: number): Point {
45 | return this.vertices[i]
46 | }
47 |
48 | numEdges(): number {
49 | return this.numVertices
50 | }
51 |
52 | edge(e: number): Edge {
53 | let e1 = e + 1
54 | if (e1 === this.numVertices) e1 = 0
55 | return new Edge(this.vertices[e], this.vertices[e1])
56 | }
57 |
58 | dimension(): number {
59 | return 2
60 | }
61 |
62 | referencePoint(): ReferencePoint {
63 | return referencePointForShape(this)
64 | }
65 |
66 | numChains(): number {
67 | return Math.min(1, this.numVertices)
68 | }
69 |
70 | chain(_i: number): Chain {
71 | return new Chain(0, this.numVertices)
72 | }
73 |
74 | chainEdge(_i: number, j: number): Edge {
75 | let k = 0
76 | if (j + 1 !== this.numVertices) k = j + 1
77 | return new Edge(this.vertices[j], this.vertices[k])
78 | }
79 |
80 | chainPosition(e: number): ChainPosition {
81 | return new ChainPosition(0, e)
82 | }
83 |
84 | isEmpty(): boolean {
85 | return defaultShapeIsEmpty(this)
86 | }
87 |
88 | isFull(): boolean {
89 | return defaultShapeIsFull(this)
90 | }
91 |
92 | typeTag(): TypeTag {
93 | return TypeTagNone
94 | }
95 |
96 | privateInterface() {}
97 | }
98 |
--------------------------------------------------------------------------------
/s2/LaxLoop_test.ts:
--------------------------------------------------------------------------------
1 | import { test, describe } from 'node:test'
2 | import { equal, deepEqual } from 'node:assert/strict'
3 | import { LaxLoop } from './LaxLoop'
4 | import { parsePoints } from './testing_textformat'
5 | import { Point } from './Point'
6 | import { Loop } from './Loop'
7 |
8 | describe('s2.LaxLoop', () => {
9 | test('empty loop', () => {
10 | const shape = LaxLoop.fromLoop(Loop.emptyLoop())
11 |
12 | equal(shape.numEdges(), 0)
13 | equal(shape.numChains(), 0)
14 | equal(shape.dimension(), 2)
15 | equal(shape.isEmpty(), true)
16 | equal(shape.isFull(), false)
17 | equal(shape.referencePoint().contained, false)
18 | })
19 |
20 | test('non-empty loop', () => {
21 | const vertices: Point[] = parsePoints('0:0, 0:1, 1:1, 1:0')
22 | const shape = LaxLoop.fromPoints(vertices)
23 |
24 | equal(shape.vertices.length, vertices.length)
25 | equal(shape.numEdges(), vertices.length)
26 | equal(shape.numChains(), 1)
27 | equal(shape.chain(0).start, 0)
28 | equal(shape.chain(0).length, vertices.length)
29 |
30 | for (let i = 0; i < vertices.length; i++) {
31 | deepEqual(shape.vertex(i), vertices[i])
32 | const edge = shape.edge(i)
33 | deepEqual(edge.v0, vertices[i])
34 | deepEqual(edge.v1, vertices[(i + 1) % vertices.length])
35 | }
36 |
37 | equal(shape.dimension(), 2)
38 | equal(shape.isEmpty(), false)
39 | equal(shape.isFull(), false)
40 | equal(shape.referencePoint().contained, false)
41 | })
42 | })
43 |
--------------------------------------------------------------------------------
/s2/LaxPolygon_test.ts:
--------------------------------------------------------------------------------
1 | import { test, describe } from 'node:test'
2 | import { equal, ok, deepEqual } from 'node:assert/strict'
3 | import { LaxPolygon } from './LaxPolygon'
4 | import { Point } from './Point'
5 | import { parsePoints } from './testing_textformat'
6 | import { containsBruteForce } from './shapeutil'
7 |
8 | describe('s2.LaxPolygon', () => {
9 | // test('shape empty polygon', () => {
10 | // const shape = LaxPolygon.fromPolygon(new Polygon())
11 | // equal(shape.numLoops, 0)
12 | // equal(shape.numVertices(), 0)
13 | // equal(shape.numEdges(), 0)
14 | // equal(shape.numChains(), 0)
15 | // equal(shape.dimension(), 2)
16 | // ok(shape.isEmpty())
17 | // ok(!shape.isFull())
18 | // ok(!shape.referencePoint().contained)
19 | // })
20 |
21 | // test('full', () => {
22 | // const shape = LaxPolygon.fromPolygon(Polygon.polygonFromLoops([makeLoop('full')]))
23 | // equal(shape.numLoops, 1)
24 | // equal(shape.numVertices(), 0)
25 | // equal(shape.numEdges(), 0)
26 | // equal(shape.numChains(), 1)
27 | // equal(shape.dimension(), 2)
28 | // ok(!shape.isEmpty())
29 | // ok(shape.isFull())
30 | // ok(shape.referencePoint().contained)
31 | // })
32 |
33 | // test('single vertex polygon', () => {
34 | // const loops: Point[][] = [parsePoints('0:0')]
35 |
36 | // const shape = LaxPolygon.fromPoints(loops)
37 | // equal(shape.numLoops, 1)
38 | // equal(shape.numVertices(), 1)
39 | // equal(shape.numEdges(), 1)
40 | // equal(shape.numChains(), 1)
41 | // equal(shape.chain(0).start, 0)
42 | // equal(shape.chain(0).length, 1)
43 |
44 | // const edge = shape.edge(0)
45 | // equal(edge.v0, loops[0][0])
46 | // equal(edge.v1, loops[0][0])
47 | // deepEqual(edge, shape.chainEdge(0, 0))
48 | // equal(shape.dimension(), 2)
49 | // ok(!shape.isEmpty())
50 | // ok(!shape.isFull())
51 | // ok(!shape.referencePoint().contained)
52 | // })
53 |
54 | // test('shape single loop polygon', () => {
55 | // const vertices = parsePoints('0:0, 0:1, 1:1, 1:0')
56 | // const lenVerts = vertices.length
57 | // const shape = LaxPolygon.fromPolygon(Polygon.polygonFromLoops([Loop.loopFromPoints(vertices)]))
58 |
59 | // equal(shape.numLoops, 1)
60 | // equal(shape.numVertices(), lenVerts)
61 | // equal(shape.numLoopVertices(0), lenVerts)
62 | // equal(shape.numEdges(), lenVerts)
63 | // equal(shape.numChains(), 1)
64 | // equal(shape.chain(0).start, 0)
65 | // equal(shape.chain(0).length, lenVerts)
66 |
67 | // for (let i = 0; i < lenVerts; i++) {
68 | // equal(shape.loopVertex(0, i), vertices[i])
69 |
70 | // const edge = shape.edge(i)
71 | // equal(edge.v0, vertices[i])
72 | // equal(edge.v1, vertices[(i + 1) % lenVerts])
73 | // equal(shape.chainEdge(0, i).v0, edge.v0)
74 | // equal(shape.chainEdge(0, i).v1, edge.v1)
75 | // }
76 | // equal(shape.dimension(), 2)
77 | // ok(!shape.isEmpty())
78 | // ok(!shape.isFull())
79 | // ok(!containsBruteForce(shape, Point.originPoint()))
80 | // })
81 |
82 | test('shape multi loop polygon', () => {
83 | const loops: Point[][] = [
84 | parsePoints('0:0, 0:3, 3:3'), // CCW
85 | parsePoints('1:1, 2:2, 1:2') // CW
86 | ]
87 | const lenLoops = loops.length
88 | const shape = LaxPolygon.fromPoints(loops)
89 |
90 | equal(shape.numLoops, lenLoops)
91 | equal(shape.numChains(), lenLoops)
92 |
93 | let numVertices = 0
94 | for (let i = 0; i < lenLoops; i++) {
95 | const loop = loops[i]
96 | equal(shape.numLoopVertices(i), loop.length)
97 | equal(shape.chain(i).start, numVertices)
98 | equal(shape.chain(i).length, loop.length)
99 | for (let j = 0; j < loop.length; j++) {
100 | equal(shape.loopVertex(i, j), loop[j])
101 | const edge = shape.edge(numVertices + j)
102 | equal(edge.v0, loop[j])
103 | equal(edge.v1, loop[(j + 1) % loop.length])
104 | }
105 | numVertices += loop.length
106 | }
107 |
108 | equal(shape.numVertices(), numVertices)
109 | equal(shape.numEdges(), numVertices)
110 | equal(shape.dimension(), 2)
111 | ok(!shape.isEmpty())
112 | ok(!shape.isFull())
113 | ok(!containsBruteForce(shape, Point.originPoint()))
114 | })
115 |
116 | test('shape degenerate loops', () => {
117 | const loops: Point[][] = [
118 | parsePoints('1:1, 1:2, 2:2, 1:2, 1:3, 1:2, 1:1'),
119 | parsePoints('0:0, 0:3, 0:6, 0:9, 0:6, 0:3, 0:0'),
120 | parsePoints('5:5, 6:6')
121 | ]
122 |
123 | const shape = LaxPolygon.fromPoints(loops)
124 | ok(!shape.referencePoint().contained)
125 | })
126 |
127 | test('shape inverted loops', () => {
128 | const loops: Point[][] = [parsePoints('1:2, 1:1, 2:2'), parsePoints('3:4, 3:3, 4:4')]
129 | const shape = LaxPolygon.fromPoints(loops)
130 |
131 | ok(containsBruteForce(shape, Point.originPoint()))
132 | })
133 | })
134 |
--------------------------------------------------------------------------------
/s2/LaxPolyline.ts:
--------------------------------------------------------------------------------
1 | import { Point } from './Point'
2 | import { Polyline } from './Polyline'
3 | import type { Shape, TypeTag } from './Shape'
4 | import {
5 | Chain,
6 | ChainPosition,
7 | defaultShapeIsEmpty,
8 | defaultShapeIsFull,
9 | Edge,
10 | originReferencePoint,
11 | ReferencePoint,
12 | TypeTagLaxPolygon
13 | } from './Shape'
14 |
15 | /**
16 | * LaxPolyline represents a polyline. It is similar to Polyline except
17 | * that adjacent vertices are allowed to be identical or antipodal, and
18 | * the representation is slightly more compact.
19 | *
20 | * Polylines may have any number of vertices, but note that polylines with
21 | * fewer than 2 vertices do not define any edges. (To create a polyline
22 | * consisting of a single degenerate edge, either repeat the same vertex twice
23 | * or use LaxClosedPolyline).
24 | */
25 | export class LaxPolyline implements Shape {
26 | vertices: Point[]
27 |
28 | /**
29 | * Constructs a LaxPolyline from the given points.
30 | * @category Constructors
31 | */
32 | constructor(vertices: Point[]) {
33 | this.vertices = [...vertices]
34 | }
35 |
36 | /**
37 | * Converts the given Polyline into a LaxPolyline.
38 | * @category Constructors
39 | */
40 | static fromPolyline(p: Polyline): LaxPolyline {
41 | return new LaxPolyline(p.points)
42 | }
43 |
44 | numEdges(): number {
45 | return Math.max(0, this.vertices.length - 1)
46 | }
47 |
48 | edge(e: number): Edge {
49 | return new Edge(this.vertices[e], this.vertices[e + 1])
50 | }
51 |
52 | referencePoint(): ReferencePoint {
53 | return originReferencePoint(false)
54 | }
55 |
56 | numChains(): number {
57 | return Math.min(1, this.numEdges())
58 | }
59 |
60 | chain(_i: number): Chain {
61 | return new Chain(0, this.numEdges())
62 | }
63 |
64 | chainEdge(_i: number, j: number): Edge {
65 | return new Edge(this.vertices[j], this.vertices[j + 1])
66 | }
67 |
68 | chainPosition(e: number): ChainPosition {
69 | return new ChainPosition(0, e)
70 | }
71 |
72 | dimension(): number {
73 | return 1
74 | }
75 |
76 | isEmpty(): boolean {
77 | return defaultShapeIsEmpty(this)
78 | }
79 |
80 | isFull(): boolean {
81 | return defaultShapeIsFull(this)
82 | }
83 |
84 | typeTag(): TypeTag {
85 | return TypeTagLaxPolygon
86 | }
87 |
88 | privateInterface() {}
89 | }
90 |
--------------------------------------------------------------------------------
/s2/LaxPolyline_test.ts:
--------------------------------------------------------------------------------
1 | import { test, describe } from 'node:test'
2 | import { ok, equal } from 'node:assert/strict'
3 | import { LaxPolyline } from './LaxPolyline'
4 | import { Point } from './Point'
5 | import { parsePoints } from './testing_textformat'
6 |
7 | describe('s2.LaxPolyline', () => {
8 | test('no vertices', () => {
9 | const shape = new LaxPolyline([])
10 |
11 | equal(shape.numEdges(), 0)
12 | equal(shape.numChains(), 0)
13 | equal(shape.dimension(), 1)
14 | equal(shape.isEmpty(), true)
15 | equal(shape.isFull(), false)
16 | equal(shape.referencePoint().contained, false)
17 | })
18 |
19 | test('one vertex', () => {
20 | const shape = new LaxPolyline([Point.fromCoords(1, 0, 0)])
21 |
22 | equal(shape.numEdges(), 0)
23 | equal(shape.numChains(), 0)
24 | equal(shape.dimension(), 1)
25 | equal(shape.isEmpty(), true)
26 | equal(shape.isFull(), false)
27 | })
28 |
29 | test('edge access', () => {
30 | const vertices = parsePoints('0:0, 0:1, 1:1')
31 | const shape = new LaxPolyline(vertices)
32 |
33 | equal(shape.numEdges(), 2)
34 | equal(shape.numChains(), 1)
35 | equal(shape.chain(0).start, 0)
36 | equal(shape.chain(0).length, 2)
37 | equal(shape.dimension(), 1)
38 | equal(shape.isEmpty(), false)
39 | equal(shape.isFull(), false)
40 |
41 | const edge0 = shape.edge(0)
42 | ok(edge0.v0.approxEqual(vertices[0]))
43 | ok(edge0.v1.approxEqual(vertices[1]))
44 |
45 | const edge1 = shape.edge(1)
46 | ok(edge1.v0.approxEqual(vertices[1]))
47 | ok(edge1.v1.approxEqual(vertices[2]))
48 | })
49 | })
50 |
--------------------------------------------------------------------------------
/s2/Metric.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This file implements functions for various S2 measurements.
3 | */
4 | import { ilogb, ldexp } from '../r1/math'
5 | import { MAX_LEVEL } from './cellid_constants'
6 |
7 | /**
8 | * A Metric is a measure for cells. It is used to describe the shape and size
9 | * of cells. They are useful for deciding which cell level to use in order to
10 | * satisfy a given condition (e.g. that cell vertices must be no further than
11 | * "x" apart). You can use the Value(level) method to compute the corresponding
12 | * length or area on the unit sphere for cells at a given level. The minimum
13 | * and maximum bounds are valid for cells at all levels, but they may be
14 | * somewhat conservative for very large cells (e.g. face cells).
15 | */
16 | export class Metric {
17 | /**
18 | * dim is either 1 or 2, for a 1D or 2D metric respectively.
19 | */
20 | dim: number
21 | /**
22 | * deriv is the scaling factor for the metric.
23 | */
24 | deriv: number
25 |
26 | /**
27 | * Returns a new Metric.
28 | * @category Constructors
29 | */
30 | constructor(dim: number, deriv: number) {
31 | this.dim = dim
32 | this.deriv = deriv
33 | }
34 |
35 | /**
36 | * Returns the value of the metric at the given level.
37 | */
38 | value(level: number): number {
39 | return ldexp(this.deriv, -this.dim * level)
40 | }
41 |
42 | /**
43 | * Returns the minimum level such that the metric is at most the given value, or MaxLevel (30) if there is no such level.
44 | *
45 | * For example, MinLevel(0.1) returns the minimum level such that all cell diagonal lengths are 0.1 or smaller.
46 | * The returned value is always a valid level.
47 | */
48 | minLevel(val: number): number {
49 | if (val <= 0) return MAX_LEVEL // missinglink <0 to <=0
50 | let level = -(ilogb(val / this.deriv) >> (this.dim - 1))
51 | if (level > MAX_LEVEL) level = MAX_LEVEL
52 | if (level < 0) level = 0
53 | return level || 0
54 | }
55 |
56 | /**
57 | * Returns the maximum level such that the metric is at least the given value, or zero if there is no such level.
58 | *
59 | * For example, MaxLevel(0.1) returns the maximum level such that all cells have a minimum width of 0.1 or larger.
60 | * The returned value is always a valid level.
61 | */
62 | maxLevel(val: number): number {
63 | if (val <= 0) return MAX_LEVEL
64 |
65 | let level = ilogb(this.deriv / val) >> (this.dim - 1)
66 | if (level > MAX_LEVEL) level = MAX_LEVEL
67 | if (level < 0) level = 0
68 | return level || 0
69 | }
70 |
71 | /**
72 | * Returns the level at which the metric has approximately the given value.
73 | * The return value is always a valid level.
74 | * For example, AvgEdgeMetric.ClosestLevel(0.1) returns the level at which the average cell edge length is approximately 0.1.
75 | */
76 | closestLevel(val: number): number {
77 | let x = Math.SQRT2
78 | if (this.dim === 2) x = 2
79 | return this.minLevel(x * val)
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/s2/Metric_constants.ts:
--------------------------------------------------------------------------------
1 | import { Metric } from './Metric'
2 |
3 | /**
4 | * Defined metrics.
5 | * Of the projection methods defined in C++, Go only supports the quadratic projection.
6 | */
7 |
8 | /**
9 | * Each cell is bounded by four planes passing through its four edges and
10 | * the center of the sphere. These metrics relate to the angle between each
11 | * pair of opposite bounding planes, or equivalently, between the planes
12 | * corresponding to two different s-values or two different t-values.
13 | */
14 | export const MinAngleSpanMetric = new Metric(1, 4.0 / 3)
15 | export const AvgAngleSpanMetric = new Metric(1, Math.PI / 2)
16 | export const MaxAngleSpanMetric = new Metric(1, 1.704897179199218452)
17 |
18 | /**
19 | * The width of geometric figure is defined as the distance between two
20 | * parallel bounding lines in a given direction. For cells, the minimum
21 | * width is always attained between two opposite edges, and the maximum
22 | * width is attained between two opposite vertices. However, for our
23 | * purposes we redefine the width of a cell as the perpendicular distance
24 | * between a pair of opposite edges. A cell therefore has two widths, one
25 | * in each direction. The minimum width according to this definition agrees
26 | * with the classic geometric one, but the maximum width is different. (The
27 | * maximum geometric width corresponds to MaxDiag defined below.)
28 | *
29 | * The average width in both directions for all cells at level k is approximately
30 | * AvgWidthMetric.Value(k).
31 | *
32 | * The width is useful for bounding the minimum or maximum distance from a
33 | * point on one edge of a cell to the closest point on the opposite edge.
34 | * For example, this is useful when growing regions by a fixed distance.
35 | */
36 | export const MinWidthMetric = new Metric(1, (2 * Math.SQRT2) / 3)
37 | export const AvgWidthMetric = new Metric(1, 1.434523672886099389)
38 | export const MaxWidthMetric = new Metric(1, MaxAngleSpanMetric.deriv)
39 |
40 | /**
41 | * The edge length metrics can be used to bound the minimum, maximum,
42 | * or average distance from the center of one cell to the center of one of
43 | * its edge neighbors. In particular, it can be used to bound the distance
44 | * between adjacent cell centers along the space-filling Hilbert curve for
45 | * cells at any given level.
46 | */
47 | export const MinEdgeMetric = new Metric(1, (2 * Math.SQRT2) / 3)
48 | export const AvgEdgeMetric = new Metric(1, 1.459213746386106062)
49 | export const MaxEdgeMetric = new Metric(1, MaxAngleSpanMetric.deriv)
50 |
51 | /**
52 | * MaxEdgeAspect is the maximum edge aspect ratio over all cells at any level,
53 | * where the edge aspect ratio of a cell is defined as the ratio of its longest
54 | * edge length to its shortest edge length.
55 | */
56 | export const MaxEdgeAspect = 1.44261527445268292
57 |
58 | export const MinAreaMetric = new Metric(2, (8 * Math.SQRT2) / 9)
59 | export const AvgAreaMetric = new Metric(2, (4 * Math.PI) / 6)
60 | export const MaxAreaMetric = new Metric(2, 2.635799256963161491)
61 |
62 | /**
63 | * The maximum diagonal is also the maximum diameter of any cell,
64 | * and also the maximum geometric width (see the comment for widths). For
65 | * example, the distance from an arbitrary point to the closest cell center
66 | * at a given level is at most half the maximum diagonal length.
67 | */
68 | export const MinDiagMetric = new Metric(1, (8 * Math.SQRT2) / 9)
69 | export const AvgDiagMetric = new Metric(1, 2.060422738998471683)
70 | export const MaxDiagMetric = new Metric(1, 2.438654594434021032)
71 |
72 | /**
73 | * MaxDiagAspect is the maximum diagonal aspect ratio over all cells at any
74 | * level, where the diagonal aspect ratio of a cell is defined as the ratio
75 | * of its longest diagonal length to its shortest diagonal length.
76 | */
77 | export const MaxDiagAspect = Math.sqrt(3)
78 |
--------------------------------------------------------------------------------
/s2/Metric_test.ts:
--------------------------------------------------------------------------------
1 | import { test, describe } from 'node:test'
2 | import { strict as assert } from 'node:assert'
3 |
4 | import { MAX_LEVEL } from './cellid_constants'
5 | import {
6 | AvgAngleSpanMetric,
7 | AvgAreaMetric,
8 | AvgDiagMetric,
9 | AvgEdgeMetric,
10 | AvgWidthMetric,
11 | MaxAngleSpanMetric,
12 | MaxAreaMetric,
13 | MaxDiagAspect,
14 | MaxDiagMetric,
15 | MaxEdgeAspect,
16 | MaxEdgeMetric,
17 | MaxWidthMetric,
18 | MinAngleSpanMetric,
19 | MinAreaMetric,
20 | MinDiagMetric,
21 | MinEdgeMetric,
22 | MinWidthMetric
23 | } from './Metric_constants'
24 |
25 | describe('s2.Metric', () => {
26 | test('metric', () => {
27 | let got = MinWidthMetric.maxLevel(0.001256)
28 | assert.equal(got, 9)
29 |
30 | assert.ok(MaxEdgeAspect >= 1)
31 |
32 | got = MaxEdgeMetric.deriv / MinEdgeMetric.deriv
33 | assert.ok(MaxEdgeAspect <= got)
34 |
35 | assert.ok(MaxDiagAspect >= 1)
36 |
37 | got = MaxDiagMetric.deriv / MinDiagMetric.deriv
38 | assert.ok(MaxDiagAspect <= got)
39 |
40 | got = MinWidthMetric.deriv * MinEdgeMetric.deriv - 1e-15
41 | assert.ok(MinAreaMetric.deriv >= got)
42 |
43 | got = MaxWidthMetric.deriv * MaxEdgeMetric.deriv + 1e-15
44 | assert.ok(MaxAreaMetric.deriv <= got)
45 |
46 | for (let level = -2; level <= MAX_LEVEL + 3; level++) {
47 | let width = MinWidthMetric.deriv * Math.pow(2, -level)
48 | if (level >= MAX_LEVEL + 3) width = 0
49 |
50 | // Check boundary cases (exactly equal to a threshold value).
51 | const expected = Math.max(0, Math.min(MAX_LEVEL, level))
52 | assert.equal(MinWidthMetric.minLevel(width), expected)
53 | assert.equal(MinWidthMetric.maxLevel(width), expected)
54 | assert.equal(MinWidthMetric.closestLevel(width), expected)
55 |
56 | // Also check non-boundary cases.
57 | assert.equal(MinWidthMetric.minLevel(1.2 * width), expected)
58 | assert.equal(MinWidthMetric.maxLevel(0.8 * width), expected)
59 | assert.equal(MinWidthMetric.closestLevel(1.2 * width), expected)
60 | assert.equal(MinWidthMetric.closestLevel(0.8 * width), expected)
61 | }
62 | })
63 |
64 | test('size relations', () => {
65 | // check that min <= avg <= max for each metric.
66 | const tests = [
67 | { min: MinAngleSpanMetric, avg: AvgAngleSpanMetric, max: MaxAngleSpanMetric },
68 | { min: MinWidthMetric, avg: AvgWidthMetric, max: MaxWidthMetric },
69 | { min: MinEdgeMetric, avg: AvgEdgeMetric, max: MaxEdgeMetric },
70 | { min: MinDiagMetric, avg: AvgDiagMetric, max: MaxDiagMetric },
71 | { min: MinAreaMetric, avg: AvgAreaMetric, max: MaxAreaMetric }
72 | ]
73 |
74 | tests.forEach((test) => {
75 | assert.ok(test.min.deriv <= test.avg.deriv)
76 | assert.ok(test.avg.deriv <= test.max.deriv)
77 | })
78 | })
79 | })
80 |
--------------------------------------------------------------------------------
/s2/PaddedCell_test.ts:
--------------------------------------------------------------------------------
1 | import { test, describe } from 'node:test'
2 | import { deepEqual, equal, ok } from 'node:assert/strict'
3 | import { PaddedCell } from './PaddedCell'
4 | import { Point as R2Point } from '../r2/Point'
5 | import { Rect as R2Rect } from '../r2/Rect'
6 | import { Interval as R1Interval } from '../r1/Interval'
7 | import * as cellid from './cellid'
8 | import { oneIn, randomCellID, randomFloat64, randomUniformFloat64, randomUniformInt } from './testing'
9 | import { Cell } from './Cell'
10 |
11 | describe('s2.PaddedCell', () => {
12 | test('methods', () => {
13 | for (let i = 0; i < 1000; i++) {
14 | const cid = randomCellID()
15 | const padding = Math.pow(1e-15, randomFloat64())
16 | const cell = Cell.fromCellID(cid)
17 | const pCell = PaddedCell.fromCellID(cid, padding)
18 | equal(cell.id, pCell.cellID())
19 | equal(cellid.level(cell.id), pCell.levelValue())
20 | equal(padding, pCell.paddingValue())
21 | deepEqual(pCell.bound(), cell.boundUV().expandedByMargin(padding))
22 | const r = R2Rect.fromPoints(cellid.centerUV(cell.id)).expandedByMargin(padding)
23 | deepEqual(pCell.middle(), r)
24 | deepEqual(cellid.point(cell.id), pCell.center())
25 | if (cellid.isLeaf(cid)) continue
26 | const children = cell.children()
27 | for (let pos = 0; pos < 4; pos++) {
28 | const [i, j] = pCell.childIJ(pos)
29 | const cellChild = children[pos]
30 | const pCellChild = PaddedCell.fromParentIJ(pCell, i, j)
31 | equal(cellChild.id, pCellChild.cellID())
32 | equal(cellid.level(cellChild.id), pCellChild.levelValue())
33 | equal(padding, pCellChild.paddingValue())
34 | deepEqual(pCellChild.bound(), cellChild.boundUV().expandedByMargin(padding))
35 | const r = R2Rect.fromPoints(cellid.centerUV(cellChild.id)).expandedByMargin(padding)
36 | ok(r.approxEqual(pCellChild.middle()))
37 | deepEqual(cellid.point(cellChild.id), pCellChild.center())
38 | }
39 | }
40 | })
41 |
42 | test('entry/exit vertices', () => {
43 | for (let i = 0; i < 1000; i++) {
44 | const id = randomCellID()
45 | const unpadded = PaddedCell.fromCellID(id, 0)
46 | const padded = PaddedCell.fromCellID(id, 0.5)
47 | deepEqual(unpadded.entryVertex(), padded.entryVertex())
48 | deepEqual(unpadded.exitVertex(), padded.exitVertex())
49 | deepEqual(PaddedCell.fromCellID(cellid.nextWrap(id), 0).entryVertex(), unpadded.exitVertex())
50 | if (!cellid.isLeaf(id)) {
51 | deepEqual(PaddedCell.fromCellID(cellid.children(id)[0], 0).entryVertex(), unpadded.entryVertex())
52 | deepEqual(PaddedCell.fromCellID(cellid.children(id)[3], 0).exitVertex(), unpadded.exitVertex())
53 | }
54 | }
55 | })
56 |
57 | test('shrinkToFit', () => {
58 | for (let iter = 0; iter < 1000; iter++) {
59 | const result = randomCellID()
60 | const resultUV = cellid.boundUV(result)
61 | const sizeUV = resultUV.size()
62 | const maxPadding = 0.5 * Math.min(sizeUV.x, sizeUV.y)
63 | const padding = maxPadding * randomFloat64()
64 | const maxRect = resultUV.expandedByMargin(-padding)
65 | const a = new R2Point(
66 | randomUniformFloat64(maxRect.x.lo, maxRect.x.hi),
67 | randomUniformFloat64(maxRect.y.lo, maxRect.y.hi)
68 | )
69 | const b = new R2Point(
70 | randomUniformFloat64(maxRect.x.lo, maxRect.x.hi),
71 | randomUniformFloat64(maxRect.y.lo, maxRect.y.hi)
72 | )
73 | if (!cellid.isLeaf(result)) {
74 | const useY = oneIn(2)
75 | let center = cellid.centerUV(result).x
76 | if (useY) center = cellid.centerUV(result).y
77 | const shared = new R1Interval(center - padding, center + padding)
78 | const intersected = useY ? shared.intersection(maxRect.y) : shared.intersection(maxRect.x)
79 | const mid = randomUniformFloat64(intersected.lo, intersected.hi)
80 | if (useY) {
81 | a.y = randomUniformFloat64(maxRect.y.lo, mid)
82 | b.y = randomUniformFloat64(mid, maxRect.y.hi)
83 | } else {
84 | a.x = randomUniformFloat64(maxRect.x.lo, mid)
85 | b.x = randomUniformFloat64(mid, maxRect.x.hi)
86 | }
87 | }
88 | const rect = R2Rect.fromPoints(a, b)
89 | const initialID = cellid.parent(result, randomUniformInt(cellid.level(result) + 1))
90 | const pCell = PaddedCell.fromCellID(initialID, padding)
91 | equal(pCell.shrinkToFit(rect), result)
92 | }
93 | })
94 | })
95 |
--------------------------------------------------------------------------------
/s2/PointVector.ts:
--------------------------------------------------------------------------------
1 | import { Point } from './Point'
2 | import type { Shape, TypeTag } from './Shape'
3 | import {
4 | Chain,
5 | ChainPosition,
6 | defaultShapeIsEmpty,
7 | defaultShapeIsFull,
8 | Edge,
9 | originReferencePoint,
10 | ReferencePoint,
11 | TypeTagPointVector
12 | } from './Shape'
13 |
14 | /**
15 | * PointVector is a Shape representing a set of Points. Each point
16 | * is represented as a degenerate edge with the same starting and ending
17 | * vertices.
18 | *
19 | * This type is useful for adding a collection of points to a ShapeIndex.
20 | *
21 | * Its methods are on PointVector due to implementation details of ShapeIndex.
22 | */
23 | export class PointVector implements Shape {
24 | private points: Point[]
25 |
26 | /**
27 | * Constructs a PointVector from the given points.
28 | * @category Constructors
29 | */
30 | constructor(points: Point[]) {
31 | this.points = points.slice()
32 | }
33 |
34 | numEdges(): number {
35 | return this.points.length
36 | }
37 |
38 | edge(i: number): Edge {
39 | return new Edge(this.points[i], this.points[i])
40 | }
41 |
42 | referencePoint(): ReferencePoint {
43 | return originReferencePoint(false)
44 | }
45 |
46 | numChains(): number {
47 | return this.points.length
48 | }
49 |
50 | chain(i: number): Chain {
51 | return new Chain(i, 1)
52 | }
53 |
54 | chainEdge(i: number, j: number): Edge {
55 | return new Edge(this.points[i], this.points[j])
56 | }
57 |
58 | chainPosition(e: number): ChainPosition {
59 | return new ChainPosition(e, 0)
60 | }
61 |
62 | dimension(): number {
63 | return 0
64 | }
65 |
66 | isEmpty(): boolean {
67 | return defaultShapeIsEmpty(this)
68 | }
69 |
70 | isFull(): boolean {
71 | return defaultShapeIsFull(this)
72 | }
73 |
74 | typeTag(): TypeTag {
75 | return TypeTagPointVector
76 | }
77 |
78 | privateInterface() {}
79 | }
80 |
--------------------------------------------------------------------------------
/s2/PointVector_test.ts:
--------------------------------------------------------------------------------
1 | import { test, describe } from 'node:test'
2 | import { equal, ok } from 'node:assert/strict'
3 | import { PointVector } from './PointVector'
4 | import { PseudoRandom, randomPointSeed } from './testing_pseudo'
5 |
6 | describe('s2.PointVector', () => {
7 | test('empty', () => {
8 | const shape = new PointVector([])
9 |
10 | equal(shape.numEdges(), 0)
11 | equal(shape.numChains(), 0)
12 | equal(shape.dimension(), 0)
13 | ok(shape.isEmpty())
14 | ok(!shape.isFull())
15 | ok(!shape.referencePoint().contained)
16 | })
17 |
18 | test('basics', () => {
19 | let sr = new PseudoRandom(8675309)
20 |
21 | const NUM_POINTS = 100
22 | const points = Array.from({ length: NUM_POINTS }, () => randomPointSeed(sr))
23 | const shape = new PointVector(points)
24 |
25 | equal(shape.numEdges(), NUM_POINTS)
26 | equal(shape.numChains(), NUM_POINTS)
27 | equal(shape.dimension(), 0)
28 | ok(!shape.isEmpty())
29 | ok(!shape.isFull())
30 |
31 | sr.seed(8675309)
32 | for (let i = 0; i < NUM_POINTS; i++) {
33 | equal(shape.chain(i).start, i)
34 | equal(shape.chain(i).length, 1)
35 | const edge = shape.edge(i)
36 | const pt = randomPointSeed(sr)
37 |
38 | ok(pt.approxEqual(edge.v0))
39 | ok(pt.approxEqual(edge.v1))
40 | }
41 | })
42 | })
43 |
--------------------------------------------------------------------------------
/s2/RectBounder.ts:
--------------------------------------------------------------------------------
1 | import { LatLng } from './LatLng'
2 | import { Point } from './Point'
3 | import { DBL_EPSILON } from './predicates'
4 | import { Rect } from './Rect'
5 | import { Interval as R1Interval } from '../r1/Interval'
6 | import { Interval as S1Interval } from '../s1/Interval'
7 | import { Vector } from '../r3/Vector'
8 |
9 | /**
10 | * Used to compute a bounding rectangle that contains all edges
11 | * defined by a vertex chain (v0, v1, v2, ...). All vertices must be unit length.
12 | * The bounding rectangle of an edge can be larger than the bounding
13 | * rectangle of its endpoints, e.g., consider an edge that passes through the North Pole.
14 | *
15 | * The bounds are calculated conservatively to account for numerical errors
16 | * when points are converted to LatLngs. This guarantees that if a point P is contained by the loop,
17 | * then `RectBound(L).ContainsPoint(LatLngFromPoint(P))` will be true.
18 | */
19 | export class RectBounder {
20 | private a: Point // The previous vertex in the chain.
21 | private aLL: LatLng // The previous vertex latitude longitude.
22 | private bound: Rect
23 |
24 | /**
25 | * Returns a new instance of a RectBounder.
26 | * @category Constructors
27 | */
28 | constructor() {
29 | this.a = new Point(0, 0, 0)
30 | this.aLL = new LatLng(0, 0)
31 | this.bound = Rect.emptyRect()
32 | }
33 |
34 | /**
35 | * Returns the maximum error in RectBound provided that the result does not include either pole.
36 | * It is only used for testing purposes.
37 | */
38 | static maxErrorForTests(): LatLng {
39 | // The maximum error in the latitude calculation is
40 | // 3.84 * DBL_EPSILON for the PointCross calculation
41 | // 0.96 * DBL_EPSILON for the Latitude calculation
42 | // 5 * DBL_EPSILON added by AddPoint/RectBound to compensate for error
43 | // -----------------
44 | // 9.80 * DBL_EPSILON maximum error in result
45 | //
46 | // The maximum error in the longitude calculation is DBL_EPSILON. RectBound
47 | // does not do any expansion because this isn't necessary in order to
48 | // bound the *rounded* longitudes of contained points.
49 | return new LatLng(10 * DBL_EPSILON, 1 * DBL_EPSILON)
50 | }
51 |
52 | /**
53 | * Adds the given point to the chain. The Point must be unit length.
54 | */
55 | addPoint(b: Point): void {
56 | const bLL = LatLng.fromPoint(b)
57 |
58 | if (this.bound.isEmpty()) {
59 | this.a = b
60 | this.aLL = bLL
61 | this.bound = this.bound.addPoint(bLL)
62 | return
63 | }
64 |
65 | // Compute the cross product N = A x B robustly. This is the normal
66 | // to the great circle through A and B. We don't use RobustSign
67 | // since that method returns an arbitrary vector orthogonal to A if the two
68 | // vectors are proportional, and we want the zero vector in that case.
69 | const n = this.a.vector.sub(b.vector).cross(this.a.vector.add(b.vector)) // N = 2 * (A x B)
70 |
71 | // Handle cases where the relative error in N gets large.
72 | const nNorm = n.norm()
73 | if (nNorm < 1.91346e-15) {
74 | if (this.a.vector.dot(b.vector) < 0) {
75 | this.bound = Rect.fullRect()
76 | } else {
77 | this.bound = this.bound.union(Rect.fromLatLng(this.aLL).addPoint(bLL))
78 | }
79 | this.a = b
80 | this.aLL = bLL
81 | return
82 | }
83 |
84 | // Compute the longitude range spanned by AB.
85 | let lngAB = S1Interval.emptyInterval().addPoint(this.aLL.lng).addPoint(bLL.lng)
86 | if (lngAB.length() >= Math.PI - 2 * DBL_EPSILON) {
87 | lngAB = S1Interval.fullInterval()
88 | }
89 |
90 | /**
91 | * Next we compute the latitude range spanned by the edge AB. We start
92 | * with the range spanning the two endpoints of the edge:
93 | */
94 | let latAB = R1Interval.fromPoint(this.aLL.lat).addPoint(bLL.lat)
95 |
96 | // Check if AB crosses the plane through N and the Z-axis.
97 | const m = n.cross(new Vector(0, 0, 1))
98 | const mA = m.dot(this.a.vector)
99 | const mB = m.dot(b.vector)
100 |
101 | const mError = 6.06638e-16 * nNorm + 6.83174e-31
102 | if (mA * mB < 0 || Math.abs(mA) <= mError || Math.abs(mB) <= mError) {
103 | const maxLat = Math.min(
104 | Math.atan2(Math.sqrt(n.x * n.x + n.y * n.y), Math.abs(n.z)) + 3 * DBL_EPSILON,
105 | Math.PI / 2
106 | )
107 |
108 | const latBudget = 2 * Math.asin(0.5 * this.a.vector.sub(b.vector).norm() * Math.sin(maxLat))
109 | const maxDelta = 0.5 * (latBudget - latAB.length()) + DBL_EPSILON
110 |
111 | if (mA <= mError && mB >= -mError) latAB.hi = Math.min(maxLat, latAB.hi + maxDelta)
112 | if (mB <= mError && mA >= -mError) latAB.lo = Math.max(-maxLat, latAB.lo - maxDelta)
113 | }
114 |
115 | this.a = b
116 | this.aLL = bLL
117 | this.bound = this.bound.union(new Rect(latAB, lngAB))
118 | }
119 |
120 | /**
121 | * Returns the bounding rectangle of the edge chain that connects the vertices defined so far.
122 | */
123 | rectBound(): Rect {
124 | return this.bound.expanded(new LatLng(2 * DBL_EPSILON, 0)).polarClosure()
125 | }
126 |
127 | /**
128 | * Expands a bounding Rect so that it is guaranteed to contain the bounds of any subregion
129 | * whose bounds are computed using `ComputeRectBound`.
130 | *
131 | * For example, consider a loop L that defines a square.
132 | * This method ensures that if a point P is contained by this square, then LatLngFromPoint(P) is contained by the bound.
133 | * But now consider a diamond-shaped loop S contained by L.
134 | * It is possible that `GetBound` returns a *larger* bound for S than it does for L, due to rounding errors.
135 | * This method expands the bound for L so that it is guaranteed to contain the bounds of any subregion S.
136 | */
137 | static expandForSubregions = (bound: Rect): Rect => {
138 | if (bound.isEmpty()) return bound
139 |
140 | const lngGap = Math.max(0, Math.PI - bound.lng.length() - 2.5 * DBL_EPSILON)
141 | const minAbsLat = Math.max(bound.lat.lo, -bound.lat.hi)
142 |
143 | const latGapSouth = Math.PI / 2 + bound.lat.lo
144 | const latGapNorth = Math.PI / 2 - bound.lat.hi
145 |
146 | if (minAbsLat >= 0) {
147 | if (2 * minAbsLat + lngGap < 1.354e-15) return Rect.fullRect()
148 | } else if (lngGap >= Math.PI / 2) {
149 | if (latGapSouth + latGapNorth < 1.687e-15) return Rect.fullRect()
150 | } else {
151 | if (Math.max(latGapSouth, latGapNorth) * lngGap < 1.765e-15) return Rect.fullRect()
152 | }
153 |
154 | const latExpansion = 9 * DBL_EPSILON
155 | let lngExpansion = 0.0
156 | if (lngGap <= 0) lngExpansion = Math.PI
157 |
158 | return bound.expanded(new LatLng(latExpansion, lngExpansion)).polarClosure()
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/s2/Region.ts:
--------------------------------------------------------------------------------
1 | import { Cap } from './Cap'
2 | import { Rect } from './Rect'
3 | import { Cell } from './Cell'
4 | import { Point } from './Point'
5 | import type { CellID } from './cellid'
6 |
7 | export interface Region {
8 | // // Returns a bounding spherical cap. This is not guaranteed to be exact.
9 | // capBound(): Cap
10 |
11 | // // Returns a bounding latitude-longitude rectangle that contains
12 | // // the region. The bounds are not guaranteed to be tight.
13 | // rectBound(): Rect
14 |
15 | // Reports whether the region completely contains the given region.
16 | // It returns false if containment could not be determined.
17 | containsCell(c: Cell): boolean
18 |
19 | // Reports whether the region intersects the given cell or
20 | // if intersection could not be determined. It returns false if the region
21 | // does not intersect.
22 | intersectsCell(c: Cell): boolean
23 |
24 | // Reports whether the region contains the given point or not.
25 | // The point should be unit length, although some implementations may relax
26 | // this restriction.
27 | containsPoint(p: Point): boolean
28 |
29 | // Returns a small collection of CellIDs whose union covers
30 | // the region. The cells are not sorted, may have redundancies (such as cells
31 | // that contain other cells), and may cover much more area than necessary.
32 | //
33 | // This method is not intended for direct use by client code. Clients
34 | // should typically use Covering, which has options to control the size and
35 | // accuracy of the covering. Alternatively, if you want a fast covering and
36 | // don't care about accuracy, consider calling FastCovering (which returns a
37 | // cleaned-up version of the covering computed by this method).
38 | //
39 | // CellUnionBound implementations should attempt to return a small
40 | // covering (ideally 4 cells or fewer) that covers the region and can be
41 | // computed quickly. The result is used by RegionCoverer as a starting
42 | // point for further refinement.
43 | cellUnionBound(): CellID[]
44 | }
45 |
46 | // NilRegion represents a Nil value
47 | export class NilRegion implements Region {
48 | capBound(): Cap {
49 | return Cap.emptyCap()
50 | }
51 | rectBound(): Rect {
52 | return Rect.emptyRect()
53 | }
54 | containsCell(_c: Cell): boolean {
55 | return false
56 | }
57 | intersectsCell(_c: Cell): boolean {
58 | return false
59 | }
60 | containsPoint(_p: Point): boolean {
61 | return false
62 | }
63 | cellUnionBound(): CellID[] {
64 | return []
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/s2/ShapeIndexCell.ts:
--------------------------------------------------------------------------------
1 | import { NilShapeIndexClippedShape, ShapeIndexClippedShape } from './ShapeIndexClippedShape'
2 |
3 | export class ShapeIndexCell {
4 | shapes: ShapeIndexClippedShape[]
5 |
6 | /**
7 | * Creates a new cell that is sized to hold the given number of shapes.
8 | *
9 | * @category Constructors
10 | */
11 | constructor(numShapes: number) {
12 | this.shapes = new Array(numShapes)
13 | }
14 |
15 | /**
16 | * Reports the total number of edges in all clipped shapes in this cell.
17 | */
18 | numEdges(): number {
19 | let e = 0
20 | for (const cs of this.shapes) {
21 | e += cs.numEdges()
22 | }
23 | return e
24 | }
25 |
26 | /**
27 | * Adds the given clipped shape to this index cell.
28 | */
29 | add(c: ShapeIndexClippedShape): void {
30 | // Note: Unlike the original C++ code, this does not check for duplicates.
31 | this.shapes.push(c)
32 | }
33 |
34 | /**
35 | * Returns the clipped shape that contains the given shapeID,
36 | * or null if none of the clipped shapes contain it.
37 | */
38 | findByShapeID(shapeID: number): ShapeIndexClippedShape | NilShapeIndexClippedShape {
39 | // Linear search is used because the number of shapes per cell is typically
40 | // very small (most often 1), and is large only for pathological inputs
41 | // (e.g., very deeply nested loops).
42 | for (const clipped of this.shapes) {
43 | if (clipped.shapeID === shapeID) {
44 | return clipped
45 | }
46 | }
47 |
48 | return new NilShapeIndexClippedShape()
49 | }
50 | }
51 |
52 | // NilShapeIndexCell represents a Nil value
53 | export class NilShapeIndexCell {
54 | shapes: ShapeIndexClippedShape[] = []
55 |
56 | numEdges(): number {
57 | return 0
58 | }
59 |
60 | add(_c: ShapeIndexClippedShape): void {}
61 |
62 | findByShapeID(_shapeID: number): NilShapeIndexClippedShape {
63 | return new NilShapeIndexClippedShape()
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/s2/ShapeIndexClippedShape.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Represents the part of a shape that intersects a Cell.
3 | * It consists of the set of edge IDs that intersect that cell and a boolean
4 | * indicating whether the center of the cell is inside the shape (for shapes
5 | * that have an interior).
6 | *
7 | * Note that the edges themselves are not clipped; we always use the original
8 | * edges for intersection tests so that the results will be the same as the
9 | * original shape.
10 | */
11 | export class ShapeIndexClippedShape {
12 | // the index of the shape this clipped shape is a part of.
13 | shapeID: number
14 |
15 | // indicates if the center of the CellID this shape has been
16 | // clipped to falls inside this shape. This is false for shapes that do not
17 | // have an interior.
18 | containsCenter: boolean = false
19 |
20 | // is the ordered set of ShapeIndex original edge IDs. Edges
21 | // are stored in increasing order of edge ID.
22 | edges: number[]
23 |
24 | /**
25 | * Constructs a new clipped shape for the given shapeID and number of expected edges.
26 | *
27 | * @category Constructors
28 | */
29 | constructor(id: number, numEdges: number) {
30 | this.shapeID = id
31 | this.containsCenter = false // Default to false, can be set later
32 | this.edges = new Array(numEdges)
33 | }
34 |
35 | /**
36 | * Returns the number of edges that intersect the CellID of the Cell this was clipped to.
37 | */
38 | numEdges(): number {
39 | return this.edges.length
40 | }
41 |
42 | /**
43 | * Reports if this clipped shape contains the given edge ID.
44 | */
45 | containsEdge(id: number): boolean {
46 | // Linear search is fast because the number of edges per shape is typically
47 | // very small (less than 10).
48 | for (const e of this.edges) {
49 | if (e === id) return true
50 | }
51 | return false
52 | }
53 | }
54 |
55 | // NilShapeIndexClippedShape represents a Nil value
56 | export class NilShapeIndexClippedShape {
57 | shapeID: number = 0
58 | containsCenter: boolean = false
59 | edges: number[] = []
60 |
61 | numEdges(): number {
62 | return 0
63 | }
64 |
65 | containsEdge(_id: number): boolean {
66 | return false
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/s2/ShapeIndexRegion.ts:
--------------------------------------------------------------------------------
1 | import { Cap } from './Cap'
2 | import type { CellID } from './cellid'
3 | import { CellUnion } from './CellUnion'
4 | import { ContainsPointQuery, VERTEX_MODEL_SEMI_OPEN } from './ContainsPointQuery'
5 | import { Rect } from './Rect'
6 | import { ShapeIndex } from './ShapeIndex'
7 | import { ShapeIndexIterator } from './ShapeIndexIterator'
8 | import * as cellid from './cellid'
9 |
10 | /**
11 | * ShapeIndexRegion wraps a ShapeIndex and implements the Region interface.
12 | * This allows RegionCoverer to work with ShapeIndexes as well as being
13 | * able to be used by some of the Query types.
14 | */
15 | export class ShapeIndexRegion {
16 | index: ShapeIndex
17 | containsQuery: ContainsPointQuery
18 | iter: ShapeIndexIterator
19 |
20 | constructor(index: ShapeIndex, query = new ContainsPointQuery(index, VERTEX_MODEL_SEMI_OPEN)) {
21 | this.index = index
22 | this.containsQuery = query
23 | this.iter = index.iterator()
24 | }
25 |
26 | /**
27 | * CapBound returns a bounding spherical cap for this collection of geometry.
28 | * This is not guaranteed to be exact.
29 | */
30 | capBound(): Cap {
31 | const cu = new CellUnion(...this.cellUnionBound())
32 | return cu.capBound()
33 | }
34 |
35 | /**
36 | * RectBound returns a bounding rectangle for this collection of geometry.
37 | * The bounds are not guaranteed to be tight.
38 | */
39 | rectBound(): Rect {
40 | const cu = new CellUnion(...this.cellUnionBound())
41 | return cu.rectBound()
42 | }
43 |
44 | /**
45 | * CellUnionBound returns the bounding CellUnion for this collection of geometry.
46 | * This method currently returns at most 4 cells, unless the index spans
47 | * multiple faces in which case it may return up to 6 cells.
48 | */
49 | cellUnionBound(): CellID[] {
50 | const cellIDs: CellID[] = []
51 |
52 | // Find the last CellID in the index.
53 | this.iter.end()
54 | if (!this.iter.prev()) return cellIDs // Empty index.
55 |
56 | const lastIndexID = this.iter.cellID()
57 | this.iter.begin()
58 | if (this.iter.cellID() !== lastIndexID) {
59 | // The index has at least two cells. Choose a CellID level such that
60 | // the entire index can be spanned with at most 6 cells (if the index
61 | // spans multiple faces) or 4 cells (if the index spans a single face).
62 | let [level] = cellid.commonAncestorLevel(this.iter.cellID(), lastIndexID)
63 | if (level === undefined) level = -1
64 | level++
65 |
66 | // For each cell C at the chosen level, compute the smallest Cell
67 | // that covers the ShapeIndex cells within C.
68 | const lastID = cellid.parent(lastIndexID, level)
69 | for (let id = cellid.parent(this.iter.cellID(), level); id != lastID; id = cellid.next(id)) {
70 | // If the cell C does not contain any index cells, then skip it.
71 | if (cellid.rangeMax(id) < this.iter.cellID()) continue
72 |
73 | // Find the range of index cells contained by C and then shrink C so
74 | // that it just covers those cells.
75 | const first = this.iter.cellID()
76 | this.iter.seek(cellid.next(cellid.rangeMax(id)))
77 | this.iter.prev()
78 | cellIDs.push(...this.coverRange(first, this.iter.cellID(), []))
79 | this.iter.next()
80 | }
81 | }
82 |
83 | return this.coverRange(this.iter.cellID(), lastIndexID, cellIDs)
84 | }
85 |
86 | /**
87 | * coverRange computes the smallest CellID that covers the Cell range (first, last)
88 | * and returns the updated slice.
89 | *
90 | * This requires first and last have a common ancestor.
91 | */
92 | coverRange(first: CellID, last: CellID, cellIDs: CellID[]): CellID[] {
93 | if (first == last) return cellIDs.concat([first])
94 |
95 | const [level, ok] = cellid.commonAncestorLevel(first, last)
96 | if (!ok) cellIDs.concat([0n])
97 |
98 | return cellIDs.concat([cellid.parent(first, level)])
99 | }
100 | }
101 |
102 | // TODO: remaining methods
103 | /*
104 | ContainsCell(target Cell): boolean {
105 | IntersectsCell(target Cell): boolean {
106 | ContainsPoint(p Point): boolean {
107 | contains(id CellID, clipped clippedShape, p Point): boolean {
108 | anyEdgeIntersects(clipped clippedShape, target Cell): boolean {
109 | */
110 |
--------------------------------------------------------------------------------
/s2/ShapeIndexRegion_test.ts:
--------------------------------------------------------------------------------
1 | import { test, describe } from 'node:test'
2 | import { equal, ok, deepEqual, fail } from 'node:assert/strict'
3 | import type { CellID } from './cellid'
4 | import * as cellid from './cellid'
5 | import { Point } from './Point'
6 | import { faceUVToXYZ } from './stuv'
7 | import { FACE_CLIP_ERROR_UV_COORD, INTERSECTS_RECT_ERROR_UV_DIST } from './edge_clipping'
8 | import { LaxLoop } from './LaxLoop'
9 | import { ShapeIndex } from './ShapeIndex'
10 | import { Cell } from './Cell'
11 | import { CellUnion } from './CellUnion'
12 |
13 | const SHAPE_INDEX_CELL_PADDING = 2 * (FACE_CLIP_ERROR_UV_COORD + INTERSECTS_RECT_ERROR_UV_DIST)
14 |
15 | export const padCell = (id: CellID, paddingUV: number) => {
16 | const { f, i, j } = cellid.faceIJOrientation(id)
17 | const uv = cellid.ijLevelToBoundUV(i, j, cellid.level(id)).expandedByMargin(paddingUV)
18 | const vertices: Point[] = uv.vertices().map((v) => Point.fromVector(faceUVToXYZ(f, v.x, v.y).normalize()))
19 | return LaxLoop.fromPoints(vertices)
20 | }
21 |
22 | describe('s2.ShapeIndexRegion', () => {
23 | test('capBound', () => {
24 | const id = cellid.fromString('3/0123012301230123012301230123')
25 |
26 | const index = new ShapeIndex()
27 | index.add(padCell(id, -SHAPE_INDEX_CELL_PADDING))
28 |
29 | const cellBound = Cell.fromCellID(id).capBound()
30 | const indexBound = index.region().capBound()
31 | ok(indexBound.contains(cellBound), `${indexBound}.contains(${cellBound}) = false, want true`)
32 |
33 | const radiusRatio = 1.00001 * cellBound.radius()
34 | ok(indexBound.radius() <= radiusRatio, `${index}.capBound.Radius() = ${indexBound.radius()}, want ${radiusRatio}`)
35 | })
36 |
37 | test('rectBound', () => {
38 | const id = cellid.fromString('3/0123012301230123012301230123')
39 |
40 | const index = new ShapeIndex()
41 | index.add(padCell(id, -SHAPE_INDEX_CELL_PADDING))
42 | const cellBound = Cell.fromCellID(id).rectBound()
43 | const indexBound = index.region().rectBound()
44 |
45 | // @todo missinglink should be exact equal
46 | ok(indexBound.approxEqual(cellBound), `${index}.rectBound() = ${indexBound}, want ${cellBound}`)
47 | })
48 |
49 | test('cellUnionBound multiple faces', () => {
50 | const have = new CellUnion(cellid.fromString('3/00123'), cellid.fromString('2/11200013'))
51 |
52 | const index = new ShapeIndex()
53 | for (const id of have) {
54 | index.add(padCell(id, -SHAPE_INDEX_CELL_PADDING))
55 | }
56 |
57 | const got = new CellUnion(...index.region().cellUnionBound())
58 |
59 | have.sort()
60 |
61 | ok(have.equals(got), `${index}.cellUnionBound() = ${got}, want ${have}`)
62 | })
63 |
64 | test('cellUnionBound one face', () => {
65 | const have = [
66 | cellid.fromString('5/010'),
67 | cellid.fromString('5/0211030'),
68 | cellid.fromString('5/110230123'),
69 | cellid.fromString('5/11023021133'),
70 | cellid.fromString('5/311020003003030303'),
71 | cellid.fromString('5/311020023')
72 | ]
73 |
74 | const want = new CellUnion(cellid.fromString('5/0'), cellid.fromString('5/110230'), cellid.fromString('5/3110200'))
75 |
76 | const index = new ShapeIndex()
77 | for (const id of have) {
78 | index.add(padCell(id, -SHAPE_INDEX_CELL_PADDING))
79 | index.add(padCell(id, -SHAPE_INDEX_CELL_PADDING))
80 | index.add(padCell(id, -SHAPE_INDEX_CELL_PADDING))
81 | }
82 |
83 | have.sort()
84 |
85 | const got = new CellUnion(...index.region().cellUnionBound())
86 | ok(want.equals(got), `${index}.cellUnionBound() = ${got}, want ${want}`)
87 | })
88 | })
89 |
--------------------------------------------------------------------------------
/s2/ShapeIndexTracker.ts:
--------------------------------------------------------------------------------
1 | import type { CellID } from './cellid'
2 | import { EdgeCrosser } from './EdgeCrosser'
3 | import { Point } from './Point'
4 | import * as cellid from './cellid'
5 | import { MAX_LEVEL } from './cellid_constants'
6 | import { faceUVToXYZ } from './stuv'
7 | import { Edge } from './Shape'
8 |
9 | export class ShapeIndexTracker {
10 | isActive: boolean
11 | a: Point
12 | b: Point
13 | nextCellID: CellID
14 | crosser: EdgeCrosser | null
15 | shapeIDs: number[]
16 | savedIDs: number[]
17 |
18 | /**
19 | * Returns a new ShapeIndexTracker instance with the appropriate defaults.
20 | * @category Constructors
21 | */
22 | constructor() {
23 | this.isActive = false
24 | this.a = this.b = ShapeIndexTracker.trackerOrigin()
25 | this.nextCellID = cellid.childBeginAtLevel(cellid.fromFace(0), MAX_LEVEL)
26 | this.crosser = null
27 | this.shapeIDs = []
28 | this.savedIDs = []
29 |
30 | this.drawTo(Point.fromVector(faceUVToXYZ(0, -1, -1).normalize()))
31 | }
32 |
33 | /**
34 | * Returns the initial focus point when the tracker is created (corresponding to the start of the CellID space-filling curve).
35 | */
36 | static trackerOrigin(): Point {
37 | return Point.fromVector(faceUVToXYZ(0, -1, -1).normalize())
38 | }
39 |
40 | /**
41 | * Returns the current focus point of the tracker.
42 | */
43 | focus(): Point {
44 | return this.b
45 | }
46 |
47 | /**
48 | * Adds a shape whose interior should be tracked.
49 | * If the focus point is inside the shape, it toggles the shape's state.
50 | */
51 | addShape(shapeID: number, containsFocus: boolean): void {
52 | this.isActive = true
53 | if (containsFocus) this.toggleShape(shapeID)
54 | }
55 |
56 | /**
57 | * Moves the focus of the tracker to the given point.
58 | */
59 | moveTo(b: Point): void {
60 | this.b = b
61 | }
62 |
63 | /**
64 | * Moves the focus of the tracker to the given point and updates the edge crosser.
65 | */
66 | drawTo(b: Point): void {
67 | this.a = this.b
68 | this.b = b
69 | this.crosser = new EdgeCrosser(this.a, this.b)
70 | }
71 |
72 | /**
73 | * Checks if the given edge crosses the current edge, and if so, toggles the state of the given shapeID.
74 | */
75 | testEdge(shapeID: number, edge: Edge): void {
76 | if (this.crosser?.edgeOrVertexCrossing(edge.v0, edge.v1)) this.toggleShape(shapeID)
77 | }
78 |
79 | /**
80 | * Indicates that the last argument to moveTo or drawTo was the entry vertex of the given CellID.
81 | */
82 | setNextCellID(ci: CellID): void {
83 | this.nextCellID = cellid.rangeMin(ci)
84 | }
85 |
86 | /**
87 | * Reports if the focus is already at the entry vertex of the given CellID.
88 | */
89 | atCellID(ci: CellID): boolean {
90 | return cellid.rangeMin(ci) === this.nextCellID
91 | }
92 |
93 | /**
94 | * Adds or removes the given shapeID from the set of IDs it is tracking.
95 | */
96 | toggleShape(shapeID: number): void {
97 | if (this.shapeIDs.length === 0) {
98 | this.shapeIDs.push(shapeID)
99 | return
100 | }
101 |
102 | if (this.shapeIDs[0] === shapeID) {
103 | this.shapeIDs.shift()
104 | return
105 | }
106 |
107 | for (let i = 0; i < this.shapeIDs.length; i++) {
108 | const s = this.shapeIDs[i]
109 | if (s < shapeID) continue
110 |
111 | if (s === shapeID) {
112 | this.shapeIDs.splice(i, 1)
113 | return
114 | }
115 |
116 | this.shapeIDs.splice(i, 0, shapeID)
117 | return
118 | }
119 |
120 | this.shapeIDs.push(shapeID)
121 | }
122 |
123 | /**
124 | * Makes an internal copy of the state for shape IDs below the given limit, and then clears the state for those shapes.
125 | */
126 | saveAndClearStateBefore(limitShapeID: number): void {
127 | const limit = this.lowerBound(limitShapeID)
128 | this.savedIDs = this.shapeIDs.slice(0, limit)
129 | this.shapeIDs = this.shapeIDs.slice(limit)
130 | }
131 |
132 | /**
133 | * Restores the state previously saved by saveAndClearStateBefore.
134 | */
135 | restoreStateBefore(limitShapeID: number): void {
136 | const limit = this.lowerBound(limitShapeID)
137 | this.shapeIDs = this.savedIDs.concat(this.shapeIDs.slice(limit))
138 | this.savedIDs = []
139 | }
140 |
141 | /**
142 | * Returns the shapeID of the first entry where the value is greater than or equal to shapeID.
143 | */
144 | lowerBound(shapeID: number): number {
145 | for (let i = 0; i < this.shapeIDs.length; i++) {
146 | if (this.shapeIDs[i] >= shapeID) return i
147 | }
148 | return this.shapeIDs.length
149 | }
150 | }
151 |
--------------------------------------------------------------------------------
/s2/_index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Module s2 is a library for working with geometry in S² (spherical geometry).
3 | *
4 | * Its related modules, parallel to this one, are s1 (operates on S¹), r1 (operates on ℝ¹),
5 | * r2 (operates on ℝ²) and r3 (operates on ℝ³).
6 |
7 | * This package provides types and functions for the S2 cell hierarchy and coordinate systems.
8 | * The S2 cell hierarchy is a hierarchical decomposition of the surface of a unit sphere (S²) into “cells”; it is highly efficient, scales from continental size to under 1 cm² and preserves spatial locality (nearby cells have close IDs).
9 | *
10 | * More information including an in-depth introduction to S2 can be found on the S2 website https://s2geometry.io/
11 | * @module s2
12 | */
13 | export * as cellid from './cellid'
14 | export { Cap } from './Cap'
15 | export { Cell } from './Cell'
16 | export { CellUnion } from './CellUnion'
17 | // export { ContainsPointQuery } from './ContainsPointQuery'
18 | // export { ContainsVertexQuery } from './ContainsVertexQuery'
19 | // export { CrossingEdgeQuery } from './CrossingEdgeQuery'
20 | // export { EdgeCrosser } from './EdgeCrosser'
21 | // export { EdgeVectorShape } from './EdgeVectorShape'
22 | export { LatLng } from './LatLng'
23 | // export { LaxLoop } from './LaxLoop'
24 | // export { LaxPolygon } from './LaxPolygon'
25 | // export { LaxPolyline } from './LaxPolyline'
26 | export { Loop } from './Loop'
27 | // export { Matrix3x3 } from './matrix3x3'
28 | // export { Metric } from './Metric'
29 | // export { PaddedCell } from './PaddedCell'
30 | export { Point } from './Point'
31 | // export { PointVector } from './PointVector'
32 | export { Polygon } from './Polygon'
33 | export { Polyline } from './Polyline'
34 | export { Rect } from './Rect'
35 | // export { RectBounder } from './RectBounder'
36 | export { Region } from './Region'
37 | export { RegionCoverer, RegionCovererOptions, Coverer } from './RegionCoverer'
38 | // export { Shape, Edge } from './Shape'
39 | export { ShapeIndex } from './ShapeIndex'
40 | // export { ShapeIndexCell } from './ShapeIndexCell'
41 | // export { ShapeIndexClippedShape } from './ShapeIndexClippedShape'
42 | // export { ShapeIndexIterator } from './ShapeIndexIterator'
43 | // export { ShapeIndexRegion } from './ShapeIndexRegion'
44 | // export { ShapeIndexTracker } from './ShapeIndexTracker'
45 |
--------------------------------------------------------------------------------
/s2/cellid_constants.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Number of bits used to encode the face number
3 | **/
4 | export const FACE_BITS = 3
5 |
6 | /**
7 | * Number of faces
8 | */
9 | export const NUM_FACES = 6
10 |
11 | /**
12 | * Number of levels needed to specify a leaf cell
13 | */
14 | export const MAX_LEVEL = 30
15 |
16 | /**
17 | * Total number of position bits.
18 | * The extra bit (61 rather than 60) lets us encode each cell as its Hilbert curve position at the cell center (which is halfway along the portion of the Hilbert curve that fills that cell).
19 | */
20 | export const POS_BITS = 2 * MAX_LEVEL + 1
21 |
22 | /**
23 | * MaxSize is the maximum index of a valid leaf cell plus one. The range of
24 | * valid leaf cell indices is [0..MaxSize-1].
25 | */
26 | export const MAX_SIZE = Number(1n << BigInt(MAX_LEVEL))
27 |
28 | //
29 | export const WRAP_OFFSET = BigInt(NUM_FACES) << BigInt(POS_BITS)
30 |
--------------------------------------------------------------------------------
/s2/centroids.ts:
--------------------------------------------------------------------------------
1 | import { Vector } from '../r3/Vector'
2 | import { Point } from './Point'
3 |
4 | /**
5 | * There are several notions of the "centroid" of a triangle. First, there
6 | * is the planar centroid, which is simply the centroid of the ordinary
7 | * (non-spherical) triangle defined by the three vertices. Second, there is
8 | * the surface centroid, which is defined as the intersection of the three
9 | * medians of the spherical triangle. It is possible to show that this
10 | * point is simply the planar centroid projected to the surface of the
11 | * sphere. Finally, there is the true centroid (mass centroid), which is
12 | * defined as the surface integral over the spherical triangle of (x,y,z)
13 | * divided by the triangle area. This is the point that the triangle would
14 | * rotate around if it was spinning in empty space.
15 | *
16 | * The best centroid for most purposes is the true centroid. Unlike the
17 | * planar and surface centroids, the true centroid behaves linearly as
18 | * regions are added or subtracted. That is, if you split a triangle into
19 | * pieces and compute the average of their centroids (weighted by triangle
20 | * area), the result equals the centroid of the original triangle. This is
21 | * not true of the other centroids.
22 | *
23 | * Also note that the surface centroid may be nowhere near the intuitive
24 | * "center" of a spherical triangle. For example, consider the triangle
25 | * with vertices A=(1,eps,0), B=(0,0,1), C=(-1,eps,0) (a quarter-sphere).
26 | * The surface centroid of this triangle is at S=(0, 2*eps, 1), which is
27 | * within a distance of 2*eps of the vertex B. Note that the median from A
28 | * (the segment connecting A to the midpoint of BC) passes through S, since
29 | * this is the shortest path connecting the two endpoints. On the other
30 | * hand, the true centroid is at M=(0, 0.5, 0.5), which when projected onto
31 | * the surface is a much more reasonable interpretation of the "center" of
32 | * this triangle.
33 | */
34 |
35 | /**
36 | * Returns the true centroid of the spherical triangle ABC
37 | * multiplied by the signed area of spherical triangle ABC. The reasons for
38 | * multiplying by the signed area are (1) this is the quantity that needs to be
39 | * summed to compute the centroid of a union or difference of triangles, and
40 | * (2) it's actually easier to calculate this way. All points must have unit length.
41 | *
42 | * Note that the result of this function is defined to be Point(0, 0, 0) if
43 | * the triangle is degenerate.
44 | */
45 | export const trueCentroid = (a: Point, b: Point, c: Point): Point => {
46 | // Use Distance to get accurate results for small triangles.
47 | let ra = 1
48 | const sa = b.distance(c)
49 | if (sa !== 0) ra = sa / Math.sin(sa)
50 |
51 | let rb = 1
52 | const sb = c.distance(a)
53 | if (sb !== 0) rb = sb / Math.sin(sb)
54 |
55 | let rc = 1
56 | const sc = a.distance(b)
57 | if (sc !== 0) rc = sc / Math.sin(sc)
58 |
59 | // Now compute a point M such that:
60 | //
61 | // [Ax Ay Az] [Mx] [ra]
62 | // [Bx By Bz] [My] = 0.5 * det(A,B,C) * [rb]
63 | // [Cx Cy Cz] [Mz] [rc]
64 | //
65 | // To improve the numerical stability we subtract the first row (A) from the
66 | // other two rows; this reduces the cancellation error when A, B, and C are
67 | // very close together. Then we solve it using Cramer's rule.
68 | //
69 | // The result is the true centroid of the triangle multiplied by the
70 | // triangle's area.
71 | //
72 | // This code still isn't as numerically stable as it could be.
73 | // The biggest potential improvement is to compute B-A and C-A more
74 | // accurately so that (B-A)x(C-A) is always inside triangle ABC.
75 | const x = new Vector(a.x, b.x - a.x, c.x - a.x)
76 | const y = new Vector(a.y, b.y - a.y, c.y - a.y)
77 | const z = new Vector(a.z, b.z - a.z, c.z - a.z)
78 | const r = new Vector(ra, rb - ra, rc - ra)
79 |
80 | return Point.fromVector(new Vector(y.cross(z).dot(r), z.cross(x).dot(r), x.cross(y).dot(r)).mul(0.5))
81 | }
82 |
83 | /**
84 | * Returns the true centroid of the spherical geodesic edge AB
85 | * multiplied by the length of the edge AB. As with triangles, the true centroid
86 | * of a collection of line segments may be computed simply by summing the result
87 | * of this method for each segment.
88 | *
89 | * Note that the planar centroid of a line segment is simply 0.5 * (a + b),
90 | * while the surface centroid is (a + b).Normalize(). However neither of
91 | * these values is appropriate for computing the centroid of a collection of
92 | * edges (such as a polyline).
93 | *
94 | * Also note that the result of this function is defined to be Point(0, 0, 0)
95 | * if the edge is degenerate.
96 | */
97 | export const edgeTrueCentroid = (a: Point, b: Point): Point => {
98 | // The centroid (multiplied by length) is a vector toward the midpoint
99 | // of the edge, whose length is twice the sine of half the angle between
100 | // the two vertices. Defining theta to be this angle, we have:
101 | const vDiff = a.vector.sub(b.vector) // Length == 2*sin(theta)
102 | const vSum = a.vector.add(b.vector) // Length == 2*cos(theta)
103 | const sin2 = vDiff.norm2()
104 | const cos2 = vSum.norm2()
105 |
106 | if (cos2 === 0) return new Point(0, 0, 0) // Ignore antipodal edges.
107 |
108 | return Point.fromVector(vSum.mul(Math.sqrt(sin2 / cos2))) // Length == 2*sin(theta)
109 | }
110 |
111 | /**
112 | * Returns the centroid of the planar triangle ABC. This can be
113 | * normalized to unit length to obtain the "surface centroid" of the corresponding
114 | * spherical triangle, i.e. the intersection of the three medians. However, note
115 | * that for large spherical triangles the surface centroid may be nowhere near
116 | * the intuitive "center".
117 | */
118 | export const planarCentroid = (a: Point, b: Point, c: Point): Point => {
119 | return Point.fromVector(
120 | a.vector
121 | .add(b.vector)
122 | .add(c.vector)
123 | .mul(1 / 3)
124 | )
125 | }
126 |
--------------------------------------------------------------------------------
/s2/centroids_test.ts:
--------------------------------------------------------------------------------
1 | import { test, describe } from 'node:test'
2 | import { equal, ok } from 'node:assert/strict'
3 | import { Point } from './Point'
4 | import { edgeTrueCentroid, planarCentroid, trueCentroid } from './centroids'
5 | import { randomFrame, randomFrameAtPoint, randomPoint } from './testing'
6 | import * as matrix from './matrix3x3'
7 |
8 | describe('s2.centroids', () => {
9 | test('planarCentroid', () => {
10 | const tests = [
11 | {
12 | name: 'xyz axis',
13 | p0: new Point(0, 0, 1),
14 | p1: new Point(0, 1, 0),
15 | p2: new Point(1, 0, 0),
16 | want: new Point(1 / 3, 1 / 3, 1 / 3)
17 | },
18 | {
19 | name: 'Same point',
20 | p0: new Point(1, 0, 0),
21 | p1: new Point(1, 0, 0),
22 | p2: new Point(1, 0, 0),
23 | want: new Point(1, 0, 0)
24 | }
25 | ]
26 |
27 | for (const { name, p0, p1, p2, want } of tests) {
28 | const got = planarCentroid(p0, p1, p2)
29 | ok(got.approxEqual(want), `${name}: PlanarCentroid(${p0}, ${p1}, ${p2}) = ${got}, want ${want}`)
30 | }
31 | })
32 |
33 | test('trueCentroid', () => {
34 | for (let i = 0; i < 100; i++) {
35 | const f = randomFrame()
36 | const p = matrix.col(f, 0)
37 | const x = matrix.col(f, 1)
38 | const y = matrix.col(f, 2)
39 | const d = 1e-4 * Math.pow(1e-4, Math.random())
40 |
41 | let p0 = Point.fromVector(p.vector.sub(x.vector.mul(d)).normalize())
42 | let p1 = Point.fromVector(p.vector.add(x.vector.mul(d)).normalize())
43 | let p2 = Point.fromVector(p.vector.add(y.vector.mul(d * 3)).normalize())
44 | let want = Point.fromVector(p.vector.add(y.vector.mul(d)).normalize())
45 |
46 | let got = trueCentroid(p0, p1, p2).vector.normalize()
47 | ok(got.distance(want.vector) < 2e-8, `TrueCentroid(${p0}, ${p1}, ${p2}).Normalize() = ${got}, want ${want}`)
48 |
49 | p0 = Point.fromVector(p.vector)
50 | p1 = Point.fromVector(p.vector.add(x.vector.mul(d * 3)).normalize())
51 | p2 = Point.fromVector(p.vector.add(y.vector.mul(d * 6)).normalize())
52 | want = Point.fromVector(p.vector.add(x.vector.add(y.vector.mul(2)).mul(d)).normalize())
53 |
54 | got = trueCentroid(p0, p1, p2).vector.normalize()
55 | ok(got.distance(want.vector) < 2e-8, `TrueCentroid(${p0}, ${p1}, ${p2}).Normalize() = ${got}, want ${want}`)
56 | }
57 | })
58 |
59 | test('edgeTrueCentroid semi-circles', () => {
60 | const a = Point.fromCoords(0, -1, 0)
61 | const b = Point.fromCoords(1, 0, 0)
62 | const c = Point.fromCoords(0, 1, 0)
63 | const centroid = Point.fromVector(edgeTrueCentroid(a, b).vector.add(edgeTrueCentroid(b, c).vector))
64 |
65 | ok(
66 | b.approxEqual(Point.fromVector(centroid.vector.normalize())),
67 | `EdgeTrueCentroid(${a}, ${b}) + EdgeTrueCentroid(${b}, ${c}) = ${centroid}, want ${b}`
68 | )
69 | equal(centroid.vector.norm(), 2.0, `${centroid}.Norm() = ${centroid.vector.norm()}, want 2.0`)
70 | })
71 |
72 | test('edgeTrueCentroid great-circles', () => {
73 | for (let iter = 0; iter < 100; iter++) {
74 | const f = randomFrameAtPoint(randomPoint())
75 | const x = matrix.col(f, 0)
76 | const y = matrix.col(f, 1)
77 |
78 | let centroid = new Point(0, 0, 0)
79 |
80 | let v0 = x
81 | for (let theta = 0.0; theta < 2 * Math.PI; theta += Math.pow(Math.random(), 10)) {
82 | const v1 = Point.fromVector(x.vector.mul(Math.cos(theta)).add(y.vector.mul(Math.sin(theta))))
83 | centroid = Point.fromVector(centroid.vector.add(edgeTrueCentroid(v0, v1).vector))
84 | v0 = v1
85 | }
86 |
87 | centroid = Point.fromVector(centroid.vector.add(edgeTrueCentroid(v0, x).vector))
88 | ok(centroid.vector.norm() <= 2e-14, `${centroid}.Norm() = ${centroid.vector.norm()}, want <= 2e-14`)
89 | }
90 | })
91 | })
92 |
--------------------------------------------------------------------------------
/s2/edge_crossings_test.ts:
--------------------------------------------------------------------------------
1 | import { test, describe } from 'node:test'
2 | import { equal, ok } from 'node:assert/strict'
3 | import { oneIn, randomFloat64, randomFrame } from './testing'
4 | import { DBL_EPSILON } from './predicates'
5 | import { Point } from './Point'
6 | import { Loop } from './Loop'
7 |
8 | import * as matrix from './matrix3x3'
9 | import * as angle from '../s1/angle'
10 |
11 | import { intersectionExact, intersection, CROSS, angleContainsVertex, INTERSECTION_ERROR } from './edge_crossings'
12 | import { distanceFromSegment } from './edge_distances'
13 | import { EdgeCrosser } from './EdgeCrosser'
14 | import { maxAngle } from './util'
15 |
16 | // Constants for tests
17 | const DISTANCE_ABS_ERROR = 3 * DBL_EPSILON
18 |
19 | const testIntersectionExact = (a0: Point, a1: Point, b0: Point, b1: Point): Point => {
20 | let x = intersectionExact(a0, a1, b0, b1)
21 | if (x.vector.dot(a0.vector.add(a1.vector).add(b0.vector.add(b1.vector))) < 0) {
22 | x = Point.fromVector(x.vector.mul(-1))
23 | }
24 | return x
25 | }
26 |
27 | describe('s2.edge_crossings', () => {
28 | test('edge util intersection error', (t) => {
29 | let maxPointDist = 0
30 | let maxEdgeDist = 0
31 |
32 | for (let iter = 0; iter < 5000; iter++) {
33 | const f = randomFrame()
34 | const p = matrix.col(f, 0)
35 | let d1 = matrix.col(f, 1)
36 | let d2 = matrix.col(f, 2)
37 |
38 | const slope = 1e-15 * Math.pow(1e30, randomFloat64())
39 | d2 = Point.fromVector(d1.vector.add(d2.vector.mul(slope)).normalize())
40 | let a: Point, b: Point, c: Point, d: Point
41 |
42 | for (;;) {
43 | const abLen = Math.pow(1e-15, randomFloat64())
44 | const cdLen = Math.pow(1e-15, randomFloat64())
45 | let aFraction = Math.pow(1e-5, randomFloat64())
46 | if (oneIn(2)) aFraction = 1 - aFraction
47 | let cFraction = Math.pow(1e-5, randomFloat64())
48 | if (oneIn(2)) cFraction = 1 - cFraction
49 | a = Point.fromVector(p.vector.sub(d1.vector.mul(aFraction * abLen)).normalize())
50 | b = Point.fromVector(p.vector.add(d1.vector.mul((1 - aFraction) * abLen)).normalize())
51 | c = Point.fromVector(p.vector.sub(d2.vector.mul(cFraction * cdLen)).normalize())
52 | d = Point.fromVector(p.vector.add(d2.vector.mul((1 - cFraction) * cdLen)).normalize())
53 | if (new EdgeCrosser(a, b).crossingSign(c, d) === CROSS) break
54 | }
55 |
56 | ok(distanceFromSegment(p, a, b) <= 1.5 * DBL_EPSILON + DISTANCE_ABS_ERROR)
57 | ok(distanceFromSegment(p, c, d) <= 1.5 * DBL_EPSILON + DISTANCE_ABS_ERROR)
58 |
59 | const expected = testIntersectionExact(a, b, c, d)
60 | ok(distanceFromSegment(expected, a, b) <= 3 * DBL_EPSILON + DISTANCE_ABS_ERROR)
61 | ok(distanceFromSegment(expected, c, d) <= 3 * DBL_EPSILON + DISTANCE_ABS_ERROR)
62 | ok(expected.distance(p) <= (3 * DBL_EPSILON) / slope + INTERSECTION_ERROR)
63 |
64 | const actual = intersection(a, b, c, d)
65 | const distAB = distanceFromSegment(actual, a, b)
66 | const distCD = distanceFromSegment(actual, c, d)
67 | const pointDist = expected.distance(actual)
68 | ok(distAB <= INTERSECTION_ERROR + DISTANCE_ABS_ERROR)
69 | ok(distCD <= INTERSECTION_ERROR + DISTANCE_ABS_ERROR)
70 | ok(pointDist <= INTERSECTION_ERROR)
71 |
72 | maxEdgeDist = maxAngle(maxEdgeDist, maxAngle(distAB, distCD))
73 | maxPointDist = maxAngle(maxPointDist, pointDist)
74 | }
75 | })
76 |
77 | test('angleContainsVertex', (t) => {
78 | const a = Point.fromCoords(1, 0, 0)
79 | const b = Point.fromCoords(0, 1, 0)
80 | const refB = b.referenceDir()
81 |
82 | ok(!angleContainsVertex(a, b, a))
83 | ok(angleContainsVertex(refB, b, a))
84 | ok(!angleContainsVertex(a, b, refB))
85 |
86 | const loop = Loop.regularLoop(b, angle.degrees(10), 10)
87 | let count = 0
88 | for (let i = 0; i < loop.vertices.length; i++) {
89 | const v = loop.vertices[i]
90 | if (angleContainsVertex(loop.vertex((i + 1) % loop.vertices.length), b, v)) {
91 | count++
92 | }
93 | }
94 | equal(count, 1)
95 | })
96 | })
97 |
--------------------------------------------------------------------------------
/s2/lookupIJ.ts:
--------------------------------------------------------------------------------
1 | export const LOOKUP_BITS = 4
2 | export const SWAP_MASK = 0x01
3 | export const INVERT_MASK = 0x02
4 |
5 | export const ijToPos = [
6 | [0, 1, 3, 2], // canonical order
7 | [0, 3, 1, 2], // axes swapped
8 | [2, 3, 1, 0], // bits inverted
9 | [2, 1, 3, 0] // swapped & inverted
10 | ]
11 | export const posToIJ = [
12 | [0, 1, 3, 2], // canonical order: (0,0), (0,1), (1,1), (1,0)
13 | [0, 2, 3, 1], // axes swapped: (0,0), (1,0), (1,1), (0,1)
14 | [3, 2, 0, 1], // bits inverted: (1,1), (1,0), (0,0), (0,1)
15 | [3, 1, 0, 2] // swapped & inverted: (1,1), (0,1), (0,0), (1,0)
16 | ]
17 | export const posToOrientation = [SWAP_MASK, 0, 0, INVERT_MASK | SWAP_MASK]
18 | const lookupIJ: number[] = []
19 | export const lookupPos: number[] = []
20 |
21 | initLookupCell(0, 0, 0, 0, 0, 0)
22 | initLookupCell(0, 0, 0, SWAP_MASK, 0, SWAP_MASK)
23 | initLookupCell(0, 0, 0, INVERT_MASK, 0, INVERT_MASK)
24 | initLookupCell(0, 0, 0, SWAP_MASK | INVERT_MASK, 0, SWAP_MASK | INVERT_MASK)
25 |
26 | // initLookupCell initializes the lookupIJ lookupIJ at init time.
27 | function initLookupCell(
28 | level: number,
29 | i: number,
30 | j: number,
31 | origOrientation: number,
32 | pos: number,
33 | orientation: number
34 | ) {
35 | if (level == LOOKUP_BITS) {
36 | const ij = (i << LOOKUP_BITS) + j
37 | lookupPos[(ij << 2) + origOrientation] = (pos << 2) + orientation
38 | lookupIJ[(pos << 2) + origOrientation] = (ij << 2) + orientation
39 | return
40 | }
41 |
42 | level++
43 | i <<= 1
44 | j <<= 1
45 | pos <<= 2
46 | const r = posToIJ[orientation]
47 | initLookupCell(level, i + (r[0] >> 1), j + (r[0] & 1), origOrientation, pos, orientation ^ posToOrientation[0])
48 | initLookupCell(level, i + (r[1] >> 1), j + (r[1] & 1), origOrientation, pos + 1, orientation ^ posToOrientation[1])
49 | initLookupCell(level, i + (r[2] >> 1), j + (r[2] & 1), origOrientation, pos + 2, orientation ^ posToOrientation[2])
50 | initLookupCell(level, i + (r[3] >> 1), j + (r[3] & 1), origOrientation, pos + 3, orientation ^ posToOrientation[3])
51 | }
52 |
53 | export default lookupIJ
54 |
--------------------------------------------------------------------------------
/s2/matrix3x3.ts:
--------------------------------------------------------------------------------
1 | import { Point } from './Point'
2 |
3 | /**
4 | * Represents a traditional 3x3 matrix of floating point values.
5 | * This is not a full-fledged matrix. It only contains the pieces needed
6 | * to satisfy the computations done within the s2 package.
7 | */
8 | export type Matrix3x3 = number[][]
9 |
10 | /**
11 | * Returns the given column as a Point.
12 | */
13 | export const col = (m: Matrix3x3, col: number): Point => {
14 | return new Point(m[0][col], m[1][col], m[2][col])
15 | }
16 |
17 | /**
18 | * Returns the given row as a Point.
19 | */
20 | export const row = (m: Matrix3x3, row: number): Point => {
21 | return new Point(m[row][0], m[row][1], m[row][2])
22 | }
23 |
24 | /**
25 | * Sets the specified column to the value in the given Point.
26 | */
27 | export const setCol = (m: Matrix3x3, col: number, p: Point): Matrix3x3 => {
28 | m[0][col] = p.x
29 | m[1][col] = p.y
30 | m[2][col] = p.z
31 | return m
32 | }
33 |
34 | /**
35 | * Sets the specified row to the value in the given Point.
36 | */
37 | export const setRow = (m: Matrix3x3, row: number, p: Point): Matrix3x3 => {
38 | m[row][0] = p.x
39 | m[row][1] = p.y
40 | m[row][2] = p.z
41 | return m
42 | }
43 |
44 | /**
45 | * Multiplies the matrix by the given value.
46 | */
47 | export const scale = (m: Matrix3x3, f: number): Matrix3x3 => {
48 | return [
49 | [f * m[0][0], f * m[0][1], f * m[0][2]],
50 | [f * m[1][0], f * m[1][1], f * m[1][2]],
51 | [f * m[2][0], f * m[2][1], f * m[2][2]]
52 | ]
53 | }
54 |
55 | /**
56 | * Returns the multiplication of m by the Point p and converts the
57 | * resulting 1x3 matrix into a Point.
58 | */
59 | export const mul = (m: Matrix3x3, p: Point): Point => {
60 | return new Point(
61 | m[0][0] * p.x + m[0][1] * p.y + m[0][2] * p.z,
62 | m[1][0] * p.x + m[1][1] * p.y + m[1][2] * p.z,
63 | m[2][0] * p.x + m[2][1] * p.y + m[2][2] * p.z
64 | )
65 | }
66 |
67 | /**
68 | * Returns the determinant of this matrix.
69 | */
70 | export const det = (m: Matrix3x3): number => {
71 | // | a b c |
72 | // det | d e f | = aei + bfg + cdh - ceg - bdi - afh
73 | // | g h i |
74 | return (
75 | m[0][0] * m[1][1] * m[2][2] +
76 | m[0][1] * m[1][2] * m[2][0] +
77 | m[0][2] * m[1][0] * m[2][1] -
78 | m[0][2] * m[1][1] * m[2][0] -
79 | m[0][1] * m[1][0] * m[2][2] -
80 | m[0][0] * m[1][2] * m[2][1]
81 | )
82 | }
83 |
84 | /**
85 | * Reflects the matrix along its diagonal and returns the result.
86 | */
87 | export const transpose = (m: Matrix3x3): Matrix3x3 => {
88 | return [
89 | [m[0][0], m[1][0], m[2][0]],
90 | [m[0][1], m[1][1], m[2][1]],
91 | [m[0][2], m[1][2], m[2][2]]
92 | ]
93 | }
94 |
95 | /**
96 | * Formats the matrix into an easier to read layout.
97 | */
98 | export const toString = (m: Matrix3x3): string => {
99 | return (
100 | `[ ${m[0][0].toFixed(4)} ${m[0][1].toFixed(4)} ${m[0][2].toFixed(4)} ] ` +
101 | `[ ${m[1][0].toFixed(4)} ${m[1][1].toFixed(4)} ${m[1][2].toFixed(4)} ] ` +
102 | `[ ${m[2][0].toFixed(4)} ${m[2][1].toFixed(4)} ${m[2][2].toFixed(4)} ]`
103 | )
104 | }
105 |
106 | /**
107 | * Returns the orthonormal frame for the given point on the unit sphere.
108 | */
109 | export const getFrame = (p: Point): Matrix3x3 => {
110 | // Given the point p on the unit sphere, extend this into a right-handed
111 | // coordinate frame of unit-length column vectors m = (x,y,z). Note that
112 | // the vectors (x,y) are an orthonormal frame for the tangent space at point p,
113 | // while p itself is an orthonormal frame for the normal space at p.
114 | const m: Matrix3x3 = [
115 | [0, 0, 0],
116 | [0, 0, 0],
117 | [0, 0, 0]
118 | ]
119 | setCol(m, 2, p)
120 | setCol(m, 1, Point.ortho(p))
121 | setCol(m, 0, Point.fromVector(col(m, 1).vector.cross(p.vector)))
122 | return m
123 | }
124 |
125 | /**
126 | * Returns the coordinates of the given point with respect to its orthonormal basis m.
127 | * The resulting point q satisfies the identity (m * q == p).
128 | *
129 | * The inverse of an orthonormal matrix is its transpose.
130 | */
131 | export const toFrame = (m: Matrix3x3, p: Point): Point => mul(transpose(m), p)
132 |
133 | /**
134 | * Returns the coordinates of the given point in standard axis-aligned basis
135 | * from its orthonormal basis m.
136 | * The resulting point p satisfies the identity (p == m * q).
137 | */
138 | export const fromFrame = (m: Matrix3x3, q: Point): Point => mul(m, q)
139 |
--------------------------------------------------------------------------------
/s2/point_measures.ts:
--------------------------------------------------------------------------------
1 | import { Point } from './Point'
2 | import type { Angle } from '../s1/angle'
3 | import { COUNTERCLOCKWISE, robustSign } from './predicates'
4 |
5 | /**
6 | * Returns the area of triangle ABC. This method combines two different
7 | * algorithms to get accurate results for both large and small triangles.
8 | * The maximum error is about 5e-15 (about 0.25 square meters on the Earth's
9 | * surface), the same as GirardArea below, but unlike that method it is
10 | * also accurate for small triangles. Example: when the true area is 100
11 | * square meters, PointArea yields an error about 1 trillion times smaller than
12 | * GirardArea.
13 | *
14 | * All points should be unit length, and no two points should be antipodal.
15 | * The area is always positive.
16 | */
17 | export const pointArea = (a: Point, b: Point, c: Point): number => {
18 | const sa = b.stableAngle(c)
19 | const sb = c.stableAngle(a)
20 | const sc = a.stableAngle(b)
21 | const s = 0.5 * (sa + sb + sc)
22 |
23 | if (s >= 3e-4) {
24 | const dmin = s - Math.max(sa, sb, sc)
25 | if (dmin < 1e-2 * s * s * s * s * s) {
26 | const area = girardArea(a, b, c)
27 | if (dmin < s * 0.1 * (area + 5e-15)) return area
28 | }
29 | }
30 |
31 | return (
32 | 4 *
33 | Math.atan(
34 | Math.sqrt(
35 | Math.max(
36 | 0.0,
37 | Math.tan(0.5 * s) * Math.tan(0.5 * (s - sa)) * Math.tan(0.5 * (s - sb)) * Math.tan(0.5 * (s - sc))
38 | )
39 | )
40 | )
41 | )
42 | }
43 |
44 | /**
45 | * Returns the area of the triangle computed using Girard's formula.
46 | * All points should be unit length, and no two points should be antipodal.
47 | *
48 | * This method is about twice as fast as PointArea() but has poor relative
49 | * accuracy for small triangles. The maximum error is about 5e-15 (about
50 | * 0.25 square meters on the Earth's surface) and the average error is about
51 | * 1e-15. These bounds apply to triangles of any size, even as the maximum
52 | * edge length of the triangle approaches 180 degrees. But note that for
53 | * such triangles, tiny perturbations of the input points can change the
54 | * true mathematical area dramatically.
55 | */
56 | export const girardArea = (a: Point, b: Point, c: Point): number => {
57 | const ab = a.pointCross(b)
58 | const bc = b.pointCross(c)
59 | const ac = a.pointCross(c)
60 |
61 | let area = ab.vector.angle(ac.vector) - ab.vector.angle(bc.vector) + bc.vector.angle(ac.vector)
62 | if (area < 0) area = 0
63 | return area
64 | }
65 |
66 | /**
67 | * Returns a positive value for counterclockwise triangles and a negative
68 | * value otherwise (similar to PointArea).
69 | */
70 | export const signedArea = (a: Point, b: Point, c: Point): number => {
71 | return robustSign(a, b, c) * pointArea(a, b, c)
72 | }
73 |
74 | /**
75 | * Returns the interior angle at the vertex B in the triangle ABC. The
76 | * return value is always in the range [0, pi]. All points should be
77 | * normalized. Ensures that Angle(a,b,c) == Angle(c,b,a) for all a,b,c.
78 | *
79 | * The angle is undefined if A or C is diametrically opposite from B, and
80 | * becomes numerically unstable as the length of edge AB or BC approaches
81 | * 180 degrees.
82 | */
83 | export const angle = (a: Point, b: Point, c: Point): Angle => {
84 | return a.pointCross(b).vector.angle(c.pointCross(b).vector)
85 | }
86 |
87 | /**
88 | * Returns the exterior angle at vertex B in the triangle ABC. The
89 | * return value is positive if ABC is counterclockwise and negative otherwise.
90 | * If you imagine an ant walking from A to B to C, this is the angle that the
91 | * ant turns at vertex B (positive = left = CCW, negative = right = CW).
92 | * This quantity is also known as the "geodesic curvature" at B.
93 | *
94 | * Ensures that TurnAngle(a,b,c) == -TurnAngle(c,b,a) for all distinct
95 | * a,b,c. The result is undefined if (a == b || b == c), but is either
96 | * -Pi or Pi if (a == c). All points should be normalized.
97 | */
98 | export const turnAngle = (a: Point, b: Point, c: Point): Angle => {
99 | const angle = a.pointCross(b).vector.angle(b.pointCross(c).vector)
100 | return robustSign(a, b, c) === COUNTERCLOCKWISE ? angle : -angle
101 | }
102 |
--------------------------------------------------------------------------------
/s2/point_measures_test.ts:
--------------------------------------------------------------------------------
1 | import { test, describe } from 'node:test'
2 | import { equal, ok } from 'node:assert/strict'
3 | import { Point } from './Point'
4 | import { Vector } from '../r3/Vector'
5 | import { LatLng } from './LatLng'
6 | import { angle, girardArea, pointArea, turnAngle } from './point_measures'
7 | import { randomFloat64, randomPoint } from './testing'
8 |
9 | const PZ = new Point(0, 0, 1)
10 | const P000 = new Point(1, 0, 0)
11 | const P045 = Point.fromVector(new Vector(1, 1, 0).normalize())
12 | const P090 = new Point(0, 1, 0)
13 | const P180 = new Point(-1, 0, 0)
14 | const PR = new Point(0.257, -0.5723, 0.112)
15 | const PQ = new Point(-0.747, 0.401, 0.2235)
16 |
17 | const EPS = 1e-10
18 | const EXP1 = 0.5 * EPS * EPS
19 | const EXP2 = 5.8578643762690495119753e-11
20 | const EPS2 = 1e-14
21 | const epsilon = 1e-15
22 |
23 | describe('s2.point_measures', () => {
24 | test('pointArea', (t) => {
25 | const tests = [
26 | { a: P000, b: P090, c: PZ, want: Math.PI / 2.0, nearness: 0 },
27 | { a: P045, b: PZ, c: P180, want: (3.0 * Math.PI) / 4.0, nearness: 0 },
28 | {
29 | a: Point.fromVector(new Vector(EPS, 0, 1).normalize()),
30 | b: Point.fromVector(new Vector(0, EPS, 1).normalize()),
31 | c: PZ,
32 | want: EXP1,
33 | nearness: 1e-14 * EXP1
34 | },
35 | { a: PR, b: PR, c: PR, want: 0.0, nearness: 0 },
36 | { a: PR, b: PQ, c: PR, want: 0.0, nearness: 1e-15 },
37 | { a: P000, b: P045, c: P090, want: 0.0, nearness: 0 },
38 | { a: P000, b: Point.fromVector(new Vector(1, 1, EPS).normalize()), c: P090, want: EXP2, nearness: 1e-9 * EXP2 }
39 | ]
40 |
41 | for (const [d, test] of tests.entries()) {
42 | const got = pointArea(test.a, test.b, test.c)
43 | ok(
44 | Math.abs(got - test.want) <= test.nearness,
45 | `${d}, PointArea(${test.a}, ${test.b}, ${test.c}), got ${got} want ${test.want}`
46 | )
47 | }
48 |
49 | let maxGirard = 0.0
50 | for (let i = 0; i < 10000; i++) {
51 | const p0 = randomPoint()
52 | const d1 = randomPoint()
53 | const d2 = randomPoint()
54 | const p1 = Point.fromVector(p0.vector.add(d1.vector.mul(1e-15)).normalize())
55 | const p2 = Point.fromVector(p0.vector.add(d2.vector.mul(1e-15)).normalize())
56 | const got = pointArea(p0, p1, p2)
57 | ok(got <= 0.7e-30, `PointArea(${p1}, ${p1}, ${p2}) = ${got}, want <= ${0.7e-30}`)
58 | const a = girardArea(p0, p1, p2)
59 | if (a > maxGirard) maxGirard = a
60 | }
61 |
62 | ok(maxGirard <= 1e-14, `maximum GirardArea = ${maxGirard}, want <= 1e-14`)
63 |
64 | const a = Point.fromLatLng(LatLng.fromDegrees(-45, -170))
65 | const b = Point.fromLatLng(LatLng.fromDegrees(45, -170))
66 | const c = Point.fromLatLng(LatLng.fromDegrees(0, -170))
67 | const area = pointArea(a, b, c)
68 | equal(area, 0.0, `PointArea(${a}, ${b}, ${c}) = ${area}, want 0.0`)
69 | })
70 |
71 | test('PointArea - quarter hemisphere', (t) => {
72 | const tests = [
73 | {
74 | a: Point.fromCoords(1, 0.1 * EPS2, EPS2),
75 | b: P000,
76 | c: P045,
77 | d: P180,
78 | e: PZ,
79 | want: Math.PI
80 | },
81 | {
82 | a: Point.fromCoords(1, 1, EPS2),
83 | b: P000,
84 | c: P045,
85 | d: P180,
86 | e: PZ,
87 | want: Math.PI
88 | }
89 | ]
90 |
91 | for (const test of tests) {
92 | const area =
93 | pointArea(test.a, test.b, test.c) +
94 | pointArea(test.a, test.c, test.d) +
95 | pointArea(test.a, test.d, test.e) +
96 | pointArea(test.a, test.e, test.b)
97 | ok(
98 | Math.abs(area - test.want) <= epsilon,
99 | `Adding up 4 quarter hemispheres with PointArea(), got ${area} want ${test.want}`
100 | )
101 | }
102 |
103 | for (let i = 0; i < 100; i++) {
104 | const lng = 2 * Math.PI * randomFloat64()
105 | const p2Lng = lng + randomFloat64()
106 | const p0 = Point.fromLatLng(new LatLng(1e-20, lng).normalized())
107 | const p1 = Point.fromLatLng(new LatLng(0, lng).normalized())
108 | const p2 = Point.fromLatLng(new LatLng(0, p2Lng).normalized())
109 | const p3 = Point.fromLatLng(new LatLng(0, lng + Math.PI).normalized())
110 | const p4 = Point.fromLatLng(new LatLng(0, lng + 5.0).normalized())
111 | const area = pointArea(p0, p1, p2) + pointArea(p0, p2, p3) + pointArea(p0, p3, p4) + pointArea(p0, p4, p1)
112 | ok(
113 | Math.abs(area - 2 * Math.PI) <= 2e-15,
114 | `hemisphere area of ${p1}, ${p2}, ${p3}, ${p4}, ${p1} = ${area}, want ${2 * Math.PI}`
115 | )
116 | }
117 | })
118 |
119 | test('angle methods', (t) => {
120 | const tests = [
121 | { a: P000, b: PZ, c: P045, wantAngle: Math.PI / 4, wantTurnAngle: (-3 * Math.PI) / 4 },
122 | { a: P045, b: PZ, c: P180, wantAngle: (3 * Math.PI) / 4, wantTurnAngle: -Math.PI / 4 },
123 | { a: P000, b: PZ, c: P180, wantAngle: Math.PI, wantTurnAngle: 0 },
124 | { a: PZ, b: P000, c: P045, wantAngle: Math.PI / 2, wantTurnAngle: Math.PI / 2 },
125 | { a: PZ, b: P000, c: PZ, wantAngle: 0, wantTurnAngle: -Math.PI }
126 | ]
127 |
128 | for (const test of tests) {
129 | const gotAngle = angle(test.a, test.b, test.c)
130 | ok(
131 | Math.abs(gotAngle - test.wantAngle) <= epsilon,
132 | `Angle(${test.a}, ${test.b}, ${test.c}) = ${gotAngle}, want ${test.wantAngle}`
133 | )
134 | const gotTurnAngle = turnAngle(test.a, test.b, test.c)
135 | ok(
136 | Math.abs(gotTurnAngle - test.wantTurnAngle) <= epsilon,
137 | `TurnAngle(${test.a}, ${test.b}, ${test.c}) = ${gotTurnAngle}, want ${test.wantTurnAngle}`
138 | )
139 | }
140 | })
141 |
142 | test('pointArea - regression', (t) => {
143 | const a = new Point(-1.705424004316021258e-1, -8.242696197922716461e-1, 5.399026611737816062e-1)
144 | const b = new Point(-1.706078905422188652e-1, -8.246067119418969416e-1, 5.393669607095969987e-1)
145 | const c = new Point(-1.705800600596222294e-1, -8.244634596153025408e-1, 5.395947061167500891e-1)
146 | const area = pointArea(a, b, c)
147 | equal(area, 0, `PointArea(${a}, ${b}, ${c}) should have been 0, got ${area}`)
148 | })
149 | })
150 |
--------------------------------------------------------------------------------
/s2/testing_pseudo.ts:
--------------------------------------------------------------------------------
1 | import { Point } from './Point'
2 |
3 | // Simple pseudorandom generator, an implementation of Multiply-with-carry
4 | export class PseudoRandom {
5 | m_w: number = 0
6 | m_z: number = 987654321
7 | mask: number = 0xffffffff
8 |
9 | constructor(s: number = 0) {
10 | this.m_w = s
11 | }
12 |
13 | seed(s: number) {
14 | this.m_w = s
15 | this.m_z = 987654321
16 | }
17 |
18 | random() {
19 | this.m_z = (36969 * (this.m_z & 65535) + (this.m_z >> 16)) & this.mask
20 | this.m_w = (18000 * (this.m_w & 65535) + (this.m_w >> 16)) & this.mask
21 | let result = ((this.m_z << 16) + (this.m_w & 65535)) >>> 0
22 | result /= 4294967296
23 | return result
24 | }
25 | }
26 |
27 | /**
28 | * Returns a uniformly distributed value in the range [0,1).
29 | */
30 | export const randomFloat64Seed = (sr: PseudoRandom): number => sr.random()
31 |
32 | /**
33 | * Returns a uniformly distributed value in the range [min, max).
34 | */
35 | export const randomUniformFloat64Seed = (sr: PseudoRandom, min: number, max: number): number => {
36 | return min + randomFloat64Seed(sr) * (max - min)
37 | }
38 |
39 | // Returns a random unit-length vector.
40 | export const randomPointSeed = (sr: PseudoRandom): Point => {
41 | return Point.fromCoords(
42 | randomUniformFloat64Seed(sr, -1, 1),
43 | randomUniformFloat64Seed(sr, -1, 1),
44 | randomUniformFloat64Seed(sr, -1, 1)
45 | )
46 | }
47 |
--------------------------------------------------------------------------------
/s2/testing_textformat.ts:
--------------------------------------------------------------------------------
1 | import { LatLng } from './LatLng'
2 | import { LaxPolygon } from './LaxPolygon'
3 | import { LaxPolyline } from './LaxPolyline'
4 | import { Loop } from './Loop'
5 | import { Point } from './Point'
6 | import { PointVector } from './PointVector'
7 | import { Polygon } from './Polygon'
8 | import { Polyline } from './Polyline'
9 | import { ShapeIndex } from './ShapeIndex'
10 |
11 | export const parsePoints = (str: string): Point[] => {
12 | return str
13 | .split(/\s*,\s*/)
14 | .filter(Boolean)
15 | .map((chunk) => {
16 | const [x, y] = chunk.split(':').map(parseFloat)
17 | return Point.fromLatLng(LatLng.fromDegrees(x, y))
18 | })
19 | }
20 |
21 | export const parsePoint = (str: string): Point => {
22 | const points = parsePoints(str)
23 | return points.length > 0 ? points[0] : new Point(0, 0, 0)
24 | }
25 |
26 | export const makePolyline = (str: string): Polyline => {
27 | return new Polyline(parsePoints(str))
28 | }
29 |
30 | export const makeLaxPolyline = (str: string): LaxPolyline => {
31 | return new LaxPolyline(parsePoints(str))
32 | }
33 |
34 | export const makeLaxPolygon = (str: string): LaxPolygon => {
35 | if (str == '') return LaxPolygon.fromPoints([])
36 |
37 | var points: Point[][] = str
38 | .split(';')
39 | .filter((l) => l !== 'empty')
40 | .map((l) => (l === 'full' ? [] : parsePoints(l)))
41 |
42 | return LaxPolygon.fromPoints(points)
43 | }
44 |
45 | // makeShapeIndex builds a ShapeIndex from the given debug string containing
46 | // the points, polylines, and loops (in the form of a single polygon)
47 | // described by the following format:
48 | //
49 | // point1|point2|... # line1|line2|... # polygon1|polygon2|...
50 | //
51 | // Examples:
52 | //
53 | // 1:2 | 2:3 # # // Two points
54 | // # 0:0, 1:1, 2:2 | 3:3, 4:4 # // Two polylines
55 | // # # 0:0, 0:3, 3:0; 1:1, 2:1, 1:2 // Two nested loops (one polygon)
56 | // 5:5 # 6:6, 7:7 # 0:0, 0:1, 1:0 // One of each
57 | // # # empty // One empty polygon
58 | // # # empty | full // One empty polygon, one full polygon
59 | //
60 | // Loops should be directed so that the region's interior is on the left.
61 | // Loops can be degenerate (they do not need to meet Loop requirements).
62 | //
63 | // Note: Because whitespace is ignored, empty polygons must be specified
64 | // as the string "empty" rather than as the empty string ("").
65 | export const makeShapeIndex = (str: string): ShapeIndex => {
66 | const fields = str.split('#').map((s) => s.trim())
67 | if (fields.length != 3) {
68 | throw new Error("shapeIndex debug string must contain 2 '#' characters")
69 | }
70 |
71 | const index = new ShapeIndex()
72 |
73 | if (fields[0].length) {
74 | const points: Point[] = fields[0]
75 | .split('|')
76 | .map((s) => s.trim())
77 | .filter(Boolean)
78 | .map(parsePoint)
79 | if (points.length) index.add(new PointVector(points))
80 | }
81 |
82 | if (fields[1].length) {
83 | const lines: LaxPolyline[] = fields[1]
84 | .split('|')
85 | .map((s) => s.trim())
86 | .filter(Boolean)
87 | .map(makeLaxPolyline)
88 | lines.forEach((line) => index.add(line))
89 | }
90 |
91 | if (fields[2].length) {
92 | const polygons: LaxPolygon[] = fields[2]
93 | .split('|')
94 | .map((s) => s.trim())
95 | .filter(Boolean)
96 | .map(makeLaxPolygon)
97 | polygons.forEach((poly) => index.add(poly))
98 | }
99 |
100 | return index
101 | }
102 |
103 | /**
104 | * Constructs a Loop from the input string.
105 | * The strings "empty" or "full" create an empty or full loop respectively.
106 | */
107 | export const makeLoop = (s: string): Loop => {
108 | if (s === 'full') return Loop.fullLoop()
109 | if (s === 'empty') return Loop.emptyLoop()
110 |
111 | return new Loop(parsePoints(s))
112 | }
113 |
114 | /**
115 | * Constructs a polygon from the sequence of loops in the input string. Loops are automatically normalized by inverting them if necessary
116 | * so that they enclose at most half of the unit sphere. (Historically this was
117 | * once a requirement of polygon loops. It also hides the problem that if the
118 | * user thinks of the coordinates as X:Y rather than LAT:LNG, it yields a loop
119 | * with the opposite orientation.)
120 | *
121 | * Loops are semicolon separated in the input string with each loop using the
122 | * same format as above.
123 | *
124 | * Examples of the input format:
125 | *
126 | * "10:20, 90:0, 20:30" // one loop
127 | * "10:20, 90:0, 20:30; 5.5:6.5, -90:-180, -15.2:20.3" // two loops
128 | * "" // the empty polygon (consisting of no loops)
129 | * "empty" // the empty polygon (consisting of no loops)
130 | * "full" // the full polygon (consisting of one full loop)
131 | */
132 | export const makePolygon = (s: string, normalize: boolean): Polygon => {
133 | const loops: Loop[] = []
134 |
135 | // Avoid the case where split on empty string will still return
136 | // one empty value, where we want no values.
137 | if (s === 'empty' || s === '') {
138 | return new Polygon(loops)
139 | }
140 |
141 | s.split(';')
142 | .map((str) => str.trim())
143 | .filter(Boolean)
144 | .forEach((str) => {
145 | const loop = makeLoop(str)
146 | if (normalize && !loop.isFull()) loop.normalize()
147 | loops.push(loop)
148 | })
149 |
150 | return new Polygon(loops)
151 | }
152 |
--------------------------------------------------------------------------------
/s2/util.ts:
--------------------------------------------------------------------------------
1 | import type { Angle } from '../s1/angle'
2 | import type { ChordAngle } from '../s1/chordangle'
3 |
4 | /** Returns the number closest to x within the range min..max. */
5 | export const clampInt = (x: T, min: T, max: T): T => {
6 | if (x < min) return min
7 | if (x > max) return max
8 | return x
9 | }
10 |
11 | /** Returns the largest of the given numbers. */
12 | export const max = (first: T, ...others: T[]): T => {
13 | let max = first
14 | for (const y of others) {
15 | if (y > max) max = y
16 | }
17 | return max
18 | }
19 |
20 | /** Returns the smallest of the given numbers. */
21 | export const min = (first: T, ...others: T[]): T => {
22 | let min = first
23 | for (const y of others) {
24 | if (y < min) min = y
25 | }
26 | return min
27 | }
28 |
29 | /** Returns the absolute value of a bigint. */
30 | export const abs = (n: bigint): bigint => (n < 0n ? -n : n)
31 |
32 | /** Returns the largest of the given Angle values. */
33 | export const maxAngle = (first: Angle, ...others: Angle[]) => max(first, ...others)
34 |
35 | /** Returns the smallest of the given Angle values. */
36 | export const minAngle = (first: Angle, ...others: Angle[]) => min(first, ...others)
37 |
38 | /** Returns the largest of the given ChordAngle values. */
39 | export const maxChordAngle = (first: ChordAngle, ...others: ChordAngle[]) => max(first, ...others)
40 |
41 | /** Returns the smallest of the given ChordAngle values. */
42 | export const minChordAngle = (first: ChordAngle, ...others: ChordAngle[]) => min(first, ...others)
43 |
44 | /**
45 | * Performs a binary search to find the smallest index `i` in the range [0, n) where the function `f` is true.
46 | *
47 | * missinglink: port of Go `sort.Search`
48 | */
49 | export const binarySearch = (n: number, f: (i: number) => boolean): number => {
50 | let i = 0
51 | let j = n
52 | while (i < j) {
53 | const h = Math.floor((i + j) / 2) // calculate mid-point
54 | if (!f(h)) {
55 | i = h + 1 // move to the right half
56 | } else {
57 | j = h // move to the left half
58 | }
59 | }
60 | return i
61 | }
62 |
--------------------------------------------------------------------------------
/s2/wedge_relations.ts:
--------------------------------------------------------------------------------
1 | import { Point } from './Point'
2 |
3 | /** WedgeRel enumerates the possible relation between two wedges A and B. */
4 | export type WedgeRel = number
5 |
6 | // Define the different possible relationships between two wedges.
7 | //
8 | // Given an edge chain (x0, x1, x2), the wedge at x1 is the region to the
9 | // left of the edges. More precisely, it is the set of all rays from x1x0
10 | // (inclusive) to x1x2 (exclusive) in the *clockwise* direction.
11 | /** A and B are equal. */
12 | export const WEDGE_EQUALS: WedgeRel = 0 // A and B are equal.
13 | export const WEDGE_PROPERLY_CONTAINS: WedgeRel = 1 // A is a strict superset of B.
14 | export const WEDGE_IS_PROPERLY_CONTAINED: WedgeRel = 2 // A is a strict subset of B.
15 | export const WEDGE_PROPERLY_OVERLAPS: WedgeRel = 3 // A-B, B-A, and A intersect B are non-empty.
16 | export const WEDGE_IS_DISJOINT: WedgeRel = 4 // A and B are disjoint.
17 |
18 | /**
19 | * Reports the relation between two non-empty wedges A=(a0, ab1, a2) and B=(b0, ab1, b2).
20 | */
21 | export const wedgeRelation = (a0: Point, ab1: Point, a2: Point, b0: Point, b2: Point): WedgeRel => {
22 | if (a0.equals(b0) && a2.equals(b2)) return WEDGE_EQUALS
23 |
24 | if (Point.orderedCCW(a0, a2, b2, ab1)) {
25 | if (Point.orderedCCW(b2, b0, a0, ab1)) return WEDGE_PROPERLY_CONTAINS
26 |
27 | if (a2.equals(b2)) return WEDGE_IS_PROPERLY_CONTAINED
28 | return WEDGE_PROPERLY_OVERLAPS
29 | }
30 |
31 | if (Point.orderedCCW(a0, b0, b2, ab1)) return WEDGE_IS_PROPERLY_CONTAINED
32 |
33 | if (Point.orderedCCW(a0, b0, a2, ab1)) return WEDGE_IS_DISJOINT
34 | return WEDGE_PROPERLY_OVERLAPS
35 | }
36 |
37 | /**
38 | * Reports whether non-empty wedge A=(a0, ab1, a2) contains B=(b0, ab1, b2).
39 | * Equivalent to WedgeRelation == WEDGE_PROPERLY_CONTAINS || WEDGE_EQUALS.
40 | */
41 | export const wedgeContains = (a0: Point, ab1: Point, a2: Point, b0: Point, b2: Point): boolean => {
42 | return Point.orderedCCW(a2, b2, b0, ab1) && Point.orderedCCW(b0, a0, a2, ab1)
43 | }
44 |
45 | /**
46 | * Reports whether non-empty wedge A=(a0, ab1, a2) intersects B=(b0, ab1, b2).
47 | * Equivalent but faster than WedgeRelation != WEDGE_IS_DISJOINT.
48 | */
49 | export const wedgeIntersects = (a0: Point, ab1: Point, a2: Point, b0: Point, b2: Point): boolean => {
50 | return !(Point.orderedCCW(a0, b2, b0, ab1) && Point.orderedCCW(b0, a2, a0, ab1))
51 | }
52 |
--------------------------------------------------------------------------------
/s2/wedge_relations_test.ts:
--------------------------------------------------------------------------------
1 | import { test, describe } from 'node:test'
2 | import { equal } from 'node:assert/strict'
3 | import { Point } from './Point'
4 | import {
5 | WEDGE_EQUALS,
6 | WEDGE_IS_DISJOINT,
7 | WEDGE_IS_PROPERLY_CONTAINED,
8 | WEDGE_PROPERLY_CONTAINS,
9 | WEDGE_PROPERLY_OVERLAPS,
10 | wedgeContains,
11 | wedgeIntersects,
12 | wedgeRelation
13 | } from './wedge_relations'
14 |
15 | describe('s2.wedge_relations', () => {
16 | test('relations', (t) => {
17 | // For simplicity, all of these tests use an origin of (0, 0, 1).
18 | // This shouldn't matter as long as the lower-level primitives are implemented correctly.
19 | const AB1 = Point.fromVector(new Point(0, 0, 1).vector.normalize())
20 |
21 | const TEST_CASES = [
22 | {
23 | desc: 'Intersection in one wedge',
24 | a0: new Point(-1, 0, 10),
25 | a1: new Point(1, 2, 10),
26 | b0: new Point(0, 1, 10),
27 | b1: new Point(1, -2, 10),
28 | contains: false,
29 | intersects: true,
30 | relation: WEDGE_PROPERLY_OVERLAPS
31 | },
32 | {
33 | desc: 'Intersection in two wedges',
34 | a0: new Point(-1, -1, 10),
35 | a1: new Point(1, -1, 10),
36 | b0: new Point(1, 0, 10),
37 | b1: new Point(-1, 1, 10),
38 | contains: false,
39 | intersects: true,
40 | relation: WEDGE_PROPERLY_OVERLAPS
41 | },
42 | {
43 | desc: 'Normal containment',
44 | a0: new Point(-1, -1, 10),
45 | a1: new Point(1, -1, 10),
46 | b0: new Point(-1, 0, 10),
47 | b1: new Point(1, 0, 10),
48 | contains: true,
49 | intersects: true,
50 | relation: WEDGE_PROPERLY_CONTAINS
51 | },
52 | {
53 | desc: 'Containment with equality on one side',
54 | a0: new Point(2, 1, 10),
55 | a1: new Point(-1, -1, 10),
56 | b0: new Point(2, 1, 10),
57 | b1: new Point(1, -5, 10),
58 | contains: true,
59 | intersects: true,
60 | relation: WEDGE_PROPERLY_CONTAINS
61 | },
62 | {
63 | desc: 'Containment with equality on the other side',
64 | a0: new Point(2, 1, 10),
65 | a1: new Point(-1, -1, 10),
66 | b0: new Point(1, -2, 10),
67 | b1: new Point(-1, -1, 10),
68 | contains: true,
69 | intersects: true,
70 | relation: WEDGE_PROPERLY_CONTAINS
71 | },
72 | {
73 | desc: 'Containment with equality on both sides',
74 | a0: new Point(-2, 3, 10),
75 | a1: new Point(4, -5, 10),
76 | b0: new Point(-2, 3, 10),
77 | b1: new Point(4, -5, 10),
78 | contains: true,
79 | intersects: true,
80 | relation: WEDGE_EQUALS
81 | },
82 | {
83 | desc: 'Disjoint with equality on one side',
84 | a0: new Point(-2, 3, 10),
85 | a1: new Point(4, -5, 10),
86 | b0: new Point(4, -5, 10),
87 | b1: new Point(-2, -3, 10),
88 | contains: false,
89 | intersects: false,
90 | relation: WEDGE_IS_DISJOINT
91 | },
92 | {
93 | desc: 'Disjoint with equality on the other side',
94 | a0: new Point(-2, 3, 10),
95 | a1: new Point(0, 5, 10),
96 | b0: new Point(4, -5, 10),
97 | b1: new Point(-2, 3, 10),
98 | contains: false,
99 | intersects: false,
100 | relation: WEDGE_IS_DISJOINT
101 | },
102 | {
103 | desc: 'Disjoint with equality on both sides',
104 | a0: new Point(-2, 3, 10),
105 | a1: new Point(4, -5, 10),
106 | b0: new Point(4, -5, 10),
107 | b1: new Point(-2, 3, 10),
108 | contains: false,
109 | intersects: false,
110 | relation: WEDGE_IS_DISJOINT
111 | },
112 | {
113 | desc: 'B contains A with equality on one side',
114 | a0: new Point(2, 1, 10),
115 | a1: new Point(1, -5, 10),
116 | b0: new Point(2, 1, 10),
117 | b1: new Point(-1, -1, 10),
118 | contains: false,
119 | intersects: true,
120 | relation: WEDGE_IS_PROPERLY_CONTAINED
121 | },
122 | {
123 | desc: 'B contains A with equality on the other side',
124 | a0: new Point(2, 1, 10),
125 | a1: new Point(1, -5, 10),
126 | b0: new Point(-2, 1, 10),
127 | b1: new Point(1, -5, 10),
128 | contains: false,
129 | intersects: true,
130 | relation: WEDGE_IS_PROPERLY_CONTAINED
131 | }
132 | ]
133 |
134 | for (const testCase of TEST_CASES) {
135 | const a0 = Point.fromVector(testCase.a0.vector.normalize())
136 | const a1 = Point.fromVector(testCase.a1.vector.normalize())
137 | const b0 = Point.fromVector(testCase.b0.vector.normalize())
138 | const b1 = Point.fromVector(testCase.b1.vector.normalize())
139 |
140 | equal(
141 | wedgeContains(a0, AB1, a1, b0, b1),
142 | testCase.contains,
143 | `${testCase.desc}: WedgeContains(${a0}, ${AB1}, ${a1}, ${b0}, ${b1}) = ${testCase.contains}`
144 | )
145 |
146 | equal(
147 | wedgeIntersects(a0, AB1, a1, b0, b1),
148 | testCase.intersects,
149 | `${testCase.desc}: WedgeIntersects(${a0}, ${AB1}, ${a1}, ${b0}, ${b1}) = ${testCase.intersects}`
150 | )
151 |
152 | equal(
153 | wedgeRelation(a0, AB1, a1, b0, b1),
154 | testCase.relation,
155 | `${testCase.desc}: WedgeRelation(${a0}, ${AB1}, ${a1}, ${b0}, ${b1}) = ${testCase.relation}`
156 | )
157 | }
158 | })
159 | })
160 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "exclude": ["**/*_test.ts"],
3 | "compilerOptions": {
4 | "module": "esnext",
5 | "lib": ["dom", "esnext"],
6 | "target": "esnext",
7 | "importHelpers": true,
8 | // output .d.ts declaration files for consumers
9 | "declaration": true,
10 | // output .js.map sourcemap files for consumers
11 | "sourceMap": true,
12 | // match output dir to input dir. e.g. dist/index instead of dist/src/index
13 | "rootDir": ".",
14 | // stricter type-checking for stronger correctness. Recommended by TS
15 | "strict": true,
16 | // linter checks for common issues
17 | "noImplicitReturns": true,
18 | "noFallthroughCasesInSwitch": true,
19 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | // use Node's module resolution algorithm, instead of the legacy TS one
23 | "moduleResolution": "node",
24 | // interop between ESM and CJS modules. Recommended by TS
25 | "esModuleInterop": true,
26 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS
27 | "skipLibCheck": true,
28 | // error out if import and file system have a casing mismatch. Recommended by TS
29 | "forceConsistentCasingInFileNames": true,
30 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc`
31 | "noEmit": true
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/typedoc.json:
--------------------------------------------------------------------------------
1 | {
2 | "out": "docs",
3 | "cleanOutputDir": true,
4 | "excludeExternals": false,
5 | "theme": "default",
6 | "readme": "README.md",
7 | "hideGenerator": true,
8 | "entryPoints": ["index.ts", "node_modules/@types/geojson/index.d.ts"]
9 | }
10 |
--------------------------------------------------------------------------------