plotboilerplate
Version:
A simple javascript plotting boilerplate for 2d stuff.
431 lines • 21.6 kB
JavaScript
"use strict";
/**
* Calculate the inset of any non-self-overlapping polygon.
*
* The polygon may be
* - convex or non-convex
* - self-intersecting
*
* The algorithms will
* - first construct a general offset polygon which may be corrupt due to ouf-of-bounds or in illegal-area problems.
* - then dissect the possibly self-intersecting inset polygon into separate simple polygons.
* - then detect for each if it's out of bounds or in some illegal area.
* - keeping only the valid ones.
*
* @required sutherlandHodgman
* @required splitPolygonToNonIntersecting
*
* @author Ikaros Kappler
* @date 2024-11-04 Ported the script to a class.
* @modified 2024-12-02 Ported to Typescript.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.PolygonInset = void 0;
var Line_1 = require("../../Line");
var Polygon_1 = require("../../Polygon");
var Vector_1 = require("../../Vector");
var splitPolygonToNonIntersecting_1 = require("./splitPolygonToNonIntersecting");
var sutherlandHodgman_1 = require("./sutherlandHodgman");
var PolygonInset = /** @class */ (function () {
/**
* Constructs a new PolygonInset instance with the underlying given polygon to work with.
*
* Please note that the algorithm will reverse the vertex order if the given polygon
* is not clockwise.
*
* @param {Polygon} polygon - The polygon to calculate the offset for.
*/
function PolygonInset(polygon) {
/**
* The original polygon as a sequence of line objects. This is easier to use than a list of points.
*/
this.originalPolygonLines = [];
/**
* The simple inset lines, directly generated by offsetting the original polygon lines by the given amount.
*/
this.insetLines = [];
/**
* The cropped inset lines that makes up the first direct iteration of the inset polygon.
* This one will probably be self-intersecting.
*/
this.insetPolygonLines = [];
/**
* The inset polygon split up into simple non-self-intersecting polygons.
* Represented as a list of vertex-lists. Each sub-list represents a single polygon.
*/
this.splitPolygons = [];
this.polygon = polygon;
}
/**
* This is the main method.
*
* Call this with the required option params to compute your desired offset polygon(s).
*
* @param {number} options.innerPolygonOffset - The offset to use. Should be a positive number. Correct result for negative numbers are not guaranteed.
* @param {number?} options.maxPolygonSplitDepth - (default is the number of polygon.vertices) The maximum amount of recursive stepts to simplify the complex first iteration of the offset polygon.
* @param {number?} options.intersectionEpsilon - (default is 1.0) The epsilon to use for detecting overlapping polygons. This indicates a tolerance value, because directly adjecent polygons may have a little arithmetic intersection area.
* @returns {Array<Array<Vertex>>} A list of vertex-lists, each one representing a simple polygon from the offset-polygons.
*/
PolygonInset.prototype.computeOutputPolygons = function (options) {
var _a;
// No offset implies: no change.
if (options.innerPolygonOffset === 0) {
return [this.polygon.vertices]; // No change
}
// This algorithm only works with polygon in clockwise vertex order.
// If the polygon is not clockwise: revert.
PolygonInset._assertPolygonIsClockwise(this.polygon.vertices);
// The potential result polygons might overlap with other parts. These are not part of the desired
// result set and need to be filtered out.
// Define an intersection-epsilon: minimal critical overlaps might result of rounding errors and
// don't represent real overlaps.
var intersectionEpsilon = options.intersectionEpsilon;
// This will create a deep clone
this.optimizedPolygon = this.polygon.clone();
// In case the potential result polygons are self intersecting we need to split them apart later.
// Using the initial vertex number as the upper split limit should be safe here.
var maxPolygonSplitDepth = (_a = options === null || options === void 0 ? void 0 : options.maxPolygonSplitDepth) !== null && _a !== void 0 ? _a : this.optimizedPolygon.vertices.length;
// For calculations of initial inset rectagles we need the original polygon's lines.
this.originalPolygonLines = this.optimizedPolygon.getEdges();
this._collectInsetLines(this.originalPolygonLines, options.innerPolygonOffset);
this._collectInsetPolygonLines(this.insetLines);
this.insetRectanglePolygons = this._collectRectangularPolygonInsets(this.originalPolygonLines, this.insetLines);
// Optimize inset polygon AND the insetRectanglePolygons?
if (options.removeEars) {
// console.log("Remove ears");
// This will modify both params!
PolygonInset._optimizeInsetPolygon(this.optimizedPolygon, this.insetRectanglePolygons);
}
// Create a naive first inset polygon for later optimization.
this.insetPolygon = PolygonInset.convertToBasicInsetPolygon(this.insetPolygonLines);
// // Optimize inset polygon AND the insetRectanglePolygons!
// if (options.removeEars) {
// // This will modify both params!
// PolygonInset._optimizeInsetPolygon(this.insetPolygon, this.insetRectanglePolygons);
// }
// The resulting polygon will likely intersect itself multiple times.
// For further calculations split apart into non-self-intersecting.
this.splitPolygons = (0, splitPolygonToNonIntersecting_1.splitPolygonToNonIntersecting)(this.insetPolygon.vertices, maxPolygonSplitDepth, true); // insideBoundsOnly
// This method was initially meant to calculate inset-polygons only.
// But with a simple filter we COULD also create outer offset-polygons.
// Maybe this is a task for the future
// console.log("DEBUG", DEBUG);
// DEBUG("TEST");
if (options.innerPolygonOffset < 0) {
// return [this.polygon.vertices]; // No change
}
// console.log("splitPolygons.length", this.splitPolygons.length);
// Assert all polygons are clockwise!
PolygonInset._assertAllPolygonsAreClockwise(this.splitPolygons);
this.filteredSplitPolygons = PolygonInset._filterInnerSplitPolygonsByCoverage(this.splitPolygons, this.insetRectanglePolygons, intersectionEpsilon);
// console.log("[0] filteredSplitPolygons.length", this.filteredSplitPolygons.length);
this.filteredSplitPolygons = PolygonInset._filterInnerSplitPolygonsByOriginalBounds(this.filteredSplitPolygons, this.polygon);
// console.log("[1] filteredSplitPolygons.length", this.filteredSplitPolygons.length);
return this.filteredSplitPolygons;
};
/**
* This method transforms each polygon line into a new line
* by moving it to the inside direction of the polygon (by the given `insetAmount`).
*
* @param {Array<Line>} polygonLines
* @param {number} insetAmount
* @return {Array<Line>} The transformed lines. The result array has the same length and order as the input array.
*/
PolygonInset.prototype._collectInsetLines = function (polygonLines, insetAmount) {
var insetLines = []; // Array<Line>
for (var i = 0; i < polygonLines.length; i++) {
var line = polygonLines[i];
var perp = new Vector_1.Vector(line.a, line.b).perp();
var t = insetAmount / perp.length();
var offsetOnPerp = perp.vertAt(t);
var diff = line.a.difference(offsetOnPerp);
// Polygon is is clockwise order.
// Move line inside polygon
var movedLine = line.clone();
movedLine.a.add(diff);
movedLine.b.add(diff);
insetLines.push(movedLine);
}
this.insetLines = insetLines;
return insetLines;
};
/**
* For a sequence of inset polygon lines get the inset polygon by detecting
* useful intersections (by cropping or extending them).
*
* The returned lines resemble a new polygon.
*
* Please note that the returned polygon can be self-intersecting!
*
* @param {Array<Line>} insetLines
* @returns {Array<Line>} The cropped or exented inset polygon lines.
*/
PolygonInset.prototype._collectInsetPolygonLines = function (insetLines) {
if (insetLines.length <= 1) {
return [];
}
var insetPolygonLines = [];
// Collect first intersection at beginning :)
var lastInsetLine = insetLines[insetLines.length - 1];
var firstInsetLine = insetLines[0];
var lastIntersectionPoint = lastInsetLine.intersection(firstInsetLine); // Must not be null
for (var i = 0; i < insetLines.length; i++) {
var insetLine = insetLines[i];
var nextInsetLine = insetLines[(i + 1) % insetLines.length];
// Find desired intersection
var intersection = insetLine.intersection(nextInsetLine);
if (intersection == null) {
console.warn("[collectInsetPolygon] WARN intersection line must not be null", i, nextInsetLine);
}
else {
// By construction they MUST have any non-null intersection!
if (lastIntersectionPoint != null) {
var resultLine = new Line_1.Line(lastIntersectionPoint, intersection);
insetPolygonLines.push(resultLine);
}
}
lastIntersectionPoint = intersection;
}
// Store intermediate result for later retrieval.
this.insetPolygonLines = insetPolygonLines;
return insetPolygonLines;
};
/**
* Converts two lists (same length) of original polygon lines and inset lines (interpreted as
* pairs) to a list of rectangular polyons.
*
* @static
* @param {Array<Line>} originalPolygonLines
* @param {Array<Line>} insetLines
* @returns {Array<Polygon>} A list of rectangular polygons; each returned polyon has exactly four vertices.
*/
PolygonInset.prototype._collectRectangularPolygonInsets = function (originalPolygonLines, insetLines) {
// Convert to rectangle polygon
var insetRectanglePolygons = originalPolygonLines.map(function (polygonLine, index) {
var rectPolygon = new Polygon_1.Polygon([], false);
// Add in original order
rectPolygon.vertices.push(polygonLine.a.clone());
rectPolygon.vertices.push(polygonLine.b.clone());
// Add in reverse order
var insetLine = insetLines[index];
rectPolygon.vertices.push(insetLine.b.clone());
rectPolygon.vertices.push(insetLine.a.clone());
return rectPolygon;
});
return insetRectanglePolygons;
};
/**
* Converts a sequence of (hopefully adjacent) lines to a polygon by using all second line vertices `b`.
*
* @static
* @param insetPolygonLines
* @returns
*/
PolygonInset.convertToBasicInsetPolygon = function (insetPolygonLines) {
var insetPolygon = new Polygon_1.Polygon([], false);
insetPolygonLines.forEach(function (insetLine) {
insetPolygon.vertices.push(insetLine.a);
});
return insetPolygon;
};
/**
* Filter split polygons: only keep those whose vertices are all contained inside the original polygon.
* Reason: scaling too much will result in excessive translation beyond the opposite bounds of the polygon (like more than 200% of possible insetting).
*
* @param {Array<Array<Vertex>>} splitPolygonsVertices
* @param {Polygon} originalPolygon
* @return {Array<Array<Vertex>>} The filtered polygon list.
*/
PolygonInset._filterInnerSplitPolygonsByOriginalBounds = function (splitPolygonsVertices, originalPolygon) {
return splitPolygonsVertices.filter(function (splitPolyVerts, _splitPolyIndex) {
return splitPolyVerts.every(function (splitPVert) {
return originalPolygon.containsVert(splitPVert);
});
});
};
/**
* Filter split polygons: only keep those that do not (signifiantly) interset with any rectangles.
*
* @static
* @param {Array<Array<Vertex>>} splitPolygonsVertices
* @param {Array<Polygon>} insetRectanglePolygons
* @param {number?=1.0} intersectionEpsilon - (optional, default is 1.0) A epsislon to define a tolerance for checking if two polygons intersect.
*/
PolygonInset._filterInnerSplitPolygonsByCoverage = function (splitPolygonsVertices, insetRectanglePolygons, intersectionEpsilon) {
// TEST: Add some jitter
// splitPolygonsVertices.forEach(split => {
// split.forEach(vert => {
// vert.x += (0.5 - Math.random()) * 0.01;
// vert.y += (0.5 - Math.random()) * 0.01;
// });
// });
splitPolygonsVertices.forEach(function (split, index) {
var isCW = Polygon_1.Polygon.utils.isClockwise(split);
if (!isCW) {
console.warn("------ split is not isClockwise!", index, isCW);
}
});
insetRectanglePolygons.forEach(function (rect, index) {
var isCW = rect.isClockwise();
if (!isCW) {
console.warn("------ rect is not isClockwise!", index, isCW);
}
});
var eps = intersectionEpsilon === undefined || typeof intersectionEpsilon === "undefined" ? 1.0 : intersectionEpsilon;
return splitPolygonsVertices.filter(function (splitPolyVerts, _splitPolyIndex) {
var intersectionTestCallback = PolygonInset._hasIntersectionCallback(splitPolyVerts, eps, _splitPolyIndex);
var intersectsWithAnyRect = insetRectanglePolygons.some(intersectionTestCallback);
return !intersectsWithAnyRect;
});
};
/**
* This private method will reverse each polygon's vertex order that's not clockwise.
*
* @param {Array<Vertex[]>} polygons
*/
PolygonInset._assertAllPolygonsAreClockwise = function (polygons) {
polygons.forEach(function (polygonVerts, _polyIndex) {
// if (!Polygon.utils.isClockwise(polygonVerts)) {
// polygonVerts.reverse(); // Attention: this happens in-place (Array.reverse is destructive!)
// }
PolygonInset._assertPolygonIsClockwise(polygonVerts);
});
};
/**
* This private method will revert the vertex order if the polygon is not clockwise.
*
* @param {Vertex[]} polygonVerts
*/
PolygonInset._assertPolygonIsClockwise = function (polygonVerts) {
if (!Polygon_1.Polygon.utils.isClockwise(polygonVerts)) {
polygonVerts.reverse(); // Attention: this happens in-place (Array.reverse is destructive!)
}
};
/**
* Optimize the inset polygon by removing critical `ear` edges.
* Such an is identified by: the edge itself is completely located inside its neighbours inset rectangle.
*
* In this process multiple edges from the ear might be dropped and two adjacent edges get a new
* common intersection point.
*
* Note: this method is not working as expected in all cases and quite experimental.
*
* @param insetPolygon
* @param insetRectangles
*/
PolygonInset._optimizeInsetPolygon = function (insetPolygon, insetRectangles) {
// Locate edge that can be removed:
var earEdgeIndex = -1;
var maxLimit = insetPolygon.vertices.length;
var i = 0;
while (i++ < maxLimit &&
insetPolygon.vertices.length > 3 &&
(earEdgeIndex = PolygonInset._locateExcessiveEarEdge(insetPolygon, insetRectangles)) != -1) {
// console.log("REMOVE EAR EDGE", earEdgeIndex);
// As this is an ear edge: don't just remove the vertex.
// i) Remove the vertex and calculate the new intersection point of neighbour edges.
// ii) Update neightbour rectangles
var leftEdge = insetPolygon.getEdgeAt(earEdgeIndex - 2);
var rightEdge = insetPolygon.getEdgeAt(earEdgeIndex + 1);
var newIntersection = leftEdge.intersection(rightEdge);
newIntersection && rightEdge.a.set(newIntersection);
// Update rectangles
// ...
insetPolygon.vertices.splice(earEdgeIndex, 1);
insetRectangles.splice(earEdgeIndex, 1);
}
// TODO remove if not -1
};
PolygonInset._locateExcessiveEarEdge = function (insetPolygon, insetRectangles) {
for (var i = 0; i < insetPolygon.vertices.length; i++) {
var thisEdge = insetPolygon.getEdgeAt(i);
var leftIndex = i == 0 ? insetPolygon.vertices.length - 1 : i - 1;
// const rightIndex = i + 1 >= insetPolygon.vertices.length ? 0 : i + 1;
var leftRect = insetRectangles[leftIndex];
// const rightRect = insetRectangles[rightIndex];
var leftRectContainsThisEdge = PolygonInset._rectangleFullyContainsLine(leftRect, thisEdge);
if (leftRectContainsThisEdge) {
return i;
}
var leftEdge = insetPolygon.getEdgeAt(leftIndex);
var thisRect = insetRectangles[i];
var thisRectContainsLeftEdge = PolygonInset._rectangleFullyContainsLine(thisRect, leftEdge);
if (thisRectContainsLeftEdge) {
return i; // leftIndex;
}
}
return -1;
};
// Pre: rectangle.vertices.length === 4
PolygonInset._rectangleFullyContainsLine = function (rectangle, edge) {
var rectWidth = rectangle.getEdgeAt(0).length();
var rectHeight = rectangle.getEdgeAt(1).length();
// Both line corners must be at least 1% within the rectangle limits.
var eps = Math.min(rectWidth, rectHeight) / 100;
var edgeLen = edge.length();
var epsPointA = edge.vertAt(0.01); // edgeLen * eps);
var epsPointB = edge.vertAt(0.99); // 1.0 - edgeLen * eps);
var centerPoint = edge.vertAt(0.5);
return rectangle.containsVerts([epsPointA, epsPointB, centerPoint]);
};
/**
* For simplification I'm using a callback generator function here.
*
* @param splitPolyVerts
* @param eps
* @param _splitPolyIndex
* @returns {Function} The callback for an `Array.some` parameter.
*/
PolygonInset._hasIntersectionCallback = function (splitPolyVerts, eps, _splitPolyIndex) {
return function (rectanglePoly, _rectanglePolyIndex) {
// const intersectionVerts: XYCoords[] = sutherlandHodgman(splitPolyVerts, rectanglePoly.vertices);
var intersectionVerts = (0, sutherlandHodgman_1.sutherlandHodgman)(rectanglePoly.vertices, splitPolyVerts);
// var intersection = GreinerHorman.intersection(sourcePolygon.vertices, clipPolygon.vertices);
// const uniqueIntersectionVerts = clearDuplicateVertices(intersectionVerts);
var intersectionAreaSize = Polygon_1.Polygon.utils.area(intersectionVerts);
if (intersectionAreaSize < 0) {
console.warn("%cFound a polygon split with negative area. Counterclockwise against all odds?", "color: red; background: black;", // font-size: 30px",
"intersectionAreaSize", intersectionAreaSize, "_splitPolyIndex", _splitPolyIndex, "_rectanglePolyIndex", _rectanglePolyIndex, "intersectionVerts", intersectionVerts
// "uniqueIntersectionVerts",
// uniqueIntersectionVerts
);
}
// if (intersectionAreaSize >= eps) {
// console.log(
// "%cFound a polygon split for removal",
// "color: orange; background: grey;", // font-size: 30px",
// "intersectionAreaSize",
// intersectionAreaSize,
// "_splitPolyIndex",
// _splitPolyIndex,
// "_rectanglePolyIndex",
// _rectanglePolyIndex,
// "intersectionVerts",
// intersectionVerts
// // "uniqueIntersectionVerts",
// // uniqueIntersectionVerts
// );
// }
// if (intersectionAreaSize < eps) {
// // console.log("%cHello", "color: green; background: yellow; font-size: 30px");
// console.log(
// "Polygon split can be kept. Area is position but below epsilon.",
// "intersectionAreaSize",
// intersectionAreaSize,
// "_splitPolyIndex",
// _splitPolyIndex,
// "_rectanglePolyIndex",
// _rectanglePolyIndex,
// "intersectionVerts",
// intersectionVerts
// // "uniqueIntersectionVerts",
// // uniqueIntersectionVerts
// );
// }
return intersectionAreaSize >= eps;
};
};
return PolygonInset;
}());
exports.PolygonInset = PolygonInset;
//# sourceMappingURL=PolygonInset.js.map