Placement Grid Issue

I’m currently trying to work on a grid for building, it works mostly fine, the issue comes whenever you introduce different sized models (2x4, 2x8, etc).

It’s fine if the block is say 2x6 because it would look fine if centered, but for blocks that can’t be centered should have its moving point to one side of it (think of a 2x2 block on the left side of a 2x8 block).

Another issue is snapping to other blocks, a 2x2 block works fine, however any other size would go inside it, what I want to happen is to have that block snap to it.

Here’s what it looks like a 2x2 block and a 2x4 block.

I have looked around a bit but couldn’t really find anything related to my issue, what I have tried is offsetting it if the half value was odd but that didn’t work out (or i did it wrong).

local uis = game:GetService("UserInputService")

local player = game.Players.LocalPlayer
local part = nil
local gridSize = 2

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

function snapToGrid(position)
	return Vector3.new(
		roundToGrid(position.X),
		roundToGrid(position.Y),
		roundToGrid(position.Z)
	)
end

function GetPlacementPosition(partSize)
	local mouseLocation = uis:GetMouseLocation()
	local viewportRay = Camera:ViewportPointToRay(mouseLocation.X, mouseLocation.Y)

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

	local result = workspace:Raycast(viewportRay.Origin, viewportRay.Direction * 1000, raycastParams)
	
	if not result then return nil end
	
	local halfSize = partSize / 2
	
	local offset = Vector3.new(
		math.abs(result.Normal.X) * halfSize.X,
		math.abs(result.Normal.Y) * halfSize.Y,
		math.abs(result.Normal.Z) * halfSize.Z
	)
	
	local rawPosition = result.Position + (result.Normal * offset)
	local snappedPosition = snapToGrid(rawPosition)

	return snappedPosition
end

First of all define which side of the block is the anchor for snapping (offset from center).
And snap the anchor to the grid, then calculate center position.
Also when snapping to other blocks, calculate the combined half sizes to position blocks edge-to-edge.
Use consistent offsets to avoid overlapping.

local gridSize = 2

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

function snapToGrid(vec)
    return Vector3.new(roundToGrid(vec.X), roundToGrid(vec.Y), roundToGrid(vec.Z))
end

-- Calculate placement considering anchor offset (anchorOffset = Vector3)
function GetPlacementPosition(hitPosition, anchorOffset)
    -- Snap anchor position to grid
    local snappedAnchor = snapToGrid(hitPosition)

    -- Calculate center position from snapped anchor
    local centerPos = snappedAnchor + anchorOffset
    return centerPos
end

heres an example



local blockSize = Vector3.new(2, 4, 8) -- your block size

-- Define anchor offset (for example, left edge on X-axis)
local anchorOffset = Vector3.new(blockSize.X / 2, 0, 0) * Vector3.new(-1, 0, 0)

-- Suppose you got your raycast hit position 'hitPos'
local hitPos = Vector3.new(10, 0, 20) 

local placementPos = GetPlacementPosition(hitPos, anchorOffset)

-- Set your block position to placementPos

this doesnt appear to work with other rotations though and breaks the other blocks as seen here

oh yeah i forgot, here it is with the rotation axis

local blockSize = Vector3.new(2, 4, 8) -- Block size

-- Define anchor offset for alignment (e.g., left edge)
local anchorOffset = Vector3.new(blockSize.X / 2, 0, 0) * Vector3.new(-1, 0, 0)

-- Function to get placement position based on hit position, block rotation, and offset
local function GetPlacementPosition(hitPos, blockCFrame, anchorOffset)
    -- Rotate the offset to align with the block's orientation
    local rotatedOffset = blockCFrame:VectorToWorldSpace(anchorOffset)
    
    -- Calculate the final placement position
    local placementPos = hitPos + rotatedOffset
    return placementPos
end

-- Suppose you got your raycast hit position and the desired block orientation
local hitPos = Vector3.new(10, 0, 20) -- Example raycast hit position
local blockOrientation = CFrame.Angles(0, math.rad(45), 0) -- Example block rotation (45 degrees on Y-axis)

-- Compute the block placement position
local placementPos = GetPlacementPosition(hitPos, blockOrientation, anchorOffset)

-- Debug output
print("Placement Position:", placementPos)

No way, i have a very simmilar issue
Heres the code that i have


local CosmeticFolder: Folder=workspace:FindFirstChild("CosmeticWorkspaceFolder") or Instance.new("Folder",workspace)
CosmeticFolder.Name="CosmeticWorkspaceFolder"

