Closest Points on BasePart Shapes

Functions and explanations for obtaining the closest point on a given BasePart Shape from another point.

For these functions:

  • If the point is outside the BasePart’s physical volume, the closest point will be on a surface.
  • If the point is inside the BasePart’s physical volume, the closest point will be the same as the point.

These functions will likely be superseded by BasePart:GetClosestPointOnSurface() upon its release (see feature request here: Please enable BasePart:GetClosestPointOnSurface()).
The closest points of MeshParts may be found with the new EditableMesh:FindClosestPointOnSurface().

Ball


Ball
A sphere.

Every point on the surface of a sphere is located at the same distance – the radius – from the sphere’s center. In Roblox, the center is given by the Position while the radius is the smallest value among its Size’s X, Y, and Z, divided by two.

Thus, the closest point on the surface of a Ball can be obtained by getting a Vector3 going from its Position towards the desired point whose Magnitude is clamped by its radius.

local function ClosestPointOnBall(ball: BasePart, point: Vector3): Vector3
	local vector = point - ball.Position -- Get vector towards point
	local radius = math.min(ball.Size.X, ball.Size.Y, ball.Size.Z)/2
	return ball.Position + vector.Unit * math.min(vector.Magnitude, radius) -- Clamp
end

Block


Block
A cube.

The shortest distance from a point to a line/surface is always perpendicular to it. For a cube, that means we can just project the point onto its nearest surface to get the closest point, clamping it by the BasePart’s bounds. In Roblox, the bounds are plus/minus half the BasePart’s Size from its Position along the X, Y, and Z axis; however, this assumes the BasePart is orthogonal along every axis, i.e. the X, Y, and Z values of its orientation are multiples of 90 (in other words, the cube is “straight”). To account for any rotation, we can transform our point into the BasePart’s local coordinates.

Based on the solution by sircfenner:


Positions before and after transforming the point’s coordinates.

By using CFrame:PointToObjectSpace(), we transform our point relative to the coordinates of the BasePart, treating the BasePart as the new origin of the world: its Position and Orientation become the new (0, 0, 0), essentially making it “straight”. This leaves us to just clamp the transformed point’s coordinates to the bounds of the BasePart as described earlier to get the closest point and multiply it by the BasePart’s original CFrame to get the closest point’s actual Position in world coordinates back.

local function ClosestPointOnBlock(block: BasePart, point: Vector3): Vector3
	local transform = block.CFrame:PointToObjectSpace(point) -- Transform into local space
	local halfSize = block.Size * 0.5
	return block.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)
	)
end

Cylinder


Cylinder
The central axis of a Cylinder is always its Z-axis.

We combine techniques from Ball and Block here. While the closest point’s X (left/right) is only limited by the BasePart’s bounds, the Y and Z (up/down and front/back) must lie on the surface of the circle lying on the YZ plane. We remove the X component of transform (as we only need the YZ direction) then get the closest point as we would with a Ball, just in 2D instead of 3D.

local function ClosestPointOnCylinder(cylinder: BasePart, point: Vector3): Vector3 -- Treat as projected circle on YZ plane
	local transform = cylinder.CFrame:PointToObjectSpace(point) -- Transform into local space
	local halfSize = cylinder.Size * 0.5
	local radius = math.min(cylinder.Size.Y, cylinder.Size.Z)/2
	local yzVector = (transform * (Vector3.one - Vector3.xAxis)) -- Get nearest YZ position
	yzVector = yzVector.Unit * math.min(yzVector.Magnitude, radius)
	return cylinder.CFrame * Vector3.new( -- Clamp & transform into world space
		math.clamp(transform.x, -halfSize.x, halfSize.x),
		yzVector.Y,
		yzVector.Z
	)
end

Wedge


Wedge
The slope of a Wedge is always along its Z-axis.

We actually need to calculate the closest point on the slope as it lies within the BasePart’s bounds and not along them (the horror). While the solution usually involves either projecting the point onto a plane (see EmilyBendsSpace’s solution here) or solving a system of equations involving the normal of the slope and the known point, we can take the easy way out and just do Ray:ClosestPoint(). We take the origin of the Ray to be the bottom of the slope and the direction to be going towards its top to create a line approximating the slope of the Wedge. The min/max of the transform’s Y and Z are compared to the closest point on the Ray in order to keep the final closest point within the bounds of the BasePart’s actual physical volume; if you imagine the plane created by the surface of the Wedge to extend outwards infinitely, the closest point can never exist above that plane, only on or below it.

