import { Vector } from '../math/vector'; import type { Ray } from '../math/ray'; import { Color } from '../color'; import { Side } from './side'; import type { ExcaliburGraphicsContext, RectGraphicsOptions } from '../graphics/context/excalibur-graphics-context'; import type { AffineMatrix } from '../math/affine-matrix'; import { getMinIndex } from '../util/util'; export interface BoundingBoxOptions { left: number; right: number; top: number; bottom: number; } /** * Axis Aligned collision primitive for Excalibur. */ export class BoundingBox { public top!: number; public right!: number; public bottom!: number; public left!: number; /** * Constructor allows passing of either an object with all coordinate components, * or the coordinate components passed separately. * @param leftOrOptions Either x coordinate of the left edge or an options object * containing the four coordinate components. * @param top y coordinate of the top edge * @param right x coordinate of the right edge * @param bottom y coordinate of the bottom edge */ constructor(leftOrOptions: number | BoundingBoxOptions = 0, top: number = 0, right: number = 0, bottom: number = 0) { if (typeof leftOrOptions === 'object') { this.left = leftOrOptions.left; this.top = leftOrOptions.top; this.right = leftOrOptions.right; this.bottom = leftOrOptions.bottom; } else if (typeof leftOrOptions === 'number') { this.left = leftOrOptions; this.top = top; this.right = right; this.bottom = bottom; } } /** * Returns a new instance of {@apilink BoundingBox} that is a copy of the current instance */ public clone(dest?: BoundingBox): BoundingBox { const result = dest || new BoundingBox(0, 0, 0, 0); result.left = this.left; result.right = this.right; result.top = this.top; result.bottom = this.bottom; return result; } /** * Resets the bounds to a zero width/height box */ public reset(): void { this.left = 0; this.top = 0; this.bottom = 0; this.right = 0; } /** * Given bounding box A & B, returns the side relative to A when intersection is performed. * @param intersection Intersection vector between 2 bounding boxes */ public static getSideFromIntersection(intersection: Vector): Side { if (!intersection) { return Side.None; } if (intersection) { if (Math.abs(intersection.x) > Math.abs(intersection.y)) { if (intersection.x < 0) { return Side.Right; } return Side.Left; } else { if (intersection.y < 0) { return Side.Bottom; } return Side.Top; } } return Side.None; } public static fromPoints(points: Vector[]): BoundingBox { let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for (let i = 0; i < points.length; i++) { if (points[i].x < minX) { minX = points[i].x; } if (points[i].x > maxX) { maxX = points[i].x; } if (points[i].y < minY) { minY = points[i].y; } if (points[i].y > maxY) { maxY = points[i].y; } } return new BoundingBox(minX, minY, maxX, maxY); } /** * Creates a bounding box from a width and height * @param width * @param height * @param anchor Default Vector.Half * @param pos Default Vector.Zero */ public static fromDimension(width: number, height: number, anchor: Vector = Vector.Half, pos: Vector = Vector.Zero): BoundingBox { return new BoundingBox( -width * anchor.x + pos.x, -height * anchor.y + pos.y, width - width * anchor.x + pos.x, height - height * anchor.y + pos.y ); } /** * Returns the calculated width of the bounding box */ public get width() { return this.right - this.left; } /** * Returns the calculated height of the bounding box */ public get height() { return this.bottom - this.top; } /** * Return whether the bounding box has zero dimensions in height,width or both */ public hasZeroDimensions() { return this.width === 0 || this.height === 0; } /** * Returns the center of the bounding box */ public get center(): Vector { return new Vector((this.left + this.right) / 2, (this.top + this.bottom) / 2); } public get topLeft(): Vector { return new Vector(this.left, this.top); } public get bottomRight(): Vector { return new Vector(this.right, this.bottom); } public get topRight(): Vector { return new Vector(this.right, this.top); } public get bottomLeft(): Vector { return new Vector(this.left, this.bottom); } public translate(pos: Vector): BoundingBox { return new BoundingBox(this.left + pos.x, this.top + pos.y, this.right + pos.x, this.bottom + pos.y); } /** * Rotates a bounding box by and angle and around a point, if no point is specified (0, 0) is used by default. The resulting bounding * box is also axis-align. This is useful when a new axis-aligned bounding box is needed for rotated geometry. */ public rotate(angle: number, point: Vector = Vector.Zero): BoundingBox { const points = this.getPoints().map((p) => p.rotate(angle, point)); return BoundingBox.fromPoints(points); } /** * Scale a bounding box by a scale factor, optionally provide a point * @param scale * @param point */ public scale(scale: Vector, point: Vector = Vector.Zero): BoundingBox { const shifted = this.translate(point); return new BoundingBox(shifted.left * scale.x, shifted.top * scale.y, shifted.right * scale.x, shifted.bottom * scale.y); } /** * Transform the axis aligned bounding box by a {@apilink Matrix}, producing a new axis aligned bounding box * @param matrix */ public transform(matrix: AffineMatrix, dest?: BoundingBox) { // inlined these calculations to not use vectors would speed it up slightly // const matFirstColumn = vec(matrix.data[0], matrix.data[1]); // const xa = matFirstColumn.scale(this.left); const xa1 = matrix.data[0] * this.left; const xa2 = matrix.data[1] * this.left; // const xb = matFirstColumn.scale(this.right); const xb1 = matrix.data[0] * this.right; const xb2 = matrix.data[1] * this.right; // const matSecondColumn = vec(matrix.data[2], matrix.data[3]); // const ya = matSecondColumn.scale(this.top); const ya1 = matrix.data[2] * this.top; const ya2 = matrix.data[3] * this.top; // const yb = matSecondColumn.scale(this.bottom); const yb1 = matrix.data[2] * this.bottom; const yb2 = matrix.data[3] * this.bottom; const matrixPos = matrix.getPosition(); // const topLeft = Vector.min(xa, xb).add(Vector.min(ya, yb)).add(matrixPos); // const bottomRight = Vector.max(xa, xb).add(Vector.max(ya, yb)).add(matrixPos); const left = Math.min(xa1, xb1) + Math.min(ya1, yb1) + matrixPos.x; const top = Math.min(xa2, xb2) + Math.min(ya2, yb2) + matrixPos.y; const right = Math.max(xa1, xb1) + Math.max(ya1, yb1) + matrixPos.x; const bottom = Math.max(xa2, xb2) + Math.max(ya2, yb2) + matrixPos.y; if (dest) { dest.left = left; dest.top = top; dest.right = right; dest.bottom = bottom; return dest; } return new BoundingBox({ left, //: topLeft.x, top, //: topLeft.y, right, //: bottomRight.x, bottom //: bottomRight.y }); } /** * Returns the perimeter of the bounding box */ public getPerimeter(): number { const wx = this.width; const wy = this.height; return 2 * (wx + wy); } // Cache bounding box point returns private _points: Vector[] = []; private _left?: number; private _right?: number; private _top?: number; private _bottom?: number; /** * Returns the world space points that make up the corners of the bounding box as a polygon */ public getPoints(): readonly Vector[] { if (this._left !== this.left || this._right !== this.right || this._top !== this.top || this._bottom !== this.bottom) { this._points.length = 0; this._points.push(new Vector(this.left, this.top)); this._points.push(new Vector(this.right, this.top)); this._points.push(new Vector(this.right, this.bottom)); this._points.push(new Vector(this.left, this.bottom)); this._left = this.left; this._right = this.right; this._top = this.top; this._bottom = this.bottom; } return this._points; } /** * Determines whether a ray intersects with a bounding box */ public rayCast(ray: Ray, farClipDistance = Infinity): boolean { // algorithm from https://tavianator.com/fast-branchless-raybounding-box-intersections/ // principle visualisation: https://youtu.be/GqwUHXvQ7oA let tMinMax: number, tMaxMin: number; const xInv = ray.dir.x === 0 ? Number.MAX_VALUE : 1 / ray.dir.x; const yInv = ray.dir.y === 0 ? Number.MAX_VALUE : 1 / ray.dir.y; const tx1 = (this.left - ray.pos.x) * xInv; const tx2 = (this.right - ray.pos.x) * xInv; tMaxMin = Math.min(tx1, tx2); tMinMax = Math.max(tx1, tx2); const ty1 = (this.top - ray.pos.y) * yInv; const ty2 = (this.bottom - ray.pos.y) * yInv; tMaxMin = Math.max(tMaxMin, Math.min(ty1, ty2)); tMinMax = Math.min(tMinMax, Math.max(ty1, ty2)); return tMinMax >= 0 && tMinMax >= tMaxMin && tMaxMin < farClipDistance; } /** * Returns the time along the ray where a raycast hits */ public rayCastTime(ray: Ray, farClipDistance = Infinity): number { // algorithm from https://tavianator.com/fast-branchless-raybounding-box-intersections/ // principle visualisation: https://youtu.be/GqwUHXvQ7oA let tMinMax: number, tMaxMin: number; const xInv = ray.dir.x === 0 ? Number.MAX_VALUE : 1 / ray.dir.x; const yInv = ray.dir.y === 0 ? Number.MAX_VALUE : 1 / ray.dir.y; const tx1 = (this.left - ray.pos.x) * xInv; const tx2 = (this.right - ray.pos.x) * xInv; tMaxMin = Math.min(tx1, tx2); tMinMax = Math.max(tx1, tx2); const ty1 = (this.top - ray.pos.y) * yInv; const ty2 = (this.bottom - ray.pos.y) * yInv; tMaxMin = Math.max(tMaxMin, Math.min(ty1, ty2)); tMinMax = Math.min(tMinMax, Math.max(ty1, ty2)); if (tMinMax >= 0 && tMinMax >= tMaxMin && tMaxMin < farClipDistance) { return tMaxMin; } return -1; } /** * Tests whether a point is contained within the bounding box * @param p The point to test */ public contains(p: Vector): boolean; /** * Tests whether another bounding box is totally contained in this one * @param bb The bounding box to test */ public contains(bb: BoundingBox): boolean; public contains(val: any): boolean { if (val instanceof Vector) { return this.left <= val.x && this.top <= val.y && val.y <= this.bottom && val.x <= this.right; } else if (val instanceof BoundingBox) { return this.left <= val.left && this.top <= val.top && val.bottom <= this.bottom && val.right <= this.right; } return false; } /** * Combines this bounding box and another together returning a new bounding box * @param other The bounding box to combine */ public combine(other: BoundingBox, dest?: BoundingBox): BoundingBox { const compositeBB = dest || new BoundingBox(0, 0, 0, 0); const left = Math.min(this.left, other.left); const top = Math.min(this.top, other.top); const right = Math.max(this.right, other.right); const bottom = Math.max(this.bottom, other.bottom); compositeBB.left = left; compositeBB.top = top; compositeBB.right = right; compositeBB.bottom = bottom; return compositeBB; } public get dimensions(): Vector { return new Vector(this.width, this.height); } /** * Returns true if the bounding boxes overlap. * @param other * @param epsilon Optionally specify a small epsilon (default 0) as amount of overlap to ignore as overlap. * This epsilon is useful in stable collision simulations. */ public overlaps(other: BoundingBox, epsilon?: number): boolean { const e = epsilon || 0; if (other.hasZeroDimensions()) { return this.contains(other); } if (this.hasZeroDimensions()) { return other.contains(this); } const totalBoundingBox = this.combine(other); return totalBoundingBox.width + e < other.width + this.width && totalBoundingBox.height + e < other.height + this.height; } private static _SCRATCH_INTERSECT = [0, 0, 0, 0]; /** * Test wether this bounding box intersects with another returning * the intersection vector that can be used to resolve the collision. If there * is no intersection null is returned. * @param other Other {@apilink BoundingBox} to test intersection with * @returns A Vector in the direction of the current BoundingBox, this <- other */ public intersect(other: BoundingBox): Vector | null { // early exit if (this.bottom <= other.top || other.bottom <= this.top || this.right <= other.left || other.right <= this.left) { return null; } // compute the pathes needed to get ahead of the other box in that direction // if path <= 0 it means this box is already ahead in that direction const topPath = this.bottom - other.top; BoundingBox._SCRATCH_INTERSECT[0] = topPath; const bottomPath = other.bottom - this.top; BoundingBox._SCRATCH_INTERSECT[1] = bottomPath; const leftPath = this.right - other.left; BoundingBox._SCRATCH_INTERSECT[2] = leftPath; const rightPath = other.right - this.left; BoundingBox._SCRATCH_INTERSECT[3] = rightPath; const minIndex = getMinIndex(BoundingBox._SCRATCH_INTERSECT) as 0 | 1 | 2 | 3; switch (minIndex) { case 0: return new Vector(0, -topPath); case 1: return new Vector(0, bottomPath); case 2: return new Vector(-leftPath, 0); case 3: return new Vector(rightPath, 0); default: const index: never = minIndex; throw new Error(`Unreachable index: [${index}] on bounding box intersection!`); } } /** * Test whether the bounding box has intersected with another bounding box, returns the side of the current bb that intersected. * @param bb The other actor to test */ public intersectWithSide(bb: BoundingBox): Side { const intersect = this.intersect(bb); return BoundingBox.getSideFromIntersection(intersect!); } /** * Draw a debug bounding box * @param ex * @param options * @deprecated */ public draw(ex: ExcaliburGraphicsContext, options: RectGraphicsOptions = { color: Color.Yellow }) { ex.debug.drawRect(this.left, this.top, this.width, this.height, options); } /** * Draw a debug bounding box * @param ex * @param options */ public debug(ex: ExcaliburGraphicsContext, options: RectGraphicsOptions = { color: Color.Yellow }) { ex.debug.drawRect(this.left, this.top, this.width, this.height, options); } }