├── .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 | --------------------------------------------------------------------------------