Stop AI monster from running into corners (without AgentRadius)

I’ve got a basic monster ai script, and I’m trying to find a way to stop the ai from running into walls when turning corners, I’m aware AgentRadius is used with traditional pathfinding, but this script doesn’t use those systems. I’m trying to find an equivalent to AgentRadius that could prevent the ai from getting stuck and breaking the entire point of my game.

Here is the code:

local Self = script.Parent
local Settings = Self:FindFirstChild'Configurations' -- Points to the settings.
local Mind = Self:FindFirstChild'Mind' -- Points to the monster's mind.  You can edit parts of this from other scripts in-game to change the monster's behavior.  Advanced users only.

--
-- Verify that everything is where it should be
assert(Self:FindFirstChild'Humanoid' ~= nil, 'Monster does not have a humanoid')
assert(Settings ~= nil, 'Monster does not have a Configurations object')
	assert(Settings:FindFirstChild'MaximumDetectionDistance' ~= nil and Settings.MaximumDetectionDistance:IsA'NumberValue', 'Monster does not have a MaximumDetectionDistance (NumberValue) setting')
	assert(Settings:FindFirstChild'CanGiveUp' ~= nil and Settings.CanGiveUp:IsA'BoolValue', 'Monster does not have a CanGiveUp (BoolValue) setting')
	assert(Settings:FindFirstChild'CanRespawn' ~= nil and Settings.CanRespawn:IsA'BoolValue', 'Monster does not have a CanRespawn (BoolValue) setting')
	assert(Settings:FindFirstChild'SpawnPoint' ~= nil and Settings.SpawnPoint:IsA'Vector3Value', 'Monster does not have a SpawnPoint (Vector3Value) setting')
	assert(Settings:FindFirstChild'AutoDetectSpawnPoint' ~= nil and Settings.AutoDetectSpawnPoint:IsA'BoolValue', 'Monster does not have a AutoDetectSpawnPoint (BoolValue) setting')
	assert(Settings:FindFirstChild'FriendlyTeam' ~= nil and Settings.FriendlyTeam:IsA'BrickColorValue', 'Monster does not have a FriendlyTeam (BrickColorValue) setting')
	assert(Settings:FindFirstChild'AttackDamage' ~= nil and Settings.AttackDamage:IsA'NumberValue', 'Monster does not have a AttackDamage (NumberValue) setting')
	assert(Settings:FindFirstChild'AttackFrequency' ~= nil and Settings.AttackFrequency:IsA'NumberValue', 'Monster does not have a AttackFrequency (NumberValue) setting')
	assert(Settings:FindFirstChild'AttackRange' ~= nil and Settings.AttackRange:IsA'NumberValue', 'Monster does not have a AttackRange (NumberValue) setting')
assert(Mind ~= nil, 'Monster does not have a Mind object')
	assert(Mind:FindFirstChild'CurrentTargetHumanoid' ~= nil and Mind.CurrentTargetHumanoid:IsA'ObjectValue', 'Monster does not have a CurrentTargetHumanoid (ObjectValue) mind setting')
assert(Self:FindFirstChild'Respawn' and Self.Respawn:IsA'BindableFunction', 'Monster does not have a Respawn BindableFunction')
assert(Self:FindFirstChild'Died' and Self.Died:IsA'BindableEvent', 'Monster does not have a Died BindableEvent')
assert(Self:FindFirstChild'Respawned' and Self.Died:IsA'BindableEvent', 'Monster does not have a Respawned BindableEvent')
assert(Self:FindFirstChild'Attacked' and Self.Died:IsA'BindableEvent', 'Monster does not have a Attacked BindableEvent')
assert(script:FindFirstChild'Attack' and script.Attack:IsA'Animation', 'Monster does not have a MonsterScript.Attack Animation')


--
--
local Info = {
	-- These are constant values.  Don't change them unless you know what you're doing.

	-- Services
	Players = game:GetService 'Players',
	PathfindingService = game:GetService 'PathfindingService',

	-- Advanced settings
	RecomputePathFrequency = 1, -- The monster will recompute its path this many times per second
	RespawnWaitTime = 5, -- How long to wait before the monster respawns
	JumpCheckFrequency = 1, -- How many times per second it will do a jump check
}
local Data = {
	-- These are variable values used internally by the script.  Advanced users only.

	LastRecomputePath = 0,
	Recomputing = false, -- Reocmputing occurs async, meaning this script will still run while it's happening.  This variable will prevent the script from running two recomputes at once.
	PathCoords = {},
	IsDead = false,
	TimeOfDeath = 0,
	CurrentNode = nil,
	CurrentNodeIndex = 1,
	AutoRecompute = true,
	LastJumpCheck = 0,
	LastAttack = 0,
	
	BaseMonster = Self:Clone(),
	AttackTrack = nil,
}

--
--
local Monster = {} -- Create the monster class


function Monster:GetCFrame()
	-- Returns the CFrame of the monster's humanoidrootpart

	local humanoidRootPart = Self:FindFirstChild('HumanoidRootPart')

	if humanoidRootPart ~= nil and humanoidRootPart:IsA('BasePart') then
		return humanoidRootPart.CFrame
	else
		return CFrame.new()
	end
end

function Monster:GetMaximumDetectionDistance()
	-- Returns the maximum detection distance
	
	local setting = Settings.MaximumDetectionDistance.Value

	if setting < 0 then
		return math.huge
	else
		return setting
	end
end

function Monster:SearchForTarget()
	-- Finds the closest player and sets the target

	local players = Info.Players:GetPlayers()
	local closestCharacter, closestCharacterDistance

	for i=1, #players do
		local player = players[i]
		
		if player.Neutral or player.TeamColor ~= Settings.FriendlyTeam.Value then
			local character = player.Character
	
			if character ~= nil and character:FindFirstChild('Humanoid') ~= nil and character.Humanoid:IsA('Humanoid') then
				local distance = player:DistanceFromCharacter(Monster:GetCFrame().p)
	
				if distance < Monster:GetMaximumDetectionDistance() then
					if closestCharacter == nil then
						closestCharacter, closestCharacterDistance = character, distance
					else
						if closestCharacterDistance > distance then
							closestCharacter, closestCharacterDistance = character, distance
						end
					end
				end
			end
		end
	end


	if closestCharacter ~= nil then
		Mind.CurrentTargetHumanoid.Value = closestCharacter.Humanoid
		Mind.CurrentTarget.Value = closestCharacter
	end
end

function Monster:TryRecomputePath()
	if Data.AutoRecompute or tick() - Data.LastRecomputePath > 1/Info.RecomputePathFrequency then
		Monster:RecomputePath()
	end
end

function Monster:GetTargetCFrame()
	local targetHumanoid = Mind.CurrentTargetHumanoid.Value
	
	if Monster:TargetIsValid() then
		return targetHumanoid.Torso.CFrame
	else
		return CFrame.new()
	end
end

function Monster:IsAlive()
	return Self.Humanoid.Health > 0 and Self.Humanoid.Torso ~= nil
end

function Monster:TargetIsValid()
	local targetHumanoid = Mind.CurrentTargetHumanoid.Value
	
	if targetHumanoid ~= nil and targetHumanoid:IsA 'Humanoid' and targetHumanoid.Torso ~= nil and targetHumanoid.Torso:IsA 'BasePart' then
		return true
	else
		return false
	end
end

function Monster:HasClearLineOfSight()
	-- Going to cast a ray to see if I can just see my target
	local myPos, targetPos = Monster:GetCFrame().p, Monster:GetTargetCFrame().p
	
	local hit, pos = workspace:FindPartOnRayWithIgnoreList(
		Ray.new(
			myPos,
			targetPos - myPos
		),
		{
			Self,
			Mind.CurrentTargetHumanoid.Value.Parent
		}
	)
	
	
	if hit == nil then
		return true
	else
		return false
	end
end

function Monster:RecomputePath()
	if not Data.Recomputing then
		if Monster:IsAlive() and Monster:TargetIsValid() then
			if Monster:HasClearLineOfSight() then
				Data.AutoRecompute = true
				Data.PathCoords = {
					Monster:GetCFrame().p,
					Monster:GetTargetCFrame().p
				}
				
				Data.LastRecomputePath = tick()
				Data.CurrentNode = nil
				Data.CurrentNodeIndex = 2 -- Starts chasing the target without evaluating its current position
			else
				-- Do pathfinding since you can't walk straight
				Data.Recomputing = true -- Basically a debounce.
				Data.AutoRecompute = false
				
				
				local path = Info.PathfindingService:ComputeSmoothPathAsync(
					Monster:GetCFrame().p,
					Monster:GetTargetCFrame().p,
					500
				)
				Data.PathCoords = path:GetPointCoordinates()
				
				
				Data.Recomputing = false
				Data.LastRecomputePath = tick()
				Data.CurrentNode = nil
				Data.CurrentNodeIndex = 1
			end
		end
	end
end

function Monster:Update()
	Monster:ReevaluateTarget()
	Monster:SearchForTarget()
	Monster:TryRecomputePath()
	Monster:TravelPath()
end

