Grid Snapping Issue [Bounty]

The bounty is 500R$.

The reason why I’m making a new post instead of using the old one is because it’s cluttered with replies and I don’t want to add a bounty onto all of that.

I’ve been having this issue for a long time, and I stopped working on the game about a year ago because of it and then finally decided to try again. I have the anchor point system so if the half size of the part is even, it’ll still work with the grid, the issue however is seen in the video below, it goes through blocks, and there is another issue where if placing on the negative sides of the blocks it goes through it.

Thanks.

function GetPlacementPosition()
	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 PartSize = Part.PrimaryPart.Size
	local PartCFrame = Part.PrimaryPart.CFrame

	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

This is not enough info to understand exactly how you want it to look, so I can’t look at the math and figure out what steps exist for what reasons. Could you provide a picture showing the anchor point, when the mouse lands on the target, and relevant distances you want to be held?


The right image shows the anchor point (a 2x2x2 block basically), which is fine, however it should look like the one on the left, in the code I have the anchor point is either on one side of the block or the center of it depending on if its half size is even or odd respectively.

What should happen in the right case, should it be slid along the normal until it fits?

In your main script, you need to keep track of when the player rotates the part. Then factor it’s current rotation into the script.

I can’t help you any further unless you provide more code.

Good luck!

It should look exactly like the left image, except its just rotated.

It looks like you account for the size of the part being placed on. You now need to do the same thing for the part being placed, except you will use the opposite face (since for two faces to touch they need to be opposite, right?), and you need to measure from the anchor point.

Heres all the code that should get it working, Part is set later on but it’s just a model with a primary part.

local UIS = game:GetService("UserInputService")
local Player = game.Players.LocalPlayer
local Camera = game.Workspace.CurrentCamera

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()
	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 PartSize = Part.PrimaryPart.Size
	local PartCFrame = Part.PrimaryPart.CFrame

	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

The red arrow is the only difference in the two situations, right? And its determined by the distance of the anchor point to the face on the opposite side (of the dragged part) from the face being moused over.

I could give it a shot and see what happens.

So, I tried adding the size corresponding to the world axis to it, and it somewhat worked, but the issue is that if you rotate it back, it’ll add that same gap to it.

Oh right I didn’t consider that you can rotate. You need to figure out which face is the opposite after the rotation has been applied. You could rotate the normal you used to find the face in the first place?

To help out a little more, I decided to make what I have into a place file.

The placement script: StarterPlayer > StarterPlayerScripts > Building > Placement

place.rbxl (115.0 KB)

place.rbxl (115.0 KB)
I took out the snapping in the process of getting it to work but you know how to add that back.

Ignoring the fact that you’re making a Plane Crazy clone, this can be solved using an iterated occlusion check, which can come in two different flavors:

  1. You scan the placement volume for colliding voxels, and you move the placement based on which quadrant of the volume the occupied voxel resides. This can be done by summing up the signs of the occupied voxel’s displacement relative to the center of the placement. Essentially, you want to nudge the placement away from the area with the most collisions, ensuring that the placement finds a suitable empty spot.
    Pseudocode:
foreach iteration:
  collision: {Vector3} = checkForCollisions(center)
  sumOfSigns: Vector3 = Vector3.zero
  for voxels in collision:
      disp = center - voxels
      sumOfSigns += disp:Sign()
  end
  center += sumOfSigns:Sign()
  1. You know what the surface normal of the adjacent block is, you can use that as the direction vector to slowly inch the placement away, repeat it until the placement no longer clips.

I have an old project that does both of those things, in the same order, which I have a video demo of:

Code sample:

@native local function rectifyPlacement(vcr: pPlot.VoxelCastResult): Vector3?
	local io: pPlot.PlayerInterface = plot.IO
	local pType: pReg.GenericEntry? = io.GhostType
	
	if pType and io.GhostRotation then
		
		--scalable parts are always 1x1x1, so they dont need to be rectified
		if pType.Part then 
			return vcr.Voxel + vcr.Normal
		end
		
		--c0 and c1 are the corners of the bounds of the part.
		local c0: Vector3 = pType.C0::Vector3
		local c1: Vector3 = pType.C1::Vector3
		local result: Vector3 = vcr.Voxel --voxel position of the center of the placement; initialized to be where the mouse is aiming at.
		
		--rotate the bounds based on the orientation of the placement preview
		local rot: Vector3 = pFuncs.AngleToRA(io.GhostRotation)
		local b0: Vector3 = pFuncs.RotVecRA(c0, rot)
		local b1: Vector3 = pFuncs.RotVecRA(c1, rot)
		local partSize: Vector3 = c1 - c0
		
		local maxTries: number = math.max(partSize.X, partSize.Y, partSize.Z) + 10
		local c0, c1: Vector3
		local querySuccess: boolean

		local timeOut: number = 0
		while true do --this is where the occlusion check happens
			timeOut += 1
			if timeOut > maxTries then break end

			c0 = result + b0
			c1 = result + b1
			local obstructions: Tensor.TensorHash<true> --this is just a hashmap in the form {[Vector3]: true}
			querySuccess, obstructions = plot:GetCollisions(c0, c1) --finds all colliding voxels within the c0 and c1 bounds

			if querySuccess then break end --no more collisions detected; we can stop now
			
			--otherwise, if there are collisions, move the placement AWAY from occupied voxels
			local moveTowards: Vector3 = c0:Lerp(c1, .5)
			local totalMove: Vector3 = Vector3.zero
			for obstacle: Vector3 in obstructions do
				totalMove += (moveTowards - obstacle):Sign()
			end
			result += totalMove:Sign()
		end
		
		--if the occlusion check above fails, here's another one
		if not querySuccess then
			result = vcr.Voxel
			c0 = result + b0
			c1 = result + b1

			timeOut = 0
			while true do
				timeOut += 1
				if timeOut > maxTries then break end

				c0 = result + b0
				c1 = result + b1
				querySuccess = plot:CanPlace(c0, c1) --:CanPlace() is a wrapper for :GetCollisions()

				if querySuccess then break end

				result += vcr.Normal --move away from the face that the mouse is aiming at
			end
		end

		if not querySuccess then
			--print('smart placement failed')
			result = vcr.Voxel
			c0 = result + b0
			c1 = result + b1
		end
		
		return result
	end
	return
end