How to get average CFrame from a table of parts?

I have a table of parts, how would I get their average CFrame?

module.inputSelectedObjectReturnTrueOrNil ← my table
module.numberSelected ← length of table (#table)

This is what I’ve come up with, with the help of AI:

local function returnCenterCFrameOfAllSelectedParts():CFrame
	
	local avgPos = Vector3.zero
	
	local qx, qy, qz, qw = 0, 0, 0, 0
	
	for part, _ in module.inputSelectedObjectReturnTrueOrNil do
		
		avgPos += part.Position
		
		local _, _, _, x, y, z, w = part.CFrame:GetComponents()
		
		-- Ensure quaternions face the same direction to avoid flipping
		if qw ~= 0 and (x*qx + y*qy + z*qz + w*qw) < 0 then
			x, y, z, w = -x, -y, -z, -w
		end
		
		qx += x
		qy += y
		qz += z
		qw += w
		
	end
	
	-- Normalize quaternion
	local length = math.sqrt(qx*qx + qy*qy + qz*qz + qw*qw)
	
	if length > 0 then
		qx, qy, qz, qw = qx/length, qy/length, qz/length, qw/length
	else
		qx, qy, qz, qw = 0, 0, 0, 1
	end
	
	--
	
	avgPos /= module.numberSelected

	return CFrame.new(avgPos.X, avgPos.Y, avgPos.Z, qx, qy, qz, qw)
	
end

Currently this doesn’t work because w in the line with part.CFrame:GetComponents() is R00, according to this website: GetComponents

Any quaternion experts, please come to the rescue! (if quaternions are even needed)

And of course, thank you all in advance :hugs:

Or do I do something like this: (I’m sure this can be simplified)

local function returnCenterCFrameOfAllSelectedParts():CFrame
	
	local avgPos = Vector3.zero
	
	local avgRot = Vector3.zero
	
	for part:BasePart, _ in module.inputSelectedObjectReturnTrueOrNil do

		avgPos += part.Position
		
		avgRot += part.Rotation
		
	end
	
	avgPos /= module.numberSelected
	
	avgRot /= module.numberSelected
	
	local function miniFunc(num)
		return math.rad(num % 360)
	end

	return CFrame.new(avgPos) * CFrame.fromEulerAnglesXYZ( miniFunc(avgRot.X) , miniFunc(avgRot.Y) , miniFunc(avgRot.Z) )
	
end

Edit: I’m thinking of degrees as if they were on a number line:

-180 ----------- 0 ----------- 180

so the average of -179 and 179 is 0, not 180 or -180. There’s some bias, which is what I hoped to fix with quaternions, if possible of course.

I had an algorithm that does something similar to that.
It’s not very accurate per se, but it may work.

I assume it’s this function:

local function AvarageCFrame(cf:{CFrame}):CFrame
	local avg = cf[1]::CFrame
	local n = 1/#cf
	for i,v in inext,cf,1 do
		avg = avg:Lerp(v,n)
	end
	return avg
end

Can you please decipher it for me :sweat_smile:

For example, what does :: mean/do?
What is “Lerp”
How does that for loop work? What is inext?
Is the input of the function an array? Is that what :{CFrame} means?
I usually write arrays like this: :{[num]:CFrame}

Thanks!

1 Like

Inext is an iterator used in ipairs(). I really don’t remember why I used it in here; regular iteration would’ve made the job better performance-wise (gotta fix it rn rq).

local function AvarageCFrame(cf:{CFrame}):CFrame
	local avg = cf[1]::CFrame
	local n = 1/#cf
	for i,v in cf do
		if i==1 then continue end
		avg = avg:Lerp(v,n)
	end
	return avg
end

:: is a Luau typecheck casting

1 Like


Jeez, I still have so much to learn!

So basically you’re skipping the first index.
Why not do this?

local function AvarageCFrame(cf:{CFrame}):CFrame
	local avg:CFrame
	local n:number = 1/#cf
	for i:number, v:CFrame in cf do
		avg = avg:Lerp(v,n) -- whatever Lerp means
	end
	return avg
end

I still don’t know what Lerp means :sweat_smile:

Okay, so, this seems to look natural for blending, but I feel like this is mathematically incorrect.. but I’m unsure why at the moment

I’ll write a follow-up if I find a better solution

1 Like

Yeah, I just learned what Lerp is, and I feel like there might be something unaccounted for in this line:

avg = avg:Lerp(v,n)

Like, for each iteration, it adds a little bit to itself… but something feels off…

1 Like

I made a little file for you:
LerpTest.rbxl (54.4 KB)

Both the angles and position are not what I expected. The angle is off by 0.002 degrees? And the position is way off.

1 Like

Okay, here’s something cool. If you rearrange the insertion order of the Part instances in the folder, you will get a different resulting CFrame after the iterative lerping. This is because continuously lerping CFrames is non-commutative as applying rotation interpolation between two orientations is not a commutative operation.

Also, you might still get a wrong answer with linearly interpolating the position in this manner (you can check by doing a few cases by hand and seeing the coefficients on the resultant “weighted sum” of positions after this nested lerp.

So, order of iteration would matter, and both generalized iteration and ipairs iterate here without any guideline for iteration aside from insertion order (or whatever order is naturally present after :GetChildren()).

I think that this looks good for blending, but I am pretty sure you could attain a better result with applying averaging to quaternions (I can’t find an easy way to do this) or doing something with finding some sort of mean with rotation matrices.. which I assume will be more computationally complex:

Aside from finding research papers on how to approach finding a Fréchet mean for quaternions, I could not find any programmatic implementations for finding a generalized mean definition or idiom for Roblox Luau.

My brain is a little fried today to write an implementation, so I hope this could help a bit

In accordance with the non-commutativity issue of rotation, I believe your implementation of returnCenterCFrameOfAllSelectedParts in the first reply to this topic would not work properly.

I’m not sure if your implementation that had been curated by AI in the topic’s original body is mathematically correct, but did you end up getting it to work somehow?

I feel like this is an undermentioned topic of CFrames (and orientation averaging in general) and is not so obvious to compute correctly

Like, obviously, finding the average position vector is a simple feat, and THAT could be done with interpolation, but I am confused on how to properly find the average orientation as there are multiple representations of the same orientation state

Thank you for putting that in writing. (for anyone who doesn’t understand, this means that the order matters)

Absolutely no worries. Take as much time as you need, and it’s totally okay if you don’t embark on this project at all, I totally understand.

I actually have not tested it, because some of the things that are written literally don’t exist, such as the example I pointed out right below the code. One of the gripes I have with Vibe Coding, it pulls stuff from other languages, or assumes I have a library/module somewhere. I haven’t even bothered to check it because it’d just spit out a useless result. Hope you understand.

Yep… I don’t even know where to start!


I read through the stack overflow post… Almost everything went in one ear and out the other/over my head.

Tomorrow, I can try to copy the code from the stack overflow post, but I’m not sure how to convert CFrames into quaternions, which is what the AI actually struggled with as well.


I haven’t even considered that! But yes, you’re absolutely correct. An average of -179 and 179 degrees should be 180, but returns 0, which quite literally couldn’t be further from the truth. The average of 359 and 0 would return 179.5, instead of 359.5. So that idea is out the door. Maybe we add 180 afterwards in both cases? That would mean an average of 10 and 0 would return 185, which is also incorrect… Maybe there should be some filter, if the number is large enough, you add 180, but that’s too much to think about at 1 in the morning.

Overall, I seriously appreciate your effort, and I’ll get some sleep as well. Good night :waving_hand:

1 Like

I want to say to look into translating this to Luau:

But, that would be a bit complex if you don’t understand the math involved

I’ll work something out if I return here tomorrow

I think your original code in the topic body is probably correct, but I will look more into it later

Looked through this a little bit, unfortunately I didn’t understand neither the math nor the code lol
What I did understand is that there are two functions, and one of them says weighted. How does one weigh a quaternion? Weight as in importance, correct?
When I do my average vector3 function, I just add all the vector 3’s into a giant very large vector 3, and divide the three elements of it by the number of positions/parts. There is no concept of importance/weight here. How do quaternions differ?
Thanks in advance!

Have you tried just taking the averages of each vector? The pos, the rightVector, and the upVector? And then recreating it using fromMatrix?

For averaging orientation across parts with differing pivots, I’m pretty sure this is mathematically incorrect, despite probably being much easier to implement

1 Like

Chat is this real?

Here’s the code it gave me:

local function averageAngles(angles)
    local n = #angles
    local sumX, sumY = 0, 0

    for i = 1, n do
        local rad = math.rad(angles[i])
        sumX += math.cos(rad)
        sumY += math.sin(rad)
    end

    local avgX = sumX / n
    local avgY = sumY / n
    local mag = math.sqrt(avgX*avgX + avgY*avgY)

    if mag < 1e-6 then
        -- Angles cancelled each other out.
        -- Find main cancellation axis:
        local rad = math.rad(angles[1])
        local axisX = math.cos(rad)
        local axisY = math.sin(rad)

        -- Two perpendicular directions:
        local perp1 = math.deg(math.atan2(axisX, -axisY))
        local perp2 = (perp1 + 180) % 360

        perp1 = perp1 % 360
        print("Mean is undefined → two valid candidates:")
        print(("  1: %.2f°"):format(perp1))
        print(("  2: %.2f°"):format(perp2))
        return
    end

    -- Normal circular mean:
    local mean = math.deg(math.atan2(avgY, avgX)) % 360
    print(("Mean angle: %.2f°"):format(mean))
end

-- Test cases:
averageAngles({-90, 90})
print("---")
averageAngles({90, 270})

New function. Y’all let me know if it’s correct and if I should bother optimizing it.

Code
local function averageAngles(angles)
	
	local n = #angles
	local sumX, sumY = 0, 0

	for i = 1, n do
		local rad = math.rad(angles[i])
		sumX += math.cos(rad)
		sumY += math.sin(rad)
	end

	local avgX = sumX / n
	local avgY = sumY / n
	local mag = math.sqrt(avgX*avgX + avgY*avgY)

	if mag < 1e-6 then -- Angles cancelled each other out
		
		-- Find main cancellation axis:
		local rad = math.rad(angles[1])
		local axisX = math.cos(rad)
		local axisY = math.sin(rad)

		-- Two perpendicular directions:
		local perp1 = math.deg(math.atan2(axisX, -axisY)) % 360
		local perp2 = (perp1 + 180) % 360
		
		return math.min(perp1, perp2)
		
	end

	-- Normal circular mean:
	return math.deg(math.atan2(avgY, avgX)) % 360
	
end

local function returnAvgCFrameOfAllSelectedParts():CFrame
	
	local avgPos = Vector3.zero
	
	local allXangles, allYangles, allZangles = {}, {}, {}
	
	for part:BasePart, _ in module.inputSelectedObjectReturnTrueOrNil do

		avgPos += part.Position
		
		table.insert(allXangles, part.Rotation.X)
		table.insert(allYangles, part.Rotation.Y)
		table.insert(allZangles, part.Rotation.Z)
		
	end
	
	avgPos /= module.numberSelected
	
	local function miniFunc(num)
		return math.rad(num % 360)
	end
	
	local backToCFrame:CFrame = CFrame.fromEulerAnglesXYZ( 
		miniFunc(averageAngles(allXangles)),
		miniFunc(averageAngles(allYangles)),
		miniFunc(averageAngles(allZangles))
	)

	return CFrame.new(avgPos) * backToCFrame
	
end

Also, I feel like this is important to mention: Whether the angle is 180 degrees, or 0 degrees, 90 or 270, it doesn’t matter in my case, because my part is symmetrical. It’s a rectangular prism, so an error of exactly 180 degrees is allowed.

Edit: First round of optimizations:

Code
local function averageAngles(angles)
	
	local n = #angles
	local sumX, sumY = 0, 0

	for i = 1, n do
		local rad = math.rad(angles[i])
		sumX += math.cos(rad)
		sumY += math.sin(rad)
	end

	local avgX = sumX / n
	local avgY = sumY / n
	local mag = math.sqrt(avgX*avgX + avgY*avgY)

	if mag < 1e-6 then -- Angles cancelled each other out
		
		-- Find main cancellation axis:
		local rad = math.rad(angles[1])
		local axisX = math.cos(rad)
		local axisY = math.sin(rad)
		
		return math.atan2(axisX, -axisY) % (2*math.pi)
		
	end

	-- Normal circular mean:
	return math.atan2(avgY, avgX) % (2*math.pi)
	
end

local function returnAvgCFrameOfAllSelectedParts():CFrame
	
	local avgPos = Vector3.zero
	
	local allXangles, allYangles, allZangles = {}, {}, {}
	
	for part:BasePart, _ in module.inputSelectedObjectReturnTrueOrNil do

		avgPos += part.Position
		
		table.insert(allXangles, part.Rotation.X)
		table.insert(allYangles, part.Rotation.Y)
		table.insert(allZangles, part.Rotation.Z)
		
	end
	
	avgPos /= module.numberSelected
	
	local backToCFrame:CFrame = CFrame.fromEulerAnglesXYZ( 
		averageAngles(allXangles),
		averageAngles(allYangles),
		averageAngles(allZangles)
	)

	return CFrame.new(avgPos) * backToCFrame
	
end