I need help making my grid build system to snap onto parts correctly

I am making a grid/block building system and I got the placement to work, but the way the block snaps onto the selected part doesnt work well.

Theres a few problems shown in the video:

  1. When the part is selected on the baseplate it is 1 block too low.
  2. Some sides selected makes the part go inside the selected part.
  3. It doesnt work on rotated parts

This is the block placement script (localscript):

local UserInputService = game:GetService("UserInputService")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local player = Players.LocalPlayer
local mouse = player:GetMouse()
local ghostTemplate = ReplicatedStorage:WaitForChild("GhostPart")

local ghostPart = ghostTemplate:Clone()
ghostPart.Anchored = true
ghostPart.CanCollide = false
ghostPart.Transparency = 0.5
ghostPart.Parent = workspace
mouse.TargetFilter = ghostPart

local gridSize = 2

local function roundToGrid(value)
	return math.floor((value + gridSize / 2) / gridSize) * gridSize
end

RunService.RenderStepped:Connect(function()
	local mouseHit = mouse.Hit.Position
	local snappedPos = Vector3.new(
		roundToGrid(mouseHit.X),
		roundToGrid(mouseHit.Y),
		roundToGrid(mouseHit.Z)
	)
	ghostPart.CFrame = CFrame.new(snappedPos)
end)

mouse.Button1Down:Connect(function()
	local mouseHit = mouse.Hit.Position
	local snappedPos = Vector3.new(
		roundToGrid(mouseHit.X),
		roundToGrid(mouseHit.Y),
		roundToGrid(mouseHit.Z)
	)
	local placedPart = ghostTemplate:Clone()
	placedPart.Anchored = true
	placedPart.CanCollide = true
	placedPart.Transparency = 0
	placedPart.CFrame = CFrame.new(snappedPos)
	placedPart.Parent = workspace
end)

I tried making the part a model (which I need to do eventually anyways) and using pivot to place the part ontop but it has the same result.

I’ve tried making a grid build system like this multiple times in the past but I can never figure out how to fix this issue so any help is appreciated!

I have answered this exact question many many times so please trust what I tell you to do:
Use Raycast instead of mouse.Hit, this gives you the Position but also the Normal vector of the surface moused over. Theres an example in the documentation. Add a little bit of this normal to the position before you do your snapping.
Additionally if you use math.round instead of floor you wont need to add gridSize / 2

1 Like

I dont know why i never thought of using raycasts lol, ill try that out once I get back to my pc

Alright, I almost got it to work.

The parts go too far by 1 stud and it doesnt match rotation properly.

Can you show what the code looks like currently?

local UserInputService = game:GetService("UserInputService")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Workspace = game:GetService("Workspace")

local player = Players.LocalPlayer
local mouse = player:GetMouse()
local camera = Workspace.CurrentCamera

local ghostTemplate = ReplicatedStorage:WaitForChild("GhostPart")
local matchRotation = true

local gridSize = 2

local ghostPart = ghostTemplate:Clone()
ghostPart.Anchored = true
ghostPart.CanCollide = false
ghostPart.Transparency = 0.5
ghostPart.Name = "GhostPart"
ghostPart.Parent = workspace
mouse.TargetFilter = ghostPart

local function roundToGrid(value)
	return math.round(value / gridSize) * gridSize
end

local function getRotationCFrame(position: Vector3, normal: Vector3)
	local up = normal
	local forward = (camera.CFrame.Position - position).Unit
	local right = forward:Cross(up).Unit
	forward = up:Cross(right).Unit

	return CFrame.fromMatrix(position, right, up)
end