function Monster:TravelPath()
	local closest, closestDistance, closestIndex
	local myPosition = Monster:GetCFrame().p
	local skipCurrentNode = Data.CurrentNode ~= nil and (Data.CurrentNode - myPosition).magnitude < 3
	
	for i=Data.CurrentNodeIndex, #Data.PathCoords do
		local coord = Data.PathCoords[i]
		if not (skipCurrentNode and coord == Data.CurrentNode) then
			local distance = (coord - myPosition).magnitude
			
			if closest == nil then
				closest, closestDistance, closestIndex = coord, distance, i
			else
				if distance < closestDistance then
					closest, closestDistance, closestIndex = coord, distance, i
				else
					break
				end
			end
		end
	end
	
	
	--
	if closest ~= nil then
		Data.CurrentNode = closest
		Data.CurrentNodeIndex = closestIndex
		
		local humanoid = Self:FindFirstChild 'Humanoid'
		
		if humanoid ~= nil and humanoid:IsA'Humanoid' then
			humanoid:MoveTo(closest)
		end
		
		if Monster:IsAlive() and Monster:TargetIsValid() then
			Monster:TryJumpCheck()
			Monster:TryAttack()
		end
		
		if closestIndex == #Data.PathCoords then
			-- Reached the end of the path, force a new check
			Data.AutoRecompute = true
		end
	end
end

function Monster:TryJumpCheck()
	if tick() - Data.LastJumpCheck > 1/Info.JumpCheckFrequency then
		Monster:JumpCheck()
	end
end

function Monster:TryAttack()
	if tick() - Data.LastAttack > 1/Settings.AttackFrequency.Value then
		Monster:Attack()
	end
end

function Monster:Attack()
	local myPos, targetPos = Monster:GetCFrame().p, Monster:GetTargetCFrame().p
	
	if (myPos - targetPos).magnitude <= Settings.AttackRange.Value then
		Mind.CurrentTargetHumanoid.Value:TakeDamage(Settings.AttackDamage.Value)
		Data.LastAttack = tick()
		Data.AttackTrack:Play()
	end
end

function Monster:JumpCheck()
	-- Do a raycast to check if we need to jump
	local myCFrame = Monster:GetCFrame()
	local checkVector = (Monster:GetTargetCFrame().p - myCFrame.p).unit*2
	
	local hit, pos = workspace:FindPartOnRay(
		Ray.new(
			myCFrame.p + Vector3.new(0, -2.4, 0),
			checkVector
		),
		Self
	)
	
	if hit ~= nil and not hit:IsDescendantOf(Mind.CurrentTargetHumanoid.Value.Parent) then
		-- Do a slope check to make sure we're not walking up a ramp
		
		local hit2, pos2 = workspace:FindPartOnRay(
			Ray.new(
				myCFrame.p + Vector3.new(0, -2.3, 0),
				checkVector
			),
			Self
		)
		
		if hit2 == hit then
			if ((pos2 - pos)*Vector3.new(1,0,1)).magnitude < 0.05 then -- Will pass for any ramp with <2 slope
				Self.Humanoid.Jump = true
			end
		end
	end
	
	Data.LastJumpCheck = tick()
end

function Monster:Connect()
	Mind.CurrentTargetHumanoid.Changed:connect(function(humanoid)
		if humanoid ~= nil then
			assert(humanoid:IsA'Humanoid', 'Monster target must be a humanoid')
			
			Monster:RecomputePath()
		end
	end)
	
	Self.Respawn.OnInvoke = function(point)
		Monster:Respawn(point)
	end
end

function Monster:Initialize()
	Monster:Connect()
	
	if Settings.AutoDetectSpawnPoint.Value then
		Settings.SpawnPoint.Value = Monster:GetCFrame().p
	end
end

function Monster:Respawn(point)
	local point = point or Settings.SpawnPoint.Value
	
	for index, obj in next, Data.BaseMonster:Clone():GetChildren() do
		if obj.Name == 'Configurations' or obj.Name == 'Mind' or obj.Name == 'Respawned' or obj.Name == 'Died' or obj.Name == 'MonsterScript' or obj.Name == 'Respawn' then
			obj:Destroy()
		else
			Self[obj.Name]:Destroy()
			obj.Parent = Self
		end
	end
	
	Monster:InitializeUnique()
	
	Self.Parent = workspace
	
	Self.HumanoidRootPart.CFrame = CFrame.new(point)
	Settings.SpawnPoint.Value = point
	Self.Respawned:Fire()
end

function Monster:InitializeUnique()
	Data.AttackTrack = Self.Humanoid:LoadAnimation(script.Attack)
end

function Monster:ReevaluateTarget()
	local currentTarget = Mind.CurrentTargetHumanoid.Value
	
	if currentTarget ~= nil and currentTarget:IsA'Humanoid' then
		local character = currentTarget.Parent
		
		if character ~= nil then
			local player = Info.Players:GetPlayerFromCharacter(character)
			
			if player ~= nil then
				if not player.Neutral and player.TeamColor == Settings.FriendlyTeam.Value then
					Mind.CurrentTargetHumanoid.Value = nil
				end
			end
		end
		
		
		if currentTarget == Mind.CurrentTargetHumanoid.Value then
			local torso = currentTarget.Torso
			
			if torso ~= nil and torso:IsA 'BasePart' then
				if Settings.CanGiveUp.Value and (torso.Position - Monster:GetCFrame().p).magnitude > Monster:GetMaximumDetectionDistance() then
					Mind.CurrentTargetHumanoid.Value = nil
				end
			end
		end
	end
end

--
--
Monster:Initialize()
Monster:InitializeUnique()

while true do
	if not Monster:IsAlive() then
		if Data.IsDead == false then
			Data.IsDead = true
			Data.TimeOfDeath = tick()
			Self.Died:Fire()
		end
		if Data.IsDead == true then
			if tick()-Data.TimeOfDeath > Info.RespawnWaitTime then
				Monster:Respawn()
			end
		end
	end
	
	if Monster:IsAlive() then
		Monster:Update()
	end
	
	wait()
end
4 Likes

my previous writeup was horrible, so i re-edited my entire post.

i was attempting to solve the same problem you are experiencing eariler. from what i can comprehend from your code, i think you’re using a raycast to confirm a line of sight to your npc’s target, but, since a raycast is quite thin, it won’t be thick enough to be able to detect walls in a straight line path to the target.

my idea is that you could weld a part to the npc’s humanoid root part to act as a wall sensor (preferably a cylinder, since it doesnt touch the ground like spheres do), using the Touched and TouchEnded events to detect nearby parts, so you can get a closest point from the nearest wall:

--variables
local WallSensorObjects = {}

--instances
local WallSensor = Instance.new("Part", Npc)
WallSensor.CanCollide = false
WallSensor.CanQuery = false
WallSensor.CanTouch = true
WallSensor.CFrame = Npc.HumanoidRootPart
WallSensor.Size = Vector3.new(15,15,15)
WallSensor.Massless = true
WallSensor.Shape = Enum.PartType.Ball
WallSensor.Transparency = 0.75
WallSensor.Material = Enum.Material.Neon

local WallSensorWeld = Instance.new("WeldConstraint", WallSensor)
WallSensorWeld.Part0 = Npc.HumanoidRootPart
WallSensorWeld.Part1 = WallSensor

WallSensor.Touched:Connect(function(Toucher)
	if Toucher:IsA("BasePart") and Toucher.Anchored then
		table.insert(WallSensorObjects, Toucher)
	end
end)

WallSensor.TouchEnded:Connect(function(Toucher)
	local indexID = table.find(WallSensorObjects, Toucher)
	if indexID then
		table.remove(WallSensorObjects, indexID)
	end
end)

afterwards we’ll find the closest point on all parts to the npc’s humanoid root part in the WallSensorObjects table, because comparing the part’s position to the npc’s humanoid root part varies (etc. a part used as a wall is about 2 storeys high, so the part’s position ends up not even close to the character).

i have a (rather incomplete) function for this, since i couldn’t find something similar to this on google. the wedge code block returns odd behaviour on it’s upper surface, you can tell by comparing it’s normal to a beam connecting the position it returns to the Point argument given, because they arent the same. the wedge corner part is also incomplete. if you could fix it, it’d be cool, but you don’t have to. i’ll go edit it once i’ve figured it sometime later. the other functions are required, because i used them in the closest point function. apologies for not providing something complete…

function module.NumberLerp(a, b, x)
	return a + (b - a) * x
end

