355 lines
15 KiB
JavaScript
355 lines
15 KiB
JavaScript
/**
|
|
* An implementation of the Weiler Atherton algorithm for clipping polygons.
|
|
* This currently only handles combinations that will not result in any holes.
|
|
* Support may be added for holes in the future.
|
|
*
|
|
* This algorithm is faster than the Clipper library for this task because it relies on the unique properties of the
|
|
* circle, ellipse, or convex simple clip object.
|
|
* It is also more precise in that it uses the actual intersection points between the circle/ellipse and polygon,
|
|
* instead of relying on the polygon approximation of the circle/ellipse to find the intersection points.
|
|
*
|
|
* For more explanation of the underlying algorithm, see:
|
|
* https://en.wikipedia.org/wiki/Weiler%E2%80%93Atherton_clipping_algorithm
|
|
* https://www.geeksforgeeks.org/weiler-atherton-polygon-clipping-algorithm
|
|
* https://h-educate.in/weiler-atherton-polygon-clipping-algorithm/
|
|
*/
|
|
class WeilerAthertonClipper {
|
|
/**
|
|
* Construct a WeilerAthertonClipper instance used to perform the calculation.
|
|
* @param {PIXI.Polygon} polygon Polygon to clip
|
|
* @param {PIXI.Rectangle|PIXI.Circle} clipObject Object used to clip the polygon
|
|
* @param {number} clipType Type of clip to use
|
|
* @param {object} clipOpts Object passed to the clippingObject methods toPolygon and pointsBetween
|
|
*/
|
|
constructor(polygon, clipObject, clipType, clipOpts) {
|
|
if ( !polygon.isPositive ) {
|
|
const msg = "WeilerAthertonClipper#constructor needs a subject polygon with a positive signed area.";
|
|
throw new Error(msg);
|
|
}
|
|
clipType ??= this.constructor.CLIP_TYPES.INTERSECT;
|
|
clipOpts ??= {};
|
|
this.polygon = polygon;
|
|
this.clipObject = clipObject;
|
|
this.config = { clipType, clipOpts };
|
|
}
|
|
|
|
/**
|
|
* The supported clip types.
|
|
* Values are equivalent to those in ClipperLib.ClipType.
|
|
* @enum {number}
|
|
*/
|
|
static CLIP_TYPES = Object.freeze({
|
|
INTERSECT: 0,
|
|
UNION: 1
|
|
});
|
|
|
|
/**
|
|
* The supported intersection types.
|
|
* @enum {number}
|
|
*/
|
|
static INTERSECTION_TYPES = Object.freeze({
|
|
OUT_IN: -1,
|
|
IN_OUT: 1,
|
|
TANGENT: 0
|
|
});
|
|
|
|
/** @type {PIXI.Polygon} */
|
|
polygon;
|
|
|
|
/** @type {PIXI.Rectangle|PIXI.Circle} */
|
|
clipObject;
|
|
|
|
/**
|
|
* Configuration settings
|
|
* @type {object} [config]
|
|
* @param {WeilerAthertonClipper.CLIP_TYPES} [config.clipType] One of CLIP_TYPES
|
|
* @param {object} [config.clipOpts] Object passed to the clippingObject methods
|
|
* toPolygon and pointsBetween
|
|
*/
|
|
config = {};
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Union a polygon and clipObject using the Weiler Atherton algorithm.
|
|
* @param {PIXI.Polygon} polygon Polygon to clip
|
|
* @param {PIXI.Rectangle|PIXI.Circle} clipObject Object to clip against the polygon
|
|
* @param {object} clipOpts Options passed to the clipping object
|
|
* methods toPolygon and pointsBetween
|
|
* @returns {PIXI.Polygon[]}
|
|
*/
|
|
static union(polygon, clipObject, clipOpts = {}) {
|
|
return this.combine(polygon, clipObject, {clipType: this.CLIP_TYPES.UNION, ...clipOpts});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Intersect a polygon and clipObject using the Weiler Atherton algorithm.
|
|
* @param {PIXI.Polygon} polygon Polygon to clip
|
|
* @param {PIXI.Rectangle|PIXI.Circle} clipObject Object to clip against the polygon
|
|
* @param {object} clipOpts Options passed to the clipping object
|
|
* methods toPolygon and pointsBetween
|
|
* @returns {PIXI.Polygon[]}
|
|
*/
|
|
static intersect(polygon, clipObject, clipOpts = {}) {
|
|
return this.combine(polygon, clipObject, {clipType: this.CLIP_TYPES.INTERSECT, ...clipOpts});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Clip a given clipObject using the Weiler-Atherton algorithm.
|
|
*
|
|
* At the moment, this will return a single PIXI.Polygon in the array unless clipType is a union and the polygon
|
|
* and clipObject do not overlap, in which case the [polygon, clipObject.toPolygon()] array will be returned.
|
|
* If this algorithm is expanded in the future to handle holes, an array of polygons may be returned.
|
|
*
|
|
* @param {PIXI.Polygon} polygon Polygon to clip
|
|
* @param {PIXI.Rectangle|PIXI.Circle} clipObject Object to clip against the polygon
|
|
* @param {object} [options] Options which configure how the union or intersection is computed
|
|
* @param {WeilerAthertonClipper.CLIP_TYPES} [options.clipType] One of CLIP_TYPES
|
|
* @param {boolean} [options.canMutate] If the WeilerAtherton constructor could mutate or not
|
|
* the subject polygon points
|
|
* @param {object} [options.clipOpts] Options passed to the WeilerAthertonClipper constructor
|
|
* @returns {PIXI.Polygon[]} Array of polygons and clipObjects
|
|
*/
|
|
static combine(polygon, clipObject, {clipType, canMutate, ...clipOpts}={}) {
|
|
if ( (clipType !== this.CLIP_TYPES.INTERSECT) && (clipType !== this.CLIP_TYPES.UNION) ) {
|
|
throw new Error("The Weiler-Atherton clipping algorithm only supports INTERSECT or UNION clip types.");
|
|
}
|
|
if ( canMutate && !polygon.isPositive ) polygon.reverseOrientation();
|
|
const wa = new this(polygon, clipObject, clipType, clipOpts);
|
|
const trackingArray = wa.#buildPointTrackingArray();
|
|
if ( !trackingArray.length ) return this.testForEnvelopment(polygon, clipObject, clipType, clipOpts);
|
|
return wa.#combineNoHoles(trackingArray);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Clip the polygon with the clipObject, assuming no holes will be created.
|
|
* For a union or intersect with no holes, a single pass through the intersections will
|
|
* build the resulting union shape.
|
|
* @param {PolygonVertex[]} trackingArray Array of linked points and intersections
|
|
* @returns {[PIXI.Polygon]}
|
|
*/
|
|
#combineNoHoles(trackingArray) {
|
|
const clipType = this.config.clipType;
|
|
const ln = trackingArray.length;
|
|
let prevIx = trackingArray[ln - 1];
|
|
let wasTracingPolygon = (prevIx.type === this.constructor.INTERSECTION_TYPES.OUT_IN) ^ clipType;
|
|
const newPoly = new PIXI.Polygon();
|
|
for ( let i = 0; i < ln; i += 1 ) {
|
|
const ix = trackingArray[i];
|
|
this.#processIntersection(ix, prevIx, wasTracingPolygon, newPoly);
|
|
wasTracingPolygon = !wasTracingPolygon;
|
|
prevIx = ix;
|
|
}
|
|
return [newPoly];
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Given an intersection and the previous intersection, fill the points
|
|
* between the two intersections, in clockwise order.
|
|
* @param {PolygonVertex} ix Intersection to process
|
|
* @param {PolygonVertex} prevIx Previous intersection to process
|
|
* @param {boolean} wasTracingPolygon Whether we were tracing the polygon (true) or the clipObject (false).
|
|
* @param {PIXI.Polygon} newPoly The new polygon that results from this clipping operation
|
|
*/
|
|
#processIntersection(ix, prevIx, wasTracingPolygon, newPoly) {
|
|
const clipOpts = this.config.clipOpts;
|
|
const pts = wasTracingPolygon ? ix.leadingPoints : this.clipObject.pointsBetween(prevIx, ix, clipOpts);
|
|
for ( const pt of pts ) newPoly.addPoint(pt);
|
|
newPoly.addPoint(ix);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Test if one shape envelops the other. Assumes the shapes do not intersect.
|
|
* 1. Polygon is contained within the clip object. Union: clip object; Intersect: polygon
|
|
* 2. Clip object is contained with polygon. Union: polygon; Intersect: clip object
|
|
* 3. Polygon and clip object are outside one another. Union: both; Intersect: null
|
|
* @param {PIXI.Polygon} polygon Polygon to clip
|
|
* @param {PIXI.Rectangle|PIXI.Circle} clipObject Object to clip against the polygon
|
|
* @param {WeilerAthertonClipper.CLIP_TYPES} clipType One of CLIP_TYPES
|
|
* @param {object} clipOpts Clip options which are forwarded to toPolygon methods
|
|
* @returns {PIXI.Polygon[]} Returns the polygon, the clipObject.toPolygon(), both, or neither.
|
|
*/
|
|
static testForEnvelopment(polygon, clipObject, clipType, clipOpts) {
|
|
const points = polygon.points;
|
|
if ( points.length < 6 ) return [];
|
|
const union = clipType === this.CLIP_TYPES.UNION;
|
|
|
|
// Option 1: Polygon contained within clipObject
|
|
// We search for the first point of the polygon that is not on the boundary of the clip object.
|
|
// One of these points can be used to determine whether the polygon is contained in the clip object.
|
|
// If all points of the polygon are on the boundary of the clip object, which is either a circle
|
|
// or a rectangle, then the polygon is contained within the clip object.
|
|
let polygonInClipObject = true;
|
|
for ( let i = 0; i < points.length; i += 2 ) {
|
|
const point = { x: points[i], y: points[i + 1] };
|
|
if ( !clipObject.pointIsOn(point) ) {
|
|
polygonInClipObject = clipObject.contains(point.x, point.y);
|
|
break;
|
|
}
|
|
}
|
|
if ( polygonInClipObject ) return union ? [clipObject.toPolygon(clipOpts)] : [polygon];
|
|
|
|
// Option 2: ClipObject contained within polygon
|
|
const center = clipObject.center;
|
|
|
|
// PointSourcePolygons need to have a bounds defined in order for polygon.contains to work.
|
|
if ( polygon instanceof PointSourcePolygon ) polygon.bounds ??= polygon.getBounds();
|
|
|
|
const clipObjectInPolygon = polygon.contains(center.x, center.y);
|
|
if ( clipObjectInPolygon ) return union ? [polygon] : [clipObject.toPolygon(clipOpts)];
|
|
|
|
// Option 3: Neither contains the other
|
|
return union ? [polygon, clipObject.toPolygon(clipOpts)] : [];
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Construct an array of intersections between the polygon and the clipping object.
|
|
* The intersections follow clockwise around the polygon.
|
|
* Round all intersections and polygon vertices to the nearest pixel (integer).
|
|
* @returns {Point[]}
|
|
*/
|
|
#buildPointTrackingArray() {
|
|
const labeledPoints = this.#buildIntersectionArray();
|
|
if ( !labeledPoints.length ) return [];
|
|
return WeilerAthertonClipper.#consolidatePoints(labeledPoints);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Construct an array that holds all the points of the polygon with all the intersections with the clipObject
|
|
* inserted, in correct position moving clockwise.
|
|
* If an intersection and endpoint are nearly the same, prefer the intersection.
|
|
* Intersections are labeled with isIntersection and type = out/in or in/out. Tangents are removed.
|
|
* @returns {Point[]} Labeled array of points
|
|
*/
|
|
#buildIntersectionArray() {
|
|
const { polygon, clipObject } = this;
|
|
const points = polygon.points;
|
|
const ln = points.length;
|
|
if ( ln < 6 ) return []; // Minimum 3 Points required
|
|
|
|
// Need to start with a non-intersecting point on the polygon.
|
|
let startIdx = -1;
|
|
let a;
|
|
for ( let i = 0; i < ln; i += 2 ) {
|
|
a = { x: points[i], y: points[i + 1] };
|
|
if ( !clipObject.pointIsOn(a) ) {
|
|
startIdx = i;
|
|
break;
|
|
}
|
|
}
|
|
if ( !~startIdx ) return []; // All intersections, so all tangent
|
|
|
|
// For each edge a|b, find the intersection point(s) with the clipObject.
|
|
// Add intersections and endpoints to the pointsIxs array, taking care to avoid duplicating
|
|
// points. For example, if the intersection equals a, add only the intersection, not both.
|
|
let previousInside = clipObject.contains(a.x, a.y);
|
|
let numPrevIx = 0;
|
|
let lastIx = undefined;
|
|
let secondLastIx = undefined;
|
|
const pointsIxs = [a];
|
|
const types = this.constructor.INTERSECTION_TYPES;
|
|
const nIter = startIdx + ln + 2; // Add +2 to close the polygon.
|
|
for ( let i = startIdx + 2; i < nIter; i += 2 ) {
|
|
const j = i >= ln ? i % ln : i; // Circle back around the points as necessary.
|
|
const b = { x: points[j], y: points[j + 1] };
|
|
const ixs = clipObject.segmentIntersections(a, b);
|
|
const ixsLn = ixs.length;
|
|
let bIsIx = false;
|
|
if ( ixsLn ) {
|
|
bIsIx = b.x.almostEqual(ixs[ixsLn - 1].x) && b.y.almostEqual(ixs[ixsLn - 1].y);
|
|
|
|
// If the intersection equals the current b, get that intersection next iteration.
|
|
if ( bIsIx ) ixs.pop();
|
|
|
|
// Determine whether the intersection is out-->in or in-->out
|
|
numPrevIx += ixs.length;
|
|
for ( const ix of ixs ) {
|
|
ix.isIntersection = true;
|
|
ix.type = lastIx ? -lastIx.type : previousInside ? types.IN_OUT : types.OUT_IN;
|
|
secondLastIx = lastIx;
|
|
lastIx = ix;
|
|
}
|
|
pointsIxs.push(...ixs);
|
|
}
|
|
|
|
// If b is an intersection, we will return to it next iteration.
|
|
if ( bIsIx ) {
|
|
a = b;
|
|
continue;
|
|
}
|
|
|
|
// Each intersection represents a move across the clipObject border.
|
|
// Count them and determine if we are now inside or outside the clipObject.
|
|
if ( numPrevIx ) {
|
|
const isInside = clipObject.contains(b.x, b.y);
|
|
const changedSide = isInside ^ previousInside;
|
|
const isOdd = numPrevIx & 1;
|
|
|
|
// If odd number of intersections, should switch. e.g., outside --> ix --> inside
|
|
// If even number of intersections, should stay same. e.g., outside --> ix --> ix --> outside.
|
|
if ( isOdd ^ changedSide ) {
|
|
if ( numPrevIx === 1 ) lastIx.isIntersection = false;
|
|
else {
|
|
secondLastIx.isIntersection = false;
|
|
lastIx.type = secondLastIx.type;
|
|
}
|
|
}
|
|
previousInside = isInside;
|
|
numPrevIx = 0;
|
|
secondLastIx = undefined;
|
|
lastIx = undefined;
|
|
}
|
|
pointsIxs.push(b);
|
|
a = b;
|
|
}
|
|
return pointsIxs;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Given an array of labeled points, consolidate into a tracking array of intersections,
|
|
* where each intersection contains its array of leadingPoints.
|
|
* @param {Point[]} labeledPoints Array of points, from _buildLabeledIntersectionsArray
|
|
* @returns {Point[]} Array of intersections
|
|
*/
|
|
static #consolidatePoints(labeledPoints) {
|
|
|
|
// Locate the first intersection
|
|
const startIxIdx = labeledPoints.findIndex(pt => pt.isIntersection);
|
|
if ( !~startIxIdx ) return []; // No intersections, so no tracking array
|
|
const labeledLn = labeledPoints.length;
|
|
let leadingPoints = [];
|
|
const trackingArray = [];
|
|
|
|
// Closed polygon, so use the last point to circle back
|
|
for ( let i = 0; i < labeledLn; i += 1 ) {
|
|
const j = (i + startIxIdx) % labeledLn;
|
|
const pt = labeledPoints[j];
|
|
if ( pt.isIntersection ) {
|
|
pt.leadingPoints = leadingPoints;
|
|
leadingPoints = [];
|
|
trackingArray.push(pt);
|
|
} else leadingPoints.push(pt);
|
|
}
|
|
|
|
// Add leading points to first intersection
|
|
trackingArray[0].leadingPoints = leadingPoints;
|
|
return trackingArray;
|
|
}
|
|
}
|