local function ClosestPointOnWedge(wedge: BasePart, point: Vector3): Vector3 -- Represent slope with a Ray
	local transform = wedge.CFrame:PointToObjectSpace(point) -- Transform into local space
	local halfSize = wedge.Size * 0.5
	local offsetVector = halfSize * (Vector3.one - Vector3.xAxis)
	local yzVector = Ray.new(-offsetVector, offsetVector.Unit):ClosestPoint(transform) -- Quick projection
	return wedge.CFrame * Vector3.new( -- Clamp & transform into world space
		math.clamp(transform.X, -halfSize.X, halfSize.X),
		math.clamp(math.min(transform.Y, yzVector.Y), -halfSize.Y, halfSize.Y),
		math.clamp(math.max(transform.Z, yzVector.Z), -halfSize.Z, halfSize.Z)
	)
end

CornerWedge


CornerWedge
The CornerWedge can be taken to be two half-Wedges with X-axis and Z-axis slopes, cut diagonally then put together.

As the CornerWedge is comprised of two half-Wedges, we must consider two different cases: whether the point is closer to the X-axis slope or the Z-axis slope. First, we get the slopes along XZ, XY, and ZY, each slope of which may be unique depending on the Size dimensions of the CornerWedge. We then determine if the transformed point is nearer to the X-axis slope or Z-axis slope by comparing its actual Z position to what its Z position would be along the XZ line. With a Ray along the outer edge of whichever slope is closer, we can get the closest point with Ray:ClosestPoint() in a similar manner to a Wedge with an additional bound along the sloped diagonal where the two Wedges are fused.

local function ClosestPointOnCornerWedge(cornerWedge: BasePart, point: Vector3): Vector3 -- Two wedges combined along their diagonals
	local transform = cornerWedge.CFrame:PointToObjectSpace(point) -- Transform into local space
	local halfSize = cornerWedge.Size * 0.5
	local xzSlope = -halfSize.Z/halfSize.X -- Z increases as X decreases
	local xySlope = halfSize.Y/halfSize.X -- Y increases as X increases
	local zySlope = -halfSize.Y/halfSize.Z -- Y increases as Z decreases
	if transform.Z <= transform.X * xzSlope then -- The point is on the "left" X slope
		local xyVector = Ray.new(-halfSize, (cornerWedge.Size * (Vector3.one - Vector3.zAxis)).Unit):ClosestPoint(transform)
		return cornerWedge.CFrame * Vector3.new( -- Clamp & transform into world space
			math.clamp(math.max(transform.X, xyVector.X), -halfSize.X, halfSize.X),
			math.clamp(math.min(transform.Y, xyVector.Y, xyVector.X * xySlope), -halfSize.Y, halfSize.Y),
			math.clamp(math.min(transform.Z, xyVector.Y / zySlope), -halfSize.Z, halfSize.Z)
		)
	else -- The point is on the "back" Z slope
		local zyVector = Ray.new(halfSize * Vector3.new(1, -1, 1), Vector3.new(0, cornerWedge.Size.Y, -cornerWedge.Size.Z).Unit):ClosestPoint(transform)
		return cornerWedge.CFrame * Vector3.new( -- Clamp & transform into world space
			math.clamp(math.max(transform.X, zyVector.Y / xySlope), -halfSize.X, halfSize.X),
			math.clamp(math.min(transform.Y, zyVector.Y, zyVector.Z * zySlope), -halfSize.Y, halfSize.Y),
			math.clamp(math.min(transform.Z, zyVector.Z), -halfSize.Z, halfSize.Z)
		)
	end
end

Practical uses


Finding the shortest distance to a Model comprised of BaseParts (for instance, finding the nearest point on one to an explosion) is not a trivial problem (see previous discussions like this):

  • Magnitude checking the Model’s Position (i.e. the center of its overall BoundingBox or its PrimaryPart’s Position) ignores the actual physical volumes of all its component BaseParts
  • Individually magnitude checking each component BasePart’s Position is unlikely to be accurate the larger the BaseParts are (imagine how far the Position of a long, thin BasePart is from its actual ends!)
  • Raycasting towards the BasePart does not guarantee getting the closest point (unless the BasePart is a Ball) and doesn’t work if the BasePart already intersects the Raycast’s origin
  • Shapecasting towards the BasePart does not guarantee getting the closest point either and doesn’t work if the BasePart already intersects the shape being cast
  • GetPartBoundsInBox/Radius are inaccurate for large non-Block BaseParts (and would require a secondary method of getting distance to detected BaseParts anyway)
  • GetPartsInPart still requires a secondary method of getting distance to detected BaseParts

In theory, a simple, relatively performant method for getting the closest point on a Model comprised of BaseParts from a point is as follows:

  1. Obtain the Models/BaseParts to check (GetPartBoundsInBox/Radius, a specific Model, etc.)
  2. Use the functions specific to each BasePart Shape to get the closest point
  3. Calculate the distance from the closest point to the point and record it as the new actual closest point if it is closer (i.e. if the Magnitude is smaller)
  4. Return the actual closest point(s) of the Model(s) involved.

