Plugin: UnUnionify (reverse unions + carve negatives)

Plugin: UnUnionify

made a tiny plugin called ununionify.
basically it takes a union, splits it back into parts, and also carves out the negatives so you don’t have to redo everything by hand.

there’s a variable in ServerStorage.Plugins where you can change the grid size – smaller grid sizes work better for some shapes, but they take longer to process.

that’s pretty much it. helps if you ever regret unioning something :sweat_smile:

UnUnionifyPlugin - Creator Store

9 Likes

So you mean, a Union to Part convertor? That’s insane

1 Like

Seems good, but please just rename it to Union2Part or something :sob:

2 Likes

what happens if you use this on a sphere that has a hole in it

1 Like

Seems cool, if you don’t mind, can we get the source code? I wanna see how it works.

-- Optimized Carve Unions Plugin (spatial indexing + union-local checks)
-- Paste into a Plugin Script in Studio. Select UnionOperation(s) and click the toolbar button.

local serverStorage = game:GetService('ServerStorage')
local Selection = game:GetService("Selection")
local Workspace = game:GetService("Workspace")

-- Settings (tune)
local GRID_SIZE = 0.25            -- voxel size
local PROBE_FACTOR = 0.5
local POSITIVE_THRESHOLD = 0.35
local CORNER_EPS = 1e-4
local SAMPLE_SUBDIV = false       -- 3x3x3 per voxel (slow)
local MIN_PART_SIZE = 0.25        -- do not spawn parts smaller than this on any axis

local toolbar = plugin:CreateToolbar("CSG Tools")
local btn = toolbar:CreateButton("Carve Unions (Optimized)", "Voxel-carve selected unions", "rbxassetid://123955089457539", "Carve Unions (Optimized)")

-- helpers
local function clamp(i, a, b) if i < a then return a elseif i > b then return b else return i end end

-- safe wrapper for plugin:Separate
local function safeSeparate(obj)
	local ok, res = pcall(function() return plugin:Separate({obj}) end)
	if not ok then return nil, res end
	return res
end

-- sample points for small fallback checks (used only for heuristic negative detection)
local function samplePointsForPart(part)
	if not part or not part:IsA("BasePart") then return {} end
	local pts = {}
	local size = part.Size
	local half = size * 0.5

	-- center
	table.insert(pts, part.CFrame.Position)

	-- face centers
	local offs = {
		Vector3.new( half.X, 0, 0), Vector3.new(-half.X, 0, 0),
		Vector3.new(0,  half.Y, 0), Vector3.new(0, -half.Y, 0),
		Vector3.new(0, 0,  half.Z), Vector3.new(0, 0, -half.Z),
	}
	for _, off in ipairs(offs) do table.insert(pts, part.CFrame:PointToWorldSpace(off)) end

	-- quarter corners
	local quarter = Vector3.new(half.X * 0.5, half.Y * 0.5, half.Z * 0.5)
	local cornerOffsets = {
		Vector3.new( quarter.X,  quarter.Y,  quarter.Z),
		Vector3.new(-quarter.X,  quarter.Y,  quarter.Z),
		Vector3.new( quarter.X, -quarter.Y,  quarter.Z),
		Vector3.new( quarter.X,  quarter.Y, -quarter.Z),
	}
	for _, off in ipairs(cornerOffsets) do table.insert(pts, part.CFrame:PointToWorldSpace(off)) end

	return pts
end

-- separate clone fully and collect basepart clones
local function separateCloneFullyAndCollect(clonedRoot, tempFolder)
	local queue = { clonedRoot }
	local collected = {}

	while #queue > 0 do
		local cur = table.remove(queue, 1)
		if not cur then break end

		local parts, err = safeSeparate(cur)
		if not parts then
			if cur:IsA("BasePart") then
				local okc, clone = pcall(function() return cur:Clone() end)
				if okc and clone then clone.Parent = tempFolder; table.insert(collected, clone)
				else cur.Parent = tempFolder; table.insert(collected, cur) end
			else
				warn("Separate failed:", tostring(cur), err)
			end
		else
			for _, p in ipairs(parts) do
				if p:IsA("UnionOperation") then
					local okc, clone = pcall(function() return p:Clone() end)
					if okc and clone then clone.Parent = tempFolder; table.insert(queue, clone) end
				elseif p:IsA("BasePart") then
					local okc, clone = pcall(function() return p:Clone() end)
					if okc and clone then clone.Parent = tempFolder; table.insert(collected, clone) end
				end
			end
		end
	end

	return collected