local ReplicatedStorage:ReplicatedStorage=game:GetService("ReplicatedStorage")

local BlockStorageFolder:Folder=ReplicatedStorage:FindFirstChild("BlockStorage") or Instance.new("Folder",ReplicatedStorage)
BlockStorageFolder.Name="BlockStorage"

local BlockStore:Folder=BlockStorageFolder:FindFirstChild("Blocks") or Instance.new("Folder",BlockStorageFolder)
BlockStore.Name="Blocks"

local HeartBeat=game:GetService("RunService").Heartbeat

local Tool=script.Parent
local ToolEquipped=false

local Players:Players=game:GetService("Players")

local Player:Player=Players.LocalPlayer
local Character:Model=Player.Character or Player.CharacterAdded:Wait()
local Handle:BasePart=Tool:WaitForChild("Handle")
local Mouse:Mouse=Player:GetMouse()
local cam=workspace.CurrentCamera or workspace:WaitForChild("Camera")

local ReplicatedStorage=game:GetService("ReplicatedStorage")
local BlockDefinitions=require(ReplicatedStorage:WaitForChild("BlockDefinitions"))

local Event:RemoteEvent=Tool:WaitForChild("PlaceBlockEvent")

local PreviewBlock:BasePart=BlockDefinitions.Preview
local CurrentSelectedBlock:BasePart=BlockDefinitions.Wood

local GridSize=script:WaitForChild("GridSize").Value
local MaxBuildDist=script:WaitForChild("MaxDistance").Value
local function OnGridSizeChanged()
	GridSize=script.GridSize.Value
	CurrentSelectedBlock.Size=Vector3.new(GridSize,GridSize,GridSize)
	PreviewBlock.Size=Vector3.new(GridSize+.1,GridSize+.1,GridSize+.1)
end
local function OnMaxDistChanged()
	MaxBuildDist=script.MaxDistance.Value
end
script.GridSize:GetPropertyChangedSignal("Value"):Connect(OnGridSizeChanged)
script.MaxDistance:GetPropertyChangedSignal("Value"):Connect(OnMaxDistChanged)

CurrentSelectedBlock.Size=Vector3.new(GridSize,GridSize,GridSize)
PreviewBlock.Size=Vector3.new(GridSize+.1,GridSize+.1,GridSize+.1)

local function Snap(Position:Vector3,SnapAmount:number,Offset:Vector3)
	if Offset~=nil then
		Position+=Offset
	end
	local X=math.floor((Position.X+(SnapAmount/2))/SnapAmount)*SnapAmount
	local Y=math.floor((Position.Y+(SnapAmount/2))/SnapAmount)*SnapAmount
	local Z=math.floor((Position.Z+(SnapAmount/2))/SnapAmount)*SnapAmount
	--local NewBlockPos = Vector3.new(math.round(X*1000)/1000,math.round(Y*1000)/1000,math.round(Z*1000)/1000) -- Removes floating point imprecision.
	local NewBlockPos=Vector3.new(X,Y,Z) 
	return NewBlockPos
end

local MouseMovedConnection=nil
--local LastMousePos=Vector3.new(99999,99999,99999)

local RayParams=RaycastParams.new()
RayParams.FilterDescendantsInstances={Character,PreviewBlock}
RayParams.FilterType=Enum.RaycastFilterType.Exclude
RayParams.RespectCanCollide=false
RayParams.IgnoreWater=true

local function MousePosFunction()	--heres the good juicy math you want
	local MousePos=Mouse.Hit.Position
	
	local Orientation=nil
	local Unit=(Mouse.Hit.Position-cam.CFrame.Position).Unit
	--local NewRaycast=workspace:Raycast(cam.CFrame.Position,Unit*MaxBuildDist,RayParams)
	local NewRaycast=workspace:Spherecast(cam.CFrame.Position,GridSize/2,Unit*MaxBuildDist,RayParams)
	if NewRaycast~=nil then
		if NewRaycast.Position~=nil then
			MousePos=NewRaycast.Position
			if NewRaycast.Normal~=nil then
				if Mouse.Target~=nil then
					Orientation=Mouse.Target.CFrame.LookVector
					MousePos=Snap((NewRaycast.Position+Vector3.new(0,-GridSize/2))+NewRaycast.Normal*GridSize/2,GridSize,Mouse.Target.Position.Unit)
				else
					MousePos=Snap((NewRaycast.Position+Vector3.new(0,-GridSize/2))+NewRaycast.Normal*GridSize/2,GridSize)
				end	
			end
		else
			MousePos=cam.CFrame.Position+(Unit*MaxBuildDist)
		end
	else
		MousePos=cam.CFrame.Position+(Unit*MaxBuildDist)
	end
	if Orientation~=nil then
		PreviewBlock.CFrame=CFrame.new(MousePos,MousePos+Orientation*5)
	else
		PreviewBlock.CFrame=CFrame.new(MousePos)
	end	
	--PreviewBlock.Position=Snap(MousePos,GridSize)
