From 85270859bb72ba2fbc3d7e129655e5f93f253001 Mon Sep 17 00:00:00 2001 From: Mitchell McCaffrey Date: Thu, 11 Feb 2021 19:57:34 +1100 Subject: [PATCH] Move to new fog snapping with guides and vertex snapping --- src/components/map/MapFog.js | 233 ++++++++++++++----- src/components/map/MapMeasure.js | 23 +- src/contexts/GridContext.js | 11 +- src/helpers/Vector2.js | 33 ++- src/helpers/drawing.js | 378 ++++++++++++++++++++++++++++++- src/hooks/useGridSnapping.js | 31 ++- 6 files changed, 604 insertions(+), 105 deletions(-) diff --git a/src/components/map/MapFog.js b/src/components/map/MapFog.js index 0e8fdea..bec840d 100644 --- a/src/components/map/MapFog.js +++ b/src/components/map/MapFog.js @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback, useRef } from "react"; import shortid from "shortid"; -import { Group, Rect } from "react-konva"; +import { Group, Rect, Line, Circle } from "react-konva"; import useImage from "use-image"; import diagonalPattern from "../../images/DiagonalPattern.png"; @@ -11,7 +11,15 @@ import { useGrid } from "../../contexts/GridContext"; import { useKeyboard } from "../../contexts/KeyboardContext"; 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 { HoleyLine, @@ -20,7 +28,7 @@ import { } from "../../helpers/konva"; import useDebounce from "../../hooks/useDebounce"; -import useGridSnapping from "../../hooks/useGridSnapping"; +import useSetting from "../../hooks/useSetting"; function MapFog({ map, @@ -39,21 +47,48 @@ function MapFog({ mapHeight, interactionEmitter, } = useMapInteraction(); - const { gridCellNormalizedSize, gridStrokeWidth } = useGrid(); + const { + grid, + gridCellNormalizedSize, + gridCellPixelSize, + gridStrokeWidth, + gridCellPixelOffset, + gridOffset, + } = useGrid(); + const [gridSnappingSensitivity] = useSetting("map.gridSnappingSensitivity"); const mapStageRef = useMapStage(); const [drawingShape, setDrawingShape] = useState(null); const [isBrushDown, setIsBrushDown] = useState(false); 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 = active && editable && (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(() => { if (!active || !editable) { @@ -62,14 +97,25 @@ function MapFog({ const mapStage = mapStageRef.current; - function getBrushPosition() { + function getBrushPosition(snapping = true) { const mapImage = mapStage.findOne("#mapImage"); let position = getRelativePointerPosition(mapImage); - if ( - map.snapToGrid && - (toolSettings.type === "polygon" || toolSettings.type === "rectangle") - ) { - position = snapPositionToGrid(position); + if (snapping) { + if (shouldRenderVertexSnapping) { + position = Vector2.multiply(vertexSnapping, { + x: mapWidth, + 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, { x: mapImage.width(), @@ -78,8 +124,8 @@ function MapFog({ } function handleBrushDown() { - const brushPosition = getBrushPosition(); if (toolSettings.type === "brush") { + const brushPosition = getBrushPosition(); setDrawingShape({ type: "fog", data: { @@ -93,6 +139,7 @@ function MapFog({ }); } if (toolSettings.type === "rectangle") { + const brushPosition = getBrushPosition(); setDrawingShape({ type: "fog", data: { @@ -225,20 +272,77 @@ function MapFog({ } function handlePolygonMove() { - if (toolSettings.type === "polygon" && drawingShape) { - const brushPosition = getBrushPosition(); - setDrawingShape((prevShape) => { - if (!prevShape) { - return; - } - return { - ...prevShape, - data: { - ...prevShape.data, - points: [...prevShape.data.points.slice(0, -1), brushPosition], - }, - }; + if ( + active && + (toolSettings.type === "polygon" || + toolSettings.type === "rectangle") && + !shouldRenderVertexSnapping + ) { + let guides = []; + const brushPosition = getBrushPosition(false); + const absoluteBrushPosition = Vector2.multiply(brushPosition, { + x: mapWidth, + 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 lineCap="round" lineJoin="round" - strokeWidth={gridStrokeWidth * shape.strokeWidth} - opacity={editable ? 0.5 : 1} + strokeWidth={editable ? gridStrokeWidth * shape.strokeWidth : 0} + opacity={editable ? (!shape.visible ? 0.2 : 0.5) : 1} fillPatternImage={patternImage} fillPriority={active && !shape.visible ? "pattern" : "color"} holes={holes} // Disable collision if the fog is transparent and we're not editing it // This allows tokens to be moved under the fog hitFunc={editable && !active ? () => {} : undefined} - shadowColor={editable ? "rgba(0, 0, 0, 0)" : "rgba(34, 34, 34, 0.50)"} - shadowOffset={{ x: 0, y: 5 }} - shadowBlur={10} + // shadowColor={editable ? "rgba(0, 0, 0, 0)" : "rgba(34, 34, 34, 1)"} + // shadowOffset={{ x: 0, y: 5 }} + // shadowBlur={10} /> ); } @@ -402,48 +506,51 @@ function MapFog({ ); } - const [fogShapes, setFogShapes] = useState(shapes); + function renderGuides() { + return guides.map((guide, index) => ( + + )); + } + + function renderSnappingVertex() { + return ( + + ); + } + useEffect(() => { function shapeVisible(shape) { return (active && !toolSettings.preview) || shape.visible; } if (editable) { - setFogShapes(shapes.filter(shapeVisible)); + const visibleShapes = shapes.filter(shapeVisible); + setFogShapeBoundingBoxes(getFogShapesBoundingBoxes(visibleShapes)); + setFogShapes(visibleShapes); } else { - setFogShapes(mergeShapes(shapes)); + setFogShapes(mergeFogShapes(shapes)); } }, [shapes, editable, active, toolSettings]); 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 ( @@ -452,6 +559,8 @@ function MapFog({ {fogShapes.map(renderShape)} + {shouldRenderGuides && renderGuides()} + {shouldRenderVertexSnapping && renderSnappingVertex()} {drawingShape && renderShape(drawingShape)} {drawingShape && toolSettings && diff --git a/src/components/map/MapMeasure.js b/src/components/map/MapMeasure.js index 18d28d2..120bfef 100644 --- a/src/components/map/MapMeasure.js +++ b/src/components/map/MapMeasure.js @@ -27,6 +27,7 @@ function MapMeasure({ map, active }) { gridCellNormalizedSize, gridStrokeWidth, gridCellPixelSize, + gridOffset, } = useGrid(); const mapStageRef = useMapStage(); const [drawingShapeData, setDrawingShapeData] = useState(null); @@ -73,14 +74,20 @@ function MapMeasure({ map, active }) { gridCellNormalizedSize ); // Convert back to pixel values - const a = Vector2.multiply(points[0], { - x: mapImage.width(), - y: mapImage.height(), - }); - const b = Vector2.multiply(points[1], { - x: mapImage.width(), - y: mapImage.height(), - }); + const a = Vector2.subtract( + Vector2.multiply(points[0], { + x: mapImage.width(), + y: mapImage.height(), + }), + gridOffset + ); + const b = Vector2.subtract( + Vector2.multiply(points[1], { + x: mapImage.width(), + y: mapImage.height(), + }), + gridOffset + ); const length = gridDistance(grid, a, b, gridCellPixelSize); setDrawingShapeData({ length, diff --git a/src/contexts/GridContext.js b/src/contexts/GridContext.js index d7df4c1..d8e9ca9 100644 --- a/src/contexts/GridContext.js +++ b/src/contexts/GridContext.js @@ -1,4 +1,4 @@ -import React, { useContext } from "react"; +import React, { useContext, useCallback } from "react"; import Vector2 from "../helpers/Vector2"; 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 {Vector2} gridOffset Offset of the grid from the top left 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), gridOffset: new Vector2(0, 0), gridStrokeWidth: 0, + gridCellPixelOffset: new Vector2(0, 0), }; const GridContext = React.createContext(defaultValue); @@ -67,6 +69,12 @@ export function GridProvider({ grid, width, height, children }) { ? gridCellPixelSize.width : 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 = { grid, gridPixelSize, @@ -74,6 +82,7 @@ export function GridProvider({ grid, width, height, children }) { gridCellNormalizedSize, gridOffset, gridStrokeWidth, + gridCellPixelOffset, }; return {children}; diff --git a/src/helpers/Vector2.js b/src/helpers/Vector2.js index 4fd842b..ee3ccf3 100644 --- a/src/helpers/Vector2.js +++ b/src/helpers/Vector2.js @@ -344,12 +344,21 @@ class Vector2 { 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 * @param {Vector2[]} points - * @returns {Object} + * @returns {BoundingBox} */ - static getBounds(points) { + static getBoundingBox(points) { let minX = Number.MAX_VALUE; let maxX = Number.MIN_VALUE; let minY = Number.MAX_VALUE; @@ -360,7 +369,16 @@ class Vector2 { minY = point.y < minY ? point.y : minY; 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} */ static pointInPolygon(p, points) { - const { minX, maxX, minY, maxY } = this.getBounds(points); - if (p.x < minX || p.x > maxX || p.y < minY || p.y > maxY) { + const bounds = this.getBoundingBox(points); + if ( + p.x < bounds.min.x || + p.x > bounds.max.x || + p.y < bounds.min.y || + p.y > bounds.max.y + ) { return false; } diff --git a/src/helpers/drawing.js b/src/helpers/drawing.js index 4db521b..45a6ab1 100644 --- a/src/helpers/drawing.js +++ b/src/helpers/drawing.js @@ -4,7 +4,83 @@ import polygonClipping from "polygon-clipping"; import Vector2 from "./Vector2"; import { toDegrees } from "./shared"; 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) { if (type === "line") { return { @@ -33,6 +109,10 @@ export function getDefaultShapeData(type, brushPosition) { } } +/** + * @param {Vector2} cellSize + * @returns {Vector2} + */ export function getGridScale(cellSize) { if (cellSize.x < cellSize.y) { 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( type, 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; -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( 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) { return shapes; } @@ -161,3 +251,267 @@ export function mergeShapes(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; +} diff --git a/src/hooks/useGridSnapping.js b/src/hooks/useGridSnapping.js index 23a720f..ddaaee9 100644 --- a/src/hooks/useGridSnapping.js +++ b/src/hooks/useGridSnapping.js @@ -19,21 +19,22 @@ function useGridSnapping(snappingSensitivity) { ); snappingSensitivity = snappingSensitivity || defaultSnappingSensitivity; - const { grid, gridOffset, gridCellPixelSize } = useGrid(); + const { + grid, + gridOffset, + gridCellPixelSize, + gridCellPixelOffset, + } = useGrid(); /** * @param {Vector2} node The node to snap */ function snapPositionToGrid(position) { // Account for grid offset - let offsetPosition = Vector2.subtract(position, gridOffset); - // Move hex tiles to top left - if (grid.type === "hexVertical" || grid.type === "hexHorizontal") { - offsetPosition = Vector2.subtract( - offsetPosition, - Vector2.multiply(gridCellPixelSize, 0.5) - ); - } + let offsetPosition = Vector2.subtract( + Vector2.subtract(position, gridOffset), + gridCellPixelOffset + ); const nearsetCell = getNearestCellCoordinates( grid, offsetPosition.x, @@ -62,14 +63,10 @@ function useGridSnapping(snappingSensitivity) { Vector2.min(gridCellPixelSize) * snappingSensitivity ) { // Reverse grid offset - let offsetSnapPoint = Vector2.add(snapPoint, gridOffset); - // Reverse offset for hex tiles - if (grid.type === "hexVertical" || grid.type === "hexHorizontal") { - offsetSnapPoint = Vector2.add( - offsetSnapPoint, - Vector2.multiply(gridCellPixelSize, 0.5) - ); - } + let offsetSnapPoint = Vector2.add( + Vector2.add(snapPoint, gridOffset), + gridCellPixelOffset + ); return offsetSnapPoint; } }