end

-- geometry helpers used by core algorithm

local function voxelCornersWorldFromLocal(cframe, localMin, localMax)
	local corners = {}
	for ix = 0,1 do
		for iy = 0,1 do
			for iz = 0,1 do
				local lx = (ix == 0) and localMin.X or localMax.X
				local ly = (iy == 0) and localMin.Y or localMax.Y
				local lz = (iz == 0) and localMin.Z or localMax.Z
				table.insert(corners, cframe:PointToWorldSpace(Vector3.new(lx, ly, lz)))
			end
		end
	end
	return corners
end

local function voxelExtraSamplePointsWorld(cframe, localMin, localMax)
	local pts = {}
	local fx = (localMax.X + localMin.X) * 0.5
	local fy = (localMax.Y + localMin.Y) * 0.5
	local fz = (localMax.Z + localMin.Z) * 0.5

	local faces = {
		Vector3.new(localMin.X, fy, fz),
		Vector3.new(localMax.X, fy, fz),
		Vector3.new(fx, localMin.Y, fz),
		Vector3.new(fx, localMax.Y, fz),
		Vector3.new(fx, fy, localMin.Z),
		Vector3.new(fx, fy, localMax.Z),
	}
	for _, lf in ipairs(faces) do table.insert(pts, cframe:PointToWorldSpace(lf)) end

	local edges = {
		Vector3.new(localMin.X, localMin.Y, fz),
		Vector3.new(localMin.X, localMax.Y, fz),
		Vector3.new(localMax.X, localMin.Y, fz),
		Vector3.new(localMax.X, localMax.Y, fz),

		Vector3.new(localMin.X, fy, localMin.Z),
		Vector3.new(localMin.X, fy, localMax.Z),
		Vector3.new(localMax.X, fy, localMin.Z),
		Vector3.new(localMax.X, fy, localMax.Z),

		Vector3.new(fx, localMin.Y, localMin.Z),
		Vector3.new(fx, localMax.Y, localMin.Z),
		Vector3.new(fx, localMin.Y, localMax.Z),
		Vector3.new(fx, localMax.Y, localMax.Z),
	}
	for _, le in ipairs(edges) do table.insert(pts, cframe:PointToWorldSpace(le)) end

	if SAMPLE_SUBDIV then
		for sx = 0,2 do for sy = 0,2 do for sz = 0,2 do
					local lx = localMin.X + (localMax.X - localMin.X) * (sx / 2)
					local ly = localMin.Y + (localMax.Y - localMin.Y) * (sy / 2)
					local lz = localMin.Z + (localMax.Z - localMin.Z) * (sz / 2)
					table.insert(pts, cframe:PointToWorldSpace(Vector3.new(lx, ly, lz)))
				end end end
	end

	return pts
end

local function partWorldAABB(part)
	local half = part.Size * 0.5
	local localMin = -half
	local localMax = half
	local corners = voxelCornersWorldFromLocal(part.CFrame, localMin, localMax)
	local minv = Vector3.new(1e9, 1e9, 1e9)
	local maxv = Vector3.new(-1e9, -1e9, -1e9)
	for _, c in ipairs(corners) do
		minv = Vector3.new(math.min(minv.X, c.X), math.min(minv.Y, c.Y), math.min(minv.Z, c.Z))
		maxv = Vector3.new(math.max(maxv.X, c.X), math.max(maxv.Y, c.Y), math.max(maxv.Z, c.Z))
	end
	return minv, maxv
end

local function aabbOverlap(minA, maxA, minB, maxB)
	if maxA.X < minB.X or minA.X > maxB.X then return false end
	if maxA.Y < minB.Y or minA.Y > maxB.Y then return false end
	if maxA.Z < minB.Z or minA.Z > maxB.Z then return false end
	return true
end

