grungnet/src/helpers/grid.ts
2021-08-29 19:45:31 +10:00

577 lines
17 KiB
TypeScript

import GridSizeModel from "../ml/gridSize/GridSizeModel";
import Vector3 from "./Vector3";
import Vector2 from "./Vector2";
import Size from "./Size";
import { logError } from "./logging";
import { Grid, GridInset, GridScale } from "../types/Grid";
const SQRT3 = 1.73205;
const GRID_TYPE_NOT_IMPLEMENTED = new Error("Grid type not implemented");
/**
* Gets the size of a grid in pixels taking into account the inset
* @param {Grid} grid
* @param {number} baseWidth Width of the grid in pixels before inset
* @param {number} baseHeight Height of the grid in pixels before inset
* @returns {Size}
*/
export function getGridPixelSize(
grid: Required<Grid>,
baseWidth: number,
baseHeight: number
): Size {
const width = (grid.inset.bottomRight.x - grid.inset.topLeft.x) * baseWidth;
const height = (grid.inset.bottomRight.y - grid.inset.topLeft.y) * baseHeight;
return new Size(width, height);
}
/**
* Gets the cell size for a grid in pixels for each grid type
* @param {Grid} grid
* @param {number} gridWidth Width of the grid in pixels after inset
* @param {number} gridHeight Height of the grid in pixels after inset
* @returns {Size}
*/
export function getCellPixelSize(
grid: Grid,
gridWidth: number,
gridHeight: number
): Size {
if (grid.size.x === 0 || grid.size.y === 0) {
return new Size(0, 0);
}
switch (grid.type) {
case "square":
return new Size(gridWidth / grid.size.x, gridHeight / grid.size.y);
case "hexVertical":
const radiusVert = gridWidth / grid.size.x / SQRT3;
return new Size(radiusVert * SQRT3, radiusVert * 2, radiusVert);
case "hexHorizontal":
const radiusHorz = gridHeight / grid.size.y / SQRT3;
return new Size(radiusHorz * 2, radiusHorz * SQRT3, radiusHorz);
default:
throw GRID_TYPE_NOT_IMPLEMENTED;
}
}
/**
* Find the center location of a cell in the grid.
* Hex is addressed in an offset coordinate system with even numbered columns/rows offset to the right
* @param {Grid} grid
* @param {number} col X-axis coordinate of the cell
* @param {number} row Y-axis coordinate of the cell
* @param {Size} cellSize Cell size in pixels
* @returns {Vector2}
*/
export function getCellLocation(
grid: Grid,
col: number,
row: number,
cellSize: Size
): Vector2 {
switch (grid.type) {
case "square":
return {
x: col * cellSize.width + cellSize.width / 2,
y: row * cellSize.height + cellSize.height / 2,
};
case "hexVertical":
return {
x: cellSize.radius * SQRT3 * (col - 0.5 * (row & 1)),
y: ((cellSize.radius * 3) / 2) * row,
};
case "hexHorizontal":
return {
x: ((cellSize.radius * 3) / 2) * col,
y: cellSize.radius * SQRT3 * (row - 0.5 * (col & 1)),
};
default:
throw GRID_TYPE_NOT_IMPLEMENTED;
}
}
/**
* Find the coordinates of the nearest cell in the grid to a point in pixels
* @param {Grid} grid
* @param {number} x X location to look for in pixels
* @param {number} y Y location to look for in pixels
* @param {Size} cellSize Cell size in pixels
* @returns {Vector2}
*/
export function getNearestCellCoordinates(
grid: Grid,
x: number,
y: number,
cellSize: Size
): Vector2 {
switch (grid.type) {
case "square":
return Vector2.divide(Vector2.floorTo({ x, y }, cellSize), cellSize);
case "hexVertical":
// Find nearest cell in cube coordinates the convert to offset coordinates
const cubeXVert = ((SQRT3 / 3) * x - (1 / 3) * y) / cellSize.radius;
const cubeZVert = ((2 / 3) * y) / cellSize.radius;
const cubeYVert = -cubeXVert - cubeZVert;
const cubeVert = new Vector3(cubeXVert, cubeYVert, cubeZVert);
return hexCubeToOffset(Vector3.cubeRound(cubeVert), "hexVertical");
case "hexHorizontal":
const cubeXHorz = ((2 / 3) * x) / cellSize.radius;
const cubeZHorz = (-(1 / 3) * x + (SQRT3 / 3) * y) / cellSize.radius;
const cubeYHorz = -cubeXHorz - cubeZHorz;
const cubeHorz = new Vector3(cubeXHorz, cubeYHorz, cubeZHorz);
return hexCubeToOffset(Vector3.cubeRound(cubeHorz), "hexHorizontal");
default:
throw GRID_TYPE_NOT_IMPLEMENTED;
}
}
/**
* Find the corners of a grid cell
* @param {Grid} grid
* @param {number} x X location of the cell in pixels
* @param {number} y Y location of the cell in pixels
* @param {Size} cellSize Cell size in pixels
* @returns {Vector2[]}
*/
export function getCellCorners(
grid: Grid,
x: number,
y: number,
cellSize: Size
): Vector2[] {
const position = new Vector2(x, y);
switch (grid.type) {
case "square":
const halfSize = Vector2.multiply(cellSize, 0.5);
return [
Vector2.add(position, Vector2.multiply(halfSize, { x: -1, y: -1 })),
Vector2.add(position, Vector2.multiply(halfSize, { x: 1, y: -1 })),
Vector2.add(position, Vector2.multiply(halfSize, { x: 1, y: 1 })),
Vector2.add(position, Vector2.multiply(halfSize, { x: -1, y: 1 })),
];
case "hexVertical":
const up = Vector2.subtract(position, { x: 0, y: cellSize.radius });
return [
up,
Vector2.rotate(up, position, 60),
Vector2.rotate(up, position, 120),
Vector2.rotate(up, position, 180),
Vector2.rotate(up, position, 240),
Vector2.rotate(up, position, 300),
];
case "hexHorizontal":
const right = Vector2.add(position, { x: cellSize.radius, y: 0 });
return [
right,
Vector2.rotate(right, position, 60),
Vector2.rotate(right, position, 120),
Vector2.rotate(right, position, 180),
Vector2.rotate(right, position, 240),
Vector2.rotate(right, position, 300),
];
default:
throw GRID_TYPE_NOT_IMPLEMENTED;
}
}
/**
* Get the height of a grid based off of its width
* @param {Grid} grid
* @param {number} gridWidth Width of the grid in pixels after inset
* @returns {number}
*/
function getGridHeightFromWidth(
grid: Pick<Grid, "type" | "size">,
gridWidth: number
): number {
switch (grid.type) {
case "square":
return (grid.size.y * gridWidth) / grid.size.x;
case "hexVertical":
const cellHeightVert = (gridWidth / grid.size.x / SQRT3) * 2;
return grid.size.y * cellHeightVert * (3 / 4) + cellHeightVert * (1 / 4);
case "hexHorizontal":
const cellHeightHroz = gridWidth / ((grid.size.x - 1) * (3 / 4) + 1);
return grid.size.y * cellHeightHroz * (SQRT3 / 2);
default:
throw GRID_TYPE_NOT_IMPLEMENTED;
}
}
/**
* Get the default inset for a grid
* @param {Grid} grid Grid with no inset property set
* @param {number} mapWidth Width of the map in pixels before inset
* @param {number} mapHeight Height of the map in pixels before inset
* @returns {GridInset}
*/
export function getGridDefaultInset(
grid: Pick<Grid, "type" | "size">,
mapWidth: number,
mapHeight: number
): GridInset {
// Max the width of the inset and figure out the resulting height value
const insetHeightNorm = getGridHeightFromWidth(grid, mapWidth) / mapHeight;
return { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: insetHeightNorm } };
}
/**
* Get an updated inset for a grid when its size changes
* @param {Grid} grid Grid with an inset property set
* @param {number} mapWidth Width of the map in pixels before inset
* @param {number} mapHeight Height of the map in pixels before inset
* @returns {GridInset}
*/
export function getGridUpdatedInset(
grid: Grid,
mapWidth: number,
mapHeight: number
): GridInset {
let inset = {
topLeft: { ...grid.inset.topLeft },
bottomRight: { ...grid.inset.bottomRight },
};
// Take current inset width and use it to calculate the new height
if (grid.size.x > 0 && grid.size.x > 0) {
// Convert to px relative to map size
const gridWidth = (inset.bottomRight.x - inset.topLeft.x) * mapWidth;
// Calculate the new inset height and convert back to normalized form
const insetHeightNorm = getGridHeightFromWidth(grid, gridWidth) / mapHeight;
inset.bottomRight.y = inset.topLeft.y + insetHeightNorm;
}
return inset;
}
/**
* Get the max zoom for a grid
* @param {Grid=} grid
* @returns {number}
*/
export function getGridMaxZoom(grid?: Grid): number {
if (!grid) {
return 10;
}
// Return max grid size / 2
return Math.max(Math.max(grid.size.x, grid.size.y) / 2, 5);
}
/**
* Convert from a 3D cube hex representation to a 2D offset one
* @param {Vector3} cube Cube representation of the hex cell
* @param {("hexVertical"|"hexHorizontal")} type
* @returns {Vector2}
*/
export function hexCubeToOffset(
cube: Vector3,
type: "hexVertical" | "hexHorizontal"
) {
if (type === "hexVertical") {
const x = cube.x + (cube.z + (cube.z & 1)) / 2;
const y = cube.z;
return new Vector2(x, y);
} else {
const x = cube.x;
const y = cube.z + (cube.x + (cube.x & 1)) / 2;
return new Vector2(x, y);
}
}
/**
* Convert from a 2D offset hex representation to a 3D cube one
* @param {Vector2} offset Offset representation of the hex cell
* @param {("hexVertical"|"hexHorizontal")} type
* @returns {Vector3}
*/
export function hexOffsetToCube(
offset: Vector2,
type: "hexVertical" | "hexHorizontal"
) {
if (type === "hexVertical") {
const x = offset.x - (offset.y + (offset.y & 1)) / 2;
const z = offset.y;
const y = -x - z;
return { x, y, z };
} else {
const x = offset.x;
const z = offset.y - (offset.x + (offset.x & 1)) / 2;
const y = -x - z;
return { x, y, z };
}
}
/**
* Get the distance between a and b on the grid
* @param {Grid} grid
* @param {Vector2} a
* @param {Vector2} b
* @param {Size} cellSize
*/
export function gridDistance(
grid: Grid,
a: Vector2,
b: Vector2,
cellSize: Size
) {
// Get grid coordinates
const aCoord = getNearestCellCoordinates(grid, a.x, a.y, cellSize);
const bCoord = getNearestCellCoordinates(grid, b.x, b.y, cellSize);
if (grid.type === "square") {
if (grid.measurement.type === "chebyshev") {
return Vector2.componentMax(
Vector2.abs(Vector2.subtract(aCoord, bCoord))
);
} else if (grid.measurement.type === "alternating") {
// Alternating diagonal distance like D&D 3.5 and Pathfinder
const delta = Vector2.abs(Vector2.subtract(aCoord, bCoord));
const max = Vector2.componentMax(delta);
const min = Vector2.componentMin(delta);
return max + Math.floor(0.5 * min);
} else if (grid.measurement.type === "euclidean") {
return Vector2.magnitude(
Vector2.divide(Vector2.subtract(a, b), cellSize)
);
} else if (grid.measurement.type === "manhattan") {
return Math.abs(aCoord.x - bCoord.x) + Math.abs(aCoord.y - bCoord.y);
}
} else {
if (grid.measurement.type === "manhattan") {
// Convert to cube coordinates to get distance easier
const aCube = hexOffsetToCube(aCoord, grid.type);
const bCube = hexOffsetToCube(bCoord, grid.type);
return (
(Math.abs(aCube.x - bCube.x) +
Math.abs(aCube.y - bCube.y) +
Math.abs(aCube.z - bCube.z)) /
2
);
} else if (grid.measurement.type === "euclidean") {
return Vector2.magnitude(
Vector2.divide(Vector2.subtract(a, b), cellSize)
);
}
}
return 0;
}
/**
* Parse a string representation of scale e.g. 5ft into a `GridScale`
* @param {string} scale
* @returns {GridScale}
*/
export function parseGridScale(scale: string | null): GridScale {
if (typeof scale === "string") {
const match = scale.match(/(\d*)(\.\d*)?([a-zA-Z]*)/);
if (match) {
const integer = parseFloat(match[1]);
const fractional = parseFloat(match[2]);
const unit = match[3] || "";
if (!isNaN(integer) && !isNaN(fractional)) {
return {
multiplier: integer + fractional,
unit: unit,
digits: match[2].length - 1,
};
} else if (!isNaN(integer) && isNaN(fractional)) {
return { multiplier: integer, unit: unit, digits: 0 };
}
}
}
return { multiplier: 1, unit: "", digits: 0 };
}
/**
* Get all factors of a number
* @param {number} n
* @returns {number[]}
*/
function factors(n: number): number[] {
const numbers = Array.from(Array(n + 1), (_, i) => i);
return numbers.filter((i) => n % i === 0);
}
/**
* Greatest common divisor
* Uses the Euclidean algorithm https://en.wikipedia.org/wiki/Euclidean_algorithm
* @param {number} a
* @param {number} b
* @returns {number}
*/
function gcd(a: number, b: number): number {
while (b !== 0) {
const t = b;
b = a % b;
a = t;
}
return a;
}
/**
* Find all dividers that fit into two numbers
* @param {number} a
* @param {number} b
* @returns {number[]}
*/
function dividers(a: number, b: number): number[] {
const d = gcd(a, b);
return factors(d);
}
// The mean and standard deviation of > 1500 maps from the web
const gridSizeMean = { x: 31.567792, y: 32.597987 };
const gridSizeStd = { x: 14.438842, y: 15.582376 };
// Most grid sizes are above 10 and below 200
const minGridSize = 10;
const maxGridSize = 200;
/**
* Get whether the grid size is likely valid by checking whether it exceeds a bounds
* @param {number} x
* @param {number} y
* @returns {boolean}
*/
export function gridSizeVaild(x: number, y: number): boolean {
return (
x > minGridSize && y > minGridSize && x < maxGridSize && y < maxGridSize
);
}
/**
* Finds a grid size for an image by finding the closest size to the average grid size
* @param {HTMLImageElement} image
* @param {number[]} candidates
* @returns {Vector2 | null}
*/
function gridSizeHeuristic(
image: HTMLImageElement,
candidates: number[]
): Vector2 | null {
const width = image.width;
const height = image.height;
// Find the best candidate by comparing the absolute z-scores of each axis
let bestX = 1;
let bestY = 1;
let bestScore = Number.MAX_VALUE;
for (let scale of candidates) {
const x = Math.floor(width / scale);
const y = Math.floor(height / scale);
const xScore = Math.abs((x - gridSizeMean.x) / gridSizeStd.x);
const yScore = Math.abs((y - gridSizeMean.y) / gridSizeStd.y);
if (xScore < bestScore || yScore < bestScore) {
bestX = x;
bestY = y;
bestScore = Math.min(xScore, yScore);
}
}
if (gridSizeVaild(bestX, bestY)) {
return { x: bestX, y: bestY };
} else {
return null;
}
}
/**
* Finds the grid size of an image by running the image through a machine learning model
* @param {HTMLImageElement} image
* @param {number[]} candidates
* @returns {Vector2 | null}
*/
async function gridSizeML(
image: HTMLImageElement,
candidates: number[]
): Promise<Vector2 | null> {
// TODO: check this function because of context and CanvasImageSource -> JSDoc and Typescript do not match
const width = image.width;
const height = image.height;
const ratio = width / height;
let canvas = document.createElement("canvas");
let context = canvas.getContext("2d");
canvas.width = 2048;
canvas.height = Math.floor(2048 / ratio);
// TODO: handle if context is null
if (!context) {
return null;
}
context.drawImage(image, 0, 0, canvas.width, canvas.height);
let imageData = context.getImageData(
0,
Math.floor(canvas.height / 2) - 16,
2048,
32
);
for (let i = 0; i < imageData.data.length; i += 4) {
const r = imageData.data[i];
const g = imageData.data[i + 1];
const b = imageData.data[i + 2];
// ITU-R 601-2 Luma Transform
const luma = (r * 299) / 1000 + (g * 587) / 1000 + (b * 114) / 1000;
imageData.data[i] = imageData.data[i + 1] = imageData.data[i + 2] = luma;
}
const model = new GridSizeModel();
const prediction = await model.predict(imageData);
// Find the candidate that is closest to the prediction
let bestScale = 1;
let bestScore = Number.MAX_VALUE;
for (let scale of candidates) {
const x = Math.floor(width / scale);
const score = Math.abs(x - prediction);
if (score < bestScore && x > minGridSize && x < maxGridSize) {
bestScale = scale;
bestScore = score;
}
}
let x = Math.floor(width / bestScale);
let y = Math.floor(height / bestScale);
if (gridSizeVaild(x, y)) {
return { x, y };
} else {
// Fallback to raw prediction
x = Math.round(prediction);
y = Math.floor(x / ratio);
}
if (gridSizeVaild(x, y)) {
return { x, y };
} else {
return null;
}
}
/**
* Finds the grid size of an image by either using a ML model or falling back to a heuristic
* @param {HTMLImageElement} image
* @returns {Vector2}
*/
export async function getGridSizeFromImage(image: HTMLImageElement) {
const width = image.width;
const height = image.height;
const candidates = dividers(width, height);
let prediction;
// Try and use ML grid detection
try {
prediction = await gridSizeML(image, candidates);
} catch (error) {
logError(error);
}
if (!prediction) {
prediction = gridSizeHeuristic(image, candidates);
}
if (!prediction) {
prediction = { x: 22, y: 22 };
}
return prediction;
}