Move grid snapping to hook and added math for hex snapping

This commit is contained in:
Mitchell McCaffrey 2021-02-06 19:29:24 +11:00
parent f20173de35
commit fbea8bd7a7
7 changed files with 293 additions and 151 deletions

View File

@ -5,7 +5,9 @@ import {
getCellLocation, getCellLocation,
gridClipFunction, gridClipFunction,
shouldClipCell, shouldClipCell,
getNearestCellCoordinates,
} from "../helpers/grid"; } from "../helpers/grid";
import Vector2 from "../helpers/Vector2";
import { useGrid } from "../contexts/GridContext"; import { useGrid } from "../contexts/GridContext";
@ -22,6 +24,8 @@ function Grid({ strokeWidth, stroke }) {
return null; return null;
} }
const negativeGridOffset = Vector2.multiply(gridOffset, -1);
const shapes = []; const shapes = [];
if (grid.type === "square") { if (grid.type === "square") {
for (let x = 1; x < grid.size.x; x++) { for (let x = 1; x < grid.size.x; x++) {
@ -37,7 +41,7 @@ function Grid({ strokeWidth, stroke }) {
stroke={stroke} stroke={stroke}
strokeWidth={gridStrokeWidth * strokeWidth} strokeWidth={gridStrokeWidth * strokeWidth}
opacity={0.5} opacity={0.5}
offset={gridOffset} offset={negativeGridOffset}
/> />
); );
} }
@ -54,7 +58,7 @@ function Grid({ strokeWidth, stroke }) {
stroke={stroke} stroke={stroke}
strokeWidth={gridStrokeWidth * strokeWidth} strokeWidth={gridStrokeWidth * strokeWidth}
opacity={0.5} opacity={0.5}
offset={gridOffset} offset={negativeGridOffset}
/> />
); );
} }
@ -73,7 +77,7 @@ function Grid({ strokeWidth, stroke }) {
} }
x={cellLocation.x} x={cellLocation.x}
y={cellLocation.y} y={cellLocation.y}
offset={gridOffset} offset={negativeGridOffset}
> >
<RegularPolygon <RegularPolygon
sides={6} sides={6}
@ -82,6 +86,16 @@ function Grid({ strokeWidth, stroke }) {
strokeWidth={gridStrokeWidth * strokeWidth} strokeWidth={gridStrokeWidth * strokeWidth}
opacity={0.5} opacity={0.5}
rotation={grid.type === "hexVertical" ? 0 : 90} rotation={grid.type === "hexVertical" ? 0 : 90}
onMouseDown={() => {
console.log(
getNearestCellCoordinates(
grid,
cellLocation.x,
cellLocation.y,
gridCellPixelSize
)
);
}}
/> />
</Group> </Group>
); );

View File