--credit: sircfenner ( https://devforum.roblox.com/t/finding-distance-to-a-wall-regardless-of-where-you-are/794606/13 )
function module.ProjectVectorToLine(Vec, Line)
	if typeof(Vec) == "Vector3" and typeof(Line) == "Vector3" then
		return Vec:Dot(Line.Unit) * Line.Unit
	else
		warn(script.Name .. ": some or all of the arguments given are not Vector3s. gave 0,0,0 instead.")
		return Vector3.new(0,0,0)
	end
end

function module.ProjectVectorToPlane(Vec, Normal)
	local NormalProjection = module.ProjectVectorToLine(Vec, Normal)
	return Vec - NormalProjection
end

--credit: [block segment] sircfenner ( https://devforum.roblox.com/t/finding-the-closest-vector3-point-on-a-part-from-the-character/130679/3 )
function module.ClosestPointOnPart(Part, Point)	
	if Part:IsA("Part") or Part:IsA("WedgePart") or Part:IsA("CornerWedgePart") then
		local Transform = Part.CFrame:PointToObjectSpace(Point) -- Transform into local space
		local HalfSize = Part.Size * 0.5
		
		if Part:IsA("Part") and Part.Shape == Enum.PartType.Block then
			return Part.CFrame * Vector3.new( -- Clamp & transform into world space
				math.clamp(Transform.x, -HalfSize.x, HalfSize.x),
				math.clamp(Transform.y, -HalfSize.y, HalfSize.y),
				math.clamp(Transform.z, -HalfSize.z, HalfSize.z)
			)
		elseif Part:IsA("Part") and Part.Shape == Enum.PartType.Ball then
			local DirectionToPoint = (Point - Part.Position).Unit
			return Part.Position + (DirectionToPoint * (Part.Size.X / 2)) --get the radius
		elseif Part:IsA("Part") and Part.Shape == Enum.PartType.Cylinder then
			--find the circle's position on the cylinder by projecting the position onto the x axis as a plane
			local YzDirection = module.ProjectVectorToPlane(Transform, Vector3.new(1,0,0))
			local circleRadius = math.min(Part.Size.Y, Part.Size.Z) / 2
			local circlePos = nil

			--limit the direction (moreso circle, actually) position from exiting the radius
			if YzDirection.Magnitude > circleRadius then
				circlePos = YzDirection.Unit * circleRadius
			else
				circlePos = YzDirection
			end
			
			return Part.CFrame * Vector3.new(
				math.clamp(Transform.X, -HalfSize.X, HalfSize.X),
				circlePos.Y,
				circlePos.Z
			)
		elseif Part:IsA("WedgePart") then
			local BoxClampPos = Vector3.new(
				math.clamp(Transform.x, -HalfSize.x, HalfSize.x),
				math.clamp(Transform.y, -HalfSize.y, HalfSize.y),
				math.clamp(Transform.z, -HalfSize.z, HalfSize.z)
			)
			
			--these limits vary on position!
			local ZPosPercent = (BoxClampPos.Z + HalfSize.Z) / 2 --the addition is to move the range from -1 - 1 to 0 - 2, the division is to turn the range 0 - 2 into 0 - 1.
			--local YPosPercent = (BoxClampPos.Y + HalfSize.Y) / 2
			local YHighLimit = module.NumberLerp(-HalfSize.Y, HalfSize.Y, ZPosPercent)
			--local ZHighLimit = module.NumberLerp(HalfSize.Z, HalfSize.Z, YPosPercent)
			
			--print("yHighLimit: " .. YHighLimit .. ", z: " .. Part.Size.Z)
			
			return Part.CFrame * Vector3.new(
				math.clamp(Transform.X, -HalfSize.X, HalfSize.X),
				math.clamp(Transform.Y, -HalfSize.Y, YHighLimit),
				math.clamp(Transform.Z, -HalfSize.Z, HalfSize.Z) --math.clamp(Transform.z, -HalfSize.z, ZHighLimit) --apparently it's the same result with or without this
			)
		elseif Part:IsA("CornerWedgePart") then
			local BoxClampPos = Vector3.new(
				math.clamp(Transform.x, -HalfSize.x, HalfSize.x),
				math.clamp(Transform.y, -HalfSize.y, HalfSize.y),
				math.clamp(Transform.z, -HalfSize.z, HalfSize.z)
			)
			
			return BoxClampPos
		end
	else
		warn(script.Name .. ": this function only supports Part, WedgePart and WedgeCornerPart.")
		return nil
	end
end

moving on, getting the closest points from all parts:

local ClosestPoints = {}
for _, Part in pairs(WallSensorObjects) do
	local ClosestPoint = module.ClosestPointOnPart(Part, Npc.HumanoidRootPart.Position)
	if ClosestPoint then --the function can return null, so put a if statement
		table.insert(ClosestPoints, ClosestPoint)
	end
end

now we just need the position that’s the nearest to the humanoid root part:

local NearestPos = nil
local NearestDist = math.huge
for _, Point in pairs(ClosestPoints) do
	local PointDist = (Point - Npc.HumanoidRootPart.Position).Magnitude
	if NearestDist > PointDist then
		NearestPos = Point
		NearestDist = PointDist
	end
end

we’ll make a vector for the distance and direction to the nearest point to the humanoid root part so that we can linearly interpolate a vector that, when too close to the wall, would move outside more, but if keeps a distance, would focus on moving towards the target by a ratio to the wall distance limit.

local MoveDirection = Vector3.zero
local TargetVec = Target.HumanoidRootPart.Position - Npc.HumanoidRootPart.Position
if NearestPos then
	local NearestWallVec = NearestPos - Npc.HumanoidRootPart.Position
	if WallDistanceLimit > NearestWallVec.Magnitude then
		local DirectionRatio = NearestWallVec.Magnitude / WallDistanceLimit
		MoveDirection = Vector3.new( --make the nearest wall vec negative so we go away from the wall. oh dang this is ugly
			module.NumberLerp(-NearestWallVec.Unit.X, TargetVec.Unit.X, DirectionRatio),
			module.NumberLerp(-NearestWallVec.Unit.Y, TargetVec.Unit.Y, DirectionRatio),
			module.NumberLerp(-NearestWallVec.Unit.Z, TargetVec.Unit.Z, DirectionRatio)
		).Unit
	else
		MoveDirection = TargetVec.Unit
	end
else
	MoveDirection = TargetVec.Unit
end

Npc.Humanoid:Move(MoveDirection)

somePreview
(i got lazy, and used a cube because you’d have to rotate the cylinder to the ground)

addendum: i keep forgetting to place crucial information, sorry about that.
some pitfalls about this method is that the npc will attempt to move out of both walls when they’re in a narrow alley, say the npc moves from the left wall, to the right side because the wall distance limit is greater than the distance of the two walls

i also ran the 3 last code blocks in RunService.Heartbeat.

7 Likes

Hey there,
I tried to fix your code, but I didn’t find any solution for this problem. I recommend you to use this model:

You can use it for the same AI you made:
You can create paths on which the AI runs when it does not detect players and it automatically prevents it from getting stuck in corners
I hope this helps you a bit :slight_smile:
EDIT: please note that this model also creates a jumpscare if you touched the AI. So i recommend you to modify some of the things in this model which aren’t necessary for you :slight_smile:

heads up:
the glitch
apparently having the npc walk into a wedge just makes them disappear. i’ll go report this bug later…

Thank you. so much. It’s clear you put a lot of work into this and I tested it out, which it works very well. The wedge issue is fine as my map doesn’t have any wedges place in a way that would break the ai. Thanks for the help

1 Like

i’ve only recently thought of the method, so i’m not entirely aware of the problems it has. truth be told, the first post i made on this topic originally started as a idea on using a block that stretches all the way to the target without me really trying it.

anyway, if you manage to figure out getting the closest points on wedge and wedge corner parts in a more accurate way sometime in the future, please post it here. i don’t know how to do that at the moment myself (that’s mainly why the closest point on part function is incomplete). i’ll also edit the earlier post if i manage to figure it out as well.

i know this thread hasn’t been active for a few months, but i’m here to dump a module script that has a closest point on part function that works slightly better than the one i put out previously (points closest to slanted wedge surfaces do not have consistent results compared to when using it on block type part’s surfaces, because the previous function only focused on limiting the point to the block’s geometry) due to my laziness to isolate the functions from it.

my heart tells me there’s definitely a better way to program the code for the wedge & corner wedge part in the new function, but i couldn’t think of one. either way i’d say it’s messy but i decided to put it here because i imagine someone might need it.

--function groups
----
local Vars = {}

local MathNumber = {}
local MathVector3 = {}
local MathGeometry = {} --no idea if the functions belonging to this should be in MathVector3
local Math = {}

local Table = {}

local Parts = {}

local Graphics = {}

--module sorting
----
local module = {
	["Vars"] = Vars,
	["Math"] = {["Number"] = MathNumber, ["Vector3"] = MathVector3, ["Geometry"] = MathGeometry},
	["Table"] = Table,
	["Parts"] = Parts,
	["Graphics"] = Graphics
}

--functions
----
--==variables==--
function Vars.DefaultTo(Var, Default) --intended for functions that have optional arguments. avoided arguments return nil, so this is just a simple if check.
	if Var then
		return Var
	else
		return Default
	end
end


--==math (numbers)==--
function MathNumber.Average(Numbers)
	local Sum = 0
	for i, v in Numbers do
		Sum += v
	end
	return Sum / #Numbers
end

function MathNumber.Lerp(a:number, b:number, x:number) --roblox strangely has a lerp function for vector3's but not numbers.
	return a + (b - a) * x
end

function MathNumber.InverseLerp(a:number, b:number, x:number)
	return (x - a) / (b - a)
end


--==math (vector3)==--
--[[
source: http://answers.unity.com/answers/1274648/view.html
]]
function MathVector3.InverseLerp(a:Vector3, b:Vector3, x:Vector3) --vec3 already has a lerp function, although there is no inverselerp.
	local AB = b - a
	local AV = x - a
	return AV:Dot(AB) / AB:Dot(AB)
end

function MathVector3.Average(Vectors) --vectors should be a table containing only vectors.
	local Sum = Vector3.zero
	for i, v in Vectors do
		Sum += v
	end
	return Vector3.new(Sum.X / #Vectors, Sum.Y / #Vectors, Sum.Z / #Vectors)
end

function MathVector3.UnitLength(Vec:Vector3, Unit:Vector3) --intended to get a value for a axis, using "Unit" as a representative of the axis, might be more sensible to use a dot product, honestly.
	local Projection = MathVector3.ProjectToLine(Vec, Unit)
	local DotProduct = Projection.Unit:Dot(Unit.Unit)
	local Result = Projection.Magnitude * DotProduct
	
	--[[
	doing 0 / 0 would equal to nan. if the vec is zeroed out on all axes, it'll also give nan.
	oddly enough, comparing 0/0 to the output of this func if a vector is 0 doesn't return true.
	we do know that they both return nan however, so the only thing i could think of was to convert
	the output into a string & compare it with a string "nan".
	]]--
	if tostring(Result) == "nan" then
		return 0
	end
	
	return Result
end

function MathVector3.ProjectToLine(Vec:Vector3, Line:Vector3)
	return Vec:Dot(Line.Unit) * Line.Unit
end

function MathVector3.ProjectToNormal(Vec:Vector3, Normal:Vector3)
	return Vec - MathVector3.ProjectToLine(Vec, Normal)
end

function MathVector3.ClampMagnitude(Vec:Vector3, Limit:number)
	if Vec.Magnitude > Limit then
		return Vec.Unit * Limit
	end
	return Vec
end


--==math (geometry)==--
function MathGeometry.GetClosestPointOnLine(Point:Vector3, Start:Vector3, End:Vector3) --same deal as getting the closest point on a edge
	local Line = End - Start
	
	--to treat my obessive compulsive disorder, we'll use a center of the line as the origin by using lerp
	local Center = Start:Lerp(End, 0.5)
	
	--the position point is representing has a origin of 0,0,0. you can imagine drawing a line from 0,0,0 to point as the world space vector. to "convert it into local space", we'd change the origin to the line's center.
	local LocalSpacePoint = Point - Center
	
	--project the point onto the line & limit the length of the vector. this limits the position where the line isn't pointing at & limits the position to the line's length respectively. since the origin is at the center, the length limit should be line.magnitude/2.
	return Center + MathVector3.ClampMagnitude(MathVector3.ProjectToLine(LocalSpacePoint, Line), Line.Magnitude/2)
end

--[[
this lazily uses sebastian lague's PointInTriangle() function, seen here:
https://github.com/SebLague/Gamedev-Maths/blob/master/PointInTriangle.cs

ProjectPoint is a optional argument that will project the point position to the triangle's normal if set to true, which
means regardless of the point either being off or on the triangle's surface, it'll count as if it was always on the surface.

also, if the point given is overlapping with a triangle's edge/line, it'll count as "within".
]]--
function MathGeometry.IsPointWithin3DTriangle(Point:Vector3, V1:Vector3, V2:Vector3, V3:Vector3, ProjectPoint:boolean?)
	--[[
	==triangle legend
	====
		    2
		    .
		   / \
		a /   \ b
		 /_____\
	  1     c     3
	
	my formatting is similar to javidx9's (seen here: https://www.youtube.com/watch?v=XgMWc6LumG4&t=285s), which orders the vertexes
	and lines/edges in clockwise direction.
	
	i'd thought about having the vertexes being automatically assigned for a clockwise order via getting a average of all vertex positions and
	creating a vector that connects to the average > vertex, then sorting the vertexes by their vector's respective dot product. but i never thought
	about what would be the other "static" variable for the dot product that'd be the same during the calculation & what that vector should be, because
	if we just use one of the global axes, it's likely it wouldn't match the intended behaviour of the sorting because the vector would've to be within
	a circle like shape on the triangle's normal (and getting the normal without the lines isn't possible because getting the normal relies on a cross product)
	]]--
	
	--==tri data==--
	local TriCenter = MathVector3.Average({V1, V2, V3})
	local TriLines = {
		["A"] = V2 - V1,
		--["B"] = V3 - V2,
		["C"] = V1 - V3
	}
	local TriNormal = TriLines.A:Cross(TriLines.C).Unit --used to tell if the point is on the tri, doesn't really matter if it's backwards or forwards
	local TriCFrame = CFrame.new(TriCenter, TriCenter + TriNormal)
	
	--==localspace conversions==--
	--[[
	the cframe helps tell if the point isn't on the triangle's surface (which is known if the z axis isn't 0), because the z axis (forward &
	backward) represents the normal's direction. thus, if the point's z pos isn't 0, we don't have to check if it's within the triangle.
	]]
	local TriLs = { --Ls > local space
		["V1"] = TriCFrame:PointToObjectSpace(V1),
		["V2"] = TriCFrame:PointToObjectSpace(V2),
		["V3"] = TriCFrame:PointToObjectSpace(V3),
		["Point"] = TriCFrame:PointToObjectSpace(Point)
	}
	
	--[[
	if ProjectPoint then
		TriLs.Point.Z = 0
	end
	]]
	
	if TriLs.Point.Z ~= 0 and not ProjectPoint then --if the optional setting only changes this, then it's probably more compact to do this method
		return false
	end
	
	--==conversion to 2d==--
	--[[
	honestly i don't understand what sebastian lague's code does, but it appears to be involved with algebra. his code is also intended for
	triangles on 2d spaces, which is where the cframe comes in.
	
	in lague's video, he uses letters for his vertexes instead, so here's a conversion:
	V1 > A
	V2 > B
	V3 > C
	]]
	local s1 = TriLs.V3.Y - TriLs.V1.Y
	local s2 = TriLs.V3.X - TriLs.V1.X
	local s3 = TriLs.V2.Y - TriLs.V1.Y
	local s4 = TriLs.Point.Y - TriLs.V1.Y
	
	local w1 = (TriLs.V1.X * s1 + s4 * s2 - TriLs.Point.X * s1) / (s3 * s2 - (TriLs.V2.X - TriLs.V1.X) * s1)
	local w2 = (s4- w1 * s3) / s1
	return w1 >= 0 and w2 >= 0 and (w1 + w2) <= 1
end

function MathGeometry.GetClosestPointOnTriangle(Point:Vector3, V1:Vector3, V2:Vector3, V3:Vector3)
	--[[
	==triangle legend
	====
	same deal as with IsPointWithin3DTriangle().
	
		    2
		    .
		   / \
		a /   \ b
		 /_____\
	  1     c     3
	
	my formatting is similar to javidx9's (seen here: https://www.youtube.com/watch?v=XgMWc6LumG4&t=285s), which orders the vertexes
	and lines/edges in clockwise direction.
	]]--
	
	--==tri data==--
	local TriCenter = MathVector3.Average({V1, V2, V3})
	local TriLines = {
		["A"] = V2 - V1,
		["B"] = V3 - V2,
		["C"] = V1 - V3
	}
	local TriNormal = TriLines.A:Cross(TriLines.C).Unit --used to tell if the point is on the tri, doesn't really matter if it's backwards or forwards
	
	if MathGeometry.IsPointWithin3DTriangle(Point, V1, V2, V3, true) then
		--[[
		if the point is within the triangle, the closest point on the triangle is facing towards the given point
		the same way the triangle's normal is facing.
		
		the world point is from the origin to the point instead of the triangle's center to the point. so we're creating a version that's fit
		for vector projection that start's from the triangle's center to the point instead. we can use that vector & add it up with tri center for
		the closest point on the triangle.
		]]
		local PointFromCenter = Point - TriCenter
		return TriCenter + MathVector3.ProjectToNormal(PointFromCenter, TriNormal)
	end
	
	--[[
	if the point is outside the triangle, we'll just see which edge has the most closest point to the given point & return that as output.
	]]
	local Edges = {
		["A"] = {["Start"] = V1, ["End"] = V2},
		["B"] = {["Start"] = V2, ["End"] = V3},
		["C"] = {["Start"] = V3, ["End"] = V1}
	}
	
	--retrieve the closest points on all edges with their distances to the given point added in
	local Points = {}
	for i, v in Edges do
		local ClosestPoint = MathGeometry.GetClosestPointOnLine(Point, v.Start, v.End)
		local DistanceToPoint = (ClosestPoint - Point).Magnitude
		table.insert(Points, {["Point"] = ClosestPoint, ["Distance"] = DistanceToPoint})
	end
	
	--sort from lowest to highest
	table.sort(Points, function(a,b)
		return a.Distance < b.Distance
	end)
	
	--return the first listed object (should return a point with the lowest distance thanks to the sorting)
	return Points[1].Point
end

--[[
this func was gonna take skewed, but symmetrical planes. it didn't go so well so i just made it suit normal square/rect planes. asymmetrical squares/rects
have a lot more variations where using the "line as coordinate axes" method wouldn't work.

if we were to go with that mind limbo, i figure that we would've done something like aligning a line perpendicular to the entire plane & calculating
where that line intersects on the start and end.
]]--
function MathGeometry.GetClosestPointOnSquarePlane(Point:Vector3, V1:Vector3, V2:Vector3, V3:Vector3, V4:Vector3)
	--[[
	==plane legend
	====
	     b
	v2 _____ v3
	  |     |
	a |     | c
	  |_____|
	v1   d   v4
	
	as with the triangle counterpart of this function, both vertexes and lines are in clockwise order.
	
	as we're only intending for symmetrical planes only, the average should be always at the center of the plane, so if
	you were to add line a & line d, both multiplied by 0.5, to v1, we should get the same result as the average.
	]]--
	
	--==plane data==--
	local PlaneCenter = MathVector3.Average({V1, V2, V3, V4})
	local PlaneLines = {
		["A"] = V2 - V1,
		--["B"] = V3 - V2,
		--["C"] = V4 - V3,
		["D"] = V1 - V4
	}
	local PlaneNormal = PlaneLines.A:Cross(PlaneLines.D).Unit
	
	--[[
	i don't have a lot of ideas to run with for this, so i'm going to use a method that i tried on a triangle.
	
	like a coordinate, line d & a act as x & y axes. the inverse lerp already does a projection (it uses a dot product)
	so it does the heavy lifting for us
	]]--
	local YCoord = math.clamp(MathVector3.InverseLerp(V1, V2, Point), 0, 1)
	local XCoord = math.clamp(MathVector3.InverseLerp(V1, V4, Point), 0, 1)
	
	--[[
	--this is where i tried to adapt to skewed lines.
	--get the position relative to the yCoord (or moreso ratio) pos (this is because the lines can be skewed)
	local XCoordStart = V1:Lerp(V2, YCoord)
	local XCoordEnd = V4:Lerp(V3, YCoord)
	local XCoord = math.clamp(MathVector3.InverseLerp(XCoordStart, XCoordEnd, Point), 0, 1)
	]]
	
	return V1 + (PlaneLines.A * YCoord) + (-PlaneLines.D * XCoord)
end


--==table==--
--[[
	checks if a element (index or key) exists in the table. this doesn't return whatever the element is holding, because
	indexes can be void. if you create a table and assign one variable in it, then assign something like thing[100] = "yeah",
	everything inbetween 1 & 100 would be nil. in situations like this, indexes from 1 to 100 seems to exist, but the inbetween
	elements do not hold variables.
	
	because nil is the same as false, if the function returns a index that exists but has void/nil in it, it would return false in
	a if statement, which doesn't make sense because the element clearly exists. keys however, if you assign "nil" to one, it would be
	the same as not writing to that key at all.
]]--
function Table.FindElement(t, Element: number|string)
	for i, v in t do
		if i == Element then
			return true
		end
	end
	return false
end

--[[
	this always returns a table. i'm not sure if i should make it return a string if there's only one key.
	
	the order given is always in alphabetical order. i think this is because how tables are built. using a
	for i, v loop usually gives that result.
	
	there's no counterpart function for the indexes, because you can get the number of existing indexes with
	# by slapping it to the back of a table's variable name, like so: #TableThing
]]--
function Table.GetKeys(t)
	local Keys = {}
	for i, v in t do
		if type(i) == "string" then
			table.insert(Keys, i)
		end
	end
	return Keys
end

--[[
	tries to find elements (keys & indexes) that both exist within the two tables
]]--
function Table.GetCommonElements(t1, t2)
	local Output = {}
	for i, v in t1 do
		if Table.FindElement(t2, i) then
			table.insert(Output, i)
		end
	end
	return Output
end

--[[
	opposite to GetCommonElements. only gets keys/indexes that only appears on the compare table.
]]--
function Table.GetMissingElements(Compare, On)
	local Output = {}
	for i, v in Compare do
		if not Table.FindElement(On, i) then
			table.insert(Output, i)
		end
	end
	return Output
end

--[[
	if you were fortunate enough, you'd know that writing something like local data = {something = 10} would mean you
	are assigning a key to the table. that key can be accessed like a property: data.something.
	
	in for loops, elements without keys go first, while keys go after them by alphabetical order. ex:
	{10, mess = "something", schizo = "real", 2, question = "what"}
	-for loop would output:-
	1 > 10
	2 > 2
	schizo > real
	mess > something
	question > what
	
	if you were to put keys with the same name, then the last one takes priority, probably due to how it overrides the old one every time.
	also, keys given a nil value is pretty redundant, as it won't appear on the table after creating it & printing the table.
	
	like searching stuff in the game from scripts, keys containing spaces would require using the "[]" brackets to get keys with
	spaces. you can also do the same for assigning them.
	assigning > {something = 10} or {["something bad"] = 10}
	accessing> data.something or data["something bad"] (if the table is a variable named data)
	
	
	for this function, if a key doesn't exist/a index is nil or nonexistant in the target table we'd create it if the key/index doesn't exist
	and/or give it the same value the matching key/index has from the default table.
]]--
function Table.DefaultTo(Default, Target)
	local MissingElements = Table.GetMissingElements(Default, Target)
	for i, v in MissingElements do
		Target[v] =  Default[v]
	end
	return Target
end

function Table.RemoveDupes(t)
	local Output = {}
	for i, Var in t do
		if not table.find(Output, Var) then
			table.insert(Output, Var)
		end
	end
	
	return Output
end


--==parts==--
--[[
	originally ment to be used in conjunction with GuaranteeExistance for making creating part instances easier,
	because wedge & corner wedges are seperate classes instead of shape enums.
	usage: TypeTraits().X | replace "X" with Ball/Block/Cylinder/Wedge/CornerWedge.
]]--
function Parts.TypeTraits()
	return {
		["Ball"] = {["Class"] = "Part", ["Shape"] = Enum.PartType.Ball},
		["Block"] = {["Class"] = "Part", ["Shape"] = Enum.PartType.Block},
		["Cylinder"] = {["Class"] = "Part", ["Shape"] = Enum.PartType.Cylinder},
		["Wedge"] = {["Class"] = "WedgePart"},
		["CornerWedge"] = {["Class"] = "CornerWedgePart"}
	}
end

--[[
	shorthand for "if part doesn't exist, make a replacement for it", which is aimed for parts getting deleted mid-function.

	had to make the replacement table include properties that are shared with parts, as a result, it's thick as a dictionary book.
	obviously, not a lot of people are going to use all these details for all the properties, so we should put defaults in case they
	don't specify a property.
]]--
function Parts.GuaranteeExistance(
	Part: BasePart?,
	Replacement:
	{
		--instance
		Archivable:boolean?,
		Locked:boolean?,
		Name:string?,
		Parent:userdata?,
		--type
		Type:{Class:string, Shape:Enum.PartType?}?,
		--transform
		cFrame: CFrame?, --cframe holds both pos & rot data, so why use both if you can use one?
		Size: Vector3?,
		--collision
		CanCollide:boolean?,
		CanQuery:boolean?,
		CanTouch:boolean?,
		CollisionGroup:string?,
		--physics
		Anchored:boolean?,
		CustomPhysicalProperties:PhysicalProperties?,
		Massless:boolean?,
		AssemblyLinearVelocity:Vector3?,
		AssemblyAngularVelocity:Vector3?,
		Material:Enum.Material?,
		MaterialVariant:string?,
		RootPriority: number?,
		--visuals/graphics
		Color: Color3?,
		Reflectance:number?,
		Transparency:number?,
		CastShadow:boolean?
	}
)
	if Part and Part.Parent ~= nil then
		return Part
	else
		--==defaults==-- (test this: local funcs = require(game.ServerScriptService.Functions.UtilityFunctions) funcs.Part.GuaranteeExistance(nil, {}))
		Replacement = Table.DefaultTo(
			{
				--==instance==--
				["Archivable"] = true,
				["Locked"] = false,
				["Name"] = "Replaced",
				["Parent"] = workspace,
				--==type==--
				["Type"] = {["Class"] = "Part", ["Shape"] = Enum.PartType.Ball},
				--==transform==--
				["cFrame"] = CFrame.new(),
				["Size"] = Vector3.one,
				--==collision==--
				["CanCollide"] = false,
				["CanQuery"] = false,
				["CanTouch"] = false,
				["CollisionGroup"] = "Default",
				--==physics==--
				["Anchored"] = true,
				--CustomPhysicalProperties (default value is already nil, so if it's blank it'd be nil, thus kinda redundant)
				["Massless"] = true,
				["AssemblyLinearVelocity"] = Vector3.zero,
				["AssemblyAngularVelocity"] = Vector3.zero,
				["Material"] = Enum.Material.Plastic,
				["MaterialVariant"] = "",
				["RootPriority"] = 0,
				--==visuals/graphics==--
				["Color"] = Color3.new(1,1,1),
				["Reflectance"] = 0,
				["Transparency"] = 1,
				["CastShadow"] = false,
			},
			Replacement
		)
		
		local ReplacementPart = Instance.new(Replacement.Type.Class)
		--instance
		ReplacementPart.Archivable = Replacement.Archivable
		ReplacementPart.Locked = Replacement.Locked
		ReplacementPart.Name = Replacement.Name
		ReplacementPart.Parent = Replacement.Parent
		--type (class is already involved with Instance.new(), so we just have to deal with the shape if it's a regular part.)
		if Replacement.Type.Class == "Part" then
			ReplacementPart.Shape = Replacement.Type.Shape
		end
		--transform
		ReplacementPart.CFrame = Replacement.cFrame
		ReplacementPart.Size = Replacement.Size
		--collision
		ReplacementPart.CanCollide = Replacement.CanCollide
		ReplacementPart.CanQuery = Replacement.CanQuery
		ReplacementPart.CanTouch = Replacement.CanTouch
		ReplacementPart.CollisionGroup = Replacement.CollisionGroup
		--physics
		ReplacementPart.Anchored = Replacement.Anchored
		ReplacementPart.CustomPhysicalProperties = Replacement.CustomPhysicalProperties
		ReplacementPart.Massless = Replacement.Massless
		ReplacementPart.AssemblyLinearVelocity = Replacement.AssemblyLinearVelocity
		ReplacementPart.AssemblyAngularVelocity = Replacement.AssemblyAngularVelocity
		ReplacementPart.Material = Replacement.Material
		ReplacementPart.MaterialVariant = Replacement.MaterialVariant
		ReplacementPart.RootPriority = Replacement.RootPriority
		--visuals/graphics
		ReplacementPart.Color = Replacement.Color
		ReplacementPart.Reflectance = Replacement.Reflectance
		ReplacementPart.Transparency = Replacement.Transparency
		ReplacementPart.CastShadow = Replacement.CastShadow
		return ReplacementPart
	end
end

--[[
	the result will be the same as the point if the point overlaps the part, i think it's okay because we don't have to deal with a
	situation where there is more than 1 closest points which happens on a sphere/ball part if you just use it's position. this gets more complicated
	with wedge parts.
]]--
function Parts.GetClosestPointOnPart(Point:Vector3, Part:BasePart) --this only works on blocks, cylinders, spheres, wedges & cornerwedges because of no mesh data access.
	local ObjSpacePoint = Part.CFrame:PointToObjectSpace(Point) --convert to object space (deals with rotations)
	local HalfSize = Part.Size / 2
	if Part:IsA("Part") and Part.Shape == Enum.PartType.Ball then
		--[[
			if you were to provide a vector that has differing numbers on all axises for the part's size property when it has the shape of
			a ball, it'd priortise x's number over everything else when resizing. i've got no better ideas, so i'm just gonna copy their
			behaviour.
			
			this doesn't use the conversion to local space because the rotation doesn't really matter for spheres.
		]]--
		local Difference = Point - Part.Position
		local Radius = Part.Size.X / 2
		return Part.Position + MathVector3.ClampMagnitude(Difference, Radius)
	elseif Part:IsA("Part") and Part.Shape == Enum.PartType.Block then
		--[[
			pretty much just a spam-up of clamps.
		]]--
		return Part.CFrame * Vector3.new( --convert it back to worldspace
			math.clamp(ObjSpacePoint.X, -HalfSize.X, HalfSize.X),
			math.clamp(ObjSpacePoint.Y, -HalfSize.Y, HalfSize.Y),
			math.clamp(ObjSpacePoint.Z, -HalfSize.Z, HalfSize.Z)
		)
	elseif Part:IsA("Part") and Part.Shape == Enum.PartType.Cylinder then
		--[[
			the usage of math.min is to get the right value for the cross section's/circle's radius as the smallest number from the
			y or z axis actually determines the circle's radius.
			
			the circlepos variable is attempting to get the circle position by using the object space point as a vector
			then projecting it on the x axis (as thats where the circle faces). the magnitude is limited so that the circle position
			doesn't overshoot the cross section on the y & z axises.
		]]--
		local Radius = math.min(Part.Size.Y, Part.Size.Z) / 2 --the smallest of the 2 axis determines the radius, so we have to use math.min
		local CirclePos = MathVector3.ClampMagnitude(MathVector3.ProjectToNormal(ObjSpacePoint, Vector3.new(1,0,0)), Radius)
		return Part.CFrame * Vector3.new(
			math.clamp(ObjSpacePoint.X, -HalfSize.X, HalfSize.X),
			CirclePos.Y,
			CirclePos.Z
		)
	elseif Part:IsA("WedgePart") then
		--limit point to shape
		----
		--[[
		the function for other types of parts would give the same position as the given point if it's inside the part. for consistancy's
		sake, we'll make it do the same thing.

		the first thing to do is to check if the point is within the shape. we'll create a different position that is within the part's geometry
		and compare it with the given point to check if it's so. if the comparision nets a match, we just return the given point. otherwise
		we'll try to get the most closest point from all the faces.
		
			y
			Lz		   /|
			    front / | rear
				 	 /  |
					/___|
			
		the wedge's position always ends at the center of the sloped surface (front). this is why we're using it's size property to
		locate the positions of certain points needed to find a overlap.
		
		if you were to measure the distance from bottom to up (y), the distance would vary depending on the z axis position. if the z axis
		pos was completely at the front, that distance would be zero, since the position is on a edge. completely on the rear, however,
		would be the same as the wedge's y axis size. this also applies on front to rear (z).
		
		to make this easy, we can use linear interpolation/lerp to determine the limit via position. since the slope is linear, linear
		interpolation fits like a glove. lerp only takes in a value of 0 to 1, so we have to convert the respective positions to a percentage
		relative to the part's size.
		]]--
		--==y's limit==--
		--the clamp is to limit the percentage to 0% - 100%. without this, the limitation may overcompensate past the part's actual size.
		local ZPosPercent = math.clamp(MathNumber.InverseLerp(-HalfSize.Z, HalfSize.Z, ObjSpacePoint.Z), 0, 1)
		local YLimit = MathNumber.Lerp(-HalfSize.Y, HalfSize.Y, ZPosPercent)

		--==z's limit==--
		--clamp's present for the same reason y had it.
		local YPosPercent = math.clamp(MathNumber.InverseLerp(-HalfSize.Y, HalfSize.Y, ObjSpacePoint.Y), 0, 1)
		local ZLimit = MathNumber.Lerp(-HalfSize.Z, HalfSize.Z, YPosPercent)

		--print("Zp: " .. ZPosPercent .. ", Yl: " .. YLimit .. ", Yp: " .. YPosPercent .. ", Zl: " .. ZLimit)
		
		local ShapeLimitPos = Part.CFrame * Vector3.new(
			math.clamp(ObjSpacePoint.X, -HalfSize.X, HalfSize.X),
			math.clamp(ObjSpacePoint.Y, -HalfSize.Y, YLimit),
			math.clamp(ObjSpacePoint.Z, -HalfSize.Z, ZLimit)
		)
		
		if Point == ShapeLimitPos then --does the point match the geometry limited version of itself?
			return Point
		end
		
		--find the most closest point from all faces
		----
		--[[
		wedges have triangle faces on their left & right (it's forward dir has the sloped face on it). we're just gonna find the
		closest point for each face & return the closest one to the given point.
		]]--
		local Verts = {
			--upper
			--[[1]] Part.Position -(Part.CFrame.RightVector * HalfSize.X) +(Part.CFrame.UpVector * HalfSize.Y) -(Part.CFrame.LookVector * HalfSize.Z), --left
			--[[2]] Part.Position +(Part.CFrame.RightVector * HalfSize.X) +(Part.CFrame.UpVector * HalfSize.Y) -(Part.CFrame.LookVector * HalfSize.Z), --right

			--lower (rear to front)
			--[[3]] Part.Position -(Part.CFrame.RightVector * HalfSize.X) -(Part.CFrame.UpVector * HalfSize.Y) +(Part.CFrame.LookVector * HalfSize.Z), --rear left
			--[[4]] Part.Position +(Part.CFrame.RightVector * HalfSize.X) -(Part.CFrame.UpVector * HalfSize.Y) +(Part.CFrame.LookVector * HalfSize.Z), --rear right
			--[[5]] Part.Position -(Part.CFrame.RightVector * HalfSize.X) -(Part.CFrame.UpVector * HalfSize.Y) -(Part.CFrame.LookVector * HalfSize.Z), --front left
			--[[6]] Part.Position +(Part.CFrame.RightVector * HalfSize.X) -(Part.CFrame.UpVector * HalfSize.Y) -(Part.CFrame.LookVector * HalfSize.Z)  --front right
		}

		local ClosestPointsFromFaces = {
			MathGeometry.GetClosestPointOnTriangle(Point, Verts[1], Verts[3], Verts[5]), --right face (+x)
			MathGeometry.GetClosestPointOnTriangle(Point, Verts[2], Verts[4], Verts[6]), --left face (-x)
			MathGeometry.GetClosestPointOnSquarePlane(Point, Verts[6], Verts[2], Verts[1], Verts[5]), --upper/front face (+y & -z)
			MathGeometry.GetClosestPointOnSquarePlane(Point, Verts[6], Verts[4], Verts[3], Verts[5]), --lower face (-y)
			MathGeometry.GetClosestPointOnSquarePlane(Point, Verts[3], Verts[1], Verts[2], Verts[4])--rear face (+z)
		}

		--retrieve the closest points on all edges with their distances to the given point added in
		local Points = {}
		for i, v in ClosestPointsFromFaces do
			table.insert(Points, {["Point"] = v, ["Distance"] = (v - Point).Magnitude})
		end

		--sort from lowest to highest
		table.sort(Points, function(a,b)
			return a.Distance < b.Distance
		end)

		return Points[1].Point
	elseif Part:IsA("CornerWedgePart") then
		--limit point to shape
		----
		--[[
		mostly same deal with a regular wedge.
		
		T x        |  y         |  y
		z          |  L z       |  L x
		    ___    |	        |
		   |  /|   |	|\      |	   /|
		   | / |   |	| \     |	  / |
		   |/__|   |	l__\    |	 /__|
			       |		    |
		
		notice that on the last 2 representations, that if a point were to go from bottom to up, there be lesser
		possible room on the wedge's cross section. if the point is at 100% y (at the tip), then it's position would be
		the same position as the upper vertex on the corner wedge. the deal being made here is that if the point is right
		on the top of the corner wedge, the position it'll be limited to is a mere point.
		]]--
		local YPercentage = math.clamp(MathVector3.InverseLerp(Vector3.new(0, -HalfSize.Y, 0), Vector3.new(0, HalfSize.Y, 0), ObjSpacePoint), 0, 1)
		
		--the "max" val should be pointing towards the upper vert of the corner wedge for each axis
		local XLimit = MathNumber.Lerp(-HalfSize.X, HalfSize.X, YPercentage)
		local ZLimit = MathNumber.Lerp(HalfSize.Z, -HalfSize.Z, YPercentage)
		
		--resorting to inverselerp again because of "max must be greater than or equal to min" errors regarding clamping those values
		local XPercentage = math.clamp(MathNumber.InverseLerp(XLimit, HalfSize.X, ObjSpacePoint.X), 0, 1)
		local ZPercentage = math.clamp(MathNumber.InverseLerp(ZLimit, -HalfSize.Z, ObjSpacePoint.Z), 0, 1)
		
		local ShapeLimitPos = Part.CFrame * Vector3.new(
			MathNumber.Lerp(XLimit, HalfSize.X, XPercentage), --math.clamp(ObjSpacePoint.X, -HalfSize.X, XLimit) --keep getting clamp errors with this config
			math.clamp(ObjSpacePoint.Y, -HalfSize.Y, HalfSize.Y),
			MathNumber.Lerp(ZLimit, -HalfSize.Z, ZPercentage) --math.clamp(ObjSpacePoint.Z, ZLimit, HalfSize.Z)
		)
		--print("hs: " .. tostring(HalfSize) .. ", slp: x=" .. ShapeLimitPos.X .. ", y=" .. ShapeLimitPos.Y .. ", z=" ..ShapeLimitPos.Z)
		--print("xp:" .. XPercentage .. ", zp:" .. ZPercentage)
		
		if Point == ShapeLimitPos then
			return Point
		end
		
		--find the most closest point from all faces
		----
		local Verts = {
			--upper
			--[[1]] Part.Position +(Part.CFrame.RightVector * HalfSize.X) +(Part.CFrame.UpVector * HalfSize.Y) +(Part.CFrame.LookVector * HalfSize.Z),
			
			--lower
			--[[2]] Part.Position -(Part.CFrame.RightVector * HalfSize.X) -(Part.CFrame.UpVector * HalfSize.Y) +(Part.CFrame.LookVector * HalfSize.Z), --rear left
			--[[3]] Part.Position +(Part.CFrame.RightVector * HalfSize.X) -(Part.CFrame.UpVector * HalfSize.Y) +(Part.CFrame.LookVector * HalfSize.Z), --rear right
			--[[4]] Part.Position -(Part.CFrame.RightVector * HalfSize.X) -(Part.CFrame.UpVector * HalfSize.Y) -(Part.CFrame.LookVector * HalfSize.Z), --front left
			--[[5]] Part.Position +(Part.CFrame.RightVector * HalfSize.X) -(Part.CFrame.UpVector * HalfSize.Y) -(Part.CFrame.LookVector * HalfSize.Z)  --front right
		}
		
		local ClosestPointsFromFaces = {
			--triangles
			MathGeometry.GetClosestPointOnTriangle(Point, Verts[3], Verts[1], Verts[5]), --right side triangle face (+x)
			MathGeometry.GetClosestPointOnTriangle(Point, Verts[4], Verts[1], Verts[2]), --left slant triangle face (-x)
			MathGeometry.GetClosestPointOnTriangle(Point, Verts[2], Verts[1], Verts[3]), --upper/rear triangle face (+y & +z)
			MathGeometry.GetClosestPointOnTriangle(Point, Verts[5], Verts[1], Verts[4]), --front face (-z)
			
			--planes/quads?
			MathGeometry.GetClosestPointOnSquarePlane(Point, Verts[2], Verts[4], Verts[5], Verts[3])
		}
		
		--retrieve the closest points on all edges with their distances to the given point added in
		local Points = {}
		for i, v in ClosestPointsFromFaces do
			table.insert(Points, {["Point"] = v, ["Distance"] = (v - Point).Magnitude})
		end

		--sort from lowest to highest
		table.sort(Points, function(a,b)
			return a.Distance < b.Distance
		end)

		return Points[1].Point
	else--if Part:IsA("UnionOperation") then
		return nil
	end
end

--==Graphics==--
--[[
	this is intended to modify parts that already have a beam instance in them already set up (which would be given in BeamPart &
	BeamInstance), otherwise it creates a replacement/a new one for convenience.
]]
function Graphics.Ray(
	BeamPart:BasePart?,
	BeamInstance:Beam?,
	BeamProperties:
	{
		--ray positions
		Origin:Vector3,
		End:Vector3,
		--core
		Enabled:boolean?,
		--dimensions
		FaceCamera:boolean?,
		ZOffset:number?,
		Width:number|{Start:number, End:number}|nil,
		--color & lighting
		Color:Color3|ColorSequence|nil,
		Brightness:number?,
		Transparency:number|NumberSequence|nil,
		LightEmission:number?,
		LightInfluence:number?,
		--texture
		Texture:string?,
		TextureLength:number?,
		TextureMode:Enum.TextureMode?,
		TextureSpeed:number?
	}
)
	--defaults
	----
	--==properties==--
	BeamProperties = Table.DefaultTo(
		{
			--==core==--
			["Enabled"] = true,
			--==dimensions==--
			["FaceCamera"] = true,
			["ZOffset"] = 0,
			["Width"] = 0.1,
			--==color & lighting==--
			["Color"] = Color3.new(1,1,1),
			["Transparency"] = 0,
			["Brightness"] = 1,
			["LightEmission"] = 0,
			["LightInfluence"] = 0,
			--==texture==--
			["Texture"] = "",
			["TextureLength"] = 1,
			["TextureMode"] = Enum.TextureMode.Stretch,
			["TextureSpeed"] = 1
		},
		BeamProperties
	)
	--==part==--
	BeamPart = Parts.GuaranteeExistance(BeamPart, {
		Type = Parts.TypeTraits().Block,
		Anchored = true,
		CanCollide = false,
		CanQuery = false,
		CanTouch = false,
		CastShadow = false,
		Size = Vector3.zero
	})
	BeamPart.CFrame = CFrame.new(BeamProperties.Origin:Lerp(BeamProperties.End, 0.5), BeamProperties.End)
	BeamPart.Size = Vector3.new(0,0,(BeamProperties.End - BeamProperties.Origin).Magnitude)
	--==instance==--
	if not BeamInstance then --if there was no BeamInstance given (nil), search for one
		BeamInstance = BeamPart:FindFirstChildOfClass("Beam")
		if not BeamInstance then --if the beam can't be found, create one.
			BeamInstance = Instance.new("Beam")
			BeamInstance.Parent = BeamPart
		end
	end
	--==attachments==--
	--origin
	if not BeamInstance.Attachment0 then
		BeamInstance.Attachment0 = Instance.new("Attachment")
		BeamInstance.Attachment0.Parent = BeamPart
	end
	BeamInstance.Attachment0.Name = "Origin"
	BeamInstance.Attachment0.WorldCFrame = CFrame.new(BeamProperties.Origin)
	--end
	if not BeamInstance.Attachment1 then
		BeamInstance.Attachment1 = Instance.new("Attachment")
		BeamInstance.Attachment1.Parent = BeamPart
	end
	BeamInstance.Attachment1.Name = "End"
	BeamInstance.Attachment1.WorldCFrame = CFrame.new(BeamProperties.End)
	
	--update the beam's properties
	----
	--==core==--
	BeamInstance.Enabled = BeamProperties.Enabled
	--==dimensions==--
	BeamInstance.FaceCamera = BeamProperties.FaceCamera
	BeamInstance.ZOffset = BeamProperties.ZOffset
	if typeof(BeamProperties.Width) == "number" then
		BeamInstance.Width0 = BeamProperties.Width
		BeamInstance.Width1 = BeamProperties.Width
	end
	if typeof(BeamProperties.Width) == "table" then
		BeamInstance.Width0 = BeamProperties.Width[1]
		BeamInstance.Width1 = BeamProperties.Width[2]
	end
	--==color & lighting==--
	if typeof(BeamProperties.Color) == "Color3" then
		BeamInstance.Color = ColorSequence.new(BeamProperties.Color)
	end
	if typeof(BeamProperties.Color) == "ColorSequence" then
		BeamInstance.Color = BeamProperties.Color
	end
	if typeof(BeamProperties.Transparency) == "number" then
		BeamInstance.Transparency = NumberSequence.new(BeamProperties.Transparency)
	end
	if typeof(BeamProperties.Transparency) == "NumberSequence" then
		BeamInstance.Transparency = BeamProperties.Transparency
	end
	BeamInstance.LightEmission = BeamProperties.LightEmission
	BeamInstance.LightInfluence = BeamProperties.LightInfluence
	--==texture==--
	BeamInstance.Texture = BeamProperties.Texture
	BeamInstance.TextureLength = BeamProperties.TextureLength
	BeamInstance.TextureMode = BeamProperties.TextureMode
	BeamInstance.TextureSpeed = BeamProperties.TextureSpeed
	
	return BeamPart, BeamInstance
end

--====--
return module

--[[
notes:
if you don't know the type of a variable, you can check it with type(). ex: type("fake") > string

function arguments can put a type requirement like so: VarName:TypeName > Health:number.
they can be also made to accept different types as well, i.e you want numbers, strings & booleans: "VarName:number|string|boolean"
this can be made optional by using "Health:number | nil" or "Health:number?"
source: https://luau-lang.org/typecheck
]]--

postscriptum edit: apparently the approach used for adapting sebastian lague’s method of detecting if a point is within a triangle into 3d doesn’t work well because attempting to point the cframe’s z axis towards the triangle’s normal direction to check if the point is off-surface involves some sort of floating point error. it works for normal wedges because the triangle faces aren’t slanted, but doesn’t for corner wedges because it has slanted triangle faces. as a result, it just gives the closest point on edges on corner wedges. i’ll come back with a fix in another edit.

fix edit: the whole point for the “IsPointWithin3DTriangle” function was to find out if i could flatten a point onto the triangle’s normal, otherwise the function for getting the closest point on a triangle resorted to searching the closest point from all the triangle’s edges. i tried thinking of just checking all of the triangle’s edges to see if the point is on the edge’s outside direction via projecting the point’s position relative to that edge’s perpendicular line. it probably sounds really silly, but it seems to work.

--==math (geometry)==--
function MathGeometry.GetClosestPointOnLine(Point:Vector3, Start:Vector3, End:Vector3) --same deal as getting the closest point on a edge
	local Line = End - Start
	
	--to treat my obessive compulsive disorder, we'll use a center of the line as the origin by using lerp
	local Center = Start:Lerp(End, 0.5)
	
	--the position point is representing has a origin of 0,0,0. you can imagine drawing a line from 0,0,0 to point as the world space vector. to "convert it into local space", we'd change the origin to the line's center.
	local LocalSpacePoint = Point - Center
	
	--project the point onto the line & limit the length of the vector. this limits the position where the line isn't pointing at & limits the position to the line's length respectively. since the origin is at the center, the length limit should be line.magnitude/2.
	return Center + MathVector3.ClampMagnitude(MathVector3.ProjectToLine(LocalSpacePoint, Line), Line.Magnitude/2)
end

function MathGeometry.IsPointProjectableOn3DTriangle(Point:Vector3, V1:Vector3, V2:Vector3, V3:Vector3, ProjectPoint:boolean?)
	--[[
	==triangle legend
	====
		    2
		    .
		   / \
		a /   \ b
		 /_____\
	  1     c     3
	
	my formatting is similar to javidx9's (seen here: https://www.youtube.com/watch?v=XgMWc6LumG4&t=285s), which orders the vertexes
	and lines/edges in clockwise direction.
	
	i'd thought about having the vertexes being automatically assigned for a clockwise order via getting a average of all vertex positions and
	creating a vector that connects to the average > vertex, then sorting the vertexes by their vector's respective dot product. but i never thought
	about what would be the other "static" variable for the dot product that'd be the same during the calculation & what that vector should be, because
	if we just use one of the global axes, it's likely it wouldn't match the intended behaviour of the sorting because the vector would've to be within
	a circle like shape on the triangle's normal (and getting the normal without the lines isn't possible because getting the normal relies on a cross product)
	]]--
	
	--==tri data==--
	local TriCenter = MathVector3.Average({V1, V2, V3})
	local TriLines = {
		["A"] = {["Vector"] = V2 - V1, ["Center"] = V2:Lerp(V1, 0.5)},
		["B"] = {["Vector"] = V3 - V2, ["Center"] = V3:Lerp(V2, 0.5)},
		["C"] = {["Vector"] = V1 - V3, ["Center"] = V1:Lerp(V3, 0.5)}
	}
	local TriNormal = TriLines.A.Vector:Cross(TriLines.C.Vector).Unit --used to tell if the point is on the tri, doesn't really matter if it's backwards or forwards
	
	--[[
	check if the point is on the inner side of the edge (towards center of the triangle)
	if either edge has the point outwards relative to the edge, it means the point isn't projectable on the triangle.
	]]
	for i, Line in TriLines do
		local Perpendicular = Line.Vector:Cross(TriNormal).Unit
		local EdgeInbound = MathVector3.ProjectToLine(TriCenter - Line.Center, Perpendicular).Unit

		if MathVector3.UnitLength(Point - Line.Center, EdgeInbound) < 0 then
			return false
		end
	end
	return true
end

function MathGeometry.GetClosestPointOnTriangle(Point:Vector3, V1:Vector3, V2:Vector3, V3:Vector3)
	--[[
	==triangle legend
	====
	same deal as with IsPointProjectableOn3DTriangle().
	
		    2
		    .
		   / \
		a /   \ b
		 /_____\
	  1     c     3
	
	my formatting is similar to javidx9's (seen here: https://www.youtube.com/watch?v=XgMWc6LumG4&t=285s), which orders the vertexes
	and lines/edges in clockwise direction.
	]]--
	
	--==tri data==--
	local TriCenter = MathVector3.Average({V1, V2, V3})
	local TriLines = {
		["A"] = V2 - V1,
		["B"] = V3 - V2,
		["C"] = V1 - V3
	}
	local TriNormal = TriLines.A:Cross(TriLines.C).Unit --used to tell if the point is on the tri, doesn't really matter if it's backwards or forwards
	
	if MathGeometry.IsPointProjectableOn3DTriangle(Point, V1, V2, V3) then
		--[[
		if the point is within the triangle, the closest point on the triangle is facing towards the given point
		the same way the triangle's normal is facing.
		
		the world point is from the origin to the point instead of the triangle's center to the point. so we're creating a version that's fit
		for vector projection that start's from the triangle's center to the point instead. we can use that vector & add it up with tri center for
		the closest point on the triangle.
		]]
		local PointFromCenter = Point - TriCenter
		return TriCenter + MathVector3.ProjectToNormal(PointFromCenter, TriNormal)
	end
	
	--[[
	if the point is outside the triangle, we'll just see which edge has the most closest point to the given point & return that as output.
	]]
	local Edges = {
		["A"] = {["Start"] = V1, ["End"] = V2},
		["B"] = {["Start"] = V2, ["End"] = V3},
		["C"] = {["Start"] = V3, ["End"] = V1}
	}
	
	--retrieve the closest points on all edges with their distances to the given point added in
	local Points = {}
	for i, v in Edges do
		local ClosestPoint = MathGeometry.GetClosestPointOnLine(Point, v.Start, v.End)
		local DistanceToPoint = (ClosestPoint - Point).Magnitude
		table.insert(Points, {["Point"] = ClosestPoint, ["Distance"] = DistanceToPoint})
	end
	
	--sort from lowest to highest
	table.sort(Points, function(a,b)
		return a.Distance < b.Distance
	end)
	
	--return the first listed object (should return a point with the lowest distance thanks to the sorting)
	return Points[1].Point
end

--[[
this func was gonna take skewed, but symmetrical planes. it didn't go so well so i just made it suit normal square/rect planes. asymmetrical squares/rects
have a lot more variations where using the "line as coordinate axes" method wouldn't work.

if we were to go with that mind limbo, i figure that we would've done something like aligning a line perpendicular to the entire plane & calculating
where that line intersects on the start and end.
]]--
function MathGeometry.GetClosestPointOnSquarePlane(Point:Vector3, V1:Vector3, V2:Vector3, V3:Vector3, V4:Vector3)
	--[[
	==plane legend
	====
	     b
	v2 _____ v3
	  |     |
	a |     | c
	  |_____|
	v1   d   v4
	
	as with the triangle counterpart of this function, both vertexes and lines are in clockwise order.
	
	as we're only intending for symmetrical planes only, the average should be always at the center of the plane, so if
	you were to add line a & line d, both multiplied by 0.5, to v1, we should get the same result as the average.
	]]--
	
	--==plane data==--
	local PlaneCenter = MathVector3.Average({V1, V2, V3, V4})
	local PlaneLines = {
		["A"] = V2 - V1,
		--["B"] = V3 - V2,
		--["C"] = V4 - V3,
		["D"] = V1 - V4
	}
	local PlaneNormal = PlaneLines.A:Cross(PlaneLines.D).Unit
	
	--[[
	i don't have a lot of ideas to run with for this, so i'm going to use a method that i tried on a triangle.
	
	like a coordinate, line d & a act as x & y axes. the inverse lerp already does a projection (it uses a dot product)
	so it does the heavy lifting for us
	]]--
	local YCoord = math.clamp(MathVector3.InverseLerp(V1, V2, Point), 0, 1)
	local XCoord = math.clamp(MathVector3.InverseLerp(V1, V4, Point), 0, 1)
	
	--[[
	--this is where i tried to adapt to skewed lines.
	--get the position relative to the yCoord (or moreso ratio) pos (this is because the lines can be skewed)
	local XCoordStart = V1:Lerp(V2, YCoord)
	local XCoordEnd = V4:Lerp(V3, YCoord)
	local XCoord = math.clamp(MathVector3.InverseLerp(XCoordStart, XCoordEnd, Point), 0, 1)
	]]
	
	return V1 + (PlaneLines.A * YCoord) + (-PlaneLines.D * XCoord)
end

you can just replace everything in the “–==math geometry==–” section on the previous module script with this block of code. the only change from the previous to this was “IsPointWithin3DTriangle” being replaced by “IsPointProjectableOn3DTriangle”.

another edit: just fixing a small mistake. the “ClampMagnitude” function had “Vector3” instead of “number” for the 2nd argument’s type checking.

5 Likes