Skip to content

Commit

Permalink
feat(s2): ShapeIndex
Browse files Browse the repository at this point in the history
  • Loading branch information
missinglink committed Aug 20, 2024
1 parent 5bd75b1 commit c294aa6
Show file tree
Hide file tree
Showing 13 changed files with 3,256 additions and 1 deletion.
164 changes: 164 additions & 0 deletions s2/ContainsPointQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { CROSS, DO_NOT_CROSS, MAYBE_CROSS, vertexCrossing } from './edge_crossings'
import { EdgeCrosser } from './EdgeCrosser'
import { Point } from './Point'
import { NilShape, Shape } from './Shape'
import { ShapeIndex } from './ShapeIndex'
import { ShapeIndexClippedShape } from './ShapeIndexClippedShape'
import { ShapeIndexIterator } from './ShapeIndexIterator'

/**
* VertexModel defines whether shapes are considered to contain their vertices.
* Note that these definitions differ from the ones used by BooleanOperation.
*
* Note that points other than vertices are never contained by polylines.
* If you want need this behavior, use ClosestEdgeQuery's IsDistanceLess
* with a suitable distance threshold instead.
*/
export type VertexModel = number

/** VERTEX_MODEL_OPEN means no shapes contain their vertices (not even points). */
export const VERTEX_MODEL_OPEN: VertexModel = 0

/**
* VERTEX_MODEL_SEMI_OPEN means that polygon point containment is defined
* such that if several polygons tile the region around a vertex, then
* exactly one of those polygons contains that vertex.
*/
export const VERTEX_MODEL_SEMI_OPEN: VertexModel = 1

/**
* VERTEX_MODEL_CLOSED means all shapes contain their vertices (including
* points and polylines).
*/
export const VERTEX_MODEL_CLOSED: VertexModel = 2