local function worldPointInsidePart(worldPoint, part, eps)
	eps = eps or CORNER_EPS
	local localP = part.CFrame:PointToObjectSpace(worldPoint)
	local hx, hy, hz = part.Size.X * 0.5 + eps, part.Size.Y * 0.5 + eps, part.Size.Z * 0.5 + eps
	return math.abs(localP.X) <= hx and math.abs(localP.Y) <= hy and math.abs(localP.Z) <= hz
end

-- cheap union probe on sample points using GetPartBoundsInBox
local function voxelIntersectsUnionSamples(union, sampleWorldPoints)
	if not union or not union:IsA("UnionOperation") then return false end
	local uSize = union.Size or Vector3.new(1,1,1)
	local probe = math.max(0.09, math.max(uSize.X, uSize.Y, uSize.Z) * PROBE_FACTOR)
	for _, p in ipairs(sampleWorldPoints) do
		local found = Workspace:GetPartBoundsInBox(CFrame.new(p), Vector3.new(probe, probe, probe))
		for _, f in ipairs(found) do
			if f == union then return true end
		end
	end
	return false
end

-- spawn part, respects MIN_PART_SIZE
local function spawnPartFromUnionLocalBox(union, minLocal, maxLocal, parentFolder)
	local size = maxLocal - minLocal
	if size.X < MIN_PART_SIZE or size.Y < MIN_PART_SIZE or size.Z < MIN_PART_SIZE then return nil end
	local p = Instance.new("Part")
	p.Size = size
	p.CFrame = union.CFrame * CFrame.new((minLocal + maxLocal) * 0.5)
	if union:IsA("BasePart") then
		p.Color = union.Color
		p.Material = union.Material
		p.Transparency = union.Transparency
	end
	p.Anchored = true
	p.CanCollide = true
	p.Name = union.Name .. "_slice"
	p.Parent = parentFolder or union.Parent
	return p
end

-- GREEDY MERGE (unchanged)
local function greedyMergeCells(cells)
	if #cells == 0 then return {} end
	local map = {}
	local minX, minY, minZ = math.huge, math.huge, math.huge
	local maxX, maxY, maxZ = -math.huge, -math.huge, -math.huge

	for _, c in ipairs(cells) do
		local key = string.format("%d_%d_%d", c.x, c.y, c.z)
		map[key] = true
		minX = math.min(minX, c.x); minY = math.min(minY, c.y); minZ = math.min(minZ, c.z)
		maxX = math.max(maxX, c.x); maxY = math.max(maxY, c.y); maxZ = math.max(maxZ, c.z)
	end

	local visited = {}
	local out = {}
	for x = minX, maxX do
		for y = minY, maxY do
			for z = minZ, maxZ do
				local key = string.format("%d_%d_%d", x,y,z)
				if map[key] and not visited[key] then
					local endX = x
					while true do
						local k = string.format("%d_%d_%d", endX+1, y, z)
						if not map[k] or visited[k] then break end
						endX = endX + 1
					end
					local endY = y
					local okY = true
					while okY do
						for xx = x, endX do
							local kk = string.format("%d_%d_%d", xx, endY+1, z)
							if not map[kk] or visited[kk] then okY = false; break end
						end
						if okY then endY = endY + 1 end
					end
					local endZ = z
					local okZ = true
					while okZ do
						for xx = x, endX do
							for yy = y, endY do
								local kk = string.format("%d_%d_%d", xx, yy, endZ+1)
								if not map[kk] or visited[kk] then okZ = false; break end
							end
							if not okZ then break end
						end
						if okZ then endZ = endZ + 1 end
					end
					for xx = x, endX do
						for yy = y, endY do
							for zz = z, endZ do
								visited[string.format("%d_%d_%d", xx,yy,zz)] = true
							end
						end
					end
					table.insert(out, { min = Vector3.new(x,y,z), max = Vector3.new(endX+1, endY+1, endZ+1) })
				end
			end
		end
	end
	return out
end

