UNPKG

plotboilerplate

Version:

A simple javascript plotting boilerplate for 2d stuff.

431 lines 21.6 kB
"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