/**
* ContainsPointQuery determines whether one or more shapes in a ShapeIndex
* contain a given Point. The ShapeIndex may contain any number of points,
* polylines, and/or polygons (possibly overlapping). Shape boundaries may be
* modeled as Open, SemiOpen, or Closed (this affects whether or not shapes are
* considered to contain their vertices).
*
* This type is not safe for concurrent use.
*
* However, note that if you need to do a large number of point containment
* tests, it is more efficient to re-use the query rather than creating a new
* one each time.
*/
export class ContainsPointQuery {
model: VertexModel
index: ShapeIndex
iter: ShapeIndexIterator

/**
* Returns a new ContainsPointQuery.
* @category Constructors
*/
constructor(index: ShapeIndex, model: VertexModel) {
this.index = index
this.model = model
this.iter = index.iterator()
}

/** Reports whether any shape in the queries index contains the point p under the queries vertex model (Open, SemiOpen, or Closed). */
contains(p: Point): boolean {
if (!this.iter.locatePoint(p)) return false

const cell = this.iter.indexCell()
for (const clipped of cell.shapes) {
if (this._shapeContains(clipped, this.iter.center(), p)) return true
}

return false
}

/** Reports whether the clippedShape from the iterator's center position contains the given point. */
private _shapeContains(clipped: ShapeIndexClippedShape, center: Point, p: Point): boolean {
let inside = clipped.containsCenter
const numEdges = clipped.numEdges()
if (numEdges <= 0) return inside

const shape = this.index.shape(clipped.shapeID)
if (shape.dimension() !== 2) {
// Points and polylines can be ignored unless the vertex model is Closed.
if (this.model !== VERTEX_MODEL_CLOSED) return false

// Otherwise, the point is contained if and only if it matches a vertex.
for (const edgeID of clipped.edges) {
const edge = shape.edge(edgeID)
if (edge.v0.equals(p) || edge.v1.equals(p)) return true
}

return false
}

// Test containment by drawing a line segment from the cell center to the given point
// and counting edge crossings.
let crosser = new EdgeCrosser(center, p)
for (const edgeID of clipped.edges) {
const edge = shape.edge(edgeID)

let sign = crosser.crossingSign(edge.v0, edge.v1)
if (sign === DO_NOT_CROSS) continue

if (sign === MAYBE_CROSS) {
// For the Open and Closed models, check whether p is a vertex.
if (this.model !== VERTEX_MODEL_SEMI_OPEN && (edge.v0.equals(p) || edge.v1.equals(p))) {
return this.model === VERTEX_MODEL_CLOSED
}

if (vertexCrossing(crosser.a, crosser.b, edge.v0, edge.v1)) sign = CROSS
else sign = DO_NOT_CROSS
}
inside = inside !== (sign === CROSS)
}

return inside
}

/** 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. */
shapeContains(shape: Shape, p: Point): boolean {
if (!shape || shape instanceof NilShape) return false
if (!this.iter.locatePoint(p)) return false

const iCell = this.iter.indexCell()
const clipped = iCell.findByShapeID(this.index.idForShape(shape))
if (!clipped) return false

return this._shapeContains(clipped, this.iter.center(), p)
}

/**
* A type of function that can be called against shapes in an index.
*/
shapeVisitorFunc(_shape: Shape): boolean {
return true
}

/**
* Visits all shapes in the given index that contain the
* given point p, terminating early if the given visitor function returns false,
* in which case visitContainingShapes returns false. Each shape is
* visited at most once.
*/
visitContainingShapes(p: Point, f: (shape: Shape) => boolean): boolean {
if (!this.iter.locatePoint(p)) return true

const cell = this.iter.indexCell()
for (const clipped of cell.shapes) {
if (this._shapeContains(clipped, this.iter.center(), p) && !f(this.index.shape(clipped.shapeID))) return false
}

return true
}

/** Returns a slice of all shapes that contain the given point. */
containingShapes(p: Point): Shape[] {
const shapes: Shape[] = []
this.visitContainingShapes(p, (shape: Shape) => {
shapes.push(shape)
return true
})
return shapes
}
}
138 changes: 138 additions & 0 deletions s2/ContainsPointQuery_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { test, describe } from 'node:test'
import { equal, ok, deepEqual } from 'node:assert/strict'
import { makeShapeIndex, parsePoint } from './testing_textformat'
import { Cap } from './Cap'
import { kmToAngle, randomFloat64, randomPoint, samplePointFromCap } from './testing'
import { Shape } from './Shape'
import { Loop } from './Loop'
import { ShapeIndex } from './ShapeIndex'
import {
ContainsPointQuery,
VERTEX_MODEL_CLOSED,
VERTEX_MODEL_OPEN,
VERTEX_MODEL_SEMI_OPEN
} from './ContainsPointQuery'