local function getPlacementCFrame()
	local origin = camera.CFrame.Position
	local direction = (mouse.Hit.Position - origin).Unit * 1000

	local rayParams = RaycastParams.new()
	rayParams.FilterDescendantsInstances = {player.Character, ghostPart}
	rayParams.FilterType = Enum.RaycastFilterType.Exclude

	local result = workspace:Raycast(origin, direction, rayParams)
	if not result then return nil end

	local hitPos = result.Position
	local normal = result.Normal
	local target = result.Instance
	local targetCF = target.CFrame

	local objectSpaceNormal = targetCF:VectorToObjectSpace(normal)
	local size = target.Size
	local offset = Vector3.new(
		objectSpaceNormal.X >= 0 and size.X / 2 or -size.X / 2,
		objectSpaceNormal.Y >= 0 and size.Y / 2 or -size.Y / 2,
		objectSpaceNormal.Z >= 0 and size.Z / 2 or -size.Z / 2
	)
	local worldOffset = targetCF:VectorToWorldSpace(offset)

	local localHit = targetCF:PointToObjectSpace(hitPos + normal * 0.01)
	local snappedLocal = Vector3.new(
		roundToGrid(localHit.X),
		roundToGrid(localHit.Y),
		roundToGrid(localHit.Z)
	)
	local snappedWorldPos = targetCF:PointToWorldSpace(snappedLocal) + normal * (gridSize / 2)

	if matchRotation then
		local up = normal
		local forward

		if math.abs(normal:Dot(Vector3.new(0, 1, 0))) > 0.9 then
			forward = targetCF.LookVector
		else
			forward = (camera.CFrame.Position - snappedWorldPos).Unit
			forward = Vector3.new(forward.X, 0, forward.Z).Unit
			if forward.Magnitude == 0 then forward = Vector3.new(0, 0, -1) end
		end

		local right = forward:Cross(up).Unit
		forward = up:Cross(right).Unit

		return CFrame.fromMatrix(snappedWorldPos, right, up)
	else
		return CFrame.new(snappedWorldPos)
	end
end

RunService.RenderStepped:Connect(function()
	local cf = getPlacementCFrame()
	if cf then
		ghostPart.CFrame = cf
	end
end)

mouse.Button1Down:Connect(function()
	local cf = getPlacementCFrame()
	if cf then
		local newPart = ghostTemplate:Clone()
		newPart.Anchored = true
		newPart.CanCollide = true
		newPart.Transparency = 0
		newPart.CFrame = cf
		newPart.Parent = workspace
	end
end)

Try this, I believe it should fix the issue.

local finalPos = snappedWorldPos + worldOffset

if matchRotation then
	local up = normal
	local forward

	if math.abs(normal:Dot(Vector3.new(0, 1, 0))) > 0.9 then
		forward = targetCF.LookVector
	else
		forward = (camera.CFrame.Position - finalPos).Unit
		forward = Vector3.new(forward.X, 0, forward.Z).Unit
		if forward.Magnitude == 0 then forward = Vector3.new(0, 0, -1) end
	end

	local right = forward:Cross(up).Unit
	forward = up:Cross(right).Unit

	return CFrame.fromMatrix(finalPos, right, up)
else
	return CFrame.new(finalPos)
end

The part doesnt align correctly when I try this:

Currently praying for you that ChatGPT can solve this issue for you. :wilted_flower:

I almost got it. It snaps perfectly when placing on the baseplate and spawn location but isnt properly snapped on other parts with different sizes.

local UserInputService = game:GetService("UserInputService")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Workspace = game:GetService("Workspace")

local player = Players.LocalPlayer
local mouse = player:GetMouse()
local camera = Workspace.CurrentCamera

local ghostTemplate = ReplicatedStorage:WaitForChild("GhostPart")
local matchRotation = true
local gridSize = 1

local ghostPart = ghostTemplate:Clone()
ghostPart.Anchored = true
ghostPart.CanCollide = false
ghostPart.Transparency = 0.5
ghostPart.Name = "GhostPart"
ghostPart.Parent = workspace
mouse.TargetFilter = ghostPart

local function roundToGrid(value)
	return math.round(value / gridSize) * gridSize
end

local function getPlacementCFrame()
	local origin = camera.CFrame.Position
	local direction = (mouse.Hit.Position - origin).Unit * 1000

	local rayParams = RaycastParams.new()
	rayParams.FilterDescendantsInstances = {player.Character, ghostPart}
	rayParams.FilterType = Enum.RaycastFilterType.Exclude

	local result = workspace:Raycast(origin, direction, rayParams)
	if not result then return nil end

	local hitPos = result.Position
	local normal = result.Normal
	local target = result.Instance

	local up = normal
	local forward
	if math.abs(normal:Dot(Vector3.new(0, 1, 0))) > 0.9 then
		forward = target.CFrame.LookVector
	else
		forward = (camera.CFrame.Position - hitPos)
		forward = Vector3.new(forward.X, 0, forward.Z)
		if forward.Magnitude == 0 then forward = Vector3.new(0, 0, -1) end
		forward = forward.Unit
	end
	local right = forward:Cross(up).Unit
	forward = up:Cross(right).Unit

	local ghostSize = ghostPart.Size
	local offset = ghostSize.Y / 2
	local normalAbs = Vector3.new(math.abs(normal.X), math.abs(normal.Y), math.abs(normal.Z))
	if normalAbs.X > normalAbs.Y and normalAbs.X > normalAbs.Z then
		offset = ghostSize.X / 2
	elseif normalAbs.Z > normalAbs.X and normalAbs.Z > normalAbs.Y then
		offset = ghostSize.Z / 2
	end

	local exactPos = hitPos + normal * offset

	local snappedPos = Vector3.new(
		roundToGrid(exactPos.X),
		roundToGrid(exactPos.Y),
		roundToGrid(exactPos.Z)
	)

	return CFrame.fromMatrix(snappedPos, right, up)
