plotboilerplate
Version:
A simple javascript plotting boilerplate for 2d stuff.
530 lines (478 loc) • 21.3 kB
text/typescript
/**
* 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.
*/
import { Line } from "../../Line";
import { Polygon } from "../../Polygon";
import { Vector } from "../../Vector";
import { Vertex } from "../../Vertex";
import { XYCoords } from "../../interfaces";
import { splitPolygonToNonIntersecting } from "./splitPolygonToNonIntersecting";
import { sutherlandHodgman } from "./sutherlandHodgman";
// import { GreinerHorman } from "greiner-hormann-typescript";
// import DEBUG from "debug";
export interface IPolygonInsetOptions {
innerPolygonOffset: number;
maxPolygonSplitDepth?: number;
intersectionEpsilon?: number;
removeEars?: boolean;
}
export class PolygonInset {
/**
* The polygon to work with.
*/
readonly polygon: Polygon;
/**
* The input polygon, but without ear edges.
*/
optimizedPolygon: Polygon;
/**
* The original polygon as a sequence of line objects. This is easier to use than a list of points.
*/
originalPolygonLines: Array<Line> = [];
/**
* The simple inset lines, directly generated by offsetting the original polygon lines by the given amount.
*/
insetLines: Array<Line> = [];
/**
* The cropped inset lines that makes up the first direct iteration of the inset polygon.
* This one will probably be self-intersecting.
*/
insetPolygonLines: Array<Line> = [];
/**
* The inset polygon lines resembling an actual polygon instance (not just a sequence of lines).
*/
insetPolygon: Polygon;
/**
* Each polygon line and it's offsetted inset-line resemble a rectangular polygon.
* Array<Polygon> with 4 vertices each
**/
insetRectanglePolygons: Array<Polygon>;
/**
* 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.
*/
splitPolygons: Array<Array<Vertex>> = [];
/**
* The final result: all valid split-polygons. These are making up the actual polygon inset.
* Note: list may be empty. Depending on the offset amount there is not a guaranteed offset polygon existing.
*/
filteredSplitPolygons: Array<Array<Vertex>>;
/**
* 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.
*/
constructor(polygon: Polygon) {
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.
*/
public computeOutputPolygons(options: IPolygonInsetOptions): Array<Array<Vertex>> {
// 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.
const intersectionEpsilon: number | undefined = 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.
const maxPolygonSplitDepth = options?.maxPolygonSplitDepth ?? 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 = 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.
*/
private _collectInsetLines(polygonLines: Array<Line>, insetAmount: number): Array<Line> {
const insetLines: Array<Line> = []; // Array<Line>
for (var i = 0; i < polygonLines.length; i++) {
const line = polygonLines[i];
const perp = new Vector(line.a, line.b).perp();
const t = insetAmount / perp.length();
const offsetOnPerp = perp.vertAt(t);
const diff = line.a.difference(offsetOnPerp);
// Polygon is is clockwise order.
// Move line inside polygon
const 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.
*/
private _collectInsetPolygonLines(insetLines: Array<Line>): Array<Line> {
if (insetLines.length <= 1) {
return [];
}
const insetPolygonLines: Array<Line> = [];
// Collect first intersection at beginning :)
const lastInsetLine: Line = insetLines[insetLines.length - 1];
const firstInsetLine: Line = insetLines[0];
let lastIntersectionPoint = lastInsetLine.intersection(firstInsetLine); // Must not be null
for (var i = 0; i < insetLines.length; i++) {
const insetLine: Line = insetLines[i];
const nextInsetLine: Line = insetLines[(i + 1) % insetLines.length];
// Find desired intersection
const intersection: Vertex | null = 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) {
const resultLine: Line = new 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.
*/
private _collectRectangularPolygonInsets(originalPolygonLines: Array<Line>, insetLines: Array<Line>): Array<Polygon> {
// Convert to rectangle polygon
const insetRectanglePolygons: Array<Polygon> = originalPolygonLines.map((polygonLine, index) => {
const rectPolygon: Polygon = new Polygon([], false);
// Add in original order
rectPolygon.vertices.push(polygonLine.a.clone());
rectPolygon.vertices.push(polygonLine.b.clone());
// Add in reverse order
const insetLine: Line = 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
*/
private static convertToBasicInsetPolygon(insetPolygonLines: Array<Line>): Polygon {
const insetPolygon = new Polygon([], false);
insetPolygonLines.forEach((insetLine: Line): void => {
insetPolygon.vertices.push(insetLine.a);
});
return insetPolygon;
}
/**
* 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
*/
private static _optimizeInsetPolygon = (insetPolygon: Polygon, insetRectangles: Array<Polygon>): void => {
// 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
};
private static _locateExcessiveEarEdge = (insetPolygon: Polygon, insetRectangles: Array<Polygon>): number => {
for (var i = 0; i < insetPolygon.vertices.length; i++) {
const thisEdge = insetPolygon.getEdgeAt(i);
const leftIndex = i == 0 ? insetPolygon.vertices.length - 1 : i - 1;
// const rightIndex = i + 1 >= insetPolygon.vertices.length ? 0 : i + 1;
const leftRect = insetRectangles[leftIndex];
// const rightRect = insetRectangles[rightIndex];
const leftRectContainsThisEdge = PolygonInset._rectangleFullyContainsLine(leftRect, thisEdge);
if (leftRectContainsThisEdge) {
return i;
}
const leftEdge = insetPolygon.getEdgeAt(leftIndex);
const thisRect = insetRectangles[i];
const thisRectContainsLeftEdge = PolygonInset._rectangleFullyContainsLine(thisRect, leftEdge);
if (thisRectContainsLeftEdge) {
return i; // leftIndex;
}
}
return -1;
};
// Pre: rectangle.vertices.length === 4
private static _rectangleFullyContainsLine = (rectangle: Polygon, edge: Line): boolean => {
var rectWidth: number = rectangle.getEdgeAt(0).length();
var rectHeight: number = rectangle.getEdgeAt(1).length();
// Both line corners must be at least 1% within the rectangle limits.
const eps = Math.min(rectWidth, rectHeight) / 100;
const 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]);
};
/**
* 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.
*/
private static _filterInnerSplitPolygonsByOriginalBounds(
splitPolygonsVertices: Array<Array<Vertex>>,
originalPolygon: Polygon
) {
return splitPolygonsVertices.filter((splitPolyVerts: Array<Vertex>, _splitPolyIndex: number): boolean => {
return splitPolyVerts.every((splitPVert: Vertex): boolean => {
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.
*/
private static _filterInnerSplitPolygonsByCoverage(
splitPolygonsVertices: Array<Array<Vertex>>,
insetRectanglePolygons: Array<Polygon>,
intersectionEpsilon?: number
) {
// 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((split, index) => {
const isCW: boolean = Polygon.utils.isClockwise(split);
if (!isCW) {
console.warn("------ split is not isClockwise!", index, isCW);
}
});
insetRectanglePolygons.forEach((rect, index) => {
const isCW: boolean = rect.isClockwise();
if (!isCW) {
console.warn("------ rect is not isClockwise!", index, isCW);
}
});
const eps: number =
intersectionEpsilon === undefined || typeof intersectionEpsilon === "undefined" ? 1.0 : intersectionEpsilon;
return splitPolygonsVertices.filter((splitPolyVerts: Vertex[], _splitPolyIndex: number) => {
const intersectionTestCallback = PolygonInset._hasIntersectionCallback(splitPolyVerts, eps, _splitPolyIndex);
const intersectsWithAnyRect = insetRectanglePolygons.some(intersectionTestCallback);
return !intersectsWithAnyRect;
});
}
/**
* This private method will reverse each polygon's vertex order that's not clockwise.
*
* @param {Array<Vertex[]>} polygons
*/
private static _assertAllPolygonsAreClockwise(polygons: Array<Vertex[]>): void {
polygons.forEach((polygonVerts: Vertex[], _polyIndex: number) => {
// 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
*/
private static _assertPolygonIsClockwise(polygonVerts: Vertex[]): void {
if (!Polygon.utils.isClockwise(polygonVerts)) {
polygonVerts.reverse(); // Attention: this happens in-place (Array.reverse is destructive!)
}
}
/**
* 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.
*/
private static _hasIntersectionCallback =
(splitPolyVerts: Vertex[], eps: number, _splitPolyIndex: number) =>
(rectanglePoly: Polygon, _rectanglePolyIndex: number): boolean => {
// const intersectionVerts: XYCoords[] = sutherlandHodgman(splitPolyVerts, rectanglePoly.vertices);
const intersectionVerts: XYCoords[] = sutherlandHodgman(rectanglePoly.vertices, splitPolyVerts);
// var intersection = GreinerHorman.intersection(sourcePolygon.vertices, clipPolygon.vertices);
// const uniqueIntersectionVerts = clearDuplicateVertices(intersectionVerts);
const intersectionAreaSize: number = 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;
};
}