describe('s2.ContainsPointQuery', () => {
test('VERTEX_MODEL_OPEN', () => {
const index = makeShapeIndex('0:0 # -1:1, 1:1 # 0:5, 0:7, 2:6')
const q = new ContainsPointQuery(index, VERTEX_MODEL_OPEN)

const tests = [
{ pt: parsePoint('0:0'), want: false },
{ pt: parsePoint('-1:1'), want: false },
{ pt: parsePoint('1:1'), want: false },
{ pt: parsePoint('0:2'), want: false },
{ pt: parsePoint('0:3'), want: false },
{ pt: parsePoint('0:5'), want: false },
{ pt: parsePoint('0:7'), want: false },
{ pt: parsePoint('2:6'), want: false },
{ pt: parsePoint('1:6'), want: true },
{ pt: parsePoint('10:10'), want: false }
]

for (const test of tests) {
const got = q.contains(test.pt)
equal(got, test.want, `query.contains(${test.pt}) = ${got}, want ${test.want}`)
}

equal(q.shapeContains(index.shape(1), parsePoint('1:6')), false, 'query.shapeContains(...) = true, want false')
equal(q.shapeContains(index.shape(2), parsePoint('1:6')), true, 'query.shapeContains(...) = false, want true')
equal(q.shapeContains(index.shape(2), parsePoint('0:5')), false, 'query.shapeContains(...) = true, want false')
equal(q.shapeContains(index.shape(2), parsePoint('0:7')), false, 'query.shapeContains(...) = true, want false')
})

test('VERTEX_MODEL_SEMI_OPEN', () => {
const index = makeShapeIndex('0:0 # -1:1, 1:1 # 0:5, 0:7, 2:6')
const q = new ContainsPointQuery(index, VERTEX_MODEL_SEMI_OPEN)

const tests = [
{ pt: parsePoint('0:0'), want: false },
{ pt: parsePoint('-1:1'), want: false },
{ pt: parsePoint('1:1'), want: false },
{ pt: parsePoint('0:2'), want: false },
{ pt: parsePoint('0:5'), want: false },
{ pt: parsePoint('0:7'), want: true },
{ pt: parsePoint('2:6'), want: false },
{ pt: parsePoint('1:6'), want: true },
{ pt: parsePoint('10:10'), want: false }
]

for (const test of tests) {
const got = q.contains(test.pt)
equal(got, test.want, `query.contains(${test.pt}) = ${got}, want ${test.want}`)
}

equal(q.shapeContains(index.shape(1), parsePoint('1:6')), false, 'query.shapeContains(...) = true, want false')
equal(q.shapeContains(index.shape(2), parsePoint('1:6')), true, 'query.shapeContains(...) = false, want true')
equal(q.shapeContains(index.shape(2), parsePoint('0:5')), false, 'query.shapeContains(...) = true, want false')
equal(q.shapeContains(index.shape(2), parsePoint('0:7')), true, 'query.shapeContains(...) = false, want true')
})

test('VERTEX_MODEL_CLOSED', () => {
const index = makeShapeIndex('0:0 # -1:1, 1:1 # 0:5, 0:7, 2:6')
const q = new ContainsPointQuery(index, VERTEX_MODEL_CLOSED)

const tests = [
{ pt: parsePoint('0:0'), want: true },
{ pt: parsePoint('-1:1'), want: true },
{ pt: parsePoint('1:1'), want: true },
{ pt: parsePoint('0:2'), want: false },
{ pt: parsePoint('0:5'), want: true },
{ pt: parsePoint('0:7'), want: true },
{ pt: parsePoint('2:6'), want: true },
{ pt: parsePoint('1:6'), want: true },
{ pt: parsePoint('10:10'), want: false }
]

for (const test of tests) {
const got = q.contains(test.pt)
equal(got, test.want, `query.contains(${test.pt}) = ${got}, want ${test.want}`)
}

equal(q.shapeContains(index.shape(1), parsePoint('1:6')), false, 'query.shapeContains(...) = true, want false')
equal(q.shapeContains(index.shape(2), parsePoint('1:6')), true, 'query.shapeContains(...) = false, want true')
equal(q.shapeContains(index.shape(2), parsePoint('0:5')), true, 'query.shapeContains(...) = false, want true')
equal(q.shapeContains(index.shape(2), parsePoint('0:7')), true, 'query.shapeContains(...) = false, want true')
})

test('containingShapes', () => {
const NUM_VERTICES_PER_LOOP = 10
const MAX_LOOP_RADIUS = kmToAngle(10)
const centerCap = Cap.fromCenterAngle(randomPoint(), MAX_LOOP_RADIUS)
const index = new ShapeIndex()

for (let i = 0; i < 100; i++) {
index.add(
Loop.regularLoop(samplePointFromCap(centerCap), randomFloat64() * MAX_LOOP_RADIUS, NUM_VERTICES_PER_LOOP)
)
}

const query = new ContainsPointQuery(index, VERTEX_MODEL_SEMI_OPEN)

for (let i = 0; i < 100; i++) {
const p = samplePointFromCap(centerCap)
const want: Shape[] = []

for (let j = 0; j < index.shapes.size; j++) {
const shape = index.shape(j)
const loop = shape as Loop
if (loop.containsPoint(p)) {
ok(
query.shapeContains(shape, p),
`index.shape(${j}).containsPoint(${p}) = true, but query.shapeContains(${p}) = false`
)
want.push(shape)
} else {
ok(
!query.shapeContains(shape, p),
`query.shapeContains(shape, ${p}) = true, but the original loop does not contain the point.`
)
}
}

const got = query.containingShapes(p)
deepEqual(got, want, `${i} query.containingShapes(${p}) = ${got}, want ${want}`)
}
})
})
Loading

0 comments on commit c294aa6

Please sign in to comment.