I’m currently building my own Rotate Tool. Players would click and drag on rings surrounding a part of their choosing to set rotation transformations. Some of those parts’ object spaces aren’t going to be aligned with the world space, so some extra work is going to be needed to handle that. I also want the rotation to be smooth (ie. not snapping when I go to set orientation). The process I’ve got so far is:
Create a mathematical representation of a plane with its normal parallel to the axis of rotation and passing through the part’s pivot
Create a ray aimed at the direction of the player’s cursor
Create a ray between the part’s pivot and the point of intersection between the plane and mouse ray
Calculate the angle difference between the part’s look, right, or up vectors, depending on which axis is being transformed
Set the part’s Orientation property to the ray found in step 3 while taking account of the offset found in step 4
This cycle will be connected to Heartbeat, though I might change it to InputChanged once I get a prototype up and running.
At the moment, I’m stuck on steps 1 and 3. I’m pretty sure it’s possible, but I don’t know how to actually represent a plane mathematically. Or if this can be simplified (eg. get a ray from a point perpendicular to another ray pointed in the direction of a point). I’m also stuck on step 5 in that I think I can do it using the Direction property, but this idea is very much theoretical.
All in all, should I go ahead with steps 1 to 3 or is there a simpler way to achieve it? How do I actually represent a plane mathematically in a way that I can get the intersection point between the plane and a ray? Is my idea for step 5 sound?
What you wrote in the quote below describes pretty well what is needed for a useful representation of the plane:
All you need to represent a plane is a point on the plane and a normal vector of the plane. In this case, the easiest to choose point on the plane is part:GetPivot().Position and a normal that is easy to get is part:GetPivot():VectorToWorldSpace(Vector3.FromAxis(rotationAxis)).
The dot product of two vectors u and v is equal to ||u||||v||cos∠(u, v), where ||u|| and ||v|| are the lengths of the vectors and ∠(u, v) is the angle between them. When ||v|| = 1, this can be simplified to ||u||cos∠(u, v). By drawing a right triangle we can see that this is the signed length of u in the direction of v (positive when ∠(u, v) < 90° and negative when ∠(u, v) > 90°).
Let’s call the normal vector of our plane n and the point on the plane (part’s pivot) p. The way i suggested to calculate the normal vector gives a unit vector (so ||n|| = 1). Let’s call the mouse ray origin (camera position) o. And let’s call its direction d.
Now dot(d, n) tells us how many coordinate units we move in the direction of the normal vector when we move the displacement defined by d. dot(p - o, n) tells us how many units we need to move from o in the direction of the normal vector in order to reach a point on the plane (this can be any point on the plane, not just p). p - o is the vector from o to p.
The intersection of a line and the plane is now easy to calculate. In this explanation, I assume that the line is not parallel to the plane. We get to the intersection point q by moving t times d from o where t is a real number that we need to calculate. In physics, there’s the simple equation v = s/t which gives the velocity when something moves distance s in time t with constant velocity. From this we get t = s/v. Now we can think of the aforementioned dot(p - o, n) as s (it’s the signed distance of o from the plane) and we can think of dot(d, n) as v. So, the intersection point q can be calculated in the following simple way:
t = dot(p - o, n)/dot(d, n) q = o + t * d
Although I explained this with the assumption that ||n|| = 1, this isn’t actually necessary because the ||n|| terms in the dividend and divisor of ||p - o||||n||cos∠(p - o, n)/(||d||||n||cos∠(d, n)) cancel each other.
I don’t really understand your steps 4 and 5 but here’s what I did. I calculate the angle difference between the direction of the intersection on last frame from the pivot and the direction of the intersection on current frame from the pivot (which I think is somewhat similar to your step 4 although not the same). Then I rotate the part using CFrame.Angles, giving it the aforementioned angle difference.
Here’s my entire code. The calculations are in the first three functions.
--!strict
local UserInputService = game:GetService("UserInputService")
local RunService = game:GetService("RunService")
local COLORS: {Color3} = {Color3.new(1, 0, 0), Color3.new(0, 1, 0), Color3.new(0, 0, 1)}
local EQUALITY_THRESHOLD: number = 1e-4
local rings: {Part} = table.create(3)
local runServiceConnection: RBXScriptConnection?
local clickConnection: RBXScriptConnection?
local vectorFromPivotToLatestIntersection: Vector3 = Vector3.zero
local function getMouseRay(): Ray
local mousePos: Vector2 = UserInputService:GetMouseLocation()
local mouseRay: Ray = workspace.CurrentCamera:ViewportPointToRay(mousePos.X, mousePos.Y)
return mouseRay
end
local function calculateVectorFromPivotToIntersection(part: Part, planeNormalAxis: Enum.Axis): Vector3
local mouseRay: Ray = getMouseRay()
local origin: Vector3, direction: Vector3 = mouseRay.Origin, mouseRay.Direction
local pointOnPlane: Vector3 = part:GetPivot().Position
local normal: Vector3 = part:GetPivot():VectorToWorldSpace(Vector3.FromAxis(planeNormalAxis))
local divisor: number = direction:Dot(normal)
if math.abs(divisor) <= EQUALITY_THRESHOLD then
-- The mouse ray is parallel to the plane so either the intersection is the entire ray or there's no intersection.
return -direction
end
-- The mouse ray intersects the plane at one point.
local intersectionPoint: Vector3 = origin + (pointOnPlane - origin):Dot(normal) / divisor * direction
return intersectionPoint - pointOnPlane
end
local function runServiceConnectedFunction(part, rotationAxis: Enum.Axis): ()
--print("rotating")
local pivot: CFrame = part:GetPivot()
local vectorFromPivotToIntersection: Vector3 = calculateVectorFromPivotToIntersection(part, rotationAxis)
local angle: number = -vectorFromPivotToIntersection:Angle(vectorFromPivotToLatestIntersection, pivot:VectorToWorldSpace(Vector3.FromAxis(rotationAxis)))
local angleVector: Vector3 = angle * Vector3.FromAxis(rotationAxis)
part:PivotTo(pivot * CFrame.Angles(angleVector.X, angleVector.Y, angleVector.Z))
vectorFromPivotToLatestIntersection = vectorFromPivotToIntersection
end
local function cleanupRings()
for _, ring: Part in rings do
ring:Destroy()
end
table.clear(rings);
end
local function stopRotating()
(runServiceConnection :: RBXScriptConnection):Disconnect()
runServiceConnection = nil
end
local function startRotating(part: Part, rorationAxis: Enum.Axis): ()
runServiceConnection = RunService.PreRender:Connect(function(): ()
runServiceConnectedFunction(part, rorationAxis)
end)
vectorFromPivotToLatestIntersection = calculateVectorFromPivotToIntersection(part, rorationAxis)
end
local function createRings(part: Part): ()
local pivot: CFrame = part:GetPivot()
local axes: {Enum.Axis} = Enum.Axis:GetEnumItems()
for iNormalAxis: number, normalAxis: Enum.Axis in axes do
local otherAxis1: Enum.Axis, otherAxis2: Enum.Axis = axes[(iNormalAxis) % 3 + 1], axes[(iNormalAxis + 1) % 3 + 1]
local ring: Part = Instance.new("Part")
ring.Color = COLORS[iNormalAxis]
ring.Shape = Enum.PartType.Cylinder
ring.CanCollide = false
ring.Size = Vector3.new(.125, 10, 10)
ring.CFrame = pivot * CFrame.fromMatrix(Vector3.zero, Vector3.FromAxis(normalAxis), Vector3.FromAxis(otherAxis1), Vector3.FromAxis(otherAxis2))
local weld: WeldConstraint = Instance.new("WeldConstraint")
weld.Part0, weld.Part1 = part, ring
weld.Parent = ring
ring.Parent = part
rings[iNormalAxis] = ring
end
--print("Rings created.")
end
local function mouseRaycast(raycastParams: RaycastParams, distance: number): RaycastResult?
local mouseRay: Ray = getMouseRay()
return workspace:Raycast(mouseRay.Origin, distance * mouseRay.Direction, raycastParams)
end
local function initializeRotationTool(part: Part): ()
createRings(part)
if clickConnection ~= nil then
return
end
clickConnection = UserInputService.InputBegan:Connect(function(inputObject: InputObject): ()
if inputObject.UserInputType ~= Enum.UserInputType.MouseButton1 then
return
end
--print("Mouse left press detected by UserInputService.")
local raycastParams: RaycastParams = RaycastParams.new()
raycastParams.FilterType = Enum.RaycastFilterType.Include
raycastParams.FilterDescendantsInstances = rings :: any
local raycastResult: RaycastResult? = mouseRaycast(raycastParams, 5_000)
if raycastResult ~= nil then
local rotationAxis: Enum.Axis = Enum.Axis:GetEnumItems()[table.find(rings, raycastResult.Instance :: Part) :: number]
--print(`The mouse is pointing to a ring. New rotation axis: {rotationAxis.Name}`)
startRotating(part, rotationAxis)
local holdEndConnection: RBXScriptConnection?
holdEndConnection = UserInputService.InputEnded:Connect(function(inputObject: InputObject): ()
if inputObject.UserInputType ~= Enum.UserInputType.MouseButton1 then
return
end
if holdEndConnection == nil then
return
end
--print("Mouse left release detected by UserInputService")
holdEndConnection:Disconnect()
holdEndConnection = nil
stopRotating()
end)
else
--print("The mouse is pointing to something other than a ring. Cleaning up the rotation tool.");
(clickConnection :: RBXScriptConnection):Disconnect()
clickConnection = nil
cleanupRings()
end
end)
end
local part: Part = Instance.new("Part")
part.Anchored = true
part.Size = Vector3.new(6, 2, 4)
part.Position = Vector3.new(0, 3, 0)
part.Orientation = Vector3.new(0, 60, 40)
local clickDetector: ClickDetector = Instance.new("ClickDetector")
clickDetector.Parent = part
part.Parent = workspace
clickDetector.MouseClick:Connect(function(): ()
initializeRotationTool(part)
end)