diff --git a/change/@minecraft-math-48d95639-f81f-4548-b688-17fcd7f7e803.json b/change/@minecraft-math-48d95639-f81f-4548-b688-17fcd7f7e803.json new file mode 100644 index 0000000..d2790df --- /dev/null +++ b/change/@minecraft-math-48d95639-f81f-4548-b688-17fcd7f7e803.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Added AABBUtils for performing functional operations on AABB objects, added Vector3Utils ceil, min and max", + "packageName": "@minecraft/math", + "email": "niamh.cuileann@skyboxlabs.com", + "dependentChangeType": "patch" +} diff --git a/libraries/math/__mocks__/minecraft-server.ts b/libraries/math/__mocks__/minecraft-server.ts new file mode 100644 index 0000000..e98c2ef --- /dev/null +++ b/libraries/math/__mocks__/minecraft-server.ts @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Vector3 } from '@minecraft/server'; +import { Vector3Utils } from '../src/vector3/coreHelpers.js'; + +export class BlockVolume { + constructor( + public from: Vector3, + public to: Vector3 + ) { + this.from = Vector3Utils.floor(from); + this.to = Vector3Utils.floor(to); + } +} + +export const createMockServerBindings = () => { + return { BlockVolume }; +}; diff --git a/libraries/math/api-extractor.json b/libraries/math/api-extractor.json index 6ca38f6..a7deb6c 100644 --- a/libraries/math/api-extractor.json +++ b/libraries/math/api-extractor.json @@ -3,5 +3,6 @@ */ { "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", - "extends": "@minecraft/api-extractor-base/api-extractor-base.json" + "extends": "@minecraft/api-extractor-base/api-extractor-base.json", + "mainEntryPointFilePath": "/temp/types/src/index.d.ts" } diff --git a/libraries/math/api-report/math.api.md b/libraries/math/api-report/math.api.md index 927e951..bfeb8f1 100644 --- a/libraries/math/api-report/math.api.md +++ b/libraries/math/api-report/math.api.md @@ -4,8 +4,35 @@ ```ts +import type { AABB } from '@minecraft/server'; +import { BlockVolume } from '@minecraft/server'; import type { Vector2 } from '@minecraft/server'; import type { Vector3 } from '@minecraft/server'; +import type { VectorXZ } from '@minecraft/server'; + +// @public +export class AABBInvalidExtentError extends Error { + constructor(extent: Vector3); +} + +// @public +export class AABBUtils { + static createFromCornerPoints(pointA: Vector3, pointB: Vector3): AABB; + static dilate(aabb: AABB, size: Vector3): AABB; + static EPSILON: number; + static equals(aabb: AABB, other: AABB): boolean; + static expand(aabb: AABB, other: AABB): AABB; + static getBlockVolume(aabb: AABB): BlockVolume; + static getIntersection(aabb: AABB, other: AABB): AABB | undefined; + static getMax(aabb: AABB): Vector3; + static getMin(aabb: AABB): Vector3; + static getSpan(aabb: AABB): Vector3; + static intersects(aabb: AABB, other: AABB): boolean; + static isInside(aabb: AABB, pos: Vector3): boolean; + static isValid(aabb: AABB): boolean; + static throwErrorIfInvalid(aabb: AABB): void; + static translate(aabb: AABB, delta: Vector3): AABB; +} // @public export function clampNumber(val: number, min: number, max: number): number; @@ -18,6 +45,23 @@ export class Vector2Builder implements Vector2 { constructor(vecStr: string, delim?: string); constructor(vec: Vector2, arg?: never); constructor(x: number, y: number); + add(v: Partial): this; + assign(vec: Vector2): this; + clamp(limits: { + min?: Partial; + max?: Partial; + }): this; + distance(vec: Vector2): number; + dot(vec: Vector2): number; + equals(v: Vector2): boolean; + floor(): this; + lerp(vec: Vector2, t: number): this; + magnitude(): number; + multiply(vec: Vector2): this; + normalize(): this; + scale(val: number): this; + slerp(vec: Vector2, t: number): this; + subtract(v: Partial): this; // (undocumented) toString(options?: { decimals?: number; @@ -31,7 +75,23 @@ export class Vector2Builder implements Vector2 { // @public export class Vector2Utils { + static add(v1: Vector2, v2: Partial): Vector2; + static clamp(v: Vector2, limits?: { + min?: Partial; + max?: Partial; + }): Vector2; + static distance(a: Vector2, b: Vector2): number; + static dot(a: Vector2, b: Vector2): number; + static equals(v1: Vector2, v2: Vector2): boolean; + static floor(v: Vector2): Vector2; static fromString(str: string, delimiter?: string): Vector2 | undefined; + static lerp(a: Vector2, b: Vector2, t: number): Vector2; + static magnitude(v: Vector2): number; + static multiply(a: Vector2, b: Vector2): Vector2; + static normalize(v: Vector2): Vector2; + static scale(v1: Vector2, scale: number): Vector2; + static slerp(a: Vector2, b: Vector2, t: number): Vector2; + static subtract(v1: Vector2, v2: Partial): Vector2; static toString(v: Vector2, options?: { decimals?: number; delimiter?: string; @@ -87,6 +147,7 @@ export class Vector3Builder implements Vector3 { constructor(x: number, y: number, z: number); add(v: Partial): this; assign(vec: Vector3): this; + ceil(): this; clamp(limits: { min?: Partial; max?: Partial; @@ -98,6 +159,8 @@ export class Vector3Builder implements Vector3 { floor(): this; lerp(vec: Vector3, t: number): this; magnitude(): number; + max(vec: Vector3): this; + min(vec: Vector3): this; multiply(vec: Vector3): this; normalize(): this; rotateX(a: number): this; @@ -121,6 +184,7 @@ export class Vector3Builder implements Vector3 { // @public export class Vector3Utils { static add(v1: Vector3, v2: Partial): Vector3; + static ceil(v: Vector3): Vector3; static clamp(v: Vector3, limits?: { min?: Partial; max?: Partial; @@ -133,6 +197,8 @@ export class Vector3Utils { static fromString(str: string, delimiter?: string): Vector3 | undefined; static lerp(a: Vector3, b: Vector3, t: number): Vector3; static magnitude(v: Vector3): number; + static max(a: Vector3, b: Vector3): Vector3; + static min(a: Vector3, b: Vector3): Vector3; static multiply(a: Vector3, b: Vector3): Vector3; static normalize(v: Vector3): Vector3; static rotateX(v: Vector3, a: number): Vector3; @@ -147,6 +213,67 @@ export class Vector3Utils { }): string; } +// @public +export const VECTORXZ_ZERO: VectorXZ; + +// @public +export class VectorXZBuilder implements VectorXZ { + constructor(vecStr: string, delim?: string); + constructor(vec: VectorXZ, arg?: never); + constructor(x: number, y: number); + add(v: Partial): this; + assign(vec: VectorXZ): this; + clamp(limits: { + min?: Partial; + max?: Partial; + }): this; + distance(vec: VectorXZ): number; + dot(vec: VectorXZ): number; + equals(v: VectorXZ): boolean; + floor(): this; + lerp(vec: VectorXZ, t: number): this; + magnitude(): number; + multiply(vec: VectorXZ): this; + normalize(): this; + scale(val: number): this; + slerp(vec: VectorXZ, t: number): this; + subtract(v: Partial): this; + // (undocumented) + toString(options?: { + decimals?: number; + delimiter?: string; + }): string; + // (undocumented) + x: number; + // (undocumented) + z: number; +} + +// @public +export class VectorXZUtils { + static add(v1: VectorXZ, v2: Partial): VectorXZ; + static clamp(v: VectorXZ, limits?: { + min?: Partial; + max?: Partial; + }): VectorXZ; + static distance(a: VectorXZ, b: VectorXZ): number; + static dot(a: VectorXZ, b: VectorXZ): number; + static equals(v1: VectorXZ, v2: VectorXZ): boolean; + static floor(v: VectorXZ): VectorXZ; + static fromString(str: string, delimiter?: string): VectorXZ | undefined; + static lerp(a: VectorXZ, b: VectorXZ, t: number): VectorXZ; + static magnitude(v: VectorXZ): number; + static multiply(a: VectorXZ, b: VectorXZ): VectorXZ; + static normalize(v: VectorXZ): VectorXZ; + static scale(v1: VectorXZ, scale: number): VectorXZ; + static slerp(a: VectorXZ, b: VectorXZ, t: number): VectorXZ; + static subtract(v1: VectorXZ, v2: Partial): VectorXZ; + static toString(v: VectorXZ, options?: { + decimals?: number; + delimiter?: string; + }): string; +} + // (No @packageDocumentation comment for this package) ``` diff --git a/libraries/math/just.config.cts b/libraries/math/just.config.cts index 85da464..4fb25aa 100644 --- a/libraries/math/just.config.cts +++ b/libraries/math/just.config.cts @@ -24,7 +24,7 @@ task('typescript', tscTask()); task('api-extractor-local', apiExtractorTask('./api-extractor.json', isOnlyBuild /* localBuild */)); task('bundle', () => { execSync( - 'npx esbuild ./lib/index.js --bundle --outfile=dist/minecraft-math.js --format=esm --sourcemap --external:@minecraft/server' + 'npx esbuild ./lib/src/index.js --bundle --outfile=dist/minecraft-math.js --format=esm --sourcemap --external:@minecraft/server', ); // Copy over type definitions and rename const officialTypes = JSON.parse(readFileSync('./package.json', 'utf-8'))['types']; diff --git a/libraries/math/src/aabb/coreHelpers.test.ts b/libraries/math/src/aabb/coreHelpers.test.ts new file mode 100644 index 0000000..a9695f4 --- /dev/null +++ b/libraries/math/src/aabb/coreHelpers.test.ts @@ -0,0 +1,286 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it, vi } from 'vitest'; +import { createMockServerBindings } from '../../__mocks__/minecraft-server.js'; +vi.mock('@minecraft/server', () => createMockServerBindings()); + +import type { AABB, Vector3 } from '@minecraft/server'; +import { + VECTOR3_FORWARD, + VECTOR3_NEGATIVE_ONE, + VECTOR3_ONE, + VECTOR3_ZERO, + Vector3Utils, +} from '../vector3/coreHelpers.js'; +import { AABBInvalidExtentError, AABBUtils } from './coreHelpers.js'; + +describe('AABB factories', () => { + it('successfully throws error when created from identical corner points', () => { + expect(() => AABBUtils.createFromCornerPoints(VECTOR3_ZERO, VECTOR3_ZERO)).toThrow( + new AABBInvalidExtentError(VECTOR3_ZERO) + ); + }); + + it('successfully reports expected AABB when corner point A is less than B', () => { + const aabb = AABBUtils.createFromCornerPoints(VECTOR3_ZERO, VECTOR3_ONE); + const expectedCenter = { x: 0.5, y: 0.5, z: 0.5 }; + const expectedextent = { x: 0.5, y: 0.5, z: 0.5 }; + expect(AABBUtils.isValid(aabb)).toBe(true); + expect(Vector3Utils.equals(aabb.center, expectedCenter)).toBe(true); + expect(Vector3Utils.equals(aabb.extent, expectedextent)).toBe(true); + }); + + it('successfully reports expected AABB when corner point B is less than A', () => { + const aabb = AABBUtils.createFromCornerPoints(VECTOR3_ONE, VECTOR3_ZERO); + const expectedCenter = { x: 0.5, y: 0.5, z: 0.5 }; + const expectedextent = { x: 0.5, y: 0.5, z: 0.5 }; + expect(AABBUtils.isValid(aabb)).toBe(true); + expect(Vector3Utils.equals(aabb.center, expectedCenter)).toBe(true); + expect(Vector3Utils.equals(aabb.extent, expectedextent)).toBe(true); + }); +}); + +describe('AABB operations', () => { + const validAABB: AABB = { center: VECTOR3_ZERO, extent: VECTOR3_ONE }; + const invalidAABB: AABB = { center: VECTOR3_ZERO, extent: VECTOR3_ZERO }; + const negativeExtentAABB: AABB = { center: VECTOR3_ZERO, extent: VECTOR3_NEGATIVE_ONE }; + + it('successfully reports zero extent AABB as invalid', () => { + expect(AABBUtils.isValid(invalidAABB)).toBe(false); + }); + + it('successfully reports negative extent AABB as invalid', () => { + expect(AABBUtils.isValid(negativeExtentAABB)).toBe(false); + }); + + it('successfully reports non-zero extent AABB as valid', () => { + expect(AABBUtils.isValid(validAABB)).toBe(true); + }); + + it('successfully compares AABBs with different centers as not equal', () => { + const firstAABB: AABB = { center: VECTOR3_ZERO, extent: VECTOR3_ONE }; + const secondAABB: AABB = { center: VECTOR3_ONE, extent: VECTOR3_ONE }; + expect(AABBUtils.equals(firstAABB, secondAABB)).toBe(false); + }); + + it('successfully compares AABBs with different extent as not equal', () => { + const firstAABB: AABB = { center: VECTOR3_ZERO, extent: VECTOR3_ONE }; + const secondAABB: AABB = { center: VECTOR3_ZERO, extent: { x: 2.0, y: 2.0, z: 2.0 } }; + expect(AABBUtils.equals(firstAABB, secondAABB)).toBe(false); + }); + + it('successfully compares AABBs with different center and extent as not equal', () => { + const firstAABB: AABB = { center: VECTOR3_ONE, extent: VECTOR3_ONE }; + const secondAABB: AABB = { center: VECTOR3_ZERO, extent: { x: 2.0, y: 2.0, z: 2.0 } }; + expect(AABBUtils.equals(firstAABB, secondAABB)).toBe(false); + }); + + it('successfully compares AABBs with same center and extent as equal', () => { + const firstAABB: AABB = { center: VECTOR3_ONE, extent: VECTOR3_ONE }; + const secondAABB: AABB = { center: VECTOR3_ONE, extent: VECTOR3_ONE }; + expect(AABBUtils.equals(firstAABB, secondAABB)).toBe(true); + }); + + it('successfully throws error when calling equals with an invalid AABB as the first param', () => { + expect(() => AABBUtils.equals(invalidAABB, validAABB)).toThrow(new AABBInvalidExtentError(VECTOR3_ZERO)); + }); + + it('successfully throws error when calling equals with an invalid AABB as the second param', () => { + expect(() => AABBUtils.equals(validAABB, invalidAABB)).toThrow(new AABBInvalidExtentError(VECTOR3_ZERO)); + }); + + it('successfully returns expected min Vector3', () => { + const aabb: AABB = { center: VECTOR3_ZERO, extent: VECTOR3_ONE }; + const min = AABBUtils.getMin(aabb); + expect(Vector3Utils.equals(min, { x: -1.0, y: -1.0, z: -1.0 })).toBe(true); + }); + + it('successfully throws error when calling getMin with an invalid AABB', () => { + expect(() => AABBUtils.getMin(invalidAABB)).toThrow(new AABBInvalidExtentError(VECTOR3_ZERO)); + }); + + it('successfully returns expected max Vector3', () => { + const aabb: AABB = { center: VECTOR3_ZERO, extent: VECTOR3_ONE }; + const max = AABBUtils.getMax(aabb); + expect(Vector3Utils.equals(max, { x: 1.0, y: 1.0, z: 1.0 })).toBe(true); + }); + + it('successfully throws error when calling getMax with an invalid AABB', () => { + expect(() => AABBUtils.getMax(invalidAABB)).toThrow(new AABBInvalidExtentError(VECTOR3_ZERO)); + }); + + it('successfully returns expected span Vector3', () => { + const aabb: AABB = { center: VECTOR3_ZERO, extent: VECTOR3_ONE }; + const span = AABBUtils.getSpan(aabb); + expect(Vector3Utils.equals(span, { x: 2.0, y: 2.0, z: 2.0 })).toBe(true); + }); + + it('successfully throws error when calling getSpan with an invalid AABB', () => { + expect(() => AABBUtils.getSpan(invalidAABB)).toThrow(new AABBInvalidExtentError(VECTOR3_ZERO)); + }); + + it('successfully translates AABB center not changing extent', () => { + const aabb: AABB = { center: VECTOR3_ZERO, extent: VECTOR3_ONE }; + const translatedAABB = AABBUtils.translate(aabb, VECTOR3_FORWARD); + expect(Vector3Utils.equals(translatedAABB.center, { x: 0.0, y: 0.0, z: 1.0 })).toBe(true); + expect(Vector3Utils.equals(translatedAABB.extent, VECTOR3_ONE)).toBe(true); + }); + + it('successfully throws error when calling translate with an invalid AABB', () => { + expect(() => AABBUtils.translate(invalidAABB, VECTOR3_ONE)).toThrow(new AABBInvalidExtentError(VECTOR3_ZERO)); + }); + + it('successfully dilates AABB extent growing with positive components not changing center', () => { + const aabb: AABB = { center: VECTOR3_ZERO, extent: VECTOR3_ONE }; + const dilatedAABB = AABBUtils.dilate(aabb, VECTOR3_ONE); + expect(Vector3Utils.equals(dilatedAABB.center, VECTOR3_ZERO)).toBe(true); + expect(Vector3Utils.equals(dilatedAABB.extent, { x: 2.0, y: 2.0, z: 2.0 })).toBe(true); + }); + + it('successfully dilates AABB extent shrinking with negative components within current extent not changing center', () => { + const aabb: AABB = { center: VECTOR3_ZERO, extent: { x: 2.0, y: 2.0, z: 2.0 } }; + const dilatedAABB = AABBUtils.dilate(aabb, VECTOR3_NEGATIVE_ONE); + expect(Vector3Utils.equals(dilatedAABB.center, VECTOR3_ZERO)).toBe(true); + expect(Vector3Utils.equals(dilatedAABB.extent, { x: 1.0, y: 1.0, z: 1.0 })).toBe(true); + }); + + it('successfully dilates AABB extent clamping with negative components exceeding current extent not changing center', () => { + const epsilon = AABBUtils.EPSILON; + const epsilonVec: Vector3 = { x: epsilon, y: epsilon, z: epsilon }; + const aabb: AABB = { center: VECTOR3_ZERO, extent: VECTOR3_ONE }; + const dilatedAABB = AABBUtils.dilate(aabb, { x: -2.0, y: -2.0, z: -2.0 }); + expect(Vector3Utils.equals(dilatedAABB.center, VECTOR3_ZERO)).toBe(true); + expect(Vector3Utils.equals(dilatedAABB.extent, epsilonVec)).toBe(true); + }); + + it('successfully throws error when calling dilate with an invalid AABB', () => { + expect(() => AABBUtils.dilate(invalidAABB, VECTOR3_ONE)).toThrow(new AABBInvalidExtentError(VECTOR3_ZERO)); + }); + + it('successfully expands AABB with other AABB', () => { + const firstAABB: AABB = { center: VECTOR3_ZERO, extent: VECTOR3_ONE }; + const secondAABB: AABB = { center: VECTOR3_ONE, extent: VECTOR3_ONE }; + const expandedAABB = AABBUtils.expand(firstAABB, secondAABB); + expect(Vector3Utils.equals(expandedAABB.center, { x: 0.5, y: 0.5, z: 0.5 })).toBe(true); + expect(Vector3Utils.equals(expandedAABB.extent, { x: 1.5, y: 1.5, z: 1.5 })).toBe(true); + }); + + it('successfully throws error when calling expand with an invalid AABB as the first parameter', () => { + expect(() => AABBUtils.expand(invalidAABB, validAABB)).toThrow(new AABBInvalidExtentError(VECTOR3_ZERO)); + }); + + it('successfully throws error when calling expand with an invalid AABB as the second parameter', () => { + expect(() => AABBUtils.expand(validAABB, invalidAABB)).toThrow(new AABBInvalidExtentError(VECTOR3_ZERO)); + }); + + it('successfully reports non-overlapping AABBs as not intersecting', () => { + const firstAABB: AABB = { center: VECTOR3_ZERO, extent: VECTOR3_ONE }; + const secondAABB: AABB = { center: { x: 2.0, y: 2.0, z: 2.0 }, extent: { x: 0.5, y: 0.5, z: 0.5 } }; + expect(AABBUtils.intersects(firstAABB, secondAABB)).toBe(false); + }); + + it('successfully reports overlapping AABBs as intersecting', () => { + const firstAABB: AABB = { center: VECTOR3_ZERO, extent: VECTOR3_ONE }; + const secondAABB: AABB = { center: VECTOR3_ONE, extent: { x: 0.5, y: 0.5, z: 0.5 } }; + expect(AABBUtils.intersects(firstAABB, secondAABB)).toBe(true); + }); + + it('successfully throws error when calling intersect with an invalid AABB as the first parameter', () => { + expect(() => AABBUtils.intersects(invalidAABB, validAABB)).toThrow(new AABBInvalidExtentError(VECTOR3_ZERO)); + }); + + it('successfully throws error when calling intersect with an invalid AABB as the second parameter', () => { + expect(() => AABBUtils.intersects(validAABB, invalidAABB)).toThrow(new AABBInvalidExtentError(VECTOR3_ZERO)); + }); + + it('successfully reports Vector3 outside AABB as not inside', () => { + const aabb: AABB = { center: VECTOR3_ZERO, extent: VECTOR3_ONE }; + const location: Vector3 = { x: 1.1, y: 1.0, z: 1.0 }; + expect(AABBUtils.isInside(aabb, location)).toBe(false); + }); + + it('successfully reports Vector3 inside of AABB as inside', () => { + const aabb: AABB = { center: VECTOR3_ZERO, extent: VECTOR3_ONE }; + const location: Vector3 = { x: 1.0, y: 1.0, z: 1.0 }; + expect(AABBUtils.isInside(aabb, location)).toBe(true); + }); + + it('successfully throws error when calling isInside with an invalid AABB', () => { + expect(() => AABBUtils.isInside(invalidAABB, VECTOR3_ONE)).toThrow(new AABBInvalidExtentError(VECTOR3_ZERO)); + }); + + it('successfully reports correct intersecting AABB for overlapping AABBs', () => { + const firstAABB: AABB = { center: VECTOR3_ZERO, extent: VECTOR3_ONE }; + const secondAABB: AABB = { center: VECTOR3_ONE, extent: { x: 0.5, y: 0.5, z: 0.5 } }; + const intersection = AABBUtils.getIntersection(firstAABB, secondAABB); + expect(intersection).toBeDefined(); + if (intersection !== undefined) { + expect(Vector3Utils.equals(intersection.center, { x: 0.75, y: 0.75, z: 0.75 })).toBe(true); + expect(Vector3Utils.equals(intersection.extent, { x: 0.25, y: 0.25, z: 0.25 })).toBe(true); + } + }); + + it('successfully reports undefined AABB for non-overlapping AABBs', () => { + const firstAABB: AABB = { center: VECTOR3_ZERO, extent: VECTOR3_ONE }; + const secondAABB: AABB = { center: { x: 2.0, y: 2.0, z: 2.0 }, extent: { x: 0.5, y: 0.5, z: 0.5 } }; + const intersection = AABBUtils.getIntersection(firstAABB, secondAABB); + expect(intersection).toBeUndefined(); + }); + + it('successfully throws error when calling getIntersection with an invalid AABB as the first parameter', () => { + expect(() => AABBUtils.getIntersection(invalidAABB, validAABB)).toThrow( + new AABBInvalidExtentError(VECTOR3_ZERO) + ); + }); + + it('successfully throws error when calling getIntersection with an invalid AABB as the second parameter', () => { + expect(() => AABBUtils.getIntersection(validAABB, invalidAABB)).toThrow( + new AABBInvalidExtentError(VECTOR3_ZERO) + ); + }); +}); + +describe('AABB BlockVolume operations', () => { + it('successfully creates a 2x2x2 BlockVolume when AABB extent is {1,1,1} around center {0,0,0}', () => { + const aabb: AABB = { center: VECTOR3_ZERO, extent: VECTOR3_ONE }; + const blockVolume = AABBUtils.getBlockVolume(aabb); + expect(blockVolume.from).toEqual({ x: -1.0, y: -1.0, z: -1.0 }); + expect(blockVolume.to).toEqual({ x: 1.0, y: 1.0, z: 1.0 }); + }); + + it('successfully creates a 2x2x2 BlockVolume when AABB extent is {0.5,0.5,0.5} around center {0,0,0}', () => { + const aabb: AABB = { center: VECTOR3_ZERO, extent: { x: 0.5, y: 0.5, z: 0.5 } }; + const blockVolume = AABBUtils.getBlockVolume(aabb); + expect(blockVolume.from).toEqual({ x: -1.0, y: -1.0, z: -1.0 }); + expect(blockVolume.to).toEqual({ x: 1.0, y: 1.0, z: 1.0 }); + }); + + it('successfully creates a 1x1x1 BlockVolume when AABB center and extent are {0.5,0.5,0.5}', () => { + const aabb: AABB = { center: { x: 0.5, y: 0.5, z: 0.5 }, extent: { x: 0.5, y: 0.5, z: 0.5 } }; + const blockVolume = AABBUtils.getBlockVolume(aabb); + expect(blockVolume.from).toEqual({ x: 0.0, y: 0.0, z: 0.0 }); + expect(blockVolume.to).toEqual({ x: 1.0, y: 1.0, z: 1.0 }); + }); + + it('successfully creates a 1x1x1 BlockVolume when AABB extent is {0.5,0.5,0.5} around center {-0.5,-0.5,-0.5}', () => { + const aabb: AABB = { center: { x: -0.5, y: -0.5, z: -0.5 }, extent: { x: 0.5, y: 0.5, z: 0.5 } }; + const blockVolume = AABBUtils.getBlockVolume(aabb); + expect(blockVolume.from).toEqual({ x: -1.0, y: -1.0, z: -1.0 }); + expect(blockVolume.to).toEqual({ x: -0.0, y: -0.0, z: -0.0 }); + }); + + it('successfully creates a 1x1x1 BlockVolume when AABB extent are {0.5,0.5,0.5} plus epsilon around {0.5,0.5,0.5}', () => { + const aabb: AABB = { center: { x: 0.5, y: 0.5, z: 0.5 }, extent: { x: 0.50001, y: 0.50001, z: 0.50001 } }; + const blockVolume = AABBUtils.getBlockVolume(aabb); + expect(blockVolume.from).toEqual({ x: 0.0, y: 0.0, z: 0.0 }); + expect(blockVolume.to).toEqual({ x: 1.0, y: 1.0, z: 1.0 }); + }); + + it('successfully creates a 3x3x3 BlockVolume when AABB extent are {0.5,0.5,0.5} plus more than epsilon around {0.5,0.5,0.5}', () => { + const aabb: AABB = { center: { x: 0.5, y: 0.5, z: 0.5 }, extent: { x: 0.50002, y: 0.50002, z: 0.50002 } }; + const blockVolume = AABBUtils.getBlockVolume(aabb); + expect(blockVolume.from).toEqual({ x: -1.0, y: -1.0, z: -1.0 }); + expect(blockVolume.to).toEqual({ x: 2.0, y: 2.0, z: 2.0 }); + }); +}); diff --git a/libraries/math/src/aabb/coreHelpers.ts b/libraries/math/src/aabb/coreHelpers.ts new file mode 100644 index 0000000..38945b0 --- /dev/null +++ b/libraries/math/src/aabb/coreHelpers.ts @@ -0,0 +1,318 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { AABB, Vector3 } from '@minecraft/server'; +import { BlockVolume } from '@minecraft/server'; +import { Vector3Utils } from '../vector3/coreHelpers.js'; + +/** + * An error that is thrown when using an invalid AABB with AABBUtils operations. + * + * @public + */ +export class AABBInvalidExtentError extends Error { + constructor(extent: Vector3) { + super(`Invalid AABB extent of '${Vector3Utils.toString(extent)}'`); + } +} + +/** + * Utilities operating on AABB objects. All methods are static and do not modify the input objects. + * + * @public + */ +export class AABBUtils { + private constructor() {} + + /** + * EPSILON + * + * The internal epsilon value that determines validity and used for block volume tolerance. + */ + static EPSILON = 0.00001; + + /** + * createFromCornerPoints + * + * Gets an AABB from points defining it's corners, the order doesn't matter. + * @param pointA - The first corner point. + * @param pointB - The second corner point. + * @throws {@link AABBInvalidExtentError} + * This exception is thrown if the resulting AABB is invalid. + * + * @returns - The resulting AABB. + */ + static createFromCornerPoints(pointA: Vector3, pointB: Vector3): AABB { + const min = Vector3Utils.min(pointA, pointB); + const max = Vector3Utils.max(pointA, pointB); + + const extent = Vector3Utils.multiply(Vector3Utils.subtract(max, min), { x: 0.5, y: 0.5, z: 0.5 }); + const aabb: AABB = { center: Vector3Utils.add(min, extent), extent: extent }; + AABBUtils.throwErrorIfInvalid(aabb); + return aabb; + } + + /** + * isValid + * + * Determines if the AABB has non-zero extent on all axes. + * @param aabb - The AABB to test for validity. + * @returns - True if all extent axes are non-zero, otherwise false. + */ + static isValid(aabb: AABB): boolean { + return ( + aabb.extent.x >= AABBUtils.EPSILON && + aabb.extent.y >= AABBUtils.EPSILON && + aabb.extent.z >= AABBUtils.EPSILON + ); + } + + /** + * throwErrorIfInvalid + * + * Throws an error if the AABB is invalid. + * @param aabb - The AABB to test for validity. + * @throws {@link AABBInvalidExtentError} + * This exception is thrown if the input AABB is invalid. + */ + static throwErrorIfInvalid(aabb: AABB): void { + if (!AABBUtils.isValid(aabb)) { + throw new AABBInvalidExtentError(aabb.extent); + } + } + + /** + * equals + * + * Compares the equality of two AABBs. + * @param aabb - The first AABB in the comparison. + * @param other - The second AABB in the comparison. + * @throws {@link AABBInvalidExtentError} + * This exception is thrown if either of the input AABBs are invalid. + * + * @returns - True if the center and extent of both AABBs are equal. + */ + static equals(aabb: AABB, other: AABB): boolean { + AABBUtils.throwErrorIfInvalid(aabb); + AABBUtils.throwErrorIfInvalid(other); + + return Vector3Utils.equals(aabb.center, other.center) && Vector3Utils.equals(aabb.extent, other.extent); + } + + /** + * getMin + * + * Gets the minimum corner of an AABB. + * @param aabb - The AABB to retrieve the minimum corner of. + * @throws {@link AABBInvalidExtentError} + * This exception is thrown if the input AABB is invalid. + * + * @returns - The minimum corner of the AABB. + */ + static getMin(aabb: AABB): Vector3 { + AABBUtils.throwErrorIfInvalid(aabb); + + return Vector3Utils.subtract(aabb.center, aabb.extent); + } + + /** + * getMax + * + * Gets the maximum corner of an AABB. + * @param aabb - The AABB to retrieve the maximum corner of. + * @throws {@link AABBInvalidExtentError} + * This exception is thrown if the input AABB is invalid. + * + * @returns - The maximum corner of the AABB. + */ + static getMax(aabb: AABB): Vector3 { + AABBUtils.throwErrorIfInvalid(aabb); + + return Vector3Utils.add(aabb.center, aabb.extent); + } + + /** + * getSpan + * + * Gets the span of an AABB. + * @param aabb - The AABB to retrieve the span of. + * @throws {@link AABBInvalidExtentError} + * This exception is thrown if the input AABB is invalid. + * + * @returns - The span of the AABB. + */ + static getSpan(aabb: AABB): Vector3 { + AABBUtils.throwErrorIfInvalid(aabb); + + return Vector3Utils.multiply(aabb.extent, { x: 2.0, y: 2.0, z: 2.0 }); + } + + /** + * getBlockVolume + * + * Creates the smallest BlockVolume that includes all of a source AABB. + * @param aabb - The source AABB. + * @throws {@link AABBInvalidExtentError} + * This exception is thrown if the input AABB is invalid. + * + * @returns - The BlockVolume containing the source AABB. + */ + static getBlockVolume(aabb: AABB): BlockVolume { + AABBUtils.throwErrorIfInvalid(aabb); + + const epsilon = AABBUtils.EPSILON; + const epsilonVec: Vector3 = { x: epsilon, y: epsilon, z: epsilon }; + const from = Vector3Utils.floor(Vector3Utils.add(AABBUtils.getMin(aabb), epsilonVec)); + const to = Vector3Utils.ceil(Vector3Utils.subtract(AABBUtils.getMax(aabb), epsilonVec)); + return new BlockVolume(from, to); + } + + /** + * translate + * + * Creates a translated AABB given a source AABB and translation vector. + * @param aabb - The source AABB. + * @param delta - The translation vector to add to the AABBs center. + * @throws {@link AABBInvalidExtentError} + * This exception is thrown if the input AABB is invalid. + * + * @returns - The resulting translated AABB. + */ + static translate(aabb: AABB, delta: Vector3): AABB { + AABBUtils.throwErrorIfInvalid(aabb); + + return { center: Vector3Utils.add(aabb.center, delta), extent: aabb.extent }; + } + + /** + * dilate + * + * Creates a dilated AABB given a source AABB and dilation vector. + * @param aabb - The source AABB. + * @param size - The dilation vector to add to the AABBs extent. + * @throws {@link AABBInvalidExtentError} + * This exception is thrown if the input AABB is invalid. + * + * @returns - The resulting dilated AABB. + */ + static dilate(aabb: AABB, size: Vector3): AABB { + AABBUtils.throwErrorIfInvalid(aabb); + + const epsilon = AABBUtils.EPSILON; + const epsilonVec: Vector3 = { x: epsilon, y: epsilon, z: epsilon }; + let dilatedExtent = Vector3Utils.add(aabb.extent, size); + dilatedExtent = Vector3Utils.clamp(dilatedExtent, { min: epsilonVec }); + return { center: aabb.center, extent: dilatedExtent }; + } + + /** + * expand + * + * Creates an expanded AABB given two source AABBs. + * @param aabb - The first source AABB. + * @param other - The second source AABB. + * @throws {@link AABBInvalidExtentError} + * This exception is thrown if either of the input AABBs are invalid. + * + * @returns - The resulting expanded AABB. + */ + static expand(aabb: AABB, other: AABB): AABB { + AABBUtils.throwErrorIfInvalid(aabb); + AABBUtils.throwErrorIfInvalid(other); + + const aabbMin = AABBUtils.getMin(aabb); + const otherMin = AABBUtils.getMin(other); + const min = Vector3Utils.min(aabbMin, otherMin); + const aabbMax = AABBUtils.getMax(aabb); + const otherMax = AABBUtils.getMax(other); + const max = Vector3Utils.max(aabbMax, otherMax); + return AABBUtils.createFromCornerPoints(min, max); + } + + /** + * getIntersection + * + * Creates an AABB of the intersecting area of two source AABBs. + * @param aabb - The first source AABB. + * @param other - The second source AABB. + * @throws {@link AABBInvalidExtentError} + * This exception is thrown if either of the input AABBs are invalid. + * + * @returns - The resulting intersecting AABB if they intersect, otherwise returns undefined. + */ + static getIntersection(aabb: AABB, other: AABB): AABB | undefined { + AABBUtils.throwErrorIfInvalid(aabb); + AABBUtils.throwErrorIfInvalid(other); + + if (!AABBUtils.intersects(aabb, other)) { + return undefined; + } + + const aabbMin = AABBUtils.getMin(aabb); + const otherMin = AABBUtils.getMin(other); + const min = Vector3Utils.max(aabbMin, otherMin); + const aabbMax = AABBUtils.getMax(aabb); + const otherMax = AABBUtils.getMax(other); + const max = Vector3Utils.min(aabbMax, otherMax); + return AABBUtils.createFromCornerPoints(min, max); + } + + /** + * intersects + * + * Calculates if two AABBs are intersecting. + * @param aabb - The first AABB. + * @param other - The second AABB. + * @throws {@link AABBInvalidExtentError} + * This exception is thrown if either of the input AABBs are invalid. + * + * @returns - True if the AABBs are intersecting, otherwise false. + */ + static intersects(aabb: AABB, other: AABB): boolean { + AABBUtils.throwErrorIfInvalid(aabb); + AABBUtils.throwErrorIfInvalid(other); + + const aabbMin = AABBUtils.getMin(aabb); + const aabbMax = AABBUtils.getMax(aabb); + const otherMin = AABBUtils.getMin(other); + const otherMax = AABBUtils.getMax(other); + + if (otherMax.x < aabbMin.x || otherMin.x > aabbMax.x) { + return false; + } + if (otherMax.y < aabbMin.y || otherMin.y > aabbMax.y) { + return false; + } + if (otherMax.z < aabbMin.z || otherMin.z > aabbMax.z) { + return false; + } + return true; + } + + /** + * isInside + * + * Calculates if a position is inside of an AABB. + * @param aabb - The AABB to test against. + * @param pos - The position to test. + * @throws {@link AABBInvalidExtentError} + * This exception is thrown if the input AABB is invalid. + * + * @returns True if the position is inside of the AABB, otherwise returns false. + */ + static isInside(aabb: AABB, pos: Vector3): boolean { + AABBUtils.throwErrorIfInvalid(aabb); + + const min = AABBUtils.getMin(aabb); + if (pos.x < min.x || pos.y < min.y || pos.z < min.z) { + return false; + } + + const max = AABBUtils.getMax(aabb); + if (pos.x > max.x || pos.y > max.y || pos.z > max.z) { + return false; + } + + return true; + } +} diff --git a/libraries/math/src/aabb/index.ts b/libraries/math/src/aabb/index.ts new file mode 100644 index 0000000..03db660 --- /dev/null +++ b/libraries/math/src/aabb/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export * from './coreHelpers.js'; diff --git a/libraries/math/src/index.ts b/libraries/math/src/index.ts index da3c68e..dfbd896 100644 --- a/libraries/math/src/index.ts +++ b/libraries/math/src/index.ts @@ -3,3 +3,4 @@ export * from './vector3/index.js'; export * from './general/index.js'; +export * from './aabb/index.js'; diff --git a/libraries/math/src/vector3/coreHelpers.test.ts b/libraries/math/src/vector3/coreHelpers.test.ts index 752dbd0..10c81f3 100644 --- a/libraries/math/src/vector3/coreHelpers.test.ts +++ b/libraries/math/src/vector3/coreHelpers.test.ts @@ -88,6 +88,28 @@ describe('Vector3 operations', () => { expect(Vector3Utils.floor(input)).toEqual(expected); }); + it('computes the ceil of the vector', () => { + const input: Vector3 = { x: 0.33, y: 1.14, z: 2.55 }; + const expected: Vector3 = { x: 1, y: 2, z: 3 }; + expect(Vector3Utils.ceil(input)).toEqual(expected); + }); + + it('computes the ceil of negative vectors', () => { + const input: Vector3 = { x: -0.33, y: -1.14, z: -2.55 }; + const expected: Vector3 = { x: -0, y: -1, z: -2 }; + expect(Vector3Utils.ceil(input)).toEqual(expected); + }); + + it('computes the min of two vectors', () => { + const result = Vector3Utils.min(v1, v2); + expect(result).toEqual(v1); + }); + + it('computes the max of two vectors', () => { + const result = Vector3Utils.max(v1, v2); + expect(result).toEqual(v2); + }); + it('normalizes the vector', () => { const result: Vector3 = Vector3Utils.normalize(v1); expect(result.x).toBeCloseTo(0.27, 2); diff --git a/libraries/math/src/vector3/coreHelpers.ts b/libraries/math/src/vector3/coreHelpers.ts index d661be5..856469a 100644 --- a/libraries/math/src/vector3/coreHelpers.ts +++ b/libraries/math/src/vector3/coreHelpers.ts @@ -100,6 +100,33 @@ export class Vector3Utils { return { x: Math.floor(v.x), y: Math.floor(v.y), z: Math.floor(v.z) }; } + /** + * ceil + * + * Ceil the components of a vector to produce a new vector + */ + static ceil(v: Vector3): Vector3 { + return { x: Math.ceil(v.x), y: Math.ceil(v.y), z: Math.ceil(v.z) }; + } + + /** + * min + * + * Min the components of two vectors to produce a new vector + */ + static min(a: Vector3, b: Vector3): Vector3 { + return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), z: Math.min(a.z, b.z) }; + } + + /** + * max + * + * Max the components of two vectors to produce a new vector + */ + static max(a: Vector3, b: Vector3): Vector3 { + return { x: Math.max(a.x, b.x), y: Math.max(a.y, b.y), z: Math.max(a.z, b.z) }; + } + /** * toString * diff --git a/libraries/math/src/vector3/vectorWrapper.test.ts b/libraries/math/src/vector3/vectorWrapper.test.ts index 98a1001..48f0787 100644 --- a/libraries/math/src/vector3/vectorWrapper.test.ts +++ b/libraries/math/src/vector3/vectorWrapper.test.ts @@ -162,6 +162,35 @@ describe('Vector3Builder', () => { expect(vectorA).toBe(result); // Referential equality must be preserved }); + it('should be able to compute the ceil of the vector with the same result as the coreHelpers function', () => { + const vectorA = new Vector3Builder(1.33, 2.14, 3.55); + const vectorB = Vector3Utils.ceil(vectorA); + + const result = vectorA.ceil(); + expect(result).toEqual(vectorB); + expect(vectorA).toBe(result); // Referential equality must be preserved + }); + + it('should be able to compute the min of two vectors with the same result as the coreHelpers function', () => { + const vectorA = new Vector3Builder(1, 2, 3); + const vectorB: Vector3 = { x: 4, y: 5, z: 6 }; + const vectorC = Vector3Utils.min(vectorA, vectorB); + + const result = vectorA.min(vectorB); + expect(result).toEqual(vectorC); + expect(vectorA).toBe(result); // Referential equality must be preserved + }); + + it('should be able to compute the max of two vectors with the same result as the coreHelpers function', () => { + const vectorA = new Vector3Builder(1, 2, 3); + const vectorB: Vector3 = { x: 4, y: 5, z: 6 }; + const vectorC = Vector3Utils.max(vectorA, vectorB); + + const result = vectorA.max(vectorB); + expect(result).toEqual(vectorC); + expect(vectorA).toBe(result); // Referential equality must be preserved + }); + it('should be able to clamp the vector with the same result as the coreHelpers function', () => { const vectorA = new Vector3Builder(1, 2, 3); const minVec: Partial = { x: 0, y: 1.5 }; diff --git a/libraries/math/src/vector3/vectorWrapper.ts b/libraries/math/src/vector3/vectorWrapper.ts index 578084f..dc12e11 100644 --- a/libraries/math/src/vector3/vectorWrapper.ts +++ b/libraries/math/src/vector3/vectorWrapper.ts @@ -145,6 +145,33 @@ export class Vector3Builder implements Vector3 { return this.assign(Vector3Utils.floor(this)); } + /** + * ceil + * + * Ceil the components of a vector to produce a new vector + */ + ceil(): this { + return this.assign(Vector3Utils.ceil(this)); + } + + /** + * min + * + * Min the components of two vectors to produce a new vector + */ + min(vec: Vector3): this { + return this.assign(Vector3Utils.min(this, vec)); + } + + /** + * max + * + * Max the components of two vectors to produce a new vector + */ + max(vec: Vector3): this { + return this.assign(Vector3Utils.max(this, vec)); + } + /** * toString * diff --git a/libraries/math/vite.config.mts b/libraries/math/vite.config.mts index c72faff..5ddb8a7 100644 --- a/libraries/math/vite.config.mts +++ b/libraries/math/vite.config.mts @@ -5,5 +5,9 @@ import { configDefaults, defineConfig } from 'vitest/config'; export default defineConfig({ - test: { exclude: [...configDefaults.exclude, '**/build/**', '**/lib/**', '**/lib-commonjs/**'], watch: false }, + test: { + exclude: [...configDefaults.exclude, '**/build/**', '**/lib/**', '**/lib-commonjs/**'], + watch: false, + alias: { '@minecraft/server': './__mocks__/minecraft-server.ts' }, + }, }); diff --git a/package-lock.json b/package-lock.json index f7e204b..2a00997 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1081,7 +1081,8 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@minecraft/common/-/common-1.2.0.tgz", "integrity": "sha512-JdmEq4P3Z/FtoBzhLijFgMSVFnFRrUoLwY8DHHrgtFo0mfLTOLTB1RErYjLMsA6b7BGVNxkX/pfFRiH7QZ0XwQ==", - "dev": true + "dev": true, + "peer": true }, "node_modules/@minecraft/core-build-tasks": { "resolved": "tools/core-build-tasks", @@ -1100,15 +1101,12 @@ "link": true }, "node_modules/@minecraft/server": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@minecraft/server/-/server-2.0.0.tgz", - "integrity": "sha512-X2LmT82eO0oCjOFaFgg6zoGenAUSyrG+YvIbe7/FSPj9hpMt4BtOIs7axoQUUvMx2NNbq8uEsnQadMMrDDBmUA==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@minecraft/server/-/server-2.4.0.tgz", + "integrity": "sha512-xd9dOWQ4RhPQBCt2aui9tp/GCnjvXpi6ZHVxaUr0SPDHfh/GkMRWnyf2Ud6D3pEThp/0giOEtHY1sx2HN9Qd+A==", "dev": true, - "license": "MIT", - "dependencies": { - "@minecraft/common": "^1.1.0" - }, "peerDependencies": { + "@minecraft/common": "^1.2.0", "@minecraft/vanilla-data": ">=1.20.70" } },