image
A slowly orbiting transparent orange Ball is used as the origin and radius for workspace:GetPartBoundsInRadius(), which checks for the closest point among the BaseParts of the cart Model found and places a highlighted red marker at it.

Below is an example of a module that could be used to hold functions for the closest points of BaseParts, being called using Module[part.Shape](part: BasePart, point: Vector3):

return {
	[Enum.PartType.Block] = function(block: BasePart, point: Vector3): Vector3 --
		local transform = block.CFrame:PointToObjectSpace(point) -- Transform into local space
		local halfSize = block.Size * 0.5
		return block.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)
		)
	end,
	[Enum.PartType.Ball] = function(ball: BasePart, point: Vector3): Vector3
		local vector = point - ball.Position -- Get vector towards point
		local radius = math.min(ball.Size.X, ball.Size.Y, ball.Size.Z)/2
		return ball.Position + vector.Unit * math.min(vector.Magnitude, radius) -- Clamp
	end,
	[Enum.PartType.Cylinder] = function(cylinder: BasePart, point: Vector3): Vector3 -- Treat as projected circle on YZ plane
		local transform = cylinder.CFrame:PointToObjectSpace(point) -- Transform into local space
		local halfSize = cylinder.Size * 0.5
		local radius = math.min(cylinder.Size.Y, cylinder.Size.Z)/2
		local yzVector = (transform * (Vector3.one - Vector3.xAxis)) -- Get nearest YZ position
		yzVector = yzVector.Unit * math.min(yzVector.Magnitude, radius)
		return cylinder.CFrame * Vector3.new( -- Clamp & transform into world space
			math.clamp(transform.x, -halfSize.x, halfSize.x),
			yzVector.Y,
			yzVector.Z
		)
	end,
	[Enum.PartType.Wedge] = function(wedge: BasePart, point: Vector3): Vector3 -- Represent slope with a Ray
		local transform = wedge.CFrame:PointToObjectSpace(point) -- Transform into local space
		local halfSize = wedge.Size * 0.5
		local offsetVector = halfSize * (Vector3.one - Vector3.xAxis)
		local yzVector = Ray.new(-offsetVector, offsetVector.Unit):ClosestPoint(transform) -- A lazy method that can be done in pure math
		return wedge.CFrame * Vector3.new( -- Clamp & transform into world space
			math.clamp(transform.X, -halfSize.X, halfSize.X),
			math.clamp(math.min(transform.Y, yzVector.Y), -halfSize.Y, halfSize.Y),
			math.clamp(math.max(transform.Z, yzVector.Z), -halfSize.Z, halfSize.Z)
		)
	end,
	[Enum.PartType.CornerWedge] = function(cornerWedge: BasePart, point: Vector3): Vector3 -- Two wedges combined along their diagonals
		local transform = cornerWedge.CFrame:PointToObjectSpace(point) -- Transform into local space
		local halfSize = cornerWedge.Size * 0.5
		local xzSlope = -halfSize.Z/halfSize.X -- Z increases as X decreases
		local xySlope = halfSize.Y/halfSize.X -- Y increases as X increases
		local zySlope = -halfSize.Y/halfSize.Z -- Y increases as Z decreases
		if transform.Z <= transform.X * xzSlope then -- The point is on the "left" slope
			cornerWedge.Color = Color3.fromRGB(255, 0, 0)
			local xyVector = Ray.new(-halfSize, (cornerWedge.Size * (Vector3.one - Vector3.zAxis)).Unit):ClosestPoint(transform)
			return cornerWedge.CFrame * Vector3.new( -- Clamp & transform into world space
				math.clamp(math.max(transform.X, xyVector.X), -halfSize.X, halfSize.X),
				math.clamp(math.min(transform.Y, xyVector.Y, xyVector.X * xySlope), -halfSize.Y, halfSize.Y),
				math.clamp(math.min(transform.Z, xyVector.Y / zySlope), -halfSize.Z, halfSize.Z)
			)
		else -- The point is on the "back" slope
			cornerWedge.Color = Color3.fromRGB(0, 0, 255)
			local zyVector = Ray.new(halfSize * Vector3.new(1, -1, 1), Vector3.new(0, cornerWedge.Size.Y, -cornerWedge.Size.Z).Unit):ClosestPoint(transform)
			return cornerWedge.CFrame * Vector3.new( -- Clamp & transform into world space
				math.clamp(math.max(transform.X, zyVector.Y / xySlope), -halfSize.X, halfSize.X),
				math.clamp(math.min(transform.Y, zyVector.Y, zyVector.Z * zySlope), -halfSize.Y, halfSize.Y),
				math.clamp(math.min(transform.Z, zyVector.Z), -halfSize.Z, halfSize.Z)
			)
		end
	end
}

A sample place is available below.
closestpoints.rbxl (64.6 KB)

20 Likes

This is super helpful for custom collisions with parts. Thanks a lot for making such a in depth guide!

1 Like