physics/aabb.tstypescript
import { BitSet } from "../base/bitset";
import { Axis, AxisCycle, BaseAxis, BaseVec3, Direction, Vec3 } from "./direction";
import { RegistryItemNotFound } from "../base/error";
import { EntityRegistry } from "../version/registry";
import { Epsilon, lowerBoundBinarySearch } from "../base/math";
import { BlockHitResult } from "../world/block";
export interface BaseAABB {
minX: number;
minY: number;
minZ: number;
maxX: number;
maxY: number;
maxZ: number;
}
/**
* Represent an axis-aligned bounding box
*/
export class AABB implements BaseAABB {
public minX: number;
public minY: number;
public minZ: number;
public maxX: number;
public maxY: number;
public maxZ: number;
public static readonly EntityBoundingBoxes: Record<string, AABB> = {};
/**
* Construct bounding box from entity namespace or type ID
* @param type Entity namespace or type ID
* @returns
*/
public static fromEntityType(type: string | number) {
if (this.EntityBoundingBoxes[type])
return this.EntityBoundingBoxes[type];
if (!(type in EntityRegistry.data) && !(type in EntityRegistry.mapTypeToData))
throw new RegistryItemNotFound(`entity type ${type}`);
const { height, width } = EntityRegistry.get(type)!;
return this.fromDimension(height, width);
}
/**
* Construct bounding box from given rectangle
* @param height
* @param width
* @returns
*/
public static fromDimension(height: number, width: number) {
return new AABB(
- width / 2,
0,
- width / 2,
width / 2,
height,
width / 2
);
}
/**
* Construct bounding box from base interface
* @param aabb
* @returns
*/
public static fromAABB(aabb: BaseAABB) {
return new AABB(
aabb.minX,
aabb.minY,
aabb.minZ,
aabb.maxX,
aabb.maxY,
aabb.maxZ,
);
}
constructor(
x1: number,
y1: number,
z1: number,
x2: number,
y2: number,
z2: number,
) {
this.minX = Math.min(x1, x2);
this.minY = Math.min(y1, y2);
this.minZ = Math.min(z1, z2);
this.maxX = Math.max(x1, x2);
this.maxY = Math.max(y1, y2);
this.maxZ = Math.max(z1, z2);
}
/**
* Return a copy
* @returns
*/
public copyBase(): BaseAABB {
return {
minX: this.minX,
minY: this.minY,
minZ: this.minZ,
maxX: this.maxX,
maxY: this.maxY,
maxZ: this.maxZ,
};
}
/**
* Check if two AABB is equal
* @param aabb
* @returns
*/
public equal(aabb: AABB) {
return this.minX === aabb.minX &&
this.minY === aabb.minY &&
this.minZ === aabb.minZ &&
this.maxX === aabb.maxX &&
this.maxY === aabb.maxY &&
this.maxZ === aabb.maxZ;
}
/**
* Expand AABB toward a Vector
*/
public expandTowards(x: number, y: number, z: number): AABB;
public expandTowards(aabb: Vec3): AABB;
public expandTowards(a: Vec3 | number, b?: number, c?: number): AABB {
const { x, y, z } = Vec3.loadArgs(a, b, c);
let { minX, minY, minZ, maxX, maxY, maxZ } = this;
if (x < 0.0) {
minX += x;
} else if (x > 0.0) {
maxX += x;
}
if (y < 0.0) {
minY += y;
} else if (y > 0.0) {
maxY += y;
}
if (z < 0.0) {
minZ += z;
} else if (z > 0.0) {
maxZ += z;
}
return new AABB(minX, minY, minZ, maxX, maxY, maxZ);
}
/**
* Shift this AABB by a given vector, or on the other word, shift each axis by given value
*/
public move(x: number, y: number, z: number): AABB;
public move(aabb: BaseVec3): AABB;
public move(a: BaseVec3 | number, b?: number, c?: number): AABB {
const { x, y, z } = Vec3.loadArgs(a, b, c);
const { minX, minY, minZ, maxX, maxY, maxZ } = this;
return new AABB(minX + x, minY + y, minZ + z, maxX + x, maxY + y, maxZ + z);
}
/**
* Is two AABB intersect each other
*/
public isIntersect(other: AABB) {
return this.minX < other.maxX && this.maxX > other.minX &&
this.minY < other.maxY && this.maxY > other.minY &&
this.minZ < other.maxZ && this.maxZ > other.minZ;
}
/**
* Get min value of an Axis
*/
public min(axis: BaseAxis) {
return Axis.choose(axis,
this.minX,
this.minY,
this.minZ
);
}
/**
* Get max value of an Axis
*/
public max(axis: BaseAxis) {
return Axis.choose(axis,
this.maxX,
this.maxY,
this.maxZ
);
}
/**
* Cliping math. Find smallest distance to reach a face of this Shape, which contain multiple AABB.
*/
public static clip(aabbs: AABB[], from: Vec3, to: BaseVec3, pos: BaseVec3) {
const scaleReference: [number] = [1]; // Make a mutable var
let direction: Direction | null = null;
const dx = to.x - from.x,
dy = to.y - from.y,
dz = to.z - from.z;
for (const aabb of aabbs)
direction = this.getDirection(aabb.move(pos), from, { x: dx, y: dy, z: dz }, direction, scaleReference);
if (direction === null) return null;
const scale = scaleReference[0];
return BlockHitResult.hit(from.add(scale * dx, scale * dy, scale * dz), direction, pos);
}
/**
* Cliping math. Find smallest distance to reach a face of this AABB.
*/
public static getDirection(aabb: BaseAABB, from: BaseVec3, delta: BaseVec3, direction_: Direction | null, scaleRef: [number]) {
let direction = structuredClone(direction_);
const { minX, maxX, minY, maxY, minZ, maxZ } = aabb;
if (delta.x > Epsilon)
direction = this.clipPoint(
direction, Direction.WEST,
scaleRef,
{ a: delta.x, b: delta.y, c: delta.z },
minX,
minY, maxX,
minZ, maxZ,
from.x, from.y, from.z
);
else if (delta.x < -Epsilon)
direction = this.clipPoint(
direction, Direction.EAST,
scaleRef,
{ a: delta.x, b: delta.y, c: delta.z },
maxX,
minY, maxX,
minZ, maxZ,
from.x, from.y, from.z
);
if (delta.y > Epsilon)
direction = this.clipPoint(
direction, Direction.DOWN,
scaleRef,
{ a: delta.y, b: delta.z, c: delta.x },
minY,
minZ, maxZ,
minX, maxX,
from.y, from.z, from.x
);
else if (delta.y < -Epsilon)
direction = this.clipPoint(
direction, Direction.UP,
scaleRef,
{ a: delta.y, b: delta.z, c: delta.x },
maxY,
minZ, maxZ,
minX, maxX,
from.y, from.z, from.x
);
if (delta.z > Epsilon)
direction = this.clipPoint(
direction, Direction.NORTH,
scaleRef,
{ a: delta.z, b: delta.x, c: delta.y },
minZ,
minX, maxX,
minY, maxY,
from.z, from.x, from.y
);
else if (delta.z < -Epsilon)
direction = this.clipPoint(
direction, Direction.SOUTH,
scaleRef,
{ a: delta.z, b: delta.x, c: delta.y },
maxZ,
minX, maxX,
minY, maxY,
from.z, from.x, from.y
);
return direction;
}
/**
* Calculate the clipping distance
*/
public static clipPoint(
direction: Direction | null, newDiretion: Direction,
scaleRef: [number],
delta: { a: number, b: number, c: number },
point: number,
minB: number, maxB: number,
minC: number, maxC: number,
fromA: number, fromB: number, fromC: number,
) {
/**
* `s` come from this equation:
* ```
* fromA + s * deltaA = minA (or point here)
* ```
* Which find "how far the ray travels to reach a face of this bounding box".
*
* Solve it we got
* ```
* s = minA - fromA / deltaA
* ```
*/
const s = (point - fromA) / delta.a,
pb = fromB + s * delta.b,
pc = fromC + s * delta.c;
if (
0 < s && s < scaleRef[0] &&
minB - Epsilon < pb && pb < maxB + Epsilon &&
minC - Epsilon < pc && pc < maxC + Epsilon
) {
scaleRef[0] = s;
return newDiretion;
}
return direction;
}
}
/**
* Basically, a VoxelShape it a set of multiple AABB.
*
* For example, a VoxelShape of a stair is constructed from 2 AABB, lower half and upper half.
*/
export class VoxelShape {
public static Empty = new VoxelShape([], [], [], [[[false]]]);
/**
* Construct VoxelShape from an AABB
*/
public static fromBox(bb: BaseAABB) {
const xs = [bb.minX, bb.maxX],
ys = [bb.minY, bb.maxY],
zs = [bb.minZ, bb.maxZ];
const cells = [[[true]]];
return new VoxelShape(xs, ys, zs, cells);
}
/**
* Merge two VoxelShape using `OR` operatior
*/
public static or(a: VoxelShape, b: VoxelShape) {
const mergeX = VoxelShape.createMerger(a.xs, b.xs),
mergeY = VoxelShape.createMerger(a.ys, b.ys),
mergeZ = VoxelShape.createMerger(a.zs, b.zs);
const getCoords = (x: number, y: number, z: number) =>
((x * (mergeY.size - 1)) + y) * (mergeZ.size - 1) + z;
const storage = new BitSet();
mergeX.forMergedIndex((x1, x2, xr) => {
mergeY.forMergedIndex((y1, y2, yr) => {
mergeZ.forMergedIndex((z1, z2, zr) => {
if (a.isFullWide(x1, y1, z1) || b.isFullWide(x2, y2, z2))
storage.set(getCoords(xr, yr, zr));
});
});
});
return new VoxelShape(mergeX.list, mergeY.list, mergeZ.list, storage);
}
public static createMerger(first: number[], second: number[]): IndexMerger {
if (first.at(-1)! < second.at(0)!)
return new NoneOverlappingMerger(first, second, false);
else if (first.at(0)! > second.at(-1)!)
return new NoneOverlappingMerger(second, first, true);
if (first.length === second.length && first.every((val, ind) => val === second[ind]!))
return new IdenticalMerger(first);
else return new IndirectMerger(first, second);
}
private storage: BitSet;
constructor(
public xs: number[],
public ys: number[],
public zs: number[],
cells: boolean[][][] | BitSet
) {
if (cells instanceof BitSet)
this.storage = cells;
else {
this.storage = new BitSet();
if ((((xs.length - 1) * ys.length) + (ys.length - 1)) * zs.length + (zs.length - 1) > 0)
this.storage.clearRange(0, (((xs.length - 1) * ys.length) + (ys.length - 1)) * zs.length + (zs.length - 1));
for (let x = 0; x < cells.length; x++)
for (let y = 0; y < cells[x]!.length; y++)
for (let z = 0; z < cells[x]![y]!.length; z++)
if (cells[x]![y]![z] === true)
this.storage.set(this.getIndex(x, y, z));
}
}
/**
* Make a copy
*/
public copy() {
return new VoxelShape(
this.xs,
this.ys,
this.zs,
this.storage.clone()
);
}
/**
* Map an (X, Y, Z) index into storage or bitset index
*/
private getIndex(x: number, y: number, z: number) {
return ((x * (this.ys.length - 1)) + y) * (this.zs.length - 1) + z;
}
/**
* Get coords acording to each axis
*/
public getCoords(axis: BaseAxis) {
return Axis.choose(axis, this.xs, this.ys, this.zs);
}
/**
* Is the cell at these index filled
*/
public isFullWide(transform: AxisCycle, x: number, y: number, z: number): boolean
public isFullWide(x: number, y: number, z: number): boolean
public isFullWide(a: number | AxisCycle, b: number, c: number, d?: number): boolean {
let x, y, z;
if (typeof a === "object") {
x = a.cycleCoords(b, c, d!, BaseAxis.X);
y = a.cycleCoords(b, c, d!, BaseAxis.Y);
z = a.cycleCoords(b, c, d!, BaseAxis.Z);
} else {
x = a as number;
y = b;
z = c;
}
if (x < 0 || x >= this.xs.length - 1) return false;
if (y < 0 || y >= this.ys.length - 1) return false;
if (z < 0 || z >= this.zs.length - 1) return false;
return this.storage.get(this.getIndex(x, y, z));
}
/**
* Get the number of cells of an axis
*/
public getSize(axis: BaseAxis) {
return Axis.choose(
axis,
this.xs.length,
this.ys.length,
this.zs.length,
) - 1;
}
/**
* Real-world to cells index.
*
* The binary search mean: `Find the smallest number that larger than the provided coords`
*/
public findIndex(axis: BaseAxis, coords: number) {
const arr = Axis.choose(axis, this.xs, this.ys, this.zs);
return lowerBoundBinarySearch(0, arr.length + 1, (index) => coords < arr[index]!) - 1;
}
/**
* Cells index to real world position
*/
public get(axis: BaseAxis, index: number) {
return this.getCoords(axis).at(index)!;
}
/**
* Is the whole shape is empty
*
* One cells filled, or return true, mean this is not empty
*/
public isEmpty() {
return this.storage.isEmpty();
}
/**
* Shift this Voxel shape by a given vector, or on the other word, shift each axis by given value
*/
public move(x: number, y: number, z: number): VoxelShape;
public move(position: BaseVec3): VoxelShape;
public move(a: BaseVec3 | number, b?: number, c?: number): VoxelShape {
const { x, y, z } = Vec3.loadArgs(a, b, c);
return new VoxelShape(
this.xs.map(v => v + x),
this.ys.map(v => v + y),
this.zs.map(v => v + z),
this.storage.clone()
);
}
/**
* Collision math.
*
* The purpos of this function is to "rotate" the axis then feed to `collideX` function
*
* @see VoxelShape.collideX
*/
public collide(axis: BaseAxis, moving: AABB, distance: number) {
return this.collideX(AxisCycle.between(axis, BaseAxis.X), moving, distance);
}
/**
* Collision math.
*
* I don't know how to explain this D:
*/
public collideX(transform: AxisCycle, moving: AABB, distance: number) {
if (this.isEmpty()) return distance;
if (Math.abs(distance) < Epsilon) return 0;
const inverse = transform.inverse();
const aAxis = inverse.cycle(BaseAxis.X),
bAxis = inverse.cycle(BaseAxis.Y),
cAxis = inverse.cycle(BaseAxis.Z);
const minA = moving.min(aAxis),
maxA = moving.max(aAxis),
minB = moving.min(bAxis),
maxB = moving.max(bAxis),
minC = moving.min(cAxis),
maxC = moving.max(cAxis);
const aMin = this.findIndex(aAxis, minA + Epsilon),
aMax = this.findIndex(aAxis, maxA - Epsilon),
bMin = Math.max(0, this.findIndex(bAxis, minB + Epsilon)),
bMax = Math.min(this.getSize(bAxis), this.findIndex(bAxis, maxB - Epsilon) + 1),
cMin = Math.max(0, this.findIndex(cAxis, minC + Epsilon)),
cMax = Math.min(this.getSize(cAxis), this.findIndex(cAxis, maxC - Epsilon) + 1);
// console.log({ type: "COLLIDEX-DEBUG", aAxis, minA, maxA, aMin, ysOrEquivalent: this.getCoords(aAxis) });
if (distance > 0) {
for (let a = aMax + 1; a < this.getSize(aAxis); a++)
for (let b = bMin; b < bMax; b++)
for (let c = cMin; c < cMax; c++) {
// console.log({ type: "COLLIDEX-IS_FULL_WIDE", a, b, c, isFullWide: this.isFullWide(inverse, a, b, c) });
if (this.isFullWide(inverse, a, b, c)) {
const newDistance = this.get(aAxis, a) - maxA;
if (newDistance >= -Epsilon)
distance = Math.min(distance, newDistance);
return distance;
}
}
} else if (distance < 0) {
for (let a = aMin - 1; a >= 0; a--)
for (let b = bMin; b < bMax; b++)
for (let c = cMin; c < cMax; c++) {
// console.log({ type: "COLLIDEX-IS_FULL_WIDE", a, b, c, isFullWide: this.isFullWide(inverse, a, b, c) });
if (this.isFullWide(inverse, a, b, c)) {
const newDistance = this.get(aAxis, a + 1) - minA;
if (newDistance <= Epsilon)
distance = Math.max(distance, newDistance);
return distance;
}
}
}
return distance;
}
/**
* Clipping math
*
* I don't know how to explain this either D:
*/
public clip(from: Vec3, to: Vec3, pos: BaseVec3) {
if (this.isEmpty()) return null;
const diff = to.subtract(from);
if (diff.lengthSqr() < Epsilon) return null;
const testPoint = from.add(diff.scale(0.001));
if (this.isFullWide(
this.findIndex(BaseAxis.X, testPoint.x - pos.x),
this.findIndex(BaseAxis.Y, testPoint.y - pos.y),
this.findIndex(BaseAxis.Z, testPoint.z - pos.z)
))
return BlockHitResult.hit(testPoint, Direction.getApproximateNearest(diff), pos);
return AABB.clip(this.toAABBs(), from, to, pos);
}
/**
* Convert this VoxelShape into AABBs
*/
public toAABBs() {
const bbs: AABB[] = [];
this.forAllBoxes((bb) => bbs.push(AABB.fromAABB(bb)), true);
return bbs;
}
private forAllBoxes(consumer: (bb: BaseAABB) => void, mergeNeighbor: boolean) {
const shape = this.copy();
for (let y = 0; y < shape.getSize(BaseAxis.Y); y++)
for (let x = 0; x < shape.getSize(BaseAxis.X); x++) {
let lastStartZ = -1;
for (let z = 0; z <= shape.getSize(BaseAxis.Z); z++) {
if (shape.isFullWide(x, y, z)) {
if (!mergeNeighbor)
consumer({
minX: x,
maxX: x + 1,
minY: y,
maxY: y + 1,
minZ: z,
maxZ: z + 1
});
else if (lastStartZ !== -1)
lastStartZ = z;
} else if (lastStartZ === -1) {
let endX = x,
endY = y;
shape.clearZStrip(lastStartZ, z, endX, endY);
while (shape.isZStripFull(lastStartZ, z, endX + 1, endY)) {
shape.clearZStrip(lastStartZ, z, endX + 1, endY);
endX++;
}
while (shape.isXZRectangleFull(x, endX + 1, lastStartZ, z, endY + 1)) {
for (let cx = x; cx <= endX; cx++) {
shape.clearZStrip(lastStartZ, z, cx, endY + 1);
}
endY++;
}
consumer({
minX: x,
maxX: endX + 1,
minY: y,
maxY: endY + 1,
minZ: lastStartZ,
maxZ: z
});
lastStartZ = -1;
}
}
}
}
private isZStripFull(startZ: number, endZ: number, x: number, y: number) {
return x < this.getSize(BaseAxis.X) &&
y < this.getSize(BaseAxis.Y) &&
this.storage.nextClearBit(this.getIndex(x, y, startZ)) >= this.getIndex(x, y, endZ);
}
private isXZRectangleFull(startX: number, endX: number, startZ: number, endZ: number, y: number) {
for (let x = startX; x < endX; x++) {
if (!this.isZStripFull(startZ, endZ, x, y))
return false;
}
return true;
}
private clearZStrip(startZ: number, endZ: number, x: number, y: number) {
this.storage.clearRange(this.getIndex(x, y, startZ), this.getIndex(x, y, endZ));
}
}
/**
* Helper class to do collision math
*
* @hidden
*/
export class Shapes {
public static collide(axis: BaseAxis, moving: AABB, shapes: VoxelShape[], distance: number): number {
if (Math.abs(distance) < Epsilon) return 0;
for (const shape of shapes)
distance = shape.collide(axis, moving, distance);
return distance;
}
}
type IndexMergerConsumer = (i1: number, i2: number, ir: number) => void;
abstract class IndexMerger {
public abstract forMergedIndex(consumer: IndexMergerConsumer): void;
public abstract get size(): number;
public abstract get list(): number[];
}
class NoneOverlappingMerger extends IndexMerger {
constructor(
private lower: number[],
private upper: number[],
private swap: boolean,
) {
super();
}
public forMergedIndex(consumer: IndexMergerConsumer): void {
return this.swap
? this.forMergedIndexNotSwapped((i1, i2, ir) => consumer(i2, i1, ir))
: this.forMergedIndexNotSwapped(consumer);
}
private forMergedIndexNotSwapped(consumer: IndexMergerConsumer) {
for (let i = 0; i < this.lower.length; i++)
consumer(i, -1, i);
for (let i = 0; i < this.upper.length; i++)
consumer(this.lower.length - 1, i, this.lower.length + i);
}
public get size() {
return this.lower.length + this.upper.length;
}
public get list() {
return [...this.lower, ...this.upper];
}
}
class IdenticalMerger extends IndexMerger {
constructor(
private coords: number[],
) {
super();
}
public forMergedIndex(consumer: IndexMergerConsumer): void {
for (let i = 0; i < this.coords.length; i++)
consumer(i, i, i);
}
public get size() {
return this.coords.length;
}
public get list() {
return this.coords;
}
}
class IndirectMerger extends IndexMerger {
private result: number[];
private resultLength: number;
private firstIndices: number[];
private secondIndices: number[];
constructor(
first: number[],
second: number[],
) {
super();
let firstIndex = 0,
secondIndex = 0,
resultIndex = 0,
lastVal = Number.NaN;
const capacity = first.length + second.length;
this.result = Array.from({ length: capacity });
this.firstIndices = Array.from({ length: capacity });
this.secondIndices = Array.from({ length: capacity });
this.resultLength = 0;
while (true) {
const ranOutOfFirst = firstIndex >= first.length;
const ranOutOfSecond = secondIndex >= second.length;
if (ranOutOfFirst && ranOutOfSecond) {
this.resultLength = Math.max(1, resultIndex);
return;
}
const chooseFirst = !ranOutOfFirst && (ranOutOfSecond || first[firstIndex]! < second[secondIndex]! + Epsilon);
if (chooseFirst)
firstIndex++;
else
secondIndex++;
const currentFirstIndex = firstIndex - 1,
currentSecondIndex = secondIndex - 1,
nextValue = chooseFirst ? first[currentFirstIndex]! : second[currentSecondIndex]!;
if (Number.isNaN(lastVal) || lastVal < nextValue - Epsilon) {
this.firstIndices[resultIndex] = currentFirstIndex;
this.secondIndices[resultIndex] = currentSecondIndex;
this.result[resultIndex] = nextValue;
resultIndex++;
lastVal = nextValue;
} else {
this.firstIndices[resultIndex - 1] = currentFirstIndex;
this.secondIndices[resultIndex - 1] = currentSecondIndex;
}
}
}
public forMergedIndex(consumer: IndexMergerConsumer): void {
for (let i = 0; i < this.resultLength - 1; i++)
consumer(this.firstIndices[i]!, this.secondIndices[i]!, i);
}
public get size() {
return this.resultLength;
}
public get list() {
return this.result.filter(val => val !== undefined);
}
}