Move to new fog snapping with guides and vertex snapping

This commit is contained in:
Mitchell McCaffrey 2021-02-11 19:57:34 +11:00
parent 547a214149
commit 85270859bb
6 changed files with 604 additions and 105 deletions

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback, useRef } from "react"; import React, { useState, useEffect, useCallback, useRef } from "react";
import shortid from "shortid"; import shortid from "shortid";
import { Group, Rect } from "react-konva"; import { Group, Rect, Line, Circle } from "react-konva";
import useImage from "use-image"; import useImage from "use-image";
import diagonalPattern from "../../images/DiagonalPattern.png"; import diagonalPattern from "../../images/DiagonalPattern.png";
@ -11,7 +11,15 @@ import { useGrid } from "../../contexts/GridContext";
import { useKeyboard } from "../../contexts/KeyboardContext"; import { useKeyboard } from "../../contexts/KeyboardContext";
import Vector2 from "../../helpers/Vector2"; import Vector2 from "../../helpers/Vector2";
import { simplifyPoints, mergeShapes } from "../../helpers/drawing"; import {
simplifyPoints,
mergeFogShapes,
getFogShapesBoundingBoxes,
getGuidesFromBoundingBoxes,
getGuidesFromGridCell,
findBestGuides,
getSnappingVertex,
} from "../../helpers/drawing";
import colors from "../../helpers/colors"; import colors from "../../helpers/colors";
import { import {
HoleyLine, HoleyLine,
@ -20,7 +28,7 @@ import {
} from "../../helpers/konva"; } from "../../helpers/konva";
import useDebounce from "../../hooks/useDebounce"; import useDebounce from "../../hooks/useDebounce";
import useGridSnapping from "../../hooks/useGridSnapping"; import useSetting from "../../hooks/useSetting";
function MapFog({ function MapFog({
map, map,
@ -39,21 +47,48 @@ function MapFog({
mapHeight, mapHeight,
interactionEmitter, interactionEmitter,
} = useMapInteraction(); } = useMapInteraction();
const { gridCellNormalizedSize, gridStrokeWidth } = useGrid(); const {
grid,
gridCellNormalizedSize,
gridCellPixelSize,
gridStrokeWidth,
gridCellPixelOffset,
gridOffset,
} = useGrid();
const [gridSnappingSensitivity] = useSetting("map.gridSnappingSensitivity");
const mapStageRef = useMapStage(); const mapStageRef = useMapStage();
const [drawingShape, setDrawingShape] = useState(null); const [drawingShape, setDrawingShape] = useState(null);
const [isBrushDown, setIsBrushDown] = useState(false); const [isBrushDown, setIsBrushDown] = useState(false);
const [editingShapes, setEditingShapes] = useState([]); const [editingShapes, setEditingShapes] = useState([]);
// Shapes that have been merged for fog
const [fogShapes, setFogShapes] = useState(shapes);
// Bounding boxes for guides
const [fogShapeBoundingBoxes, setFogShapeBoundingBoxes] = useState([]);
const [guides, setGuides] = useState([]);
const [vertexSnapping, setVertexSnapping] = useState();
const shouldHover = const shouldHover =
active && active &&
editable && editable &&
(toolSettings.type === "toggle" || toolSettings.type === "remove"); (toolSettings.type === "toggle" || toolSettings.type === "remove");
const [patternImage] = useImage(diagonalPattern); const shouldRenderGuides =
active &&
editable &&
(toolSettings.type === "rectangle" || toolSettings.type === "polygon") &&
!vertexSnapping;
const shouldRenderVertexSnapping =
active &&
editable &&
(toolSettings.type === "rectangle" ||
toolSettings.type === "polygon" ||
toolSettings.type === "brush") &&
toolSettings.useEdgeSnapping &&
vertexSnapping;
const snapPositionToGrid = useGridSnapping(); const [patternImage] = useImage(diagonalPattern);
useEffect(() => { useEffect(() => {
if (!active || !editable) { if (!active || !editable) {
@ -62,14 +97,25 @@ function MapFog({
const mapStage = mapStageRef.current; const mapStage = mapStageRef.current;
function getBrushPosition() { function getBrushPosition(snapping = true) {
const mapImage = mapStage.findOne("#mapImage"); const mapImage = mapStage.findOne("#mapImage");
let position = getRelativePointerPosition(mapImage); let position = getRelativePointerPosition(mapImage);
if ( if (snapping) {
map.snapToGrid && if (shouldRenderVertexSnapping) {
(toolSettings.type === "polygon" || toolSettings.type === "rectangle") position = Vector2.multiply(vertexSnapping, {
) { x: mapWidth,
position = snapPositionToGrid(position); y: mapHeight,
});
} else if (shouldRenderGuides) {
for (let guide of guides) {
if (guide.orientation === "vertical") {
position.x = guide.start.x * mapWidth;
}
if (guide.orientation === "horizontal") {
position.y = guide.start.y * mapHeight;
}
}
}
} }
return Vector2.divide(position, { return Vector2.divide(position, {
x: mapImage.width(), x: mapImage.width(),
@ -78,8 +124,8 @@ function MapFog({
} }
function handleBrushDown() { function handleBrushDown() {
const brushPosition = getBrushPosition();
if (toolSettings.type === "brush") { if (toolSettings.type === "brush") {
const brushPosition = getBrushPosition();
setDrawingShape({ setDrawingShape({
type: "fog", type: "fog",
data: { data: {
@ -93,6 +139,7 @@ function MapFog({
}); });
} }
if (toolSettings.type === "rectangle") { if (toolSettings.type === "rectangle") {
const brushPosition = getBrushPosition();
setDrawingShape({ setDrawingShape({
type: "fog", type: "fog",
data: { data: {
@ -225,20 +272,77 @@ function MapFog({
} }
function handlePolygonMove() { function handlePolygonMove() {
if (toolSettings.type === "polygon" && drawingShape) { if (
const brushPosition = getBrushPosition(); active &&
setDrawingShape((prevShape) => { (toolSettings.type === "polygon" ||
if (!prevShape) { toolSettings.type === "rectangle") &&
return; !shouldRenderVertexSnapping
} ) {
return { let guides = [];
...prevShape, const brushPosition = getBrushPosition(false);
data: { const absoluteBrushPosition = Vector2.multiply(brushPosition, {
...prevShape.data, x: mapWidth,
points: [...prevShape.data.points.slice(0, -1), brushPosition], y: mapHeight,
},
};
}); });
if (map.snapToGrid) {
guides.push(
...getGuidesFromGridCell(
absoluteBrushPosition,
grid,
gridCellPixelSize,
gridOffset,
gridCellPixelOffset,
gridSnappingSensitivity,
{ x: mapWidth, y: mapHeight }
)
);
}
guides.push(
...getGuidesFromBoundingBoxes(
brushPosition,
fogShapeBoundingBoxes,
gridCellNormalizedSize,
gridSnappingSensitivity
)
);
setGuides(findBestGuides(brushPosition, guides));
}
if (
active &&
toolSettings.useEdgeSnapping &&
(toolSettings.type === "polygon" ||
toolSettings.type === "rectangle" ||
toolSettings.type === "brush")
) {
const brushPosition = getBrushPosition(false);
setVertexSnapping(
getSnappingVertex(
brushPosition,
fogShapes,
fogShapeBoundingBoxes,
gridCellNormalizedSize,
Math.min(0.4 / stageScale, 0.4)
)
);
}
if (toolSettings.type === "polygon") {
const brushPosition = getBrushPosition();
if (toolSettings.type === "polygon" && drawingShape) {
setDrawingShape((prevShape) => {
if (!prevShape) {
return;
}
return {
...prevShape,
data: {
...prevShape.data,
points: [...prevShape.data.points.slice(0, -1), brushPosition],
},
};
});
}
} }
} }
@ -356,17 +460,17 @@ function MapFog({
closed closed
lineCap="round" lineCap="round"
lineJoin="round" lineJoin="round"
strokeWidth={gridStrokeWidth * shape.strokeWidth} strokeWidth={editable ? gridStrokeWidth * shape.strokeWidth : 0}
opacity={editable ? 0.5 : 1} opacity={editable ? (!shape.visible ? 0.2 : 0.5) : 1}
fillPatternImage={patternImage} fillPatternImage={patternImage}
fillPriority={active && !shape.visible ? "pattern" : "color"} fillPriority={active && !shape.visible ? "pattern" : "color"}
holes={holes} holes={holes}
// Disable collision if the fog is transparent and we're not editing it // Disable collision if the fog is transparent and we're not editing it
// This allows tokens to be moved under the fog // This allows tokens to be moved under the fog
hitFunc={editable && !active ? () => {} : undefined} hitFunc={editable && !active ? () => {} : undefined}
shadowColor={editable ? "rgba(0, 0, 0, 0)" : "rgba(34, 34, 34, 0.50)"} // shadowColor={editable ? "rgba(0, 0, 0, 0)" : "rgba(34, 34, 34, 1)"}
shadowOffset={{ x: 0, y: 5 }} // shadowOffset={{ x: 0, y: 5 }}
shadowBlur={10} // shadowBlur={10}
/> />
); );
} }
@ -402,48 +506,51 @@ function MapFog({
); );
} }
const [fogShapes, setFogShapes] = useState(shapes); function renderGuides() {
return guides.map((guide, index) => (
<Line
points={[
guide.start.x * mapWidth,
guide.start.y * mapHeight,
guide.end.x * mapWidth,
guide.end.y * mapHeight,
]}
stroke="hsl(260, 100%, 80%)"
key={index}
strokeWidth={gridStrokeWidth * 0.25}
lineCap="round"
lineJoin="round"
/>
));
}
function renderSnappingVertex() {
return (
<Circle
x={vertexSnapping.x * mapWidth}
y={vertexSnapping.y * mapHeight}
radius={gridStrokeWidth}
stroke="hsl(260, 100%, 80%)"
strokeWidth={gridStrokeWidth * 0.25}
/>
);
}
useEffect(() => { useEffect(() => {
function shapeVisible(shape) { function shapeVisible(shape) {
return (active && !toolSettings.preview) || shape.visible; return (active && !toolSettings.preview) || shape.visible;
} }
if (editable) { if (editable) {
setFogShapes(shapes.filter(shapeVisible)); const visibleShapes = shapes.filter(shapeVisible);
setFogShapeBoundingBoxes(getFogShapesBoundingBoxes(visibleShapes));
setFogShapes(visibleShapes);
} else { } else {
setFogShapes(mergeShapes(shapes)); setFogShapes(mergeFogShapes(shapes));
} }
}, [shapes, editable, active, toolSettings]); }, [shapes, editable, active, toolSettings]);
const fogGroupRef = useRef(); const fogGroupRef = useRef();
const debouncedStageScale = useDebounce(stageScale, 50);
useEffect(() => {
const fogGroup = fogGroupRef.current;
if (!editable) {
const canvas = fogGroup.getChildren()[0].getCanvas();
const pixelRatio = canvas.pixelRatio || 1;
// Constrain fog buffer to the map resolution
const fogRect = fogGroup.getClientRect();
const maxMapSize = map ? Math.max(map.width, map.height) : 4096; // Default to 4096
const maxFogSize =
Math.max(fogRect.width, fogRect.height) / debouncedStageScale;
const maxPixelRatio = maxMapSize / maxFogSize;
fogGroup.cache({
pixelRatio: Math.min(
Math.max(debouncedStageScale * pixelRatio, 1),
maxPixelRatio
),
});
} else {
fogGroup.clearCache();
}
fogGroup.getLayer().draw();
}, [fogShapes, editable, active, debouncedStageScale, mapWidth, map]);
return ( return (
<Group> <Group>
@ -452,6 +559,8 @@ function MapFog({
<Rect width={1} height={1} /> <Rect width={1} height={1} />
{fogShapes.map(renderShape)} {fogShapes.map(renderShape)}
</Group> </Group>
{shouldRenderGuides && renderGuides()}
{shouldRenderVertexSnapping && renderSnappingVertex()}
{drawingShape && renderShape(drawingShape)} {drawingShape && renderShape(drawingShape)}
{drawingShape && {drawingShape &&
toolSettings && toolSettings &&

View File

@ -27,6 +27,7 @@ function MapMeasure({ map, active }) {
gridCellNormalizedSize, gridCellNormalizedSize,
gridStrokeWidth, gridStrokeWidth,
gridCellPixelSize, gridCellPixelSize,
gridOffset,
} = useGrid(); } = useGrid();
const mapStageRef = useMapStage(); const mapStageRef = useMapStage();
const [drawingShapeData, setDrawingShapeData] = useState(null); const [drawingShapeData, setDrawingShapeData] = useState(null);
@ -73,14 +74,20 @@ function MapMeasure({ map, active }) {
gridCellNormalizedSize gridCellNormalizedSize
); );
// Convert back to pixel values // Convert back to pixel values
const a = Vector2.multiply(points[0], { const a = Vector2.subtract(
x: mapImage.width(), Vector2.multiply(points[0], {
y: mapImage.height(), x: mapImage.width(),
}); y: mapImage.height(),
const b = Vector2.multiply(points[1], { }),
x: mapImage.width(), gridOffset
y: mapImage.height(), );
}); const b = Vector2.subtract(
Vector2.multiply(points[1], {
x: mapImage.width(),
y: mapImage.height(),
}),
gridOffset
);
const length = gridDistance(grid, a, b, gridCellPixelSize); const length = gridDistance(grid, a, b, gridCellPixelSize);
setDrawingShapeData({ setDrawingShapeData({
length, length,

View File

@ -1,4 +1,4 @@
import React, { useContext } from "react"; import React, { useContext, useCallback } from "react";
import Vector2 from "../helpers/Vector2"; import Vector2 from "../helpers/Vector2";
import Size from "../helpers/Size"; import Size from "../helpers/Size";
@ -13,6 +13,7 @@ import { getGridPixelSize, getCellPixelSize, Grid } from "../helpers/grid";
* @property {Size} gridCellNormalizedSize Size of each cell normalized to the grid * @property {Size} gridCellNormalizedSize Size of each cell normalized to the grid
* @property {Vector2} gridOffset Offset of the grid from the top left in pixels * @property {Vector2} gridOffset Offset of the grid from the top left in pixels
* @property {number} gridStrokeWidth Stroke width of the grid in pixels * @property {number} gridStrokeWidth Stroke width of the grid in pixels
* @property {Vector2} gridCellPixelOffset Offset of the grid cells to convert the center position of hex cells to the top left
*/ */
/** /**
@ -33,6 +34,7 @@ const defaultValue = {
gridCellNormalizedSize: new Size(0, 0, 0), gridCellNormalizedSize: new Size(0, 0, 0),
gridOffset: new Vector2(0, 0), gridOffset: new Vector2(0, 0),
gridStrokeWidth: 0, gridStrokeWidth: 0,
gridCellPixelOffset: new Vector2(0, 0),
}; };
const GridContext = React.createContext(defaultValue); const GridContext = React.createContext(defaultValue);
@ -67,6 +69,12 @@ export function GridProvider({ grid, width, height, children }) {
? gridCellPixelSize.width ? gridCellPixelSize.width
: gridCellPixelSize.height) * defaultStrokeWidth; : gridCellPixelSize.height) * defaultStrokeWidth;
let gridCellPixelOffset = { x: 0, y: 0 };
// Move hex tiles to top left
if (grid.type === "hexVertical" || grid.type === "hexHorizontal") {
gridCellPixelOffset = Vector2.multiply(gridCellPixelSize, 0.5);
}
const value = { const value = {
grid, grid,
gridPixelSize, gridPixelSize,
@ -74,6 +82,7 @@ export function GridProvider({ grid, width, height, children }) {
gridCellNormalizedSize, gridCellNormalizedSize,
gridOffset, gridOffset,
gridStrokeWidth, gridStrokeWidth,
gridCellPixelOffset,
}; };
return <GridContext.Provider value={value}>{children}</GridContext.Provider>; return <GridContext.Provider value={value}>{children}</GridContext.Provider>;

View File

@ -344,12 +344,21 @@ class Vector2 {
return { distance: Math.sqrt(distance), point: point }; return { distance: Math.sqrt(distance), point: point };
} }
/**
* @typedef BoundingBox
* @property {Vector2} min
* @property {Vector2} max
* @property {number} width
* @property {number} height
* @property {Vector2} center
*/
/** /**
* Calculates an axis-aligned bounding box around an array of point * Calculates an axis-aligned bounding box around an array of point
* @param {Vector2[]} points * @param {Vector2[]} points
* @returns {Object} * @returns {BoundingBox}
*/ */
static getBounds(points) { static getBoundingBox(points) {
let minX = Number.MAX_VALUE; let minX = Number.MAX_VALUE;
let maxX = Number.MIN_VALUE; let maxX = Number.MIN_VALUE;
let minY = Number.MAX_VALUE; let minY = Number.MAX_VALUE;
@ -360,7 +369,16 @@ class Vector2 {
minY = point.y < minY ? point.y : minY; minY = point.y < minY ? point.y : minY;
maxY = point.y > maxY ? point.y : maxY; maxY = point.y > maxY ? point.y : maxY;
} }
return { minX, maxX, minY, maxY }; let width = maxX - minX;
let height = maxY - minY;
let center = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 };
return {
min: { x: minX, y: minY },
max: { x: maxX, y: maxY },
width,
height,
center,
};
} }
/** /**
@ -372,8 +390,13 @@ class Vector2 {
* @returns {boolean} * @returns {boolean}
*/ */
static pointInPolygon(p, points) { static pointInPolygon(p, points) {
const { minX, maxX, minY, maxY } = this.getBounds(points); const bounds = this.getBoundingBox(points);
if (p.x < minX || p.x > maxX || p.y < minY || p.y > maxY) { if (
p.x < bounds.min.x ||
p.x > bounds.max.x ||
p.y < bounds.min.y ||
p.y > bounds.max.y
) {
return false; return false;
} }

View File

@ -4,7 +4,83 @@ import polygonClipping from "polygon-clipping";
import Vector2 from "./Vector2"; import Vector2 from "./Vector2";
import { toDegrees } from "./shared"; import { toDegrees } from "./shared";
import { logError } from "./logging"; import { logError } from "./logging";
import { getNearestCellCoordinates, getCellLocation } from "./grid";
/**
* @typedef PointsData
* @property {Vector2[]} points
*/
/**
* @typedef RectData
* @property {number} x
* @property {number} y
* @property {number} width
* @property {number} height
*/
/**
* @typedef CircleData
* @property {number} x
* @property {number} y
* @property {number} radius
*/
/**
* @typedef FogData
* @property {Vector2[]} points
* @property {Vector2[]} holes
*/
/**
* @typedef {(PointsData|RectData|CircleData)} ShapeData
*/
/**
* @typedef {("line"|"rectangle"|"circle"|"triangle")} ShapeType
*/
/**
* @typedef {("fill"|"stroke")} PathType
*/
/**
* @typedef Path
* @property {boolean} blend
* @property {string} color
* @property {PointsData} data
* @property {string} id
* @property {PathType} pathType
* @property {number} strokeWidth
* @property {"path"} type
*/
/**
* @typedef Shape
* @property {boolean} blend
* @property {string} color
* @property {ShapeData} data
* @property {string} id
* @property {ShapeType} shapeType
* @property {number} strokeWidth
* @property {"shape"} type
*/
/**
* @typedef Fog
* @property {string} color
* @property {FogData} data
* @property {string} id
* @property {number} strokeWidth
* @property {"fog"} type
* @property {boolean} visible
*/
/**
*
* @param {ShapeType} type
* @param {Vector2} brushPosition
* @returns {ShapeData}
*/
export function getDefaultShapeData(type, brushPosition) { export function getDefaultShapeData(type, brushPosition) {
if (type === "line") { if (type === "line") {
return { return {
@ -33,6 +109,10 @@ export function getDefaultShapeData(type, brushPosition) {
} }
} }
/**
* @param {Vector2} cellSize
* @returns {Vector2}
*/
export function getGridScale(cellSize) { export function getGridScale(cellSize) {
if (cellSize.x < cellSize.y) { if (cellSize.x < cellSize.y) {
return { x: cellSize.y / cellSize.x, y: 1 }; return { x: cellSize.y / cellSize.x, y: 1 };
@ -43,6 +123,14 @@ export function getGridScale(cellSize) {
} }
} }
/**
*
* @param {ShapeType} type
* @param {ShapeData} data
* @param {Vector2} brushPosition
* @param {Vector2} gridCellNormalizedSize
* @returns {ShapeData}
*/
export function getUpdatedShapeData( export function getUpdatedShapeData(
type, type,
data, data,
@ -99,24 +187,26 @@ export function getUpdatedShapeData(
} }
} }
const defaultStrokeWidth = 1 / 10;
export function getStrokeWidth(multiplier, gridSize, mapWidth, mapHeight) {
const gridPixelSize = Vector2.multiply(gridSize, {
x: mapWidth,
y: mapHeight,
});
return Vector2.min(gridPixelSize) * defaultStrokeWidth * multiplier;
}
const defaultSimplifySize = 1 / 100; const defaultSimplifySize = 1 / 100;
export function simplifyPoints(points, gridCellNormalizedSize, scale) { /**
* Simplify points to a grid size
* @param {Vector2[]} points
* @param {Vector2} gridCellSize
* @param {number} scale
*/
export function simplifyPoints(points, gridCellSize, scale) {
return simplify( return simplify(
points, points,
(Vector2.min(gridCellNormalizedSize) * defaultSimplifySize) / scale (Vector2.min(gridCellSize) * defaultSimplifySize) / scale
); );
} }
export function mergeShapes(shapes) { /**
* Merges overlapping fog shapes
* @param {Fog[]} shapes
* @returns {Fog[]}
*/
export function mergeFogShapes(shapes) {
if (shapes.length === 0) { if (shapes.length === 0) {
return shapes; return shapes;
} }
@ -161,3 +251,267 @@ export function mergeShapes(shapes) {
return shapes; return shapes;
} }
} }
/**
* @param {Fog[]} shapes
* @returns {Vector2.BoundingBox[]}
*/
export function getFogShapesBoundingBoxes(shapes) {
let boxes = [];
for (let shape of shapes) {
boxes.push(Vector2.getBoundingBox(shape.data.points));
}
return boxes;
}
/**
* @typedef Edge
* @property {Vector2} start
* @property {Vector2} end
*/
/**
* @typedef Guide
* @property {Vector2} start
* @property {Vector2} end
* @property {("horizontal"|"vertical")} orientation
* @property {number}
*/
/**
* @param {Vector2} brushPosition Brush position in pixels
* @param {Vector2} grid
* @param {Vector2} gridCellSize Grid cell size in pixels
* @param {Vector2} gridOffset
* @param {Vector2} gridCellOffset
* @param {number} snappingSensitivity
* @param {Vector2} mapSize
* @returns {Guide[]}
*/
export function getGuidesFromGridCell(
brushPosition,
grid,
gridCellSize,
gridOffset,
gridCellOffset,
snappingSensitivity,
mapSize
) {
let boundingBoxes = [];
// Add map bounds
boundingBoxes.push(
Vector2.getBoundingBox([
{ x: 0, y: 0 },
{ x: 1, y: 1 },
])
);
let offsetPosition = Vector2.subtract(
Vector2.subtract(brushPosition, gridOffset),
gridCellOffset
);
const cellCoords = getNearestCellCoordinates(
grid,
offsetPosition.x,
offsetPosition.y,
gridCellSize
);
let cellPosition = getCellLocation(
grid,
cellCoords.x,
cellCoords.y,
gridCellSize
);
cellPosition = Vector2.add(
Vector2.add(cellPosition, gridOffset),
gridCellOffset
);
// Normalize values so output is normalized
cellPosition = Vector2.divide(cellPosition, mapSize);
const gridCellNormalizedSize = Vector2.divide(gridCellSize, mapSize);
const brushPositionNorm = Vector2.divide(brushPosition, mapSize);
const boundingBox = Vector2.getBoundingBox([
{
x: cellPosition.x - gridCellNormalizedSize.x / 2,
y: cellPosition.y - gridCellNormalizedSize.y / 2,
},
{
x: cellPosition.x + gridCellNormalizedSize.x / 2,
y: cellPosition.y + gridCellNormalizedSize.y / 2,
},
]);
boundingBoxes.push(boundingBox);
return getGuidesFromBoundingBoxes(
brushPositionNorm,
boundingBoxes,
gridCellNormalizedSize,
snappingSensitivity
);
}
/**
* @param {Vector2} brushPosition
* @param {Vector2.BoundingBox[]} boundingBoxes
* @param {Vector2} gridCellSize
* @param {number} snappingSensitivity
* @returns {Guide[]}
*/
export function getGuidesFromBoundingBoxes(
brushPosition,
boundingBoxes,
gridCellSize,
snappingSensitivity
) {
let horizontalEdges = [];
let verticalEdges = [];
for (let bounds of boundingBoxes) {
horizontalEdges.push({
start: { x: bounds.min.x, y: bounds.min.y },
end: { x: bounds.max.x, y: bounds.min.y },
});
horizontalEdges.push({
start: { x: bounds.min.x, y: bounds.center.y },
end: { x: bounds.max.x, y: bounds.center.y },
});
horizontalEdges.push({
start: { x: bounds.min.x, y: bounds.max.y },
end: { x: bounds.max.x, y: bounds.max.y },
});
verticalEdges.push({
start: { x: bounds.min.x, y: bounds.min.y },
end: { x: bounds.min.x, y: bounds.max.y },
});
verticalEdges.push({
start: { x: bounds.center.x, y: bounds.min.y },
end: { x: bounds.center.x, y: bounds.max.y },
});
verticalEdges.push({
start: { x: bounds.max.x, y: bounds.min.y },
end: { x: bounds.max.x, y: bounds.max.y },
});
}
let guides = [];
for (let edge of verticalEdges) {
const distance = Math.abs(brushPosition.x - edge.start.x);
if (distance / gridCellSize.x < snappingSensitivity) {
guides.push({ ...edge, distance, orientation: "vertical" });
}
}
for (let edge of horizontalEdges) {
const distance = Math.abs(brushPosition.y - edge.start.y);
if (distance / gridCellSize.y < snappingSensitivity) {
guides.push({ ...edge, distance, orientation: "horizontal" });
}
}
return guides;
}
/**
* @param {Vector2} brushPosition
* @param {Guide[]} guides
* @returns {Guide[]}
*/
export function findBestGuides(brushPosition, guides) {
let bestGuides = [];
let verticalGuide = guides
.filter((guide) => guide.orientation === "vertical")
.sort((a, b) => a.distance - b.distance)[0];
let horizontalGuide = guides
.filter((guide) => guide.orientation === "horizontal")
.sort((a, b) => a.distance - b.distance)[0];
// Offset edges to match brush position
if (verticalGuide && !horizontalGuide) {
verticalGuide.start.y = Math.min(verticalGuide.start.y, brushPosition.y);
verticalGuide.end.y = Math.max(verticalGuide.end.y, brushPosition.y);
bestGuides.push(verticalGuide);
}
if (horizontalGuide && !verticalGuide) {
horizontalGuide.start.x = Math.min(
horizontalGuide.start.x,
brushPosition.x
);
horizontalGuide.end.x = Math.max(horizontalGuide.end.x, brushPosition.x);
bestGuides.push(horizontalGuide);
}
if (horizontalGuide && verticalGuide) {
verticalGuide.start.y = Math.min(
verticalGuide.start.y,
horizontalGuide.start.y
);
verticalGuide.end.y = Math.max(
verticalGuide.end.y,
horizontalGuide.start.y
);
horizontalGuide.start.x = Math.min(
horizontalGuide.start.x,
verticalGuide.start.x
);
horizontalGuide.end.x = Math.max(
horizontalGuide.end.x,
verticalGuide.start.x
);
bestGuides.push(horizontalGuide);
bestGuides.push(verticalGuide);
}
return bestGuides;
}
/**
* @param {Vector2} brushPosition
* @param {Fog[]} shapes
* @param {Vector2.BoundingBox} boundingBoxes
* @param {Vector2} gridCellSize
* @param {number} snappingSensitivity
*/
export function getSnappingVertex(
brushPosition,
shapes,
boundingBoxes,
gridCellSize,
snappingSensitivity
) {
const minGrid = Vector2.min(gridCellSize);
const snappingDistance = minGrid * snappingSensitivity;
let closestDistance = Number.MAX_VALUE;
let closestPosition;
for (let i = 0; i < shapes.length; i++) {
// TODO: Check bounds before checking all points
// const bounds = boundingBoxes[i];
const shape = shapes[i];
// Include shape points and holes
let pointArray = [shape.data.points, ...shape.data.holes];
for (let points of pointArray) {
// Find the closest point to each edge of the shape
for (let i = 0; i < points.length; i++) {
const a = points[i];
// Wrap around points to the start to account for closed shape
const b = points[(i + 1) % points.length];
let { distance, point } = Vector2.distanceToLine(brushPosition, a, b);
// Bias towards vertices
distance += snappingDistance / 2;
const isCloseToShape = distance < snappingDistance;
if (isCloseToShape && distance < closestDistance) {
closestPosition = point;
closestDistance = distance;
}
}
// Find cloest vertex
for (let point of points) {
const distance = Vector2.distance(point, brushPosition);
const isCloseToShape = distance < snappingDistance;
if (isCloseToShape && distance < closestDistance) {
closestPosition = point;
closestDistance = distance;
}
}
}
}
return closestPosition;
}

View File

@ -19,21 +19,22 @@ function useGridSnapping(snappingSensitivity) {
); );
snappingSensitivity = snappingSensitivity || defaultSnappingSensitivity; snappingSensitivity = snappingSensitivity || defaultSnappingSensitivity;
const { grid, gridOffset, gridCellPixelSize } = useGrid(); const {
grid,
gridOffset,
gridCellPixelSize,
gridCellPixelOffset,
} = useGrid();
/** /**
* @param {Vector2} node The node to snap * @param {Vector2} node The node to snap
*/ */
function snapPositionToGrid(position) { function snapPositionToGrid(position) {
// Account for grid offset // Account for grid offset
let offsetPosition = Vector2.subtract(position, gridOffset); let offsetPosition = Vector2.subtract(
// Move hex tiles to top left Vector2.subtract(position, gridOffset),
if (grid.type === "hexVertical" || grid.type === "hexHorizontal") { gridCellPixelOffset
offsetPosition = Vector2.subtract( );
offsetPosition,
Vector2.multiply(gridCellPixelSize, 0.5)
);
}
const nearsetCell = getNearestCellCoordinates( const nearsetCell = getNearestCellCoordinates(
grid, grid,
offsetPosition.x, offsetPosition.x,
@ -62,14 +63,10 @@ function useGridSnapping(snappingSensitivity) {
Vector2.min(gridCellPixelSize) * snappingSensitivity Vector2.min(gridCellPixelSize) * snappingSensitivity
) { ) {
// Reverse grid offset // Reverse grid offset
let offsetSnapPoint = Vector2.add(snapPoint, gridOffset); let offsetSnapPoint = Vector2.add(
// Reverse offset for hex tiles Vector2.add(snapPoint, gridOffset),
if (grid.type === "hexVertical" || grid.type === "hexHorizontal") { gridCellPixelOffset
offsetSnapPoint = Vector2.add( );
offsetSnapPoint,
Vector2.multiply(gridCellPixelSize, 0.5)
);
}
return offsetSnapPoint; return offsetSnapPoint;
} }
} }