Add shape detection to token outline creation
This commit is contained in:
parent
21986231fa
commit
3d42b62b74
@ -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}
|
||||||
|
@ -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;
|
||||||
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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(),
|
||||||
|
@ -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;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user