Add shape detection to token outline creation

This commit is contained in:
Mitchell McCaffrey 2021-06-10 11:02:54 +10:00
parent 21986231fa
commit 3d42b62b74
6 changed files with 204 additions and 37 deletions

View File

@ -246,34 +246,68 @@ function MapToken({
tokenName = tokenName + "-locked"; tokenName = tokenName + "-locked";
} }
let outline = tokenState.outline; function getScaledOutline() {
if (Array.isArray(tokenState.outline)) { let outline = tokenState.outline;
outline = [...outline]; // Copy array so we can edit it imutably if (outline.type === "rect") {
for (let i = 0; i < outline.length; i += 2) { return {
// Scale outline to the token ...outline,
outline[i] = (outline[i] / tokenState.width) * tokenWidth; x: (outline.x / tokenState.width) * tokenWidth,
outline[i + 1] = (outline[i + 1] / tokenState.height) * tokenHeight; 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() { function renderOutline() {
const outline = getScaledOutline();
const sharedProps = { const sharedProps = {
fill: colors.black, fill: colors.black,
width: tokenWidth,
height: tokenHeight,
x: 0,
y: 0,
rotation: tokenState.rotation,
offsetX: tokenWidth / 2,
offsetY: tokenHeight / 2,
opacity: 0.8, opacity: 0.8,
}; };
if (outline === "rect") { if (outline.type === "rect") {
return <Rect {...sharedProps} />; return (
} else if (outline === "circle") { <Rect
return <Circle {...sharedProps} offsetX={0} offsetY={0} />; width={outline.width}
height={outline.height}
x={outline.x}
y={outline.y}
{...sharedProps}
/>
);
} else if (outline.type === "circle") {
return (
<Circle
radius={outline.radius}
x={outline.x}
y={outline.y}
{...sharedProps}
/>
);
} else { } else {
return <Line {...sharedProps} points={outline} closed tension={0.33} />; return (
<Line
points={outline.points}
closed
tension={outline.points < 200 ? 0 : 0.33}
{...sharedProps}
/>
);
} }
} }
@ -299,7 +333,17 @@ function MapToken({
id={tokenState.id} id={tokenState.id}
> >
{!tokenImage ? ( {!tokenImage ? (
renderOutline() <Group
width={tokenWidth}
height={tokenHeight}
x={0}
y={0}
rotation={tokenState.rotation}
offsetX={tokenWidth / 2}
offsetY={tokenHeight / 2}
>
{renderOutline()}
</Group>
) : ( ) : (
<KonvaImage <KonvaImage
ref={imageRef} ref={imageRef}

View File

@ -508,6 +508,66 @@ class Vector2 {
return { x: -p.y, y: p.x }; return { x: -p.y, y: p.x };
} }
} }
/**
* Returns the centroid of the given points
* @param {Vector2[]} points
* @returns {Vector2}
*/
static centroid(points) {
let center = { x: 0, y: 0 };
for (let point of points) {
center.x += point.x;
center.y += point.y;
}
if (points.length > 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; export default Vector2;

View File

@ -1,4 +1,7 @@
import imageOutline from "image-outline";
import blobToBuffer from "./blobToBuffer"; import blobToBuffer from "./blobToBuffer";
import Vector2 from "./Vector2";
const lightnessDetectionOffset = 0.1; const lightnessDetectionOffset = 0.1;
@ -152,3 +155,75 @@ export async function createThumbnail(image, type, size = 300, quality = 0.5) {
mime: type, 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 };
}
}

View File

@ -1,10 +1,8 @@
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import Case from "case"; import Case from "case";
import imageOutline from "image-outline";
import blobToBuffer from "./blobToBuffer"; import blobToBuffer from "./blobToBuffer";
import { createThumbnail } from "./image"; import { createThumbnail, getImageOutline } from "./image";
import Vector2 from "./Vector2";
export function createTokenState(token, position, userId) { export function createTokenState(token, position, userId) {
let tokenState = { let tokenState = {
@ -76,14 +74,7 @@ export async function createTokenFromFile(file, userId) {
}; };
assets.push(fileAsset); assets.push(fileAsset);
let outline = imageOutline(image); const outline = getImageOutline(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 token = { const token = {
name, name,

View File

@ -97,7 +97,7 @@ export function getDefaultTokens(userId) {
hideInSidebar: false, hideInSidebar: false,
width: 256, width: 256,
height: 256, height: 256,
outline: "circle", outline: { type: "circle", x: 128, y: 128, radius: 128 },
owner: userId, owner: userId,
created: tokenKeys.length - i, created: tokenKeys.length - i,
lastModified: Date.now(), lastModified: Date.now(),

View File

@ -555,11 +555,8 @@ export const versions = {
token.defaultCategory = token.category; token.defaultCategory = token.category;
delete token.category; delete token.category;
token.defaultLabel = ""; token.defaultLabel = "";
if (token.width === token.height) { // TODO: move to outline detection
token.outline = "circle"; token.outline = { type: "circle", x: 256, y: 256, radius: 256 };
} else {
token.outline = "rect";
}
delete token.lastUsed; delete token.lastUsed;
}); });
}); });