@ -7,8 +7,7 @@ import Konva from "konva";
import useDataSource from "../../hooks/useDataSource"; import useDataSource from "../../hooks/useDataSource";
import useDebounce from "../../hooks/useDebounce"; import useDebounce from "../../hooks/useDebounce";
import usePrevious from "../../hooks/usePrevious"; import usePrevious from "../../hooks/usePrevious";
import useGridSnapping from "../../hooks/useGridSnapping";
import { snapNodeToGrid } from "../../helpers/grid";
import { useAuth } from "../../contexts/AuthContext"; import { useAuth } from "../../contexts/AuthContext";
import { useMapInteraction } from "../../contexts/MapInteractionContext"; import { useMapInteraction } from "../../contexts/MapInteractionContext";
@ -52,6 +51,8 @@ function MapToken({
} }
}, [tokenSourceImage]); }, [tokenSourceImage]);
const snapNodeToGrid = useGridSnapping(snappingThreshold);
function handleDragStart(event) { function handleDragStart(event) {
const tokenGroup = event.target; const tokenGroup = event.target;
const tokenImage = imageRef.current; const tokenImage = imageRef.current;
@ -88,13 +89,7 @@ function MapToken({
const tokenGroup = event.target; const tokenGroup = event.target;
// Snap to corners of grid // Snap to corners of grid
if (map.snapToGrid) { if (map.snapToGrid) {
snapNodeToGrid( snapNodeToGrid(tokenGroup);
map.grid,
mapWidth,
mapHeight,
tokenGroup,
snappingThreshold
);
} }
} }

View File

@ -6,10 +6,10 @@ import { useAuth } from "../../contexts/AuthContext";
import { useMapInteraction } from "../../contexts/MapInteractionContext"; import { useMapInteraction } from "../../contexts/MapInteractionContext";
import { useGrid } from "../../contexts/GridContext"; import { useGrid } from "../../contexts/GridContext";
import { snapNodeToGrid } from "../../helpers/grid";
import colors from "../../helpers/colors"; import colors from "../../helpers/colors";
import usePrevious from "../../hooks/usePrevious"; import usePrevious from "../../hooks/usePrevious";
import useGridSnapping from "../../hooks/useGridSnapping";
const snappingThreshold = 1 / 5; const snappingThreshold = 1 / 5;
@ -31,6 +31,8 @@ function Note({
const noteHeight = noteWidth; const noteHeight = noteWidth;
const notePadding = noteWidth / 10; const notePadding = noteWidth / 10;
const snapNodeToGrid = useGridSnapping(snappingThreshold);
function handleDragStart(event) { function handleDragStart(event) {
onNoteDragStart && onNoteDragStart(event, note.id); onNoteDragStart && onNoteDragStart(event, note.id);
} }
@ -39,13 +41,7 @@ function Note({
const noteGroup = event.target; const noteGroup = event.target;
// Snap to corners of grid // Snap to corners of grid
if (map.snapToGrid) { if (map.snapToGrid) {
snapNodeToGrid( snapNodeToGrid(noteGroup);
map.grid,
mapWidth,
mapHeight,
noteGroup,
snappingThreshold
);
} }
} }

View File

@ -19,14 +19,14 @@ import { getGridPixelSize, getCellPixelSize, Grid } from "../helpers/grid";
*/ */
const defaultValue = { const defaultValue = {
grid: { grid: {
size: { x: 0, y: 0 }, size: new Vector2(0, 0),
inset: { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 } }, inset: { topLeft: new Vector2(0, 0), bottomRight: new Vector2(1, 1) },
type: "square", type: "square",
}, },
gridPixelSize: new Size(0, 0), gridPixelSize: new Size(0, 0),
gridCellPixelSize: new Size(0, 0, 0), gridCellPixelSize: new Size(0, 0, 0),
gridCellNormalizedSize: new Size(0, 0, 0), gridCellNormalizedSize: new Size(0, 0, 0),
gridOffset: { x: 0, y: 0 }, gridOffset: new Vector2(0, 0),
gridStrokeWidth: 0, gridStrokeWidth: 0,
}; };
@ -53,10 +53,10 @@ export function GridProvider({ grid, width, height, children }) {
gridCellPixelSize.width / gridPixelSize.width, gridCellPixelSize.width / gridPixelSize.width,
gridCellPixelSize.height / gridPixelSize.height gridCellPixelSize.height / gridPixelSize.height
); );
const gridOffset = { const gridOffset = Vector2.multiply(grid.inset.topLeft, {
x: grid.inset.topLeft.x * width * -1, x: width,
y: grid.inset.topLeft.y * height * -1, y: height,
}; });
const gridStrokeWidth = const gridStrokeWidth =
(gridCellPixelSize.width < gridCellPixelSize.height (gridCellPixelSize.width < gridCellPixelSize.height
? gridCellPixelSize.width ? gridCellPixelSize.width

55
src/helpers/Vector3.js Normal file
View File

@ -0,0 +1,55 @@
/**
* Vector class with x, y, z and static helper methods
*/
class Vector3 {
/**
* @type {number} x - X component of the vector
*/
x;
/**
* @type {number} y - Y component of the vector
*/
y;
/**
* @type {number} z - Z component of the vector
*/
z;
/**
* @param {number} x
* @param {number} y
* @param {number} z
*/
constructor(x, y, z) {
this.x = x;
this.y = y;
this.z = z;
}
/**
* Round a Vector3 to the nearest integer while maintaining x + y + z = 0
* @param {Vector3} cube
* @returns {Vector3}
*/
static cubeRound(cube) {
var rX = Math.round(cube.x);
var rY = Math.round(cube.y);
var rZ = Math.round(cube.z);
var xDiff = Math.abs(rX - cube.x);
var yDiff = Math.abs(rY - cube.y);
var zDiff = Math.abs(rZ - cube.z);
if (xDiff > yDiff && xDiff > zDiff) {
rX = -rY - rZ;
} else if (yDiff > zDiff) {
rY = -rX - rZ;
} else {
rZ = -rX - rY;
}
return new Vector3(rX, rY, rZ);
}
}
export default Vector3;

View File

@ -1,4 +1,5 @@
import GridSizeModel from "../ml/gridSize/GridSizeModel"; import GridSizeModel from "../ml/gridSize/GridSizeModel";
import Vector3 from "./Vector3";
import Vector2 from "./Vector2"; import Vector2 from "./Vector2";
import Size from "./Size"; import Size from "./Size";
@ -56,50 +57,81 @@ export function getCellPixelSize(grid, gridWidth, gridHeight) {
} }
/** /**
* Find the location of cell in the grid * Find the location of a cell in the grid.
* Hex is addressed in an offset coordinate system with even numbered columns/rows offset to the right
* @param {Grid} grid * @param {Grid} grid
* @param {number} x X-axis location of the cell * @param {number} col X-axis coordinate of the cell
* @param {number} y Y-axis location of the cell * @param {number} row Y-axis coordinate of the cell
* @param {Size} cellSize Cell size in pixels * @param {Size} cellSize Cell size in pixels
* @returns {Vector2} * @returns {Vector2}
*/ */
export function getCellLocation(grid, x, y, cellSize) { export function getCellLocation(grid, col, row, cellSize) {
switch (grid.type) { switch (grid.type) {
case "square": case "square":
return { x: x * cellSize.width, y: y * cellSize.height }; return { x: col * cellSize.width, y: row * cellSize.height };
case "hexVertical": case "hexVertical":
return { return {
x: x * cellSize.width + (cellSize.width * (1 + (y % 2))) / 2, x: col * cellSize.width + (cellSize.width * (1 + (row & 1))) / 2,
y: y * cellSize.height * (3 / 4) + cellSize.radius, y: row * cellSize.height * (3 / 4) + cellSize.radius,
}; };
case "hexHorizontal": case "hexHorizontal":
return { return {
x: x * cellSize.width * (3 / 4) + cellSize.radius, x: col * cellSize.width * (3 / 4) + cellSize.radius,
y: y * cellSize.height + (cellSize.height * (1 + (x % 2))) / 2, y: row * cellSize.height + (cellSize.height * (1 + (col & 1))) / 2,
}; };
default: default:
throw GRID_TYPE_NOT_IMPLEMENTED; throw GRID_TYPE_NOT_IMPLEMENTED;
} }
} }
/**
* Find the coordinates of the nearest cell in the grid to a point in pixels
* @param {Grid} grid
* @param {number} x X location to look for in pixels
* @param {number} y Y location to look for in pixels
* @param {Size} cellSize Cell size in pixels
* @returns {Vector2}
*/
export function getNearestCellCoordinates(grid, x, y, cellSize) {
switch (grid.type) {
case "square":
return Vector2.roundTo({ x, y }, cellSize);
case "hexVertical":
// Find nearest cell in cube coordinates the convert to offset coordinates
const cubeXVert = ((SQRT3 / 3) * x - (1 / 3) * y) / cellSize.radius;
const cubeZVert = ((2 / 3) * y) / cellSize.radius;
const cubeYVert = -cubeXVert - cubeZVert;
const cubeVert = new Vector3(cubeXVert, cubeYVert, cubeZVert);
return hexCubeToOffset(Vector3.cubeRound(cubeVert), "hexVertical");
case "hexHorizontal":
const cubeXHorz = ((2 / 3) * x) / cellSize.radius;
const cubeZHorz = (-(1 / 3) * x + (SQRT3 / 3) * y) / cellSize.radius;
const cubeYHorz = -cubeXHorz - cubeZHorz;
const cubeHorz = new Vector3(cubeXHorz, cubeYHorz, cubeZHorz);
return hexCubeToOffset(Vector3.cubeRound(cubeHorz), "hexHorizontal");
default:
throw GRID_TYPE_NOT_IMPLEMENTED;
}
}
/** /**
* Whether the cell located at `x, y` is out of bounds of the grid * Whether the cell located at `x, y` is out of bounds of the grid
* @param {Grid} grid * @param {Grid} grid
* @param {number} x X-axis location of the cell * @param {number} col X-axis coordinate of the cell
* @param {number} y Y-axis location of the cell * @param {number} row Y-axis coordinate of the cell
* @returns {boolean} * @returns {boolean}
*/ */
export function shouldClipCell(grid, x, y) { export function shouldClipCell(grid, col, row) {
if (x < 0 || y < 0) { if (col < 0 || row < 0) {
return true; return true;
} }
switch (grid.type) { switch (grid.type) {
case "square": case "square":
return false; return false;
case "hexVertical": case "hexVertical":
return x === grid.size.x - 1 && y % 2 !== 0; return col === grid.size.x - 1 && (row & 1) !== 0;
case "hexHorizontal": case "hexHorizontal":
return y === grid.size.y - 1 && x % 2 !== 0; return row === grid.size.y - 1 && (col & 1) !== 0;
default: default:
throw GRID_TYPE_NOT_IMPLEMENTED; throw GRID_TYPE_NOT_IMPLEMENTED;
} }
@ -109,25 +141,33 @@ export function shouldClipCell(grid, x, y) {
* Canvas clip function for culling hex cells that overshoot/undershoot the grid * Canvas clip function for culling hex cells that overshoot/undershoot the grid
* @param {CanvasRenderingContext2D} context The canvas context of the clip function * @param {CanvasRenderingContext2D} context The canvas context of the clip function
* @param {Grid} grid * @param {Grid} grid
* @param {number} x X-axis location of the cell * @param {number} col X-axis coordinate of the cell
* @param {number} y Y-axis location of the cell * @param {number} row Y-axis coordinate of the cell
* @param {Size} cellSize Cell size in pixels * @param {Size} cellSize Cell size in pixels
*/ */
export function gridClipFunction(context, grid, x, y, cellSize) { export function gridClipFunction(context, grid, col, row, cellSize) {
// Clip the undershooting cells unless they are needed to fill out a specific grid type // Clip the undershooting cells unless they are needed to fill out a specific grid type
if ((x < 0 && grid.type !== "hexVertical") || (x < 0 && y % 2 === 0)) { if (
(col < 0 && grid.type !== "hexVertical") ||
(col < 0 && (row & 1) === 0)
) {
return; return;
} }
if ((y < 0 && grid.type !== "hexHorizontal") || (y < 0 && x % 2 === 0)) { if (
(row < 0 && grid.type !== "hexHorizontal") ||
(row < 0 && (col & 1) === 0)
) {
return; return;
} }
context.rect( context.rect(
x < 0 ? 0 : -cellSize.radius, col < 0 ? 0 : -cellSize.radius,
y < 0 ? 0 : -cellSize.radius, row < 0 ? 0 : -cellSize.radius,
x > 0 && grid.type === "hexVertical" col > 0 && grid.type === "hexVertical"
? cellSize.radius ? cellSize.radius
: cellSize.radius * 2, : cellSize.radius * 2,
y > 0 && grid.type === "hexVertical" ? cellSize.radius * 2 : cellSize.radius row > 0 && grid.type === "hexVertical"
? cellSize.radius * 2
: cellSize.radius
); );
} }
@ -184,6 +224,56 @@ export function getGridUpdatedInset(grid, mapWidth, mapHeight) {
return inset; return inset;
} }
/**
* Get the max zoom for a grid
* @param {Grid} grid
* @returns {number}
*/
export function getGridMaxZoom(grid) {
if (!grid) {
return 10;
}
// Return max grid size / 2
return Math.max(Math.max(grid.size.x, grid.size.y) / 2, 5);
}
/**
* Convert from a 3D cube hex representation to a 2D offset one
* @param {Vector3} cube Cube representation of the hex cell
* @param {("hexVertical"|"hexHorizontal")} type
* @returns {Vector2}
*/
function hexCubeToOffset(cube, type) {
if (type === "hexVertical") {
const x = cube.x + (cube.z + (cube.z & 1)) / 2;
const y = cube.z;
return new Vector2(x, y);
} else {
const x = cube.x;
const y = cube.z + (cube.x + (cube.x & 1)) / 2;
return new Vector2(x, y);
}
}
/**
* Convert from a 2D offset hex representation to a 3D cube one
* @param {Vector2} offset Offset representation of the hex cell
* @param {("hexVertical"|"hexHorizontal")} type
* @returns {Vector3}
*/
function hexOffsetToCube(offset, type) {
if (type === "hexVertical") {
const x = offset.x - (offset.y - (offset.y & 1)) / 2;
const z = offset.y;
const y = -x - z;
return { x, y, z };
} else {
const x = offset.x;
const z = offset.y - (offset.x - (offset.x & 1)) / 2;
const y = -x - z;
return { x, y, z };
}
}
/** /**
* Get all factors of a number * Get all factors of a number
* @param {number} n * @param {number} n
@ -364,103 +454,3 @@ export async function getGridSizeFromImage(image) {
return prediction; return prediction;
} }
/**
* Get the max zoom for a grid
* @param {Grid} grid
* @returns {number}
*/
export function getGridMaxZoom(grid) {
if (!grid) {
return 10;
}
// Return max grid size / 2
return Math.max(Math.max(grid.size.x, grid.size.y) / 2, 5);
}
/**
* Snap a Konva Node to a the closest grid cell
* @param {Grid} grid
* @param {number} mapWidth
* @param {number} mapHeight
* @param {Konva.Node} node
* @param {number} snappingThreshold 1 = Always snap, 0 = never snap
*/
export function snapNodeToGrid(
grid,
mapWidth,
mapHeight,
node,
snappingThreshold
) {
const offset = Vector2.multiply(grid.inset.topLeft, {
x: mapWidth,
y: mapHeight,
});
const gridSize = {
x:
(mapWidth * (grid.inset.bottomRight.x - grid.inset.topLeft.x)) /
grid.size.x,
y:
(mapHeight * (grid.inset.bottomRight.y - grid.inset.topLeft.y)) /
grid.size.y,
};
const position = node.position();
const halfSize = Vector2.divide({ x: node.width(), y: node.height() }, 2);
// Offsets to tranform the centered position into the four corners
const cornerOffsets = [
{ x: 0, y: 0 },
halfSize,
{ x: -halfSize.x, y: -halfSize.y },
{ x: halfSize.x, y: -halfSize.y },
{ x: -halfSize.x, y: halfSize.y },
];
// Minimum distance from a corner to the grid
let minCornerGridDistance = Number.MAX_VALUE;
// Minimum component of the difference between the min corner and the grid
let minCornerMinComponent;
// Closest grid value
let minGridSnap;
// Find the closest corner to the grid
for (let cornerOffset of cornerOffsets) {
const corner = Vector2.add(position, cornerOffset);
// Transform into offset space, round, then transform back
const gridSnap = Vector2.add(
Vector2.roundTo(Vector2.subtract(corner, offset), gridSize),
offset
);
const gridDistance = Vector2.length(Vector2.subtract(gridSnap, corner));
const minComponent = Vector2.min(gridSize);
if (gridDistance < minCornerGridDistance) {
minCornerGridDistance = gridDistance;
minCornerMinComponent = minComponent;
// Move the grid value back to the center
minGridSnap = Vector2.subtract(gridSnap, cornerOffset);
}
}
// Snap to center of grid
// Subtract offset and half grid size to transform it into offset half space then transform it back
const halfGridSize = Vector2.multiply(gridSize, 0.5);
const centerSnap = Vector2.add(
Vector2.add(
Vector2.roundTo(
Vector2.subtract(Vector2.subtract(position, offset), halfGridSize),
gridSize
),
halfGridSize
),
offset
);
const centerDistance = Vector2.length(Vector2.subtract(centerSnap, position));
if (minCornerGridDistance < minCornerMinComponent * snappingThreshold) {
node.position(minGridSnap);
} else if (centerDistance < Vector2.min(gridSize) * snappingThreshold) {
node.position(centerSnap);
}
}

View File

@ -0,0 +1,92 @@
import Konva from "konva";
import Vector2 from "../helpers/Vector2";
import { getCellLocation } from "../helpers/grid";
import { useGrid } from "../contexts/GridContext";
/**
* Returns a function that when called will snap a node to the current grid
* @param {number} snappingThreshold 1 = Always snap, 0 = never snap
*/
function useGridSnapping(snappingThreshold) {
const { gridOffset, gridCellPixelSize } = useGrid();
/**
* @param {Konva.Node} node The node to snap
*/
function snapNodeToGrid(node) {
const position = node.position();
const halfSize = Vector2.divide({ x: node.width(), y: node.height() }, 2);
// Offsets to tranform the centered position into the four corners
const cornerOffsets = [
{ x: 0, y: 0 },
// halfSize,
// { x: -halfSize.x, y: -halfSize.y },
// { x: halfSize.x, y: -halfSize.y },
// { x: -halfSize.x, y: halfSize.y },
];
// Minimum distance from a corner to the grid
let minCornerGridDistance = Number.MAX_VALUE;
// Minimum component of the difference between the min corner and the grid
let minCornerMinComponent;
// Closest grid value
let minGridSnap;
// Find the closest corner to the grid
for (let cornerOffset of cornerOffsets) {
const corner = Vector2.add(position, cornerOffset);
// Transform into gridOffset space, round, then transform back
const gridSnap = Vector2.add(
Vector2.roundTo(
Vector2.subtract(corner, gridOffset),
gridCellPixelSize
),
gridOffset
);
const gridDistance = Vector2.length(Vector2.subtract(gridSnap, corner));
const minComponent = Vector2.min(gridCellPixelSize);
if (gridDistance < minCornerGridDistance) {
minCornerGridDistance = gridDistance;
minCornerMinComponent = minComponent;
// Move the grid value back to the center
minGridSnap = Vector2.subtract(gridSnap, cornerOffset);
}
}
// Snap to center of grid
// Subtract gridOffset and half grid size to transform it into gridOffset half space then transform it back
const halfGridSize = Vector2.multiply(gridCellPixelSize, 0.5);
const centerSnap = Vector2.add(
Vector2.add(
Vector2.roundTo(
Vector2.subtract(
Vector2.subtract(position, gridOffset),
halfGridSize
),
gridCellPixelSize
),
halfGridSize
),
gridOffset
);
const centerDistance = Vector2.length(
Vector2.subtract(centerSnap, position)
);
if (minCornerGridDistance < minCornerMinComponent * snappingThreshold) {
node.position(minGridSnap);
} else if (
centerDistance <
Vector2.min(gridCellPixelSize) * snappingThreshold
) {
node.position(centerSnap);
}
}
return snapNodeToGrid;
}
export default useGridSnapping;