555 lines
14 KiB
TypeScript
555 lines
14 KiB
TypeScript
import simplify from "simplify-js";
|
|
import polygonClipping, { Geom, Polygon, Ring } from "polygon-clipping";
|
|
|
|
import Vector2, { BoundingBox } from "./Vector2";
|
|
import Size from "./Size"
|
|
import { toDegrees } from "./shared";
|
|
import { Grid, getNearestCellCoordinates, getCellLocation } from "./grid";
|
|
|
|
/**
|
|
* @typedef PointsData
|
|
* @property {Vector2[]} points
|
|
*/
|
|
|
|
type PointsData = {
|
|
points: Vector2[]
|
|
}
|
|
|
|
/**
|
|
* @typedef RectData
|
|
* @property {number} x
|
|
* @property {number} y
|
|
* @property {number} width
|
|
* @property {number} height
|
|
*/
|
|
|
|
type RectData = {
|
|
x: number,
|
|
y: number,
|
|
width: number,
|
|
height: number
|
|
}
|
|
|
|
/**
|
|
* @typedef CircleData
|
|
* @property {number} x
|
|
* @property {number} y
|
|
* @property {number} radius
|
|
*/
|
|
|
|
type CircleData = {
|
|
x: number,
|
|
y: number,
|
|
radius: number
|
|
}
|
|
|
|
/**
|
|
* @typedef FogData
|
|
* @property {Vector2[]} points
|
|
* @property {Vector2[][]} holes
|
|
*/
|
|
|
|
type FogData = {
|
|
points: Vector2[]
|
|
holes: Vector2[][]
|
|
}
|
|
|
|
/**
|
|
* @typedef {(PointsData|RectData|CircleData)} ShapeData
|
|
*/
|
|
|
|
type ShapeData = PointsData | RectData | CircleData
|
|
|
|
/**
|
|
* @typedef {("line"|"rectangle"|"circle"|"triangle")} ShapeType
|
|
*/
|
|
|
|
type ShapeType = "line" | "rectangle" | "circle" | "triangle"
|
|
|
|
/**
|
|
* @typedef {("fill"|"stroke")} PathType
|
|
*/
|
|
|
|
type PathType = "fill" | "stroke"
|
|
|
|
/**
|
|
* @typedef Path
|
|
* @property {boolean} blend
|
|
* @property {string} color
|
|
* @property {PointsData} data
|
|
* @property {string} id
|
|
* @property {PathType} pathType
|
|
* @property {number} strokeWidth
|
|
* @property {"path"} type
|
|
*/
|
|
|
|
export type Path = {
|
|
blend: boolean,
|
|
color: string,
|
|
data: PointsData,
|
|
id: string,
|
|
pathType: PathType,
|
|
strokeWidth: number,
|
|
type: "path"
|
|
}
|
|
|
|
/**
|
|
* @typedef Shape
|
|
* @property {boolean} blend
|
|
* @property {string} color
|
|
* @property {ShapeData} data
|
|
* @property {string} id
|
|
* @property {ShapeType} shapeType
|
|
* @property {number} strokeWidth
|
|
* @property {"shape"} type
|
|
*/
|
|
|
|
export type Shape = {
|
|
blend: boolean,
|
|
color: string,
|
|
data: ShapeData,
|
|
id: string,
|
|
shapeType: ShapeType,
|
|
strokeWidth: number,
|
|
type: "shape"
|
|
}
|
|
|
|
/**
|
|
* @typedef Fog
|
|
* @property {string} color
|
|
* @property {FogData} data
|
|
* @property {string} id
|
|
* @property {number} strokeWidth
|
|
* @property {"fog"} type
|
|
* @property {boolean} visible
|
|
*/
|
|
|
|
export type Fog = {
|
|
color: string,
|
|
data: FogData,
|
|
id: string,
|
|
strokeWidth: number,
|
|
type: "fog",
|
|
visible: boolean
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {ShapeType} type
|
|
* @param {Vector2} brushPosition
|
|
* @returns {ShapeData}
|
|
*/
|
|
export function getDefaultShapeData(type: ShapeType, brushPosition: Vector2): ShapeData | undefined{
|
|
// TODO: handle undefined if no type found
|
|
if (type === "line") {
|
|
return {
|
|
points: [
|
|
{ x: brushPosition.x, y: brushPosition.y },
|
|
{ x: brushPosition.x, y: brushPosition.y },
|
|
],
|
|
} as PointsData;
|
|
} else if (type === "circle") {
|
|
return { x: brushPosition.x, y: brushPosition.y, radius: 0 } as CircleData;
|
|
} else if (type === "rectangle") {
|
|
return {
|
|
x: brushPosition.x,
|
|
y: brushPosition.y,
|
|
width: 0,
|
|
height: 0,
|
|
} as RectData;
|
|
} else if (type === "triangle") {
|
|
return {
|
|
points: [
|
|
{ x: brushPosition.x, y: brushPosition.y },
|
|
{ x: brushPosition.x, y: brushPosition.y },
|
|
{ x: brushPosition.x, y: brushPosition.y },
|
|
],
|
|
} as PointsData;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Vector2} cellSize
|
|
* @returns {Vector2}
|
|
*/
|
|
export function getGridCellRatio(cellSize: Vector2): Vector2 {
|
|
if (cellSize.x < cellSize.y) {
|
|
return { x: cellSize.y / cellSize.x, y: 1 };
|
|
} else if (cellSize.y < cellSize.x) {
|
|
return { x: 1, y: cellSize.x / cellSize.y };
|
|
} else {
|
|
return { x: 1, y: 1 };
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {ShapeType} type
|
|
* @param {ShapeData} data
|
|
* @param {Vector2} brushPosition
|
|
* @param {Vector2} gridCellNormalizedSize
|
|
* @returns {ShapeData}
|
|
*/
|
|
export function getUpdatedShapeData(
|
|
type: ShapeType,
|
|
data: ShapeData,
|
|
brushPosition: Vector2,
|
|
gridCellNormalizedSize: Vector2,
|
|
mapWidth: number,
|
|
mapHeight: number
|
|
): ShapeData | undefined {
|
|
// TODO: handle undefined type
|
|
if (type === "line") {
|
|
data = data as PointsData;
|
|
return {
|
|
points: [data.points[0], { x: brushPosition.x, y: brushPosition.y }],
|
|
} as PointsData;
|
|
} else if (type === "circle") {
|
|
data = data as CircleData;
|
|
const gridRatio = getGridCellRatio(gridCellNormalizedSize);
|
|
const dif = Vector2.subtract(brushPosition, {
|
|
x: data.x,
|
|
y: data.y,
|
|
});
|
|
const scaled = Vector2.multiply(dif, gridRatio);
|
|
const distance = Vector2.setLength(scaled);
|
|
return {
|
|
...data,
|
|
radius: distance,
|
|
};
|
|
} else if (type === "rectangle") {
|
|
data = data as RectData;
|
|
const dif = Vector2.subtract(brushPosition, { x: data.x, y: data.y });
|
|
return {
|
|
...data,
|
|
width: dif.x,
|
|
height: dif.y,
|
|
};
|
|
} else if (type === "triangle") {
|
|
data = data as PointsData;
|
|
// Convert to absolute coordinates
|
|
const mapSize = { x: mapWidth, y: mapHeight };
|
|
const brushPositionPixel = Vector2.multiply(brushPosition, mapSize);
|
|
|
|
const points = data.points;
|
|
const startPixel = Vector2.multiply(points[0], mapSize);
|
|
const dif = Vector2.subtract(brushPositionPixel, startPixel);
|
|
const length = Vector2.setLength(dif);
|
|
const direction = Vector2.normalize(dif);
|
|
// Get the angle for a triangle who's width is the same as it's length
|
|
const angle = Math.atan(length / 2 / (length === 0 ? 1 : length));
|
|
const sideLength = length / Math.cos(angle);
|
|
|
|
const leftDir = Vector2.rotateDirection(direction, toDegrees(angle));
|
|
const rightDir = Vector2.rotateDirection(direction, -toDegrees(angle));
|
|
|
|
// Convert back to normalized coordinates
|
|
const leftDirNorm = Vector2.divide(leftDir, mapSize);
|
|
const rightDirNorm = Vector2.divide(rightDir, mapSize);
|
|
|
|
return {
|
|
points: [
|
|
points[0],
|
|
Vector2.add(Vector2.multiply(leftDirNorm, sideLength), points[0]),
|
|
Vector2.add(Vector2.multiply(rightDirNorm, sideLength), points[0]),
|
|
],
|
|
};
|
|
}
|
|
}
|
|
|
|
const defaultSimplifySize = 1 / 100;
|
|
/**
|
|
* Simplify points to a grid size
|
|
* @param {Vector2[]} points
|
|
* @param {Vector2} gridCellSize
|
|
* @param {number} scale
|
|
*/
|
|
export function simplifyPoints(points: Vector2[], gridCellSize: Vector2, scale: number): any {
|
|
return simplify(
|
|
points,
|
|
(Vector2.min(gridCellSize) as number * defaultSimplifySize) / scale
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Merges overlapping fog shapes
|
|
* @param {Fog[]} shapes
|
|
* @param {boolean} ignoreHidden
|
|
* @returns {Fog[]}
|
|
*/
|
|
export function mergeFogShapes(shapes: Fog[], ignoreHidden: boolean = true): Fog[] {
|
|
if (shapes.length === 0) {
|
|
return shapes;
|
|
}
|
|
let geometries: Geom[] = [];
|
|
for (let shape of shapes) {
|
|
if (ignoreHidden && !shape.visible) {
|
|
continue;
|
|
}
|
|
const shapePoints: Ring = shape.data.points.map(({ x, y }) => [x, y]);
|
|
const shapeHoles: Polygon = shape.data.holes.map((hole) =>
|
|
hole.map(({ x, y }: { x: number, y: number }) => [x, y])
|
|
);
|
|
let shapeGeom: Geom = [[shapePoints, ...shapeHoles]];
|
|
geometries.push(shapeGeom);
|
|
}
|
|
if (geometries.length === 0) {
|
|
return [];
|
|
}
|
|
try {
|
|
let union = polygonClipping.union(geometries[0], ...geometries.slice(1));
|
|
let merged: Fog[] = [];
|
|
for (let i = 0; i < union.length; i++) {
|
|
let holes: Vector2[][] = [];
|
|
if (union[i].length > 1) {
|
|
for (let j = 1; j < union[i].length; j++) {
|
|
holes.push(union[i][j].map(([x, y]) => ({ x, y })));
|
|
}
|
|
}
|
|
// find the first visible shape
|
|
let visibleShape = shapes.find((shape) => ignoreHidden || shape.visible);
|
|
if (!visibleShape) {
|
|
// TODO: handle if visible shape not found
|
|
throw Error;
|
|
}
|
|
merged.push({
|
|
// Use the data of the first visible shape as the merge
|
|
...visibleShape,
|
|
id: `merged-${i}`,
|
|
data: {
|
|
points: union[i][0].map(([x, y]) => ({ x, y })),
|
|
holes,
|
|
},
|
|
type: "fog"
|
|
});
|
|
}
|
|
return merged;
|
|
} catch {
|
|
console.error("Unable to merge shapes");
|
|
return shapes;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Fog[]} shapes
|
|
* @param {boolean} maxPoints Max amount of points per shape to get bounds for
|
|
* @returns {Vector2.BoundingBox[]}
|
|
*/
|
|
export function getFogShapesBoundingBoxes(shapes: Fog[], maxPoints = 0): BoundingBox[] {
|
|
let boxes = [];
|
|
for (let shape of shapes) {
|
|
if (maxPoints > 0 && shape.data.points.length > maxPoints) {
|
|
continue;
|
|
}
|
|
boxes.push(Vector2.getBoundingBox(shape.data.points));
|
|
}
|
|
return boxes;
|
|
}
|
|
|
|
/**
|
|
* @typedef Edge
|
|
* @property {Vector2} start
|
|
* @property {Vector2} end
|
|
*/
|
|
|
|
// type Edge = {
|
|
// start: Vector2,
|
|
// end: Vector2
|
|
// }
|
|
|
|
/**
|
|
* @typedef Guide
|
|
* @property {Vector2} start
|
|
* @property {Vector2} end
|
|
* @property {("horizontal"|"vertical")} orientation
|
|
* @property {number} distance
|
|
*/
|
|
|
|
type Guide = {
|
|
start: Vector2,
|
|
end: Vector2,
|
|
orientation: "horizontal" | "vertical",
|
|
distance: number
|
|
}
|
|
|
|
/**
|
|
* @param {Vector2} brushPosition Brush position in pixels
|
|
* @param {Vector2} grid
|
|
* @param {Vector2} gridCellSize Grid cell size in pixels
|
|
* @param {Vector2} gridOffset
|
|
* @param {Vector2} gridCellOffset
|
|
* @param {number} snappingSensitivity
|
|
* @param {Vector2} mapSize
|
|
* @returns {Guide[]}
|
|
*/
|
|
export function getGuidesFromGridCell(
|
|
brushPosition: Vector2,
|
|
grid: Grid,
|
|
gridCellSize: Size,
|
|
gridOffset: Vector2,
|
|
gridCellOffset: Vector2,
|
|
snappingSensitivity: number,
|
|
mapSize: Vector2
|
|
): Guide[] {
|
|
let boundingBoxes = [];
|
|
// Add map bounds
|
|
boundingBoxes.push(
|
|
Vector2.getBoundingBox([
|
|
{ x: 0, y: 0 },
|
|
{ x: 1, y: 1 },
|
|
])
|
|
);
|
|
let offsetPosition = Vector2.subtract(
|
|
Vector2.subtract(brushPosition, gridOffset),
|
|
gridCellOffset
|
|
);
|
|
const cellCoords = getNearestCellCoordinates(
|
|
grid,
|
|
offsetPosition.x,
|
|
offsetPosition.y,
|
|
gridCellSize
|
|
);
|
|
let cellPosition = getCellLocation(
|
|
grid,
|
|
cellCoords.x,
|
|
cellCoords.y,
|
|
gridCellSize
|
|
);
|
|
cellPosition = Vector2.add(
|
|
Vector2.add(cellPosition, gridOffset),
|
|
gridCellOffset
|
|
);
|
|
// Normalize values so output is normalized
|
|
cellPosition = Vector2.divide(cellPosition, mapSize);
|
|
const gridCellNormalizedSize = Vector2.divide(gridCellSize, mapSize);
|
|
const brushPositionNorm = Vector2.divide(brushPosition, mapSize);
|
|
const boundingBox = Vector2.getBoundingBox([
|
|
{
|
|
x: cellPosition.x - gridCellNormalizedSize.x / 2,
|
|
y: cellPosition.y - gridCellNormalizedSize.y / 2,
|
|
},
|
|
{
|
|
x: cellPosition.x + gridCellNormalizedSize.x / 2,
|
|
y: cellPosition.y + gridCellNormalizedSize.y / 2,
|
|
},
|
|
]);
|
|
boundingBoxes.push(boundingBox);
|
|
return getGuidesFromBoundingBoxes(
|
|
brushPositionNorm,
|
|
boundingBoxes,
|
|
gridCellNormalizedSize,
|
|
snappingSensitivity
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param {Vector2} brushPosition
|
|
* @param {Vector2.BoundingBox[]} boundingBoxes
|
|
* @param {Vector2} gridCellSize
|
|
* @param {number} snappingSensitivity
|
|
* @returns {Guide[]}
|
|
*/
|
|
export function getGuidesFromBoundingBoxes(
|
|
brushPosition: Vector2,
|
|
boundingBoxes: BoundingBox[],
|
|
gridCellSize: Vector2, // TODO: check if this was meant to be of type Size
|
|
snappingSensitivity: number
|
|
): Guide[] {
|
|
let horizontalEdges = [];
|
|
let verticalEdges = [];
|
|
for (let bounds of boundingBoxes) {
|
|
horizontalEdges.push({
|
|
start: { x: bounds.min.x, y: bounds.min.y },
|
|
end: { x: bounds.max.x, y: bounds.min.y },
|
|
});
|
|
horizontalEdges.push({
|
|
start: { x: bounds.min.x, y: bounds.center.y },
|
|
end: { x: bounds.max.x, y: bounds.center.y },
|
|
});
|
|
horizontalEdges.push({
|
|
start: { x: bounds.min.x, y: bounds.max.y },
|
|
end: { x: bounds.max.x, y: bounds.max.y },
|
|
});
|
|
|
|
verticalEdges.push({
|
|
start: { x: bounds.min.x, y: bounds.min.y },
|
|
end: { x: bounds.min.x, y: bounds.max.y },
|
|
});
|
|
verticalEdges.push({
|
|
start: { x: bounds.center.x, y: bounds.min.y },
|
|
end: { x: bounds.center.x, y: bounds.max.y },
|
|
});
|
|
verticalEdges.push({
|
|
start: { x: bounds.max.x, y: bounds.min.y },
|
|
end: { x: bounds.max.x, y: bounds.max.y },
|
|
});
|
|
}
|
|
let guides: Guide[] = [];
|
|
for (let edge of verticalEdges) {
|
|
const distance = Math.abs(brushPosition.x - edge.start.x);
|
|
if (distance / gridCellSize.x < snappingSensitivity) {
|
|
guides.push({ ...edge, distance, orientation: "vertical" });
|
|
}
|
|
}
|
|
for (let edge of horizontalEdges) {
|
|
const distance = Math.abs(brushPosition.y - edge.start.y);
|
|
if (distance / gridCellSize.y < snappingSensitivity) {
|
|
guides.push({ ...edge, distance, orientation: "horizontal" });
|
|
}
|
|
}
|
|
return guides;
|
|
}
|
|
|
|
/**
|
|
* @param {Vector2} brushPosition
|
|
* @param {Guide[]} guides
|
|
* @returns {Guide[]}
|
|
*/
|
|
export function findBestGuides(brushPosition: Vector2, guides: Guide[]): Guide[] {
|
|
let bestGuides: Guide[] = [];
|
|
let verticalGuide = guides
|
|
.filter((guide) => guide.orientation === "vertical")
|
|
.sort((a, b) => a.distance - b.distance)[0];
|
|
let horizontalGuide = guides
|
|
.filter((guide) => guide.orientation === "horizontal")
|
|
.sort((a, b) => a.distance - b.distance)[0];
|
|
|
|
// Offset edges to match brush position
|
|
if (verticalGuide && !horizontalGuide) {
|
|
verticalGuide.start.y = Math.min(verticalGuide.start.y, brushPosition.y);
|
|
verticalGuide.end.y = Math.max(verticalGuide.end.y, brushPosition.y);
|
|
bestGuides.push(verticalGuide);
|
|
}
|
|
if (horizontalGuide && !verticalGuide) {
|
|
horizontalGuide.start.x = Math.min(
|
|
horizontalGuide.start.x,
|
|
brushPosition.x
|
|
);
|
|
horizontalGuide.end.x = Math.max(horizontalGuide.end.x, brushPosition.x);
|
|
bestGuides.push(horizontalGuide);
|
|
}
|
|
if (horizontalGuide && verticalGuide) {
|
|
verticalGuide.start.y = Math.min(
|
|
verticalGuide.start.y,
|
|
horizontalGuide.start.y
|
|
);
|
|
verticalGuide.end.y = Math.max(
|
|
verticalGuide.end.y,
|
|
horizontalGuide.start.y
|
|
);
|
|
|
|
horizontalGuide.start.x = Math.min(
|
|
horizontalGuide.start.x,
|
|
verticalGuide.start.x
|
|
);
|
|
horizontalGuide.end.x = Math.max(
|
|
horizontalGuide.end.x,
|
|
verticalGuide.start.x
|
|
);
|
|
|
|
bestGuides.push(horizontalGuide);
|
|
bestGuides.push(verticalGuide);
|
|
}
|
|
return bestGuides;
|
|
}
|