end

RunService.RenderStepped:Connect(function()
	local cf = getPlacementCFrame()
	if cf then
		ghostPart.CFrame = cf
	end
end)

mouse.Button1Down:Connect(function()
	local cf = getPlacementCFrame()
	if cf then
		local newPart = ghostTemplate:Clone()
		newPart.Anchored = true
		newPart.CanCollide = true
		newPart.Transparency = 0
		newPart.CFrame = cf
		newPart.Parent = workspace
	end
end)

It almost solved the problem, I got it to snap ontop of parts but only if they are aligned in the grid properly. I need to figure out how to make a grid orgin so it bases it off the part position instead of just (0,0,0);

local UserInputService = game:GetService("UserInputService")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Workspace = game:GetService("Workspace")

local player = Players.LocalPlayer
local mouse = player:GetMouse()
local camera = Workspace.CurrentCamera

local ghostTemplate = ReplicatedStorage:WaitForChild("GhostPart")
local matchRotation = true
local gridSize = 1

local ghostPart = ghostTemplate:Clone()
ghostPart.Anchored = true
ghostPart.CanCollide = false
ghostPart.Transparency = 0.5
ghostPart.Name = "GhostPart"
ghostPart.Parent = workspace
mouse.TargetFilter = ghostPart

local function roundToGrid(value)
	return math.round(value / gridSize) * gridSize
end

local function getPlacementCFrame()
	local origin = camera.CFrame.Position
	local direction = (mouse.Hit.Position - origin).Unit * 1000

	local rayParams = RaycastParams.new()
	rayParams.FilterDescendantsInstances = {player.Character, ghostPart}
	rayParams.FilterType = Enum.RaycastFilterType.Exclude

	local result = workspace:Raycast(origin, direction, rayParams)
	if not result then return nil end

	local hitPos = result.Position
	local normal = result.Normal
	local target = result.Instance

	local up = normal
	local forward
	if math.abs(normal:Dot(Vector3.new(0, 1, 0))) > 0.9 then
		forward = target.CFrame.LookVector
	else
		forward = (camera.CFrame.Position - hitPos)
		forward = Vector3.new(forward.X, 0, forward.Z)
		if forward.Magnitude == 0 then forward = Vector3.new(0, 0, -1) end
		forward = forward.Unit
	end
	local right = forward:Cross(up).Unit
	forward = up:Cross(right).Unit

	local ghostSize = ghostPart.Size
	local offset = ghostSize.Y / 2
	local normalAbs = Vector3.new(math.abs(normal.X), math.abs(normal.Y), math.abs(normal.Z))
	if normalAbs.X > normalAbs.Y and normalAbs.X > normalAbs.Z then
		offset = ghostSize.X / 2
	elseif normalAbs.Z > normalAbs.X and normalAbs.Z > normalAbs.Y then
		offset = ghostSize.Z / 2
	end

	local exactPos = hitPos + normal * offset

	local snappedPos = Vector3.new(
		roundToGrid(exactPos.X),
		roundToGrid(exactPos.Y),
		roundToGrid(exactPos.Z)
	)

	return CFrame.fromMatrix(snappedPos, right, up)
end

RunService.RenderStepped:Connect(function()
	local cf = getPlacementCFrame()
	if cf then
		ghostPart.CFrame = cf
	end
end)

mouse.Button1Down:Connect(function()
	local cf = getPlacementCFrame()
	if cf then
		local newPart = ghostTemplate:Clone()
		newPart.Anchored = true
		newPart.CanCollide = true
		newPart.Transparency = 0
		newPart.CFrame = cf
		newPart.Parent = workspace
	end
end)