-- KEY: carve using spatial index (neg map) and union-local AABBs
local function carveUnionGloballyOptimized(union, negatives, gridSize, outFolder)
	gridSize = gridSize or GRID_SIZE
	local size = union.Size
	if size.X < gridSize or size.Y < gridSize or size.Z < gridSize then
		spawnPartFromUnionLocalBox(union, -size/2, size/2, outFolder)
		return
	end

	local slicesX = math.floor(size.X / gridSize)
	local slicesY = math.floor(size.Y / gridSize)
	local slicesZ = math.floor(size.Z / gridSize)
	if slicesX <= 0 or slicesY <= 0 or slicesZ <= 0 then
		spawnPartFromUnionLocalBox(union, -size/2, size/2, outFolder)
		return
	end

	local gridExtentX = slicesX * gridSize
	local gridExtentY = slicesY * gridSize
	local gridExtentZ = slicesZ * gridSize
	local startX = -gridExtentX / 2
	local startY = -gridExtentY / 2
	local startZ = -gridExtentZ / 2

	-- 1) Precompute negatives in union-local coordinates and build occupancy map (voxel -> negatives list)
	local negLocalAABBs = {}   -- negPart -> {min = Vector3, max = Vector3}
	local occupancy = {}       -- keyed by "ix_iy_iz" -> {neg, neg, ...}

	for _, neg in ipairs(negatives) do
		if neg and neg:IsA("BasePart") then
			-- compute neg corners world -> transform into union local
			local corners = voxelCornersWorldFromLocal(neg.CFrame, -neg.Size*0.5, neg.Size*0.5)
			local localMin = Vector3.new(1e9,1e9,1e9)
			local localMax = Vector3.new(-1e9,-1e9,-1e9)
			for _, wc in ipairs(corners) do
				-- union CFrame: PointToObjectSpace maps world -> union-local
				local lc = union.CFrame:PointToObjectSpace(wc)
				localMin = Vector3.new(math.min(localMin.X, lc.X), math.min(localMin.Y, lc.Y), math.min(localMin.Z, lc.Z))
				localMax = Vector3.new(math.max(localMax.X, lc.X), math.max(localMax.Y, lc.Y), math.max(localMax.Z, lc.Z))
			end
			negLocalAABBs[neg] = { min = localMin, max = localMax }

			-- compute which voxels this negative overlaps (in union-local voxel indices)
			local ixMin = math.floor((localMin.X - startX) / gridSize)
			local iyMin = math.floor((localMin.Y - startY) / gridSize)
			local izMin = math.floor((localMin.Z - startZ) / gridSize)
			local ixMax = math.floor((localMax.X - startX) / gridSize)
			local iyMax = math.floor((localMax.Y - startY) / gridSize)
			local izMax = math.floor((localMax.Z - startZ) / gridSize)

			ixMin = clamp(ixMin, 0, slicesX-1); iyMin = clamp(iyMin, 0, slicesY-1); izMin = clamp(izMin, 0, slicesZ-1)
			ixMax = clamp(ixMax, 0, slicesX-1); iyMax = clamp(iyMax, 0, slicesY-1); izMax = clamp(izMax, 0, slicesZ-1)

			for ix = ixMin, ixMax do
				for iy = iyMin, iyMax do
					for iz = izMin, izMax do
						local key = ix.."_"..iy.."_"..iz
						local list = occupancy[key]
						if not list then
							list = {}
							occupancy[key] = list
						end
						table.insert(list, neg)
					end
				end
			end
		end
	end

	-- 2) Iterate voxels; only check negatives listed in occupancy map for that voxel.
	local keepCells = {}
	local smallVoxelCenterOnly = (gridSize <= 0.25)  -- heuristic: small voxels use center-only sampling

	for ix = 0, slicesX-1 do
		for iy = 0, slicesY-1 do
			for iz = 0, slicesZ-1 do
				local localMin = Vector3.new(startX + ix*gridSize, startY + iy*gridSize, startZ + iz*gridSize)
				local localMax = localMin + Vector3.new(gridSize, gridSize, gridSize)

				-- build minimal sample set:
				local samples = {}
				local centerLocal = (localMin + localMax) * 0.5
				table.insert(samples, union.CFrame:PointToWorldSpace(centerLocal))
				if not smallVoxelCenterOnly then
					-- add corners and faces/edges (fewer than before)
					for _, v in ipairs(voxelCornersWorldFromLocal(union.CFrame, localMin, localMax)) do table.insert(samples, v) end
					for _, v in ipairs(voxelExtraSamplePointsWorld(union.CFrame, localMin, localMax)) do table.insert(samples, v) end
				end

				-- quick test: does this voxel intersect the union at all?
				local intersectsUnion = voxelIntersectsUnionSamples(union, samples)
				if not intersectsUnion then
					-- skip voxel entirely
				else
					-- compute occupancy key and candidate negatives
					local key = ix.."_"..iy.."_"..iz
					local candidates = occupancy[key]  -- could be nil (fast common case)

					local intersectsNeg = false
					if candidates then
						-- compute voxel AABB in union-local (fast)
						local vmin = localMin
						local vmax = localMax

						-- test candidates only
						for _, neg in ipairs(candidates) do
							local negA = negLocalAABBs[neg]
							if negA and aabbOverlap(vmin, vmax, negA.min, negA.max) then
								-- fine tests: check any sample point in negative (use part.CFrame:PointToObjectSpace)
								for _, sp in ipairs(samples) do
									if worldPointInsidePart(sp, neg) then intersectsNeg = true; break end
								end
								if intersectsNeg then break end

								-- also test if neg corners fall inside voxel (covers tiny negatives)
								local negCorners = voxelCornersWorldFromLocal(neg.CFrame, -neg.Size * 0.5, neg.Size * 0.5)
								for _, nc in ipairs(negCorners) do
									-- nc is world; transform to union-local and compare to vmin..vmax
									local nloc = union.CFrame:PointToObjectSpace(nc)
									if nloc.X >= vmin.X and nloc.X <= vmax.X and nloc.Y >= vmin.Y and nloc.Y <= vmax.Y and nloc.Z >= vmin.Z and nloc.Z <= vmax.Z then
										intersectsNeg = true; break
									end
								end
								if intersectsNeg then break end
							end
						end
					end

					if not intersectsNeg then
						table.insert(keepCells, { x = ix, y = iy, z = iz })
					end
				end
			end
		end
	end

	-- 3) Merge and spawn
	local merged = greedyMergeCells(keepCells)
	for _, m in ipairs(merged) do
		local minLocal = Vector3.new(startX + m.min.X * gridSize, startY + m.min.Y * gridSize, startZ + m.min.Z * gridSize)
		local maxLocal = Vector3.new(startX + m.max.X * gridSize, startY + m.max.Y * gridSize, startZ + m.max.Z * gridSize)
		spawnPartFromUnionLocalBox(union, minLocal, maxLocal, outFolder)
	end

	-- rim slabs both sides (respect MIN_PART_SIZE)
	local gridExtentX, gridExtentY, gridExtentZ = slicesX * gridSize, slicesY * gridSize, slicesZ * gridSize
	local marginX = size.X - gridExtentX
	local marginY = size.Y - gridExtentY
	local marginZ = size.Z - gridExtentZ

	-- left / right along X
	if (marginX/2) >= MIN_PART_SIZE then
		spawnPartFromUnionLocalBox(union, Vector3.new(-size.X/2, -size.Y/2, -size.Z/2), Vector3.new(startX, size.Y/2, size.Z/2), outFolder)
		spawnPartFromUnionLocalBox(union, Vector3.new(startX + gridExtentX, -size.Y/2, -size.Z/2), Vector3.new(size.X/2, size.Y/2, size.Z/2), outFolder)
	end
	-- bottom / top along Y
	if (marginY/2) >= MIN_PART_SIZE then
		spawnPartFromUnionLocalBox(union, Vector3.new(-size.X/2, -size.Y/2, -size.Z/2), Vector3.new(size.X/2, startY, size.Z/2), outFolder)
		spawnPartFromUnionLocalBox(union, Vector3.new(-size.X/2, startY + gridExtentY, -size.Z/2), Vector3.new(size.X/2, size.Y/2, size.Z/2), outFolder)
	end
	-- back / front along Z
	if (marginZ/2) >= MIN_PART_SIZE then
		spawnPartFromUnionLocalBox(union, Vector3.new(-size.X/2, -size.Y/2, -size.Z/2), Vector3.new(size.X/2, size.Y/2, startZ), outFolder)
		spawnPartFromUnionLocalBox(union, Vector3.new(-size.X/2, -size.Y/2, startZ + gridExtentZ), Vector3.new(size.X/2, size.Y/2, size.Z/2), outFolder)
	end
