onsight
Version:
Interactive, easy to use JavaScript game framework.
240 lines (219 loc) • 10.8 kB
JavaScript
// 2D POLYGONS
// isPointInPolygon() Returns true if a point is contained within a polygon (convex only)
// isPointInConcavePolygon() Returns true if a point is contained within a concave polygon
// OBJECTS
// findObjects() Find objects in pixel data
// findHoles() Find holes in object (used within findObjects())
// floodFill() Floods an area of pixel data (used within findObjects())
// traceContour() Finds outer edge of a chunk of pixel data (used within findObjects())
// simplifyContour() Simplify point list with Douglas-Peucker algorithm (used within findObjects())
const ALPHA_THRESHOLD = 5; // 0 to 255
class PolyUtils {
/******************** 2D POLYGONS ********************/
/** Returns true if a point is contained within a polygon (convex only) */
static isPointInPolygon(point, polygon) {
// Ray casting algorithm, see: https://en.wikipedia.org/wiki/Point_in_polygon
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x;
const yi = polygon[i].y;
const xj = polygon[j].x;
const yj = polygon[j].y;
const intersect = ((yi > point.y) !== (yj > point.y)) && (point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi);
if (intersect) inside = !inside;
}
return inside;
}
/** Returns true if a point is contained within a concave polygon */
static isPointInConcavePolygon(point, polygon) {
// Winding number algorithm, see: https://en.wikipedia.org/wiki/Point_in_polygon
function isLeft(p1, p2, point) {
return (p2.x - p1.x) * (point.y - p1.y) - (point.x - p1.x) * (p2.y - p1.y);
}
let windingNumber = 0;
for (let i = 0; i < polygon.length; i++) {
const p1 = polygon[i];
const p2 = polygon[(i + 1) % polygon.length];
if (p1.y <= point.y) {
if (p2.y > point.y && isLeft(p1, p2, point) > 0) windingNumber++;
} else {
if (p2.y <= point.y && isLeft(p1, p2, point) < 0) windingNumber--;
}
}
return windingNumber !== 0;
}
/******************** OBJECTS ********************/
/**
* Find objects in pixel data
* @param {ImageData} maskData - Pixel data (https://developer.mozilla.org/en-US/docs/Web/API/ImageData).
* @param {Number} simplify - Simplification intensity (~ 0.1 to 10-ish).
*/
static findObjects(maskData, simplify = 1.0) {
const width = maskData.width;
const height = maskData.height;
// Track pixels visited, start by flood filling outer perimeter
const visited = new Array(width * height).fill(false);
for (let x = 0; x < width; x++) PolyUtils.floodFill(x, 0, maskData, visited, 'border');
for (let x = 0; x < width; x++) PolyUtils.floodFill(x, height - 1, maskData, visited, 'border');
for (let y = 0; y < height; y++) PolyUtils.floodFill(0, y, maskData, visited, 'border');
for (let y = 0; y < height; y++) PolyUtils.floodFill(width - 1, y, maskData, visited, 'border');
// Find Objects
const objects = [];
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const alreadyVisited = visited[y * width + x];
const alphaValue = maskData.data[(y * width + x) * 4 + 3];
if (!alreadyVisited && alphaValue > ALPHA_THRESHOLD) {
const pixels = PolyUtils.floodFill(x, y, maskData, visited, 'object');
if (pixels.length > 0) {
const holes = PolyUtils.findHoles(pixels, maskData, visited);
const outerContour = PolyUtils.traceContour(pixels, maskData);
objects.push({ outerContour, holes });
}
}
}
}
// Simplify
const contours = [];
objects.forEach((object) => {
if (object.outerContour.length > 0) {
const simplifiedOuterContour = PolyUtils.simplifyContour(object.outerContour, simplify);
contours.push({ outerContour: simplifiedOuterContour, holes: [] });
// Process each hole
object.holes.forEach((hole) => {
if (hole.length > 0) {
const simplifiedHole = PolyUtils.simplifyContour(hole, simplify);
contours[contours.length - 1].holes.push(simplifiedHole);
}
});
}
});
return contours;
}
static findHoles(pixels, maskData, visited) {
const width = maskData.width;
const height = maskData.height;
const minX = Math.min(...pixels.map(point => point[0]));
const maxX = Math.max(...pixels.map(point => point[0]));
const minY = Math.min(...pixels.map(point => point[1]));
const maxY = Math.max(...pixels.map(point => point[1]));
// Create a copy of the visited array, mark pixels as visited
const objectVisited = [ ...visited ];
for (const [ x, y ] of pixels) {
objectVisited[y * width + x] = true;
}
for (let x = minX; x <= maxX; x++) PolyUtils.floodFill(x, minY, maskData, objectVisited, 'border');
for (let x = minX; x <= maxX; x++) PolyUtils.floodFill(x, maxY, maskData, objectVisited, 'border');
for (let y = minY; y <= maxY; y++) PolyUtils.floodFill(minX, y, maskData, objectVisited, 'border');
for (let y = minY; y <= maxY; y++) PolyUtils.floodFill(maxX, y, maskData, objectVisited, 'border');
// Find Holes
let holes = [];
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
const alreadyVisited = objectVisited[y * width + x];
const alphaValue = maskData.data[(y * width + x) * 4 + 3];
if (!alreadyVisited && alphaValue <= ALPHA_THRESHOLD) {
const hole = PolyUtils.floodFill(x, y, maskData, objectVisited, 'hole');
if (hole.length > 0) holes.push(PolyUtils.traceContour(hole, maskData, true /* isHole */));
}
}
}
return holes;
}
static floodFill(x, y, maskData, visited, type) {
const width = maskData.width;
const height = maskData.height;
const queue = [ [ x, y ] ];
const directions = [ [ 0, 1 ], [ 1, 0 ], [ 0, -1 ], [ -1, 0 ], ];
const floodedPixels = [];
while (queue.length > 0) {
const [ cx, cy ] = queue.shift();
const index = cy * width + cx;
if (cx < 0 || cx >= width) continue;
if (cy < 0 || cy >= height) continue;
if (visited[index]) continue;
if (type === 'border' && maskData.data[index * 4 + 3] > ALPHA_THRESHOLD) continue;
if (type === 'object' && maskData.data[index * 4 + 3] <= ALPHA_THRESHOLD) continue;
if (type === 'hole') { /* EMPTY */ }
visited[index] = true;
floodedPixels.push([ cx, cy ]);
for (let i = 0; i < 4; i++) {
const nx = cx + directions[i][0];
const ny = cy + directions[i][1];
queue.push([ nx, ny ]);
}
}
return floodedPixels;
}
static traceContour(pixels, maskData, isHole = false) {
if (!pixels || pixels.length === 0) return [];
const width = maskData.width;
const height = maskData.height;
const contour = [];
const directions = [ [ 1, 0 ], [ 0, 1 ], [ -1, 0 ], [ 0, -1 ], ];
let startX = pixels[0][0];
let startY = pixels[0][1];
let currentX = startX;
let currentY = startY;
let dir = isHole ? 2 : 0;
do {
contour.push([ currentX, currentY ]);
for (let i = 0; i < 4; i++) {
const newDir = (dir + i) % 4;
const newX = currentX + directions[newDir][0];
const newY = currentY + directions[newDir][1];
let opaque = true;
opaque = opaque && (newX >= 0 && newX < width && newY >= 0 && newY < height);
opaque = opaque && (maskData.data[((newY * width + newX) * 4) + 3] > ALPHA_THRESHOLD);
if (isHole ? !opaque : opaque) {
currentX = newX;
currentY = newY;
dir = (newDir + 3) % 4;
break;
}
}
} while (currentX !== startX || currentY !== startY);
return contour;
}
/** Simplify point list (https://en.wikipedia.org/wiki/Ramer%E2%80%93Douglas%E2%80%93Peucker_algorithm) */
static simplifyContour(contour, tolerance = 1) {
function perpendicularDistance(point, lineStart, lineEnd) {
const dx = lineEnd[0] - lineStart[0];
const dy = lineEnd[1] - lineStart[1];
const norm = Math.sqrt(dx * dx + dy * dy);
const nx = dy / norm;
const ny = -dx / norm;
const vx = point[0] - lineStart[0];
const vy = point[1] - lineStart[1];
return Math.abs(nx * vx + ny * vy);
}
function simplifySegment(contour, start, end, tolerance, simplified) {
let maxDist = 0;
let maxIndex = 0;
for (let i = start + 1; i < end; i++) {
const dist = perpendicularDistance(contour[i], contour[start], contour[end]);
if (dist > maxDist) {
maxDist = dist;
maxIndex = i;
}
}
if (maxDist > tolerance) {
simplifySegment(contour, start, maxIndex, tolerance, simplified);
simplified.push(contour[maxIndex]);
simplifySegment(contour, maxIndex, end, tolerance, simplified);
}
}
// Simplify polygon points
const simplified = [ contour[0] ];
simplifySegment(contour, 0, contour.length - 1, tolerance, simplified);
simplified.push(contour[contour.length - 1]);
// Check if the distance between the last and first point in simplified is less than tolerance
const dx = simplified[simplified.length - 1][0] - simplified[0][0];
const dy = simplified[simplified.length - 1][1] - simplified[0][1];
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < tolerance) simplified[simplified.length - 1] = simplified[0];
// Return simplified polygon
return simplified;
}
}
export { PolyUtils };