diff --git a/src/components/map/MapDrawing.js b/src/components/map/MapDrawing.js index ca8bc9c..8495a7d 100644 --- a/src/components/map/MapDrawing.js +++ b/src/components/map/MapDrawing.js @@ -1,8 +1,7 @@ -import React, { useRef, useEffect, useState } from "react"; +import React, { useRef, useEffect, useState, useContext } from "react"; import shortid from "shortid"; import { compare as comparePoints } from "../../helpers/vector2"; - import { getBrushPositionForTool, getDefaultShapeData, @@ -13,6 +12,8 @@ import { getRelativePointerPosition, } from "../../helpers/drawing"; +import MapInteractionContext from "../../contexts/MapInteractionContext"; + function MapDrawing({ width, height, @@ -36,6 +37,8 @@ function MapDrawing({ selectedTool === "shape" || selectedTool === "erase"; + const { scaleRef } = useContext(MapInteractionContext); + // Reset pointer position when tool changes useEffect(() => { setPointerPosition({ x: -1, y: -1 }); @@ -128,7 +131,8 @@ function MapDrawing({ } const simplified = simplifyPoints( [...prevPoints, brushPosition], - gridSize + gridSize, + scaleRef.current ); return { ...prevShape, diff --git a/src/components/map/MapFog.js b/src/components/map/MapFog.js index 1d81a6f..7b45f4d 100644 --- a/src/components/map/MapFog.js +++ b/src/components/map/MapFog.js @@ -1,8 +1,7 @@ -import React, { useRef, useEffect, useState } from "react"; +import React, { useRef, useEffect, useState, useContext } from "react"; import shortid from "shortid"; import { compare as comparePoints } from "../../helpers/vector2"; - import { getBrushPositionForTool, isShapeHovered, @@ -11,6 +10,8 @@ import { getRelativePointerPosition, } from "../../helpers/drawing"; +import MapInteractionContext from "../../contexts/MapInteractionContext"; + function MapFog({ width, height, @@ -32,6 +33,8 @@ function MapFog({ isEditing && (toolSettings.type === "toggle" || toolSettings.type === "remove"); + const { scaleRef } = useContext(MapInteractionContext); + // Reset pointer position when tool changes useEffect(() => { setPointerPosition({ x: -1, y: -1 }); @@ -65,7 +68,6 @@ function MapFog({ color: "black", blend: true, // Blend while drawing id: shortid.generate(), - fogType: toolSettings.useGridSnapping ? "sharp" : "smooth", }); } } @@ -124,7 +126,14 @@ function MapFog({ if (drawingShape.data.points.length > 1) { const shape = { ...drawingShape, - data: { points: simplifyPoints(drawingShape.data.points, gridSize) }, + data: { + points: simplifyPoints( + drawingShape.data.points, + gridSize, + // Downscale fog as smoothing doesn't currently work with edge snapping + scaleRef.current / 2 + ), + }, blend: false, }; onShapeAdd(shape); diff --git a/src/components/map/MapInteraction.js b/src/components/map/MapInteraction.js index 3088316..9a041b0 100644 --- a/src/components/map/MapInteraction.js +++ b/src/components/map/MapInteraction.js @@ -2,6 +2,8 @@ import React, { useRef, useEffect } from "react"; import { Box } from "theme-ui"; import interact from "interactjs"; +import { MapInteractionProvider } from "../../contexts/MapInteractionContext"; + const zoomSpeed = -0.005; const minZoom = 0.1; const maxZoom = 5; @@ -134,7 +136,14 @@ function MapInteraction({ map, aspectRatio, isEnabled, children, controls }) { paddingBottom: `${(1 / aspectRatio) * 100}%`, }} /> - {children} + + {children} + {controls} diff --git a/src/contexts/MapInteractionContext.js b/src/contexts/MapInteractionContext.js new file mode 100644 index 0000000..504c31e --- /dev/null +++ b/src/contexts/MapInteractionContext.js @@ -0,0 +1,9 @@ +import React from "react"; + +const MapInteractionContext = React.createContext({ + translateRef: null, + scaleRef: null, +}); +export const MapInteractionProvider = MapInteractionContext.Provider; + +export default MapInteractionContext; diff --git a/src/helpers/drawing.js b/src/helpers/drawing.js index 27a60fc..89a192b 100644 --- a/src/helpers/drawing.js +++ b/src/helpers/drawing.js @@ -34,19 +34,22 @@ export function getBrushPositionForTool( if (shape.type === "fog") { const points = shape.data.points; const isInShape = Vector2.pointInPolygon(position, points); - // Find the closest point to each line 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]; - const distanceToLine = Vector2.distanceToLine(position, a, b); + + const { + distance: distanceToLine, + point: pointOnLine, + } = Vector2.distanceToLine(position, a, b); const isCloseToShape = distanceToLine < minGrid * snappingThreshold; if ( (isInShape || isCloseToShape) && distanceToLine < closestDistance ) { - closestPosition = Vector2.closestPointOnLine(position, a, b); + closestPosition = pointOnLine; closestDistance = distanceToLine; } } @@ -154,6 +157,30 @@ export function shapeHasFill(shape) { ); } +export function pointsToQuadraticBezier(points) { + const quadraticPoints = []; + + // Draw a smooth curve between the points where each control point + // is the current point in the array and the next point is the center of + // the current point and the next point + for (let i = 1; i < points.length - 2; i++) { + const start = points[i - 1]; + const controlPoint = points[i]; + const next = points[i + 1]; + const end = Vector2.divide(Vector2.add(controlPoint, next), 2); + + quadraticPoints.push({ start, controlPoint, end }); + } + // Curve through the last two points + quadraticPoints.push({ + start: points[points.length - 2], + controlPoint: points[points.length - 1], + end: points[points.length - 1], + }); + + return quadraticPoints; +} + export function pointsToPathSmooth(points, close, canvasWidth, canvasHeight) { const path = new Path2D(); if (points.length < 2) { @@ -161,27 +188,23 @@ export function pointsToPathSmooth(points, close, canvasWidth, canvasHeight) { } path.moveTo(points[0].x * canvasWidth, points[0].y * canvasHeight); - // Draw a smooth curve between the points - for (let i = 1; i < points.length - 2; i++) { - const pointScaled = Vector2.multiply(points[i], { + const quadraticPoints = pointsToQuadraticBezier(points); + for (let quadPoint of quadraticPoints) { + const pointScaled = Vector2.multiply(quadPoint.end, { x: canvasWidth, y: canvasHeight, }); - const nextPointScaled = Vector2.multiply(points[i + 1], { + const controlScaled = Vector2.multiply(quadPoint.controlPoint, { x: canvasWidth, y: canvasHeight, }); - var xc = (pointScaled.x + nextPointScaled.x) / 2; - var yc = (pointScaled.y + nextPointScaled.y) / 2; - path.quadraticCurveTo(pointScaled.x, pointScaled.y, xc, yc); + path.quadraticCurveTo( + controlScaled.x, + controlScaled.y, + pointScaled.x, + pointScaled.y + ); } - // Curve through the last two points - path.quadraticCurveTo( - points[points.length - 2].x * canvasWidth, - points[points.length - 2].y * canvasHeight, - points[points.length - 1].x * canvasWidth, - points[points.length - 1].y * canvasHeight - ); if (close) { path.closePath(); @@ -265,21 +288,12 @@ export function shapeToPath(shape, canvasWidth, canvasHeight) { return pointsToPathSharp(data.points, true, canvasWidth, canvasHeight); } } else if (shape.type === "fog") { - if (shape.fogType === "smooth") { - return pointsToPathSmooth( - shape.data.points, - true, - canvasWidth, - canvasHeight - ); - } else if (shape.fogType === "sharp") { - return pointsToPathSharp( - shape.data.points, - true, - canvasWidth, - canvasHeight - ); - } + return pointsToPathSharp( + shape.data.points, + true, + canvasWidth, + canvasHeight + ); } } @@ -330,8 +344,11 @@ export function drawShape(shape, context, gridSize, canvasWidth, canvasHeight) { } const defaultSimplifySize = 1 / 100; -export function simplifyPoints(points, gridSize) { - return simplify(points, Vector2.min(gridSize) * defaultSimplifySize); +export function simplifyPoints(points, gridSize, scale) { + return simplify( + points, + (Vector2.min(gridSize) * defaultSimplifySize) / scale + ); } export function getRelativePointerPosition(event, container) { diff --git a/src/helpers/vector2.js b/src/helpers/vector2.js index 56e529c..41be521 100644 --- a/src/helpers/vector2.js +++ b/src/helpers/vector2.js @@ -78,19 +78,93 @@ export function roundTo(p, to) { }; } +export function sign(a) { + return { x: Math.sign(a.x), y: Math.sign(a.y) }; +} + +export function abs(a) { + return { x: Math.abs(a.x), y: Math.abs(a.y) }; +} + +export function pow(a, b) { + if (typeof b === "number") { + return { x: Math.pow(a.x, b), y: Math.pow(a.y, b) }; + } else { + return { x: Math.pow(a.x, b.x), y: Math.pow(a.y, b.y) }; + } +} + +export function dot2(a) { + return dot(a, a); +} + +export function clamp(a, min, max) { + return { + x: Math.min(Math.max(a.x, min), max), + y: Math.min(Math.max(a.y, min), max), + }; +} + // https://iquilezles.org/www/articles/distfunctions2d/distfunctions2d export function distanceToLine(p, a, b) { const pa = subtract(p, a); const ba = subtract(b, a); const h = Math.min(Math.max(dot(pa, ba) / dot(ba, ba), 0), 1); - return length(subtract(pa, multiply(ba, h))); + const distance = length(subtract(pa, multiply(ba, h))); + const point = add(a, multiply(ba, h)); + return { distance, point }; } -export function closestPointOnLine(p, a, b) { - const pa = subtract(p, a); - const ba = subtract(b, a); - const h = dot(pa, ba) / lengthSquared(ba); - return add(a, multiply(ba, h)); +// TODO: Fix the robustness of this to allow smoothing on fog layers +// https://www.shadertoy.com/view/MlKcDD +export function distanceToQuadraticBezier(pos, A, B, C) { + let distance = 0; + let point = { x: pos.x, y: pos.y }; + + const a = subtract(B, A); + const b = add(subtract(A, multiply(B, 2)), C); + const c = multiply(a, 2); + const d = subtract(A, pos); + + // Solve cubic roots to find closest points + const kk = 1 / dot(b, b); + const kx = kk * dot(a, b); + const ky = (kk * (2 * dot(a, a) + dot(d, b))) / 3; + const kz = kk * dot(d, a); + + const p = ky - kx * kx; + const p3 = p * p * p; + const q = kx * (2 * kx * kx - 3 * ky) + kz; + let h = q * q + 4 * p3; + + if (h >= 0) { + // 1 root + h = Math.sqrt(h); + const x = divide(subtract({ x: h, y: -h }, q), 2); + const uv = multiply(sign(x), pow(abs(x), 1 / 3)); + const t = Math.min(Math.max(uv.x + uv.y - kx, 0), 1); + point = add(A, multiply(add(c, multiply(b, t)), t)); + distance = dot2(add(d, multiply(add(c, multiply(b, t)), t))); + } else { + // 3 roots but ignore the 3rd one as it will never be closest + // https://www.shadertoy.com/view/MdXBzB + const z = Math.sqrt(-p); + const v = Math.acos(q / (p * z * 2)) / 3; + const m = Math.cos(v); + const n = Math.sin(v) * 1.732050808; + + const t = clamp(subtract(multiply({ x: m + m, y: -n - m }, z), kx), 0, 1); + const d1 = dot2(add(d, multiply(add(c, multiply(b, t.x)), t.x))); + const d2 = dot2(add(d, multiply(add(c, multiply(b, t.y)), t.y))); + distance = Math.min(d1, d2); + if (d1 < d2) { + point = add(d, multiply(add(c, multiply(b, t.x)), t.x)); + } else { + point = add(d, multiply(add(c, multiply(b, t.y)), t.y)); + } + } + + return { distance: Math.sqrt(distance), point: point }; } export function getBounds(points) {