UNPKG

@countertype/clipper2-ts

Version:

TypeScript port of Clipper2 polygon clipping and offsetting library

649 lines 26.4 kB
"use strict"; /******************************************************************************* * Author : Angus Johnson * * Date : 11 October 2025 * * Website : https://www.angusj.com * * Copyright : Angus Johnson 2010-2025 * * Purpose : Path Offset (Inflate/Shrink) * * License : https://www.boost.org/LICENSE_1_0.txt * *******************************************************************************/ Object.defineProperty(exports, "__esModule", { value: true }); exports.ClipperOffset = exports.EndType = exports.JoinType = void 0; const Core_1 = require("./Core"); const Engine_1 = require("./Engine"); var JoinType; (function (JoinType) { JoinType[JoinType["Miter"] = 0] = "Miter"; JoinType[JoinType["Square"] = 1] = "Square"; JoinType[JoinType["Bevel"] = 2] = "Bevel"; JoinType[JoinType["Round"] = 3] = "Round"; })(JoinType || (exports.JoinType = JoinType = {})); var EndType; (function (EndType) { EndType[EndType["Polygon"] = 0] = "Polygon"; EndType[EndType["Joined"] = 1] = "Joined"; EndType[EndType["Butt"] = 2] = "Butt"; EndType[EndType["Square"] = 3] = "Square"; EndType[EndType["Round"] = 4] = "Round"; })(EndType || (exports.EndType = EndType = {})); class Group { constructor(paths, joinType, endType = EndType.Polygon) { this.joinType = joinType; this.endType = endType; const isJoined = (endType === EndType.Polygon) || (endType === EndType.Joined); this.inPaths = []; for (const path of paths) { this.inPaths.push(ClipperOffset.stripDuplicates(path, isJoined)); } if (endType === EndType.Polygon) { const lowestInfo = ClipperOffset.getLowestPathInfo(this.inPaths); this.lowestPathIdx = lowestInfo.idx; // the lowermost path must be an outer path, so if its orientation is negative, // then flag that the whole group is 'reversed' (will negate delta etc.) // as this is much more efficient than reversing every path. this.pathsReversed = (this.lowestPathIdx >= 0) && lowestInfo.isNegArea; } else { this.lowestPathIdx = -1; this.pathsReversed = false; } } } class ClipperOffset { constructor(miterLimit = 2.0, arcTolerance = 0.0, preserveCollinear = false, reverseSolution = false) { this.groupList = []; this.pathOut = []; this.normals = []; this.solution = []; this.solutionTree = null; this.groupDelta = 0; //*0.5 for open paths; *-1.0 for negative areas this.delta = 0; this.mitLimSqr = 0; this.stepsPerRad = 0; this.stepSin = 0; this.stepCos = 0; this.joinType = JoinType.Bevel; this.endType = EndType.Polygon; this.arcTolerance = 0; this.mergeGroups = true; this.miterLimit = 2.0; this.preserveCollinear = false; this.reverseSolution = false; this.deltaCallback = null; this.miterLimit = miterLimit; this.arcTolerance = arcTolerance; this.mergeGroups = true; this.preserveCollinear = preserveCollinear; this.reverseSolution = reverseSolution; } clear() { this.groupList.length = 0; } addPath(path, joinType, endType) { if (path.length === 0) return; const pp = [path]; this.addPaths(pp, joinType, endType); } addPaths(paths, joinType, endType) { if (paths.length === 0) return; this.groupList.push(new Group(paths, joinType, endType)); } calcSolutionCapacity() { let result = 0; for (const g of this.groupList) { result += (g.endType === EndType.Joined) ? g.inPaths.length * 2 : g.inPaths.length; } return result; } checkPathsReversed() { let result = false; for (const g of this.groupList) { if (g.endType === EndType.Polygon) { result = g.pathsReversed; break; } } return result; } executeInternal(delta) { if (this.groupList.length === 0) return; // make sure the offset delta is significant if (Math.abs(delta) < 0.5) { for (const group of this.groupList) { for (const path of group.inPaths) { this.solution.push(path); } } return; } this.delta = delta; this.mitLimSqr = (this.miterLimit <= 1 ? 2.0 : 2.0 / ClipperOffset.sqr(this.miterLimit)); for (const group of this.groupList) { this.doGroupOffset(group); } if (this.groupList.length === 0) return; const pathsReversed = this.checkPathsReversed(); const fillRule = pathsReversed ? Core_1.FillRule.Negative : Core_1.FillRule.Positive; // clean up self-intersections ... const c = new Engine_1.Clipper64(); c.preserveCollinear = this.preserveCollinear; c.reverseSolution = this.reverseSolution !== pathsReversed; c.addSubject(this.solution); if (this.solutionTree !== null) { c.execute(Core_1.ClipType.Union, fillRule, this.solutionTree); } else { c.execute(Core_1.ClipType.Union, fillRule, this.solution); } } execute(delta, solutionOrTree) { if (Array.isArray(solutionOrTree)) { // Paths64 version const solution = solutionOrTree; solution.length = 0; this.solution = solution; this.executeInternal(delta); } else { // PolyTree64 version const solutionTree = solutionOrTree; solutionTree.clear(); this.solutionTree = solutionTree; this.solution = []; this.executeInternal(delta); } } executeWithCallback(deltaCallback, solution) { this.deltaCallback = deltaCallback; this.execute(1.0, solution); } static getUnitNormal(pt1, pt2) { const dx = (pt2.x - pt1.x); const dy = (pt2.y - pt1.y); if ((dx === 0) && (dy === 0)) return { x: 0, y: 0 }; const f = 1.0 / Math.sqrt(dx * dx + dy * dy); return { x: dy * f, y: -dx * f }; } static getLowestPathInfo(paths) { let idx = -1; let isNegArea = false; let botPt = { x: Number.MAX_SAFE_INTEGER, y: Number.MIN_SAFE_INTEGER }; for (let i = 0; i < paths.length; ++i) { let a = Number.MAX_VALUE; for (const pt of paths[i]) { if ((pt.y < botPt.y) || ((pt.y === botPt.y) && (pt.x >= botPt.x))) continue; if (a === Number.MAX_VALUE) { a = ClipperOffset.area(paths[i]); if (a === 0) break; // invalid closed path so break from inner loop isNegArea = a < 0; } idx = i; botPt.x = pt.x; botPt.y = pt.y; } } return { idx, isNegArea }; } static translatePoint(pt, dx, dy) { return { x: pt.x + dx, y: pt.y + dy }; } static reflectPoint(pt, pivot) { return { x: pivot.x + (pivot.x - pt.x), y: pivot.y + (pivot.y - pt.y) }; } static almostZero(value, epsilon = 0.001) { return Math.abs(value) < epsilon; } static hypotenuse(x, y) { return Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)); } static normalizeVector(vec) { const h = ClipperOffset.hypotenuse(vec.x, vec.y); if (ClipperOffset.almostZero(h)) return { x: 0, y: 0 }; const inverseHypot = 1 / h; return { x: vec.x * inverseHypot, y: vec.y * inverseHypot }; } static getAvgUnitVector(vec1, vec2) { return ClipperOffset.normalizeVector({ x: vec1.x + vec2.x, y: vec1.y + vec2.y }); } static intersectPoint(pt1a, pt1b, pt2a, pt2b) { if (Core_1.InternalClipper.isAlmostZero(pt1a.x - pt1b.x)) { // vertical if (Core_1.InternalClipper.isAlmostZero(pt2a.x - pt2b.x)) return { x: 0, y: 0 }; const m2 = (pt2b.y - pt2a.y) / (pt2b.x - pt2a.x); const b2 = pt2a.y - m2 * pt2a.x; return { x: pt1a.x, y: m2 * pt1a.x + b2 }; } if (Core_1.InternalClipper.isAlmostZero(pt2a.x - pt2b.x)) { // vertical const m1 = (pt1b.y - pt1a.y) / (pt1b.x - pt1a.x); const b1 = pt1a.y - m1 * pt1a.x; return { x: pt2a.x, y: m1 * pt2a.x + b1 }; } else { const m1 = (pt1b.y - pt1a.y) / (pt1b.x - pt1a.x); const b1 = pt1a.y - m1 * pt1a.x; const m2 = (pt2b.y - pt2a.y) / (pt2b.x - pt2a.x); const b2 = pt2a.y - m2 * pt2a.x; if (Core_1.InternalClipper.isAlmostZero(m1 - m2)) return { x: 0, y: 0 }; const x = (b2 - b1) / (m1 - m2); return { x: x, y: m1 * x + b1 }; } } getPerpendic(pt, norm) { return { x: Math.round(pt.x + norm.x * this.groupDelta), y: Math.round(pt.y + norm.y * this.groupDelta) }; } getPerpendicD(pt, norm) { return { x: pt.x + norm.x * this.groupDelta, y: pt.y + norm.y * this.groupDelta }; } doBevel(path, j, k) { let pt1, pt2; if (j === k) { const absDelta = Math.abs(this.groupDelta); pt1 = { x: Math.round(path[j].x - absDelta * this.normals[j].x), y: Math.round(path[j].y - absDelta * this.normals[j].y) }; pt2 = { x: Math.round(path[j].x + absDelta * this.normals[j].x), y: Math.round(path[j].y + absDelta * this.normals[j].y) }; } else { pt1 = { x: Math.round(path[j].x + this.groupDelta * this.normals[k].x), y: Math.round(path[j].y + this.groupDelta * this.normals[k].y) }; pt2 = { x: Math.round(path[j].x + this.groupDelta * this.normals[j].x), y: Math.round(path[j].y + this.groupDelta * this.normals[j].y) }; } this.pathOut.push(pt1); this.pathOut.push(pt2); } doSquare(path, j, k) { let vec; if (j === k) { vec = { x: this.normals[j].y, y: -this.normals[j].x }; } else { vec = ClipperOffset.getAvgUnitVector({ x: -this.normals[k].y, y: this.normals[k].x }, { x: this.normals[j].y, y: -this.normals[j].x }); } const absDelta = Math.abs(this.groupDelta); // now offset the original vertex delta units along unit vector let ptQ = { x: path[j].x, y: path[j].y }; ptQ = ClipperOffset.translatePoint(ptQ, absDelta * vec.x, absDelta * vec.y); // get perpendicular vertices const pt1 = ClipperOffset.translatePoint(ptQ, this.groupDelta * vec.y, this.groupDelta * -vec.x); const pt2 = ClipperOffset.translatePoint(ptQ, this.groupDelta * -vec.y, this.groupDelta * vec.x); // get 2 vertices along one edge offset const pt3 = this.getPerpendicD(path[k], this.normals[k]); if (j === k) { const pt4 = { x: pt3.x + vec.x * this.groupDelta, y: pt3.y + vec.y * this.groupDelta }; const pt = ClipperOffset.intersectPoint(pt1, pt2, pt3, pt4); //get the second intersect point through reflecion this.pathOut.push(Core_1.Point64Utils.fromPointD(ClipperOffset.reflectPoint(pt, ptQ))); this.pathOut.push(Core_1.Point64Utils.fromPointD(pt)); } else { const pt4 = this.getPerpendicD(path[j], this.normals[k]); const pt = ClipperOffset.intersectPoint(pt1, pt2, pt3, pt4); this.pathOut.push(Core_1.Point64Utils.fromPointD(pt)); //get the second intersect point through reflecion this.pathOut.push(Core_1.Point64Utils.fromPointD(ClipperOffset.reflectPoint(pt, ptQ))); } } doMiter(path, j, k, cosA) { const q = this.groupDelta / (cosA + 1); this.pathOut.push({ x: Math.round(path[j].x + (this.normals[k].x + this.normals[j].x) * q), y: Math.round(path[j].y + (this.normals[k].y + this.normals[j].y) * q) }); } doRound(path, j, k, angle) { if (this.deltaCallback !== null) { // when deltaCallback is assigned, groupDelta won't be constant, // so we'll need to do the following calculations for *every* vertex. const absDelta = Math.abs(this.groupDelta); const arcTol = this.arcTolerance > 0.01 ? this.arcTolerance : absDelta * ClipperOffset.arc_const; const stepsPer360 = Math.PI / Math.acos(1 - arcTol / absDelta); this.stepSin = Math.sin((2 * Math.PI) / stepsPer360); this.stepCos = Math.cos((2 * Math.PI) / stepsPer360); if (this.groupDelta < 0.0) this.stepSin = -this.stepSin; this.stepsPerRad = stepsPer360 / (2 * Math.PI); } const pt = path[j]; let offsetVec = { x: this.normals[k].x * this.groupDelta, y: this.normals[k].y * this.groupDelta }; if (j === k) Core_1.PointDUtils.negate(offsetVec); this.pathOut.push({ x: Math.round(pt.x + offsetVec.x), y: Math.round(pt.y + offsetVec.y) }); const steps = Math.ceil(this.stepsPerRad * Math.abs(angle)); for (let i = 1; i < steps; i++) { // ie 1 less than steps offsetVec = { x: offsetVec.x * this.stepCos - this.stepSin * offsetVec.y, y: offsetVec.x * this.stepSin + offsetVec.y * this.stepCos }; this.pathOut.push({ x: Math.round(pt.x + offsetVec.x), y: Math.round(pt.y + offsetVec.y) }); } this.pathOut.push(this.getPerpendic(path[j], this.normals[j])); } buildNormals(path) { const cnt = path.length; this.normals.length = 0; if (cnt === 0) return; for (let i = 0; i < cnt - 1; i++) { this.normals.push(ClipperOffset.getUnitNormal(path[i], path[i + 1])); } this.normals.push(ClipperOffset.getUnitNormal(path[cnt - 1], path[0])); } offsetPoint(group, path, j, k) { if (Core_1.Point64Utils.equals(path[j], path[k])) return; // Let A = change in angle where edges join // A == 0: ie no change in angle (flat join) // A == PI: edges 'spike' // sin(A) < 0: right turning // cos(A) < 0: change in angle is more than 90 degree let sinA = Core_1.InternalClipper.crossProductD(this.normals[j], this.normals[k]); const cosA = Core_1.InternalClipper.dotProductD(this.normals[j], this.normals[k]); if (sinA > 1.0) sinA = 1.0; else if (sinA < -1.0) sinA = -1.0; if (this.deltaCallback !== null) { this.groupDelta = this.deltaCallback(path, this.normals, j, k); if (group.pathsReversed) this.groupDelta = -this.groupDelta; } if (Math.abs(this.groupDelta) < ClipperOffset.Tolerance) { this.pathOut.push(path[j]); return; } if (cosA > -0.999 && (sinA * this.groupDelta < 0)) { // test for concavity first (#593) // is concave // by far the simplest way to construct concave joins, especially those joining very // short segments, is to insert 3 points that produce negative regions. These regions // will be removed later by the finishing union operation. This is also the best way // to ensure that path reversals (ie over-shrunk paths) are removed. this.pathOut.push(this.getPerpendic(path[j], this.normals[k])); this.pathOut.push(path[j]); // (#405, #873, #916) this.pathOut.push(this.getPerpendic(path[j], this.normals[j])); } else if ((cosA > 0.999) && (this.joinType !== JoinType.Round)) { // almost straight - less than 2.5 degree (#424, #482, #526 & #724) this.doMiter(path, j, k, cosA); } else { switch (this.joinType) { // miter unless the angle is sufficiently acute to exceed ML case JoinType.Miter: if (cosA > this.mitLimSqr - 1) { this.doMiter(path, j, k, cosA); } else { this.doSquare(path, j, k); } break; case JoinType.Round: this.doRound(path, j, k, Math.atan2(sinA, cosA)); break; case JoinType.Bevel: this.doBevel(path, j, k); break; default: this.doSquare(path, j, k); break; } } } offsetPolygon(group, path) { this.pathOut = []; const cnt = path.length; let prev = cnt - 1; for (let i = 0; i < cnt; i++) { this.offsetPoint(group, path, i, prev); prev = i; } this.solution.push([...this.pathOut]); } offsetOpenJoined(group, path) { this.offsetPolygon(group, path); const reversePath = [...path].reverse(); this.buildNormals(reversePath); this.offsetPolygon(group, reversePath); } offsetOpenPath(group, path) { this.pathOut = []; const highI = path.length - 1; if (this.deltaCallback !== null) { this.groupDelta = this.deltaCallback(path, this.normals, 0, 0); } // do the line start cap if (Math.abs(this.groupDelta) < ClipperOffset.Tolerance) { this.pathOut.push(path[0]); } else { switch (this.endType) { case EndType.Butt: this.doBevel(path, 0, 0); break; case EndType.Round: this.doRound(path, 0, 0, Math.PI); break; default: this.doSquare(path, 0, 0); break; } } // offset the left side going forward for (let i = 1, k = 0; i < highI; i++) { this.offsetPoint(group, path, i, k); k = i; } // reverse normals ... for (let i = highI; i > 0; i--) { this.normals[i] = { x: -this.normals[i - 1].x, y: -this.normals[i - 1].y }; } this.normals[0] = this.normals[highI]; if (this.deltaCallback !== null) { this.groupDelta = this.deltaCallback(path, this.normals, highI, highI); } // do the line end cap if (Math.abs(this.groupDelta) < ClipperOffset.Tolerance) { this.pathOut.push(path[highI]); } else { switch (this.endType) { case EndType.Butt: this.doBevel(path, highI, highI); break; case EndType.Round: this.doRound(path, highI, highI, Math.PI); break; default: this.doSquare(path, highI, highI); break; } } // offset the left side going back for (let i = highI - 1, k = highI; i > 0; i--) { this.offsetPoint(group, path, i, k); k = i; } this.solution.push([...this.pathOut]); } doGroupOffset(group) { if (group.endType === EndType.Polygon) { // a straight path (2 points) can now also be 'polygon' offset // where the ends will be treated as (180 deg.) joins if (group.lowestPathIdx < 0) this.delta = Math.abs(this.delta); this.groupDelta = group.pathsReversed ? -this.delta : this.delta; } else { this.groupDelta = Math.abs(this.delta); } const absDelta = Math.abs(this.groupDelta); this.joinType = group.joinType; this.endType = group.endType; if (group.joinType === JoinType.Round || group.endType === EndType.Round) { const arcTol = this.arcTolerance > 0.01 ? this.arcTolerance : absDelta * ClipperOffset.arc_const; const stepsPer360 = Math.PI / Math.acos(1 - arcTol / absDelta); this.stepSin = Math.sin((2 * Math.PI) / stepsPer360); this.stepCos = Math.cos((2 * Math.PI) / stepsPer360); if (this.groupDelta < 0.0) this.stepSin = -this.stepSin; this.stepsPerRad = stepsPer360 / (2 * Math.PI); } for (const pathIn of group.inPaths) { this.pathOut = []; const cnt = pathIn.length; if (cnt === 1) { // single point const pt = pathIn[0]; if (this.deltaCallback !== null) { this.groupDelta = this.deltaCallback(pathIn, this.normals, 0, 0); if (group.pathsReversed) this.groupDelta = -this.groupDelta; } // single vertex so build a circle or square ... if (group.endType === EndType.Round) { const steps = Math.ceil(this.stepsPerRad * 2 * Math.PI); this.pathOut = ClipperOffset.ellipse(pt, Math.abs(this.groupDelta), Math.abs(this.groupDelta), steps); } else { const d = Math.ceil(Math.abs(this.groupDelta)); const r = { left: pt.x - d, top: pt.y - d, right: pt.x + d, bottom: pt.y + d }; this.pathOut = [ { x: r.left, y: r.top }, { x: r.right, y: r.top }, { x: r.right, y: r.bottom }, { x: r.left, y: r.bottom } ]; } this.solution.push([...this.pathOut]); continue; // end of offsetting a single point } if (cnt === 2 && group.endType === EndType.Joined) { this.endType = (group.joinType === JoinType.Round) ? EndType.Round : EndType.Square; } this.buildNormals(pathIn); switch (this.endType) { case EndType.Polygon: this.offsetPolygon(group, pathIn); break; case EndType.Joined: this.offsetOpenJoined(group, pathIn); break; default: this.offsetOpenPath(group, pathIn); break; } } } static stripDuplicates(path, isClosedPath) { const cnt = path.length; const result = []; if (cnt === 0) return result; let lastPt = path[0]; result.push(lastPt); for (let i = 1; i < cnt; i++) { if (!Core_1.Point64Utils.equals(lastPt, path[i])) { lastPt = path[i]; result.push(lastPt); } } if (isClosedPath && Core_1.Point64Utils.equals(lastPt, result[0])) { result.pop(); } return result; } static area(path) { // https://en.wikipedia.org/wiki/Shoelace_formula let a = 0.0; const cnt = path.length; if (cnt < 3) return 0.0; let prevPt = path[cnt - 1]; for (const pt of path) { a += (prevPt.y + pt.y) * (prevPt.x - pt.x); prevPt = pt; } return a * 0.5; } static sqr(val) { return val * val; } static ellipse(center, radiusX, radiusY = 0, steps = 0) { if (radiusX <= 0) return []; if (radiusY <= 0) radiusY = radiusX; if (steps <= 2) { steps = Math.ceil(Math.PI * Math.sqrt((radiusX + radiusY) / 2)); } const si = Math.sin(2 * Math.PI / steps); const co = Math.cos(2 * Math.PI / steps); let dx = co; let dy = si; const result = [{ x: Math.round(center.x + radiusX), y: center.y }]; for (let i = 1; i < steps; ++i) { result.push({ x: Math.round(center.x + radiusX * dx), y: Math.round(center.y + radiusY * dy) }); const x = dx * co - dy * si; dy = dy * co + dx * si; dx = x; } return result; } } exports.ClipperOffset = ClipperOffset; ClipperOffset.Tolerance = 1.0E-12; // Clipper2 approximates arcs by using series of relatively short straight //line segments. And logically, shorter line segments will produce better arc // approximations. But very short segments can degrade performance, usually // with little or no discernable improvement in curve quality. Very short // segments can even detract from curve quality, due to the effects of integer // rounding. Since there isn't an optimal number of line segments for any given // arc radius (that perfectly balances curve approximation with performance), // arc tolerance is user defined. Nevertheless, when the user doesn't define // an arc tolerance (ie leaves alone the 0 default value), the calculated // default arc tolerance (offset_radius / 500) generally produces good (smooth) // arc approximations without producing excessively small segment lengths. // See also: https://www.angusj.com/clipper2/Docs/Trigonometry.htm ClipperOffset.arc_const = 0.002; // <-- 1/500 //# sourceMappingURL=Offset.js.map