From Scratch

Brief Deconstruction of Snapping in CAD-like Web Drawing

A Series of Articles Covering the Development of Various Snapping Systems for 3D Drawing in Three.js and Typescript

Daniil Rychkov
5 min readAug 6, 2023

Part 1. Grid snapping

Theory and Algorithm

Let’s begin with the simplest type of drawing snapping — grid snapping. The whole idea of grid snapping can be described as follows — we have some sort of a helper grid projected onto a plane, and the moving pointer ‘snaps’ or ‘jumps’ to the nearest grid node. In other words, the pointer’s coordinates values are rounded up to match the nearest values of the grid nodes’ coordinates. The grid can be of any configuration, but for my case, I don’t see any preference for any grids other than rectangular.

Imagine the creation of a line as a continuous sequence of pointer clicks and movements. Breaking down this process into a set of cyclical steps:

  1. On each pointer move:
  • Getting the current values of the pointer’s coordinates projected onto the base surface (the surface we are working on, referred to as ‘ground’).
  • Calculating the coordinates of the nearest grid node.
  • Visually displaying the grid node as the exact location to which the pointer has been ‘snapped’.

2. On each click performed:

  • The snapped values are used to build up an object or its part, and the actual coordinates of the pointer location are ignored.

What is the way to calculate ‘snapped’ values for initial X and Y coordinates given a grid size?

First of all, let’s establish that we have a square grid with cells of equal width and height. Now, let’s define the inputs:

Cell Size: S
Current pointer coordinates: X and Y

The desired output is to find coordinates X’ and Y’ that satisfy two conditions:

  • matching grid nodes’ coordinates
  • being nearest to the initial ones

Let’s use some values to illustrate the calculation process:

X, Y = 37, 82
S = 10

It wouldn’t take us long to calculate the coordinates of the nearest grid node. What’s the closest number to 37 that is also divisible by 10? 40. What’s the closest number to 82 that is divided by 10? 80:

X', Y' = 40, 80.

Now, let’s try to come up with an algorithm to find the nearest value that is divisible by 10 (our grid size) without any remainder. There will always be two numbers that are divisible by 10 and closest to the initial value — one is greater, and one is smaller. We need to find them and compare the difference. The needed value is the one that is closest to the initial value.

For example, take 37 — the two numbers closest to 37 and divisible by 10 without a remainder are 30 and 40. We need the closest one, which is the number that, when subtracted from the initial value, gives a smaller result than the other.

In general, this problem can be referred to as “finding the number closest to N and divisible by M”. In our case, N represents the initial coordinate (X or Y), and M represents the size of the grid (S). The solution can be narrowed down to the following steps:

  • Find Q = X/S (quotient of X and S)
Q = X/S = 37/10 = 3.7
  • Round this value Q to the nearest integer
Q' = round(Q) = 4
  • Multiply the rounded integer Q’ by S
X' = Q' * S = 4 * 10 = 40
  • Do the same for Y to get the resulting coordinates:
Y' = round(Y/S) * S = round(82/10) * 10 = 80

This algorithm helps us find the snapped coordinates (X’ and Y’), which are the nearest values to the initial coordinates (37, 82) that match the grid nodes.

Implementation

Implementation of grid snapping is extremely easy because it doesn’t include any vector calculations, but simple arithmetic operations.

Calculation for general case itself is just one single line of code:

Math.round(coord / gridSize) * gridSize

Where ‘round’ — rounds to the nearest integer, the result of division of the original coordinate value by the grid size value. That result is then multiplied by the grid size value.

In my case this is a part of a function that is a class method — part of snapping manger:

  private _snapToGrid = (pointerCoords: THREE.Vector3): void => {
//as this is not the only snapping mode which can be active
//need to clone coordinates not to mess with the original value
const newCoords = pointerCoords.clone();
//getting grid size
const grid = this.helpersModel._getItem(InstrumentHelpersId.SNAP_GRID);
if (!grid) throw new Error('SnapManager: Cant find grid helper options');
const gridSize = grid.options.value;

//declaration of function which calculates new values and returns them
const getCoordByOriginAndGridSize = (coord: number, gridSize: number): number => {
if (gridSize < 1) {
return Math.round(coord * 2) / 2;
} else return Math.round(coord / gridSize) * gridSize;
};

//calling this function and assigning new values to respective properties
newCoords.x = getCoordByOriginAndGridSize(newCoords.x, gridSize);
newCoords.z = getCoordByOriginAndGridSize(newCoords.z, gridSize);

//checking distance to initial coords for this snapping type (used for selecting snapped values when there are several snappings active)
const distanceToCurrent = pointerCoords.distanceTo(newCoords);
this.snapStatuses.snap_grid.distToOrigin = distanceToCurrent;
this.snapStatuses.snap_grid.snappedCoords = newCoords;
return;
};

Results and Discussion

Here are some things to keep and mind and things that can be improved.

In the algorithm part, there are a few cases that are not explicitly covered. Specifically, cases with grid sizes smaller than 1 and non-integer grid sizes are not addressed, except for one instance in the code that covers grid size = 0.5.

In the implementation, we can optimize the process by reducing unnecessary addressing the grid parameter when computing snapped coordinates on every move. Since the grid parameters are likely to remain constant during drawing, it’s better to initialize the drawing with predefined grid parameters. If there’s a need to change grid options during drawing, we can use a subscription mechanism to handle such changes efficiently.

The result of the work of snapping manager with grid snapping in Three.js:

Bonus: video version!

Daniil Rychkov, aug 2023

--

--