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:
When the part is selected on the baseplate it is 1 block too low.
Some sides selected makes the part go inside the selected part.
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
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)
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
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)