diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20d8718de25..393c7e4f909 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3917,9 +3917,18 @@ importers: '@quenty/maid': specifier: workspace:* version: link:../maid + '@quenty/nevermore-test-runner': + specifier: workspace:* + version: link:../nevermore-test-runner '@quenty/rx': specifier: workspace:* version: link:../rx + '@quenty/vector3utils': + specifier: workspace:* + version: link:../vector3utils + '@quentystudios/jest-lua': + specifier: 3.10.0-quenty.2 + version: 3.10.0-quenty.2 src/pillbacking: dependencies: @@ -6597,6 +6606,12 @@ importers: '@quenty/math': specifier: workspace:* version: link:../math + '@quenty/nevermore-test-runner': + specifier: workspace:* + version: link:../nevermore-test-runner + '@quentystudios/jest-lua': + specifier: 3.10.0-quenty.2 + version: 3.10.0-quenty.2 src/viewport: dependencies: diff --git a/src/physicsutils/deploy.nevermore.json b/src/physicsutils/deploy.nevermore.json new file mode 100644 index 00000000000..f8953f18022 --- /dev/null +++ b/src/physicsutils/deploy.nevermore.json @@ -0,0 +1,10 @@ +{ + "targets": { + "test": { + "universeId": 9716264427, + "placeId": 83896797008680, + "project": "test/default.project.json", + "scriptTemplate": "test/scripts/Server/ServerMain.server.lua" + } + } +} diff --git a/src/physicsutils/package.json b/src/physicsutils/package.json index 315feae2327..6e2a9057cbc 100644 --- a/src/physicsutils/package.json +++ b/src/physicsutils/package.json @@ -33,6 +33,9 @@ "dependencies": { "@quenty/loader": "workspace:*", "@quenty/maid": "workspace:*", - "@quenty/rx": "workspace:*" + "@quenty/nevermore-test-runner": "workspace:*", + "@quenty/rx": "workspace:*", + "@quenty/vector3utils": "workspace:*", + "@quentystudios/jest-lua": "3.10.0-quenty.2" } } diff --git a/src/physicsutils/src/Shared/PhysicsUtils.lua b/src/physicsutils/src/Shared/PhysicsUtils.lua index 7c7f98dcb37..eb7d750386c 100644 --- a/src/physicsutils/src/Shared/PhysicsUtils.lua +++ b/src/physicsutils/src/Shared/PhysicsUtils.lua @@ -4,19 +4,26 @@ @class PhysicsUtils ]=] +local require = require(script.Parent.loader).load(script) + local Workspace = game:GetService("Workspace") +local Vector3Utils = require("Vector3Utils") + local PhysicsUtils = {} PhysicsUtils.WATER_DENSITY = 1 -- (mass/volume) --[=[ - Retrieves all connected parts of a part, plus the connected part. + Retrieves all connected parts of a part, plus the part itself. The part appears + exactly once even though the engine already includes it in the assembly list. @param part BasePart @return { BasePart } ]=] function PhysicsUtils.getConnectedParts(part: BasePart): { BasePart } local parts: { BasePart } = part:GetConnectedParts(true) :: any - table.insert(parts, part) + if not table.find(parts, part) then + table.insert(parts, part) + end return parts end @@ -51,7 +58,7 @@ function PhysicsUtils.estimateBuoyancyContribution(parts: { BasePart }): (number totalFloat = totalFloat - mass * Workspace.Gravity if part.CanCollide then - local volume = part.Size.X * part.Size.Y * part.Size.Z + local volume = Vector3Utils.volume(part.Size) totalFloat = totalFloat + volume * PhysicsUtils.WATER_DENSITY * Workspace.Gravity totalVolumeApplicable = totalVolumeApplicable + volume end @@ -139,7 +146,7 @@ end @param force Vector3 -- the force vector to apply @param forcePosition Vector3 -- The position that the force is to be applied from (World vector). ]=] -function PhysicsUtils.applyForce(part: BasePart, force: Vector3, forcePosition: Vector3) +function PhysicsUtils.applyForce(part: BasePart, force: Vector3, forcePosition: Vector3): () local parts = PhysicsUtils.getConnectedParts(part) forcePosition = forcePosition or part.Position @@ -170,7 +177,7 @@ end @param emittingPart BasePart @param acceleration Vector3 ]=] -function PhysicsUtils.acceleratePart(part: BasePart, emittingPart: BasePart, acceleration: Vector3) +function PhysicsUtils.acceleratePart(part: BasePart, emittingPart: BasePart, acceleration: Vector3): () local force = acceleration * part:GetMass() local position = part.Position diff --git a/src/physicsutils/src/Shared/PhysicsUtils.spec.lua b/src/physicsutils/src/Shared/PhysicsUtils.spec.lua new file mode 100644 index 00000000000..53a174dac95 --- /dev/null +++ b/src/physicsutils/src/Shared/PhysicsUtils.spec.lua @@ -0,0 +1,313 @@ +--!strict +--[[ + @class PhysicsUtils.spec.lua +]] + +local require = require(script.Parent.loader).load(script) + +local Workspace = game:GetService("Workspace") + +local Jest = require("Jest") +local Maid = require("Maid") +local PhysicsUtils = require("PhysicsUtils") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +type PartOptions = { + size: Vector3?, + position: Vector3?, + density: number?, + canCollide: boolean?, +} + +type Controller = { + newPart: (options: PartOptions?) -> BasePart, + weld: (part0: BasePart, part1: BasePart) -> WeldConstraint, + Destroy: (self: Controller) -> (), +} + +local function setup(): Controller + local maid = Maid.new() + + local folder = Instance.new("Folder") + folder.Name = "PhysicsUtilsSpec" + folder.Parent = Workspace + maid:GiveTask(folder) + + local self = {} :: Controller + + function self.newPart(options: PartOptions?): BasePart + local resolved: PartOptions = options or {} + + local part = Instance.new("Part") + part.Anchored = false + part.Size = resolved.size or Vector3.new(2, 2, 2) + part.Position = resolved.position or Vector3.zero + part.CanCollide = if resolved.canCollide ~= nil then resolved.canCollide else true + part.CustomPhysicalProperties = PhysicalProperties.new(resolved.density or 1, 0, 0) + part.Parent = folder + + return part + end + + function self.weld(part0: BasePart, part1: BasePart): WeldConstraint + local weld = Instance.new("WeldConstraint") + weld.Part0 = part0 + weld.Part1 = part1 + weld.Parent = part0 + + return weld + end + + function self.Destroy(_self: Controller) + maid:DoCleaning() + end + + return self +end + +describe("PhysicsUtils.getMass", function() + it("sums the mass of every part", function() + local controller = setup() + local partA = controller.newPart({ size = Vector3.new(2, 2, 2), density = 1 }) + local partB = controller.newPart({ size = Vector3.new(1, 2, 3), density = 2 }) + + expect(PhysicsUtils.getMass({ partA, partB })).toBeCloseTo(8 + 12, 3) + + controller:Destroy() + end) + + it("is zero for no parts", function() + expect(PhysicsUtils.getMass({})).toBe(0) + end) +end) + +describe("PhysicsUtils.getConnectedParts", function() + it("includes the part itself when nothing is connected", function() + local controller = setup() + local part = controller.newPart() + + expect(PhysicsUtils.getConnectedParts(part)).toEqual({ part }) + + controller:Destroy() + end) + + it("includes welded parts and the part itself", function() + local controller = setup() + local partA = controller.newPart({ position = Vector3.new(0, 0, 0) }) + local partB = controller.newPart({ position = Vector3.new(2, 0, 0) }) + local partC = controller.newPart({ position = Vector3.new(4, 0, 0) }) + controller.weld(partA, partB) + controller.weld(partB, partC) + + local parts = PhysicsUtils.getConnectedParts(partA) + + expect(#parts).toBe(3) + expect(parts).toContain(partA) + expect(parts).toContain(partB) + expect(parts).toContain(partC) + + controller:Destroy() + end) +end) + +describe("PhysicsUtils.estimateBuoyancyContribution", function() + it("is neutral when a colliding part matches water density", function() + local controller = setup() + local part = controller.newPart({ size = Vector3.new(2, 3, 4), density = PhysicsUtils.WATER_DENSITY }) + + local float, mass, volume = PhysicsUtils.estimateBuoyancyContribution({ part }) + + expect(float).toBeCloseTo(0, 3) + expect(mass).toBeCloseTo(24, 3) + expect(volume).toBeCloseTo(24, 3) + + controller:Destroy() + end) + + it("sinks when a colliding part is denser than water", function() + local controller = setup() + local part = controller.newPart({ size = Vector3.new(2, 3, 4), density = 2 }) + + local float, mass, volume = PhysicsUtils.estimateBuoyancyContribution({ part }) + + expect(float).toBeCloseTo(-24 * Workspace.Gravity, 3) + expect(mass).toBeCloseTo(48, 3) + expect(volume).toBeCloseTo(24, 3) + + controller:Destroy() + end) + + it("floats when a colliding part is lighter than water", function() + local controller = setup() + local part = controller.newPart({ size = Vector3.new(2, 3, 4), density = 0.5 }) + + local float = PhysicsUtils.estimateBuoyancyContribution({ part }) + + expect(float).toBeCloseTo(12 * Workspace.Gravity, 3) + + controller:Destroy() + end) + + it("counts mass but not volume for non-colliding parts", function() + local controller = setup() + local part = controller.newPart({ size = Vector3.new(2, 3, 4), density = 1, canCollide = false }) + + local float, mass, volume = PhysicsUtils.estimateBuoyancyContribution({ part }) + + expect(float).toBeCloseTo(-24 * Workspace.Gravity, 3) + expect(mass).toBeCloseTo(24, 3) + expect(volume).toBe(0) + + controller:Destroy() + end) + + it("accumulates across parts", function() + local controller = setup() + local floating = controller.newPart({ size = Vector3.new(2, 2, 2), density = 0.5 }) + local sinking = controller.newPart({ size = Vector3.new(1, 1, 1), density = 3, canCollide = false }) + + local float, mass, volume = PhysicsUtils.estimateBuoyancyContribution({ floating, sinking }) + + expect(float).toBeCloseTo((8 - 4 - 3) * Workspace.Gravity, 3) + expect(mass).toBeCloseTo(4 + 3, 3) + expect(volume).toBeCloseTo(8, 3) + + controller:Destroy() + end) + + it("returns zeros for no parts", function() + local float, mass, volume = PhysicsUtils.estimateBuoyancyContribution({}) + + expect(float).toBe(0) + expect(mass).toBe(0) + expect(volume).toBe(0) + end) +end) + +describe("PhysicsUtils.getCenterOfMass", function() + it("returns the midpoint for equal masses", function() + local controller = setup() + local partA = controller.newPart({ position = Vector3.new(0, 0, 0) }) + local partB = controller.newPart({ position = Vector3.new(10, 0, 0) }) + + local center, mass = PhysicsUtils.getCenterOfMass({ partA, partB }) + + expect(center.X).toBeCloseTo(5, 3) + expect(center.Y).toBeCloseTo(0, 3) + expect(center.Z).toBeCloseTo(0, 3) + expect(mass).toBeCloseTo(16, 3) + + controller:Destroy() + end) + + it("weights the center towards the heavier part", function() + local controller = setup() + local light = controller.newPart({ position = Vector3.new(0, 0, 0), density = 1 }) + local heavy = controller.newPart({ position = Vector3.new(0, 12, 0), density = 3 }) + + local center = PhysicsUtils.getCenterOfMass({ light, heavy }) + + expect(center.Y).toBeCloseTo(9, 3) + + controller:Destroy() + end) +end) + +describe("PhysicsUtils.momentOfInertia", function() + it("uses only the cuboid term when the origin is the part position", function() + local controller = setup() + local part = controller.newPart({ size = Vector3.new(2, 4, 6), position = Vector3.new(1, 2, 3) }) + local mass = part:GetMass() + + local inertia = PhysicsUtils.momentOfInertia(part, Vector3.yAxis, part.Position) + + expect(inertia).toBeCloseTo((2 ^ 2 + 6 ^ 2) * mass / 12, 3) + + controller:Destroy() + end) + + it("adds the parallel axis term for an offset origin", function() + local controller = setup() + local part = controller.newPart({ size = Vector3.new(2, 4, 6), position = Vector3.new(3, 0, 0) }) + local mass = part:GetMass() + + local inertia = PhysicsUtils.momentOfInertia(part, Vector3.yAxis, Vector3.zero) + + expect(inertia).toBeCloseTo(mass * 9 + (2 ^ 2 + 6 ^ 2) * mass / 12, 3) + + controller:Destroy() + end) + + it("ignores offsets along the axis", function() + local controller = setup() + local part = controller.newPart({ size = Vector3.new(2, 4, 6), position = Vector3.new(0, 5, 0) }) + local mass = part:GetMass() + + local inertia = PhysicsUtils.momentOfInertia(part, Vector3.yAxis, Vector3.zero) + + expect(inertia).toBeCloseTo((2 ^ 2 + 6 ^ 2) * mass / 12, 3) + + controller:Destroy() + end) +end) + +describe("PhysicsUtils.bodyMomentOfInertia", function() + it("sums the moment of inertia of each part", function() + local controller = setup() + local partA = controller.newPart({ size = Vector3.new(2, 4, 6), position = Vector3.new(3, 0, 0) }) + local partB = controller.newPart({ size = Vector3.new(1, 1, 1), position = Vector3.new(-2, 0, 0) }) + + local expected = PhysicsUtils.momentOfInertia(partA, Vector3.yAxis, Vector3.zero) + + PhysicsUtils.momentOfInertia(partB, Vector3.yAxis, Vector3.zero) + + expect(PhysicsUtils.bodyMomentOfInertia({ partA, partB }, Vector3.yAxis, Vector3.zero)).toBeCloseTo(expected, 3) + + controller:Destroy() + end) +end) + +describe("PhysicsUtils.applyForce", function() + it("adds linear velocity without rotation when applied at the center of mass", function() + local controller = setup() + local part = controller.newPart({ size = Vector3.new(2, 2, 2), density = 1 }) + + PhysicsUtils.applyForce(part, Vector3.new(16, 0, 0), part.Position) + + expect(part.AssemblyLinearVelocity.X).toBeCloseTo(2, 3) + expect(part.AssemblyLinearVelocity.Y).toBeCloseTo(0, 3) + expect(part.AssemblyLinearVelocity.Z).toBeCloseTo(0, 3) + expect(part.AssemblyAngularVelocity.Magnitude).toBeCloseTo(0, 3) + + controller:Destroy() + end) + + it("adds angular velocity when applied off center", function() + local controller = setup() + local part = controller.newPart({ size = Vector3.new(2, 2, 2), density = 1 }) + + PhysicsUtils.applyForce(part, Vector3.new(0, 0, 8), part.Position + Vector3.new(1, 0, 0)) + + expect(part.AssemblyLinearVelocity.Z).toBeCloseTo(1, 3) + expect(part.AssemblyAngularVelocity.Magnitude).toBeGreaterThan(0) + + controller:Destroy() + end) +end) + +describe("PhysicsUtils.acceleratePart", function() + it("applies equal and opposite forces to the part and emitter", function() + local controller = setup() + local part = controller.newPart({ size = Vector3.new(2, 2, 2), density = 1, position = Vector3.zero }) + local emitter = controller.newPart({ size = Vector3.new(4, 4, 4), density = 1, position = Vector3.zero }) + + PhysicsUtils.acceleratePart(part, emitter, Vector3.new(0, 5, 0)) + + expect(part.AssemblyLinearVelocity.Y).toBeCloseTo(5, 3) + expect(emitter.AssemblyLinearVelocity.Y).toBeCloseTo(-5 * 8 / 64, 3) + + controller:Destroy() + end) +end) diff --git a/src/physicsutils/src/Shared/RxPhysicsUtils.spec.lua b/src/physicsutils/src/Shared/RxPhysicsUtils.spec.lua new file mode 100644 index 00000000000..12234bcfe5b --- /dev/null +++ b/src/physicsutils/src/Shared/RxPhysicsUtils.spec.lua @@ -0,0 +1,153 @@ +--!strict +--[[ + @class RxPhysicsUtils.spec.lua +]] + +local require = require(script.Parent.loader).load(script) + +local Workspace = game:GetService("Workspace") + +local Jest = require("Jest") +local Maid = require("Maid") +local RxPhysicsUtils = require("RxPhysicsUtils") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +type Controller = { + newPart: (density: number?) -> BasePart, + observeMass: (part: BasePart) -> { number }, + Destroy: (self: Controller) -> (), +} + +local function setup(): Controller + local maid = Maid.new() + + local folder = Instance.new("Folder") + folder.Name = "RxPhysicsUtilsSpec" + folder.Parent = Workspace + maid:GiveTask(folder) + + local self = {} :: Controller + + function self.newPart(density: number?): BasePart + local part = Instance.new("Part") + part.Anchored = true + part.Size = Vector3.new(2, 2, 2) + if density then + part.CustomPhysicalProperties = PhysicalProperties.new(density, 0, 0) + end + part.Parent = folder + + return part + end + + function self.observeMass(part: BasePart): { number } + local values = {} + maid:GiveTask(RxPhysicsUtils.observePartMass(part):Subscribe(function(mass) + table.insert(values, mass) + end)) + + return values + end + + function self.Destroy(_self: Controller) + maid:DoCleaning() + end + + return self +end + +describe("RxPhysicsUtils.observePartMass", function() + it("emits the current mass on subscribe", function() + local controller = setup() + local part = controller.newPart(1) + + local values = controller.observeMass(part) + + expect(values).toEqual({ 8 }) + + controller:Destroy() + end) + + it("emits when the size changes", function() + local controller = setup() + local part = controller.newPart(1) + local values = controller.observeMass(part) + + part.Size = Vector3.new(1, 2, 3) + task.wait() + + expect(values).toEqual({ 8, 6 }) + + controller:Destroy() + end) + + it("emits when the custom physical properties change", function() + local controller = setup() + local part = controller.newPart(1) + local values = controller.observeMass(part) + + part.CustomPhysicalProperties = PhysicalProperties.new(2, 0, 0) + task.wait() + + expect(values).toEqual({ 8, 16 }) + + controller:Destroy() + end) + + it("emits when the material changes the density", function() + local controller = setup() + local part = controller.newPart() + part.Material = Enum.Material.Plastic + local values = controller.observeMass(part) + local plasticMass = part:GetMass() + + part.Material = Enum.Material.Metal + task.wait() + + local metalMass = part:GetMass() + expect(metalMass).never.toBeCloseTo(plasticMass, 3) + expect(values).toEqual({ plasticMass, metalMass }) + + controller:Destroy() + end) + + it("does not emit when a watched property changes but the mass does not", function() + local controller = setup() + local part = controller.newPart(1) + local values = controller.observeMass(part) + + part.Material = Enum.Material.Metal + task.wait() + + expect(values).toEqual({ 8 }) + + controller:Destroy() + end) + + it("stops emitting after unsubscribe", function() + local controller = setup() + local part = controller.newPart(1) + + local values = {} + local subscription = RxPhysicsUtils.observePartMass(part):Subscribe(function(mass) + table.insert(values, mass) + end) + subscription:Destroy() + + part.Size = Vector3.new(4, 4, 4) + task.wait() + + expect(values).toEqual({ 8 }) + + controller:Destroy() + end) + + it("errors when given a non-part", function() + expect(function() + RxPhysicsUtils.observePartMass(Instance.new("Folder") :: any) + end).toThrow("Bad part") + end) +end) diff --git a/src/physicsutils/src/jest.config.lua b/src/physicsutils/src/jest.config.lua new file mode 100644 index 00000000000..4294d00462f --- /dev/null +++ b/src/physicsutils/src/jest.config.lua @@ -0,0 +1,3 @@ +return { + testMatch = { "**/*.spec" }, +} diff --git a/src/physicsutils/test/default.project.json b/src/physicsutils/test/default.project.json new file mode 100644 index 00000000000..6aad73db041 --- /dev/null +++ b/src/physicsutils/test/default.project.json @@ -0,0 +1,17 @@ +{ + "name": "PhysicsUtilsTest", + "tree": { + "$className": "DataModel", + "ServerScriptService": { + "$properties": { + "LoadStringEnabled": true + }, + "physicsutils": { + "$path": ".." + }, + "Script": { + "$path": "scripts/Server" + } + } + } +} diff --git a/src/physicsutils/test/scripts/Server/ServerMain.server.lua b/src/physicsutils/test/scripts/Server/ServerMain.server.lua new file mode 100644 index 00000000000..c9912aa172f --- /dev/null +++ b/src/physicsutils/test/scripts/Server/ServerMain.server.lua @@ -0,0 +1,12 @@ +--!nonstrict +local ServerScriptService = game:GetService("ServerScriptService") + +local root = ServerScriptService.physicsutils +local loader = root:FindFirstChild("LoaderUtils", true).Parent +local require = require(loader).bootstrapGame(root) + +local NevermoreTestRunnerUtils = require("NevermoreTestRunnerUtils") + +if NevermoreTestRunnerUtils.runTestsIfNeededAsync(root) then + return +end diff --git a/src/vector3utils/deploy.nevermore.json b/src/vector3utils/deploy.nevermore.json new file mode 100644 index 00000000000..b1612392883 --- /dev/null +++ b/src/vector3utils/deploy.nevermore.json @@ -0,0 +1,10 @@ +{ + "targets": { + "test": { + "universeId": 9716264427, + "placeId": 118614617021991, + "project": "test/default.project.json", + "scriptTemplate": "test/scripts/Server/ServerMain.server.lua" + } + } +} diff --git a/src/vector3utils/package.json b/src/vector3utils/package.json index 5d8409e64c4..a534a34dd67 100644 --- a/src/vector3utils/package.json +++ b/src/vector3utils/package.json @@ -29,7 +29,9 @@ ], "dependencies": { "@quenty/loader": "workspace:*", - "@quenty/math": "workspace:*" + "@quenty/math": "workspace:*", + "@quenty/nevermore-test-runner": "workspace:*", + "@quentystudios/jest-lua": "3.10.0-quenty.2" }, "publishConfig": { "access": "public" diff --git a/src/vector3utils/src/Shared/RandomVector3Utils.spec.lua b/src/vector3utils/src/Shared/RandomVector3Utils.spec.lua new file mode 100644 index 00000000000..73cebbd2001 --- /dev/null +++ b/src/vector3utils/src/Shared/RandomVector3Utils.spec.lua @@ -0,0 +1,166 @@ +--!strict +--[[ + @class RandomVector3Utils.spec.lua +]] + +local require = require(script.Parent.loader).load(script) + +local Jest = require("Jest") +local RandomVector3Utils = require("RandomVector3Utils") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +local SAMPLE_COUNT = 500 +local UNIT_TOLERANCE = 1e-4 + +local function sample(count: number, generate: () -> Vector3): { Vector3 } + local samples = table.create(count) + for i = 1, count do + samples[i] = generate() + end + return samples +end + +local function expectAllUnit(samples: { Vector3 }) + for _, vector in samples do + expect(math.abs(vector.Magnitude - 1)).toBeLessThan(UNIT_TOLERANCE) + end +end + +local function coversBothSigns(samples: { Vector3 }, axis: Vector3): boolean + local sawNegative, sawPositive = false, false + for _, vector in samples do + local component = vector:Dot(axis) + if component < 0 then + sawNegative = true + elseif component > 0 then + sawPositive = true + end + end + return sawNegative and sawPositive +end + +local function mean(samples: { Vector3 }): Vector3 + local total = Vector3.zero + for _, vector in samples do + total += vector + end + return total / #samples +end + +describe("RandomVector3Utils.getRandomUnitVector", function() + it("returns unit vectors", function() + expectAllUnit(sample(SAMPLE_COUNT, RandomVector3Utils.getRandomUnitVector)) + end) + + it("covers every axis in both directions", function() + local samples = sample(SAMPLE_COUNT, RandomVector3Utils.getRandomUnitVector) + + expect(coversBothSigns(samples, Vector3.xAxis)).toBe(true) + expect(coversBothSigns(samples, Vector3.yAxis)).toBe(true) + expect(coversBothSigns(samples, Vector3.zAxis)).toBe(true) + end) +end) + +describe("RandomVector3Utils.gaussianRandom", function() + it("returns the mean when the spread is zero", function() + local center = Vector3.new(1, -2, 3) + + expect(RandomVector3Utils.gaussianRandom(center, Vector3.zero)).toEqual(center) + end) + + it("only varies along axes with spread", function() + local samples = sample(SAMPLE_COUNT, function() + return RandomVector3Utils.gaussianRandom(Vector3.zero, Vector3.yAxis) + end) + + for _, vector in samples do + expect(vector.X).toBe(0) + expect(vector.Z).toBe(0) + end + expect(coversBothSigns(samples, Vector3.yAxis)).toBe(true) + end) + + it("clusters around the mean", function() + local center = Vector3.new(10, 20, 30) + local samples = sample(4000, function() + return RandomVector3Utils.gaussianRandom(center, Vector3.one) + end) + + local average = mean(samples) + expect(math.abs(average.X - center.X)).toBeLessThan(0.15) + expect(math.abs(average.Y - center.Y)).toBeLessThan(0.15) + expect(math.abs(average.Z - center.Z)).toBeLessThan(0.15) + end) +end) + +describe("RandomVector3Utils.getDirectedRandomUnitVector", function() + local DIRECTIONS = { + Vector3.xAxis, + -Vector3.xAxis, + Vector3.yAxis, + -Vector3.zAxis, + Vector3.new(3, -4, 12), + } + + it("returns the direction itself when the angle is zero", function() + for _, direction in DIRECTIONS do + local result = RandomVector3Utils.getDirectedRandomUnitVector(direction, 0) + + expect(math.abs(result:Dot(direction.Unit) - 1)).toBeLessThan(UNIT_TOLERANCE) + end + end) + + it("returns unit vectors within the cone for every direction", function() + local angleRad = math.rad(30) + local minDot = math.cos(angleRad) - UNIT_TOLERANCE + + for _, direction in DIRECTIONS do + local samples = sample(SAMPLE_COUNT, function() + return RandomVector3Utils.getDirectedRandomUnitVector(direction, angleRad) + end) + + expectAllUnit(samples) + for _, vector in samples do + expect(vector:Dot(direction.Unit)).toBeGreaterThanOrEqual(minDot) + end + end + end) + + it("spreads out to the cone edge", function() + local angleRad = math.rad(60) + local samples = sample(SAMPLE_COUNT, function() + return RandomVector3Utils.getDirectedRandomUnitVector(Vector3.yAxis, angleRad) + end) + + local widestDot = 1 + for _, vector in samples do + widestDot = math.min(widestDot, vector:Dot(Vector3.yAxis)) + end + + expect(widestDot).toBeLessThan(math.cos(math.rad(50))) + end) + + it("covers the full sphere at a half turn", function() + local samples = sample(SAMPLE_COUNT, function() + return RandomVector3Utils.getDirectedRandomUnitVector(Vector3.xAxis, math.pi) + end) + + expectAllUnit(samples) + expect(coversBothSigns(samples, Vector3.xAxis)).toBe(true) + expect(coversBothSigns(samples, Vector3.yAxis)).toBe(true) + expect(coversBothSigns(samples, Vector3.zAxis)).toBe(true) + end) + + it("errors on bad arguments", function() + expect(function() + RandomVector3Utils.getDirectedRandomUnitVector("up" :: any, 1) + end).toThrow("Bad direction") + + expect(function() + RandomVector3Utils.getDirectedRandomUnitVector(Vector3.xAxis, "wide" :: any) + end).toThrow("Bad angleRad") + end) +end) diff --git a/src/vector3utils/src/Shared/Vector3SerializationUtils.spec.lua b/src/vector3utils/src/Shared/Vector3SerializationUtils.spec.lua new file mode 100644 index 00000000000..db3d265b3b1 --- /dev/null +++ b/src/vector3utils/src/Shared/Vector3SerializationUtils.spec.lua @@ -0,0 +1,74 @@ +--!strict +--[[ + @class Vector3SerializationUtils.spec.lua +]] + +local require = require(script.Parent.loader).load(script) + +local Jest = require("Jest") +local Vector3SerializationUtils = require("Vector3SerializationUtils") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +describe("Vector3SerializationUtils.serialize", function() + it("returns the components as a three element list", function() + expect(Vector3SerializationUtils.serialize(Vector3.new(1, -2, 3.5))).toEqual({ 1, -2, 3.5 }) + end) + + it("serializes the zero vector", function() + expect(Vector3SerializationUtils.serialize(Vector3.zero)).toEqual({ 0, 0, 0 }) + end) +end) + +describe("Vector3SerializationUtils.deserialize", function() + it("rebuilds the vector from a list", function() + expect(Vector3SerializationUtils.deserialize({ 1, -2, 3.5 })).toEqual(Vector3.new(1, -2, 3.5)) + end) + + it("round trips through serialize", function() + local original = Vector3.new(10, 20.25, -30) + + local roundTripped = Vector3SerializationUtils.deserialize(Vector3SerializationUtils.serialize(original)) + + expect(roundTripped).toEqual(original) + end) + + it("errors on a non-table", function() + expect(function() + Vector3SerializationUtils.deserialize("1,2,3" :: any) + end).toThrow("Bad data") + end) + + it("errors on the wrong number of components", function() + expect(function() + Vector3SerializationUtils.deserialize({ 1, 2 }) + end).toThrow("Bad data") + + expect(function() + Vector3SerializationUtils.deserialize({ 1, 2, 3, 4 }) + end).toThrow("Bad data") + end) +end) + +describe("Vector3SerializationUtils.isSerializedVector3", function() + it("accepts a three element list", function() + expect(Vector3SerializationUtils.isSerializedVector3({ 1, 2, 3 })).toBe(true) + expect(Vector3SerializationUtils.isSerializedVector3(Vector3SerializationUtils.serialize(Vector3.one))).toBe( + true + ) + end) + + it("rejects lists of other lengths", function() + expect(Vector3SerializationUtils.isSerializedVector3({})).toBe(false) + expect(Vector3SerializationUtils.isSerializedVector3({ 1, 2 })).toBe(false) + expect(Vector3SerializationUtils.isSerializedVector3({ 1, 2, 3, 4 })).toBe(false) + end) + + it("rejects non-tables", function() + expect(Vector3SerializationUtils.isSerializedVector3(nil)).toBe(false) + expect(Vector3SerializationUtils.isSerializedVector3("1,2,3")).toBe(false) + expect(Vector3SerializationUtils.isSerializedVector3(Vector3.new(1, 2, 3))).toBe(false) + end) +end) diff --git a/src/vector3utils/src/Shared/Vector3Utils.lua b/src/vector3utils/src/Shared/Vector3Utils.lua index 02f80175600..5852280fff4 100644 --- a/src/vector3utils/src/Shared/Vector3Utils.lua +++ b/src/vector3utils/src/Shared/Vector3Utils.lua @@ -135,4 +135,19 @@ function Vector3Utils.areClose(a: Vector3, b: Vector3, epsilon: number): boolean return math.abs(a.X - b.X) <= epsilon and math.abs(a.Y - b.Y) <= epsilon and math.abs(a.Z - b.Z) <= epsilon end +--[=[ + Computes the volume of the box the vector spans. Always non-negative, so a negated size + still reports the same volume. + + ```lua + local volume = Vector3Utils.volume(part.Size) + ``` + + @param vector3 Vector3 + @return number +]=] +function Vector3Utils.volume(vector3: Vector3): number + return math.abs(vector3.X * vector3.Y * vector3.Z) +end + return Vector3Utils diff --git a/src/vector3utils/src/Shared/Vector3Utils.spec.lua b/src/vector3utils/src/Shared/Vector3Utils.spec.lua new file mode 100644 index 00000000000..37b20d840a4 --- /dev/null +++ b/src/vector3utils/src/Shared/Vector3Utils.spec.lua @@ -0,0 +1,152 @@ +--!strict +--[[ + @class Vector3Utils.spec.lua +]] + +local require = require(script.Parent.loader).load(script) + +local Jest = require("Jest") +local Vector3Utils = require("Vector3Utils") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +local EPSILON = 1e-5 + +local function expectVector3Close(actual: Vector3, expected: Vector3) + expect(actual.X).toBeCloseTo(expected.X, 5) + expect(actual.Y).toBeCloseTo(expected.Y, 5) + expect(actual.Z).toBeCloseTo(expected.Z, 5) +end + +describe("Vector3Utils.volume", function() + it("multiplies the three components", function() + expect(Vector3Utils.volume(Vector3.new(2, 3, 4))).toBe(24) + end) + + it("is non-negative when a component is negative", function() + expect(Vector3Utils.volume(Vector3.new(-2, 3, 4))).toBe(24) + expect(Vector3Utils.volume(Vector3.new(-2, -3, 4))).toBe(24) + expect(Vector3Utils.volume(-Vector3.new(2, 3, 4))).toBe(24) + end) + + it("is zero when any component is zero", function() + expect(Vector3Utils.volume(Vector3.new(0, 3, 4))).toBe(0) + expect(Vector3Utils.volume(Vector3.zero)).toBe(0) + end) + + it("handles fractional components", function() + expect(Vector3Utils.volume(Vector3.new(0.5, 0.5, 0.5))).toBeCloseTo(0.125, 5) + end) +end) + +describe("Vector3Utils.fromVector2XY", function() + it("places the vector in the XY plane", function() + expect(Vector3Utils.fromVector2XY(Vector2.new(3, 4))).toEqual(Vector3.new(3, 4, 0)) + end) +end) + +describe("Vector3Utils.fromVector2XZ", function() + it("places the vector in the XZ plane", function() + expect(Vector3Utils.fromVector2XZ(Vector2.new(3, 4))).toEqual(Vector3.new(3, 0, 4)) + end) +end) + +describe("Vector3Utils.getAngleRad", function() + it("returns the angle between unit vectors", function() + expect(Vector3Utils.getAngleRad(Vector3.xAxis, Vector3.yAxis)).toBeCloseTo(math.pi / 2, 5) + expect(Vector3Utils.getAngleRad(Vector3.xAxis, Vector3.xAxis)).toBeCloseTo(0, 5) + expect(Vector3Utils.getAngleRad(Vector3.xAxis, -Vector3.xAxis)).toBeCloseTo(math.pi, 5) + end) + + it("returns nil for a zero vector", function() + expect(Vector3Utils.getAngleRad(Vector3.zero, Vector3.xAxis)).toBeNil() + end) +end) + +describe("Vector3Utils.reflect", function() + it("flips the component along the normal", function() + local reflected = Vector3Utils.reflect(Vector3.new(1, -1, 0), Vector3.yAxis) + expectVector3Close(reflected, Vector3.new(1, 1, 0)) + end) + + it("leaves a vector parallel to the surface unchanged", function() + local reflected = Vector3Utils.reflect(Vector3.new(1, 0, 2), Vector3.yAxis) + expectVector3Close(reflected, Vector3.new(1, 0, 2)) + end) +end) + +describe("Vector3Utils.angleBetweenVectors", function() + it("returns the angle for non-unit vectors", function() + expect(Vector3Utils.angleBetweenVectors(Vector3.new(5, 0, 0), Vector3.new(0, 2, 0))).toBeCloseTo(math.pi / 2, 5) + end) + + it("returns zero for parallel vectors", function() + expect(Vector3Utils.angleBetweenVectors(Vector3.new(1, 1, 0), Vector3.new(3, 3, 0))).toBeCloseTo(0, 5) + end) + + it("returns pi for opposite vectors", function() + expect(Vector3Utils.angleBetweenVectors(Vector3.xAxis, -Vector3.xAxis)).toBeCloseTo(math.pi, 5) + end) +end) + +describe("Vector3Utils.slerp", function() + it("returns the start at t=0", function() + expectVector3Close(Vector3Utils.slerp(Vector3.xAxis, Vector3.yAxis, 0), Vector3.xAxis) + end) + + it("returns the finish at t=1", function() + expectVector3Close(Vector3Utils.slerp(Vector3.xAxis, Vector3.yAxis, 1), Vector3.yAxis) + end) + + it("returns the midpoint on the arc at t=0.5", function() + local halfway = Vector3Utils.slerp(Vector3.xAxis, Vector3.yAxis, 0.5) + expectVector3Close(halfway, Vector3.new(math.sqrt(0.5), math.sqrt(0.5), 0)) + expect(halfway.Magnitude).toBeCloseTo(1, 5) + end) +end) + +describe("Vector3Utils.constrainToCone", function() + it("returns the direction unchanged when inside the cone", function() + local direction = Vector3.new(1, 0.1, 0) + local constrained = Vector3Utils.constrainToCone(direction, Vector3.xAxis, math.rad(90)) + expect(constrained).toBe(direction) + end) + + it("clamps the direction to the cone edge and preserves magnitude", function() + local direction = Vector3.new(0, 3, 0) + local constrained = Vector3Utils.constrainToCone(direction, Vector3.xAxis, math.rad(90)) + + expect(constrained.Magnitude).toBeCloseTo(3, 5) + expect(Vector3Utils.angleBetweenVectors(constrained, Vector3.xAxis)).toBeCloseTo(math.rad(45), 5) + expectVector3Close(constrained, Vector3.new(3 * math.sqrt(0.5), 3 * math.sqrt(0.5), 0)) + end) +end) + +describe("Vector3Utils.round", function() + it("rounds each component to the nearest multiple", function() + expect(Vector3Utils.round(Vector3.new(1.4, 2.6, -0.4), 1)).toEqual(Vector3.new(1, 3, 0)) + expect(Vector3Utils.round(Vector3.new(5, 6, 7), 4)).toEqual(Vector3.new(4, 8, 8)) + end) +end) + +describe("Vector3Utils.areClose", function() + it("is true when every component is within epsilon", function() + expect(Vector3Utils.areClose(Vector3.new(1, 2, 3), Vector3.new(1.001, 1.999, 3), 0.01)).toBe(true) + end) + + it("is false when any component exceeds epsilon", function() + expect(Vector3Utils.areClose(Vector3.new(1, 2, 3), Vector3.new(1, 2, 3.1), 0.01)).toBe(false) + end) + + it("treats epsilon as inclusive", function() + expect(Vector3Utils.areClose(Vector3.zero, Vector3.new(EPSILON, 0, 0), EPSILON)).toBe(true) + end) + + it("errors on a non-number epsilon", function() + expect(function() + Vector3Utils.areClose(Vector3.zero, Vector3.zero, nil :: any) + end).toThrow() + end) +end) diff --git a/src/vector3utils/src/jest.config.lua b/src/vector3utils/src/jest.config.lua new file mode 100644 index 00000000000..4294d00462f --- /dev/null +++ b/src/vector3utils/src/jest.config.lua @@ -0,0 +1,3 @@ +return { + testMatch = { "**/*.spec" }, +} diff --git a/src/vector3utils/test/default.project.json b/src/vector3utils/test/default.project.json new file mode 100644 index 00000000000..1466d0b5f67 --- /dev/null +++ b/src/vector3utils/test/default.project.json @@ -0,0 +1,17 @@ +{ + "name": "Vector3UtilsTest", + "tree": { + "$className": "DataModel", + "ServerScriptService": { + "$properties": { + "LoadStringEnabled": true + }, + "vector3utils": { + "$path": ".." + }, + "Script": { + "$path": "scripts/Server" + } + } + } +} diff --git a/src/vector3utils/test/scripts/Server/ServerMain.server.lua b/src/vector3utils/test/scripts/Server/ServerMain.server.lua new file mode 100644 index 00000000000..c7799c08386 --- /dev/null +++ b/src/vector3utils/test/scripts/Server/ServerMain.server.lua @@ -0,0 +1,12 @@ +--!nonstrict +local ServerScriptService = game:GetService("ServerScriptService") + +local root = ServerScriptService.vector3utils +local loader = root:FindFirstChild("LoaderUtils", true).Parent +local require = require(loader).bootstrapGame(root) + +local NevermoreTestRunnerUtils = require("NevermoreTestRunnerUtils") + +if NevermoreTestRunnerUtils.runTestsIfNeededAsync(root) then + return +end