end

local function OnMouseMoved()
	MousePosFunction()
	HeartBeat:Wait() HeartBeat:Wait() HeartBeat:Wait() HeartBeat:Wait() --X4  --Only 15? operations per second to not make your phone overheat af. (Please dont ban me.)	
	MouseMovedConnection=Mouse.Move:Once(OnMouseMoved)
end

local function OnToolEquipped()
	PreviewBlock.Parent=CosmeticFolder
	MouseMovedConnection=Mouse.Move:Once(OnMouseMoved)
end

local function OnToolUnequipped()
	PreviewBlock.Parent=BlockStore	
	if MouseMovedConnection~=nil then
		MouseMovedConnection:Disconnect()
		MouseMovedConnection=nil
	end	
end

local MouseDown:boolean=false
local Id:number=0

local function OnToolActivated()
	MouseDown=true
	Id+=1
	local OldId=Id
	while OldId==Id and MouseDown==true do
		MousePosFunction()
		Event:FireServer(Player.Name,PreviewBlock.CFrame,workspace.CurrentCamera.CFrame or Handle.CFrame,CurrentSelectedBlock.Name,GridSize)--just tells the server to place a block just ignore this
		MousePosFunction()
		task.wait(Tool.ContinuousPlacingInterval.Value or .35)
	end
end

local function OnToolDeactivated()
	MouseDown=false
end
--strategic_oof

Tool.Equipped:Connect(OnToolEquipped)
Tool.Unequipped:Connect(OnToolUnequipped)

Tool.Activated:Connect(OnToolActivated)
Tool.Deactivated:Connect(OnToolDeactivated)

Tool.Destroying:Once(OnToolDeactivated)
Tool.Destroying:Once(OnToolUnequipped)
--strategic_oof

I just dropped this because people in my game would of already deobfiscated this script with exploits eventually

still didnt work, the 2x4x2 block is floating, and the 2x2x2 block is offset from the mouse by a stud

Update, I have made some more progress towards it, however there are a couple issues still, it goes through other blocks when rotated, and when moving the mouse over the negative sides of the block it goes inside of it.

local uis = game:GetService("UserInputService")

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

local part = nil
local gridsize = 2

local function roundToGrid(number)
	return math.floor((number + gridsize / 2) / gridsize) * gridsize
end

local function snapToGrid(position)
	return Vector3.new(
		roundToGrid(position.X),
		roundToGrid(position.Y),
		roundToGrid(position.Z)
	)
end

local function getPlacementPosition(partSize, partCFrame)
	local mouseLocation = uis:GetMouseLocation()
	local viewportRay = camera:ViewportPointToRay(mouseLocation.X, mouseLocation.Y)

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

	local result = workspace:Raycast(viewportRay.Origin, viewportRay.Direction * 1000, raycastParams)

	if not result then return nil end

	local halfSize = partSize / 2

	local localNormal = partCFrame:VectorToObjectSpace(result.Normal)

	local anchorDirection = Vector3.new(
		math.round(localNormal.X),
		math.round(localNormal.Y),
		math.round(localNormal.Z)
	)

	local worldAnchorDirection = partCFrame:VectorToWorldSpace(anchorDirection)

	local shouldOffset = Vector3.new(1, 1, 1) - Vector3.new(
		halfSize.X % 2,
		halfSize.Y % 2,
		halfSize.Z % 2
	)

	local anchorOffset = Vector3.new(
		-halfSize.X + 1,
		-halfSize.Y + 1,
		-halfSize.Z + 1
	) * shouldOffset

	local anchorWorldPosition = partCFrame:PointToWorldSpace(anchorOffset)

	local snappedAnchor = snapToGrid(result.Position)

	local finalPosition = snappedAnchor - partCFrame:VectorToWorldSpace(anchorOffset)

	return finalPosition
end