From 3d42b62b74a8056ec3ba5302a042309412132eab Mon Sep 17 00:00:00 2001 From: Mitchell McCaffrey Date: Thu, 10 Jun 2021 11:02:54 +1000 Subject: [PATCH] Add shape detection to token outline creation --- src/components/map/MapToken.js | 84 ++++++++++++++++++++++++++-------- src/helpers/Vector2.js | 60 ++++++++++++++++++++++++ src/helpers/image.js | 75 ++++++++++++++++++++++++++++++ src/helpers/token.js | 13 +----- src/tokens/index.js | 2 +- src/upgrade.js | 7 +-- 6 files changed, 204 insertions(+), 37 deletions(-) diff --git a/src/components/map/MapToken.js b/src/components/map/MapToken.js index e30b36c..532e5dd 100644 --- a/src/components/map/MapToken.js +++ b/src/components/map/MapToken.js @@ -246,34 +246,68 @@ function MapToken({ tokenName = tokenName + "-locked"; } - let outline = tokenState.outline; - if (Array.isArray(tokenState.outline)) { - outline = [...outline]; // Copy array so we can edit it imutably - for (let i = 0; i < outline.length; i += 2) { - // Scale outline to the token - outline[i] = (outline[i] / tokenState.width) * tokenWidth; - outline[i + 1] = (outline[i + 1] / tokenState.height) * tokenHeight; + function getScaledOutline() { + let outline = tokenState.outline; + if (outline.type === "rect") { + return { + ...outline, + x: (outline.x / tokenState.width) * tokenWidth, + y: (outline.y / tokenState.height) * tokenHeight, + width: (outline.width / tokenState.width) * tokenWidth, + height: (outline.height / tokenState.height) * tokenHeight, + }; + } else if (outline.type === "circle") { + return { + ...outline, + x: (outline.x / tokenState.width) * tokenWidth, + y: (outline.y / tokenState.height) * tokenHeight, + radius: (outline.radius / tokenState.width) * tokenWidth, + }; + } else { + let points = [...outline.points]; // Copy array so we can edit it imutably + for (let i = 0; i < points.length; i += 2) { + // Scale outline to the token + points[i] = (points[i] / tokenState.width) * tokenWidth; + points[i + 1] = (points[i + 1] / tokenState.height) * tokenHeight; + } + return { ...outline, points }; } } function renderOutline() { + const outline = getScaledOutline(); const sharedProps = { fill: colors.black, - width: tokenWidth, - height: tokenHeight, - x: 0, - y: 0, - rotation: tokenState.rotation, - offsetX: tokenWidth / 2, - offsetY: tokenHeight / 2, opacity: 0.8, }; - if (outline === "rect") { - return ; - } else if (outline === "circle") { - return ; + if (outline.type === "rect") { + return ( + + ); + } else if (outline.type === "circle") { + return ( + + ); } else { - return ; + return ( + + ); } } @@ -299,7 +333,17 @@ function MapToken({ id={tokenState.id} > {!tokenImage ? ( - renderOutline() + + {renderOutline()} + ) : ( 0) { + center = { x: center.x / points.length, y: center.y / points.length }; + } + return center; + } + + /** + * Determine whether given points are rectangular + * @param {Vector2[]} points + * @returns {boolean} + */ + static rectangular(points) { + if (points.length !== 4) { + return false; + } + // Check whether distance to the center is the same for all four points + const centroid = this.centroid(points); + let prevDist; + for (let point of points) { + const dist = this.distance(point, centroid); + if (prevDist && dist !== prevDist) { + return false; + } else { + prevDist = dist; + } + } + return true; + } + + /** + * Determine whether given points are circular + * @param {Vector2[]} points + * @returns {boolean} + */ + static circular(points, threshold = 0.1) { + const centroid = this.centroid(points); + let distances = []; + for (let point of points) { + distances.push(this.distance(point, centroid)); + } + if (distances.length > 0) { + const maxDistance = Math.max(...distances); + const minDistance = Math.min(...distances); + return maxDistance - minDistance < threshold; + } else { + return false; + } + } } export default Vector2; diff --git a/src/helpers/image.js b/src/helpers/image.js index 00f012c..64f9765 100644 --- a/src/helpers/image.js +++ b/src/helpers/image.js @@ -1,4 +1,7 @@ +import imageOutline from "image-outline"; + import blobToBuffer from "./blobToBuffer"; +import Vector2 from "./Vector2"; const lightnessDetectionOffset = 0.1; @@ -152,3 +155,75 @@ export async function createThumbnail(image, type, size = 300, quality = 0.5) { mime: type, }; } + +/** + * @typedef CircleOutline + * @property {"circle"} type + * @property {number} x - Center X of the circle + * @property {number} y - Center Y of the circle + * @property {number} radius + */ + +/** + * @typedef RectOutline + * @property {"rect"} type + * @property {number} width + * @property {number} height + * @property {number} x - Leftmost X position of the rect + * @property {number} y - Topmost Y position of the rect + */ + +/** + * @typedef PathOutline + * @property {"path"} type + * @property {number[]} points - Alternating x, y coordinates zipped together + */ + +/** + * @typedef {CircleOutline|RectOutline|PathOutline} Outline + */ + +/** + * Get the outline of an image + * @param {HTMLImageElement} image + * @returns {Outline} + */ +export function getImageOutline(image, maxPoints = 100) { + let baseOutline = imageOutline(image); + + if (baseOutline) { + if (baseOutline.length > maxPoints) { + baseOutline = Vector2.resample(baseOutline, maxPoints); + } + const bounds = Vector2.getBoundingBox(baseOutline); + if (Vector2.rectangular(baseOutline)) { + return { + type: "rect", + x: Math.round(bounds.min.x), + y: Math.round(bounds.min.y), + width: Math.round(bounds.width), + height: Math.round(bounds.height), + }; + } else if ( + Vector2.circular( + baseOutline, + Math.max(bounds.width / 10, bounds.height / 10) + ) + ) { + return { + type: "circle", + x: Math.round(bounds.center.x), + y: Math.round(bounds.center.y), + radius: Math.round(Math.min(bounds.width, bounds.height) / 2), + }; + } else { + // Flatten and round outline to save on storage size + const points = baseOutline + .map(({ x, y }) => [Math.round(x), Math.round(y)]) + .flat(); + return { type: "path", points }; + } + } else { + return { type: "rect", x: 0, y: 0, width: 1, height: 1 }; + } +} diff --git a/src/helpers/token.js b/src/helpers/token.js index 5c8a137..9319ad2 100644 --- a/src/helpers/token.js +++ b/src/helpers/token.js @@ -1,10 +1,8 @@ import { v4 as uuid } from "uuid"; import Case from "case"; -import imageOutline from "image-outline"; import blobToBuffer from "./blobToBuffer"; -import { createThumbnail } from "./image"; -import Vector2 from "./Vector2"; +import { createThumbnail, getImageOutline } from "./image"; export function createTokenState(token, position, userId) { let tokenState = { @@ -76,14 +74,7 @@ export async function createTokenFromFile(file, userId) { }; assets.push(fileAsset); - let outline = imageOutline(image); - if (outline.length > 100) { - outline = Vector2.resample(outline, 100); - } - // Flatten and round outline to save on storage size - outline = outline - .map(({ x, y }) => [Math.round(x), Math.round(y)]) - .flat(); + const outline = getImageOutline(image); const token = { name, diff --git a/src/tokens/index.js b/src/tokens/index.js index 7826052..baf607d 100644 --- a/src/tokens/index.js +++ b/src/tokens/index.js @@ -97,7 +97,7 @@ export function getDefaultTokens(userId) { hideInSidebar: false, width: 256, height: 256, - outline: "circle", + outline: { type: "circle", x: 128, y: 128, radius: 128 }, owner: userId, created: tokenKeys.length - i, lastModified: Date.now(), diff --git a/src/upgrade.js b/src/upgrade.js index 941e9e7..521c5c9 100644 --- a/src/upgrade.js +++ b/src/upgrade.js @@ -555,11 +555,8 @@ export const versions = { token.defaultCategory = token.category; delete token.category; token.defaultLabel = ""; - if (token.width === token.height) { - token.outline = "circle"; - } else { - token.outline = "rect"; - } + // TODO: move to outline detection + token.outline = { type: "circle", x: 256, y: 256, radius: 256 }; delete token.lastUsed; }); });