end

-- process a union: clone -> separate -> collect negatives -> carve
local function processUnion(union)
	if not union or not union:IsA("UnionOperation") then return end
	print("Processing union:", union:GetFullName())

	local temp = Instance.new("Folder")
	temp.Name = "_separate_temp_" .. union.Name .. "_" .. tostring(math.random(1,9999))
	temp.Parent = Workspace

	local okClone, cloneUnion = pcall(function() return union:Clone() end)
	if not okClone or not cloneUnion then warn("Clone failed", union.Name); if temp.Parent then temp:Destroy() end; return end
	cloneUnion.Parent = temp

	local clones = separateCloneFullyAndCollect(cloneUnion, temp)
	print("Separated parts count (from clone):", #clones)

	-- collect negatives by name
	local negatives = {}
	for _, c in ipairs(clones) do
		if c and c:IsA("BasePart") and c.Name == "NegativePart" then
			table.insert(negatives, c)
		end
	end
	print(("Detected %d negative parts"):format(#negatives))

	-- heuristic fallback if none named negative
	if #negatives == 0 then
		local heur = {}
		for _, piece in ipairs(clones) do
			local pts = samplePointsForPart(piece)
			local inside = 0
			for _, sp in ipairs(pts) do
				if voxelIntersectsUnionSamples(union, {sp}) then inside = inside + 1 end
			end
			local frac = inside / math.max(1, #pts)
			if frac < POSITIVE_THRESHOLD then table.insert(heur, piece) end
		end
		negatives = heur
		print(("Heuristic negatives found: %d"):format(#negatives))
	end

	local out = Instance.new("Folder")
	out.Name = union.Name .. "_carved"
	out.Parent = union.Parent

	-- carve with optimized function
	carveUnionGloballyOptimized(union, negatives, GRID_SIZE, out)

	-- cleanup
	if union and union.Parent then union:Destroy() end
	if temp and temp.Parent then
		for _, c in ipairs(temp:GetChildren()) do c:Destroy() end
		temp:Destroy()
	end

	print("Done carving:", union.Name)
end

-- Use serverStorage float for grid size if present (your existing mechanism)
local function gridSizeCheck()
	if not serverStorage:FindFirstChild('Plugins') then
		local folder = Instance.new('Folder')
		folder.Name = 'Plugins'
		folder.Parent = serverStorage
	end
	if serverStorage.Plugins:FindFirstChild('GRID_SIZE_UNUNIONIFY') then
		GRID_SIZE = serverStorage.Plugins.GRID_SIZE_UNUNIONIFY.Value
	else
		local floatValue = Instance.new('NumberValue')
		floatValue.Name = 'GRID_SIZE_UNUNIONIFY'
		floatValue.Parent = serverStorage.Plugins
		floatValue.Value = GRID_SIZE
	end
end

-- toolbar click
btn.Click:Connect(function()
	gridSizeCheck()
	local sel = Selection:Get()
	if #sel == 0 then warn("Select union(s) in Explorer") return end
	for _, obj in ipairs(sel) do
		if obj and obj:IsA("UnionOperation") then
			processUnion(obj)
			-- small yield so Studio remains responsive for big jobs
			task.wait(0.05)
		else
			warn("Skipping non-union:", tostring(obj))
		end
	end
	print("All selected unions processed.")
end)
2 Likes

So, what does this do better than just using Separate in the Solid Modeling tab apart from keeping the negative holes?

Like don’t get me wrong keeping the holes made from negative parts is really useful, I just struggle to see what’s exactly better.

Hey, how possible would it be to repurpose this into a “mesh to (part collection to) union tool”?
I’ve got a weird use case that requires that, but changing “UnionOperation” to “MeshPart” in here before using it on a mesh causes what might be a memory leak. Any ideas?