clipper2-ts 1.5.4-3.9a869ba

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Offset.ts ADDED
@@ -0,0 +1,711 @@
1
+ /*******************************************************************************
2
+ * Author : Angus Johnson *
3
+ * Date : 11 October 2025 *
4
+ * Website : https://www.angusj.com *
5
+ * Copyright : Angus Johnson 2010-2025 *
6
+ * Purpose : Path Offset (Inflate/Shrink) *
7
+ * License : https://www.boost.org/LICENSE_1_0.txt *
8
+ *******************************************************************************/
9
+
10
+ import {
11
+ Point64, PointD, Path64, PathD, Paths64, PathsD,
12
+ ClipType, FillRule,
13
+ Point64Utils, PointDUtils, InternalClipper
14
+ } from './Core';
15
+ import { Clipper64, PolyTree64 } from './Engine';
16
+
17
+ export enum JoinType {
18
+ Miter = 0,
19
+ Square = 1,
20
+ Bevel = 2,
21
+ Round = 3
22
+ }
23
+
24
+ export enum EndType {
25
+ Polygon = 0,
26
+ Joined = 1,
27
+ Butt = 2,
28
+ Square = 3,
29
+ Round = 4
30
+ }
31
+
32
+ class Group {
33
+ public inPaths: Paths64;
34
+ public joinType: JoinType;
35
+ public endType: EndType;
36
+ public pathsReversed: boolean;
37
+ public lowestPathIdx: number;
38
+
39
+ constructor(paths: Paths64, joinType: JoinType, endType: EndType = EndType.Polygon) {
40
+ this.joinType = joinType;
41
+ this.endType = endType;
42
+
43
+ const isJoined = (endType === EndType.Polygon) || (endType === EndType.Joined);
44
+ this.inPaths = [];
45
+ for (const path of paths) {
46
+ this.inPaths.push(ClipperOffset.stripDuplicates(path, isJoined));
47
+ }
48
+
49
+ if (endType === EndType.Polygon) {
50
+ const lowestInfo = ClipperOffset.getLowestPathInfo(this.inPaths);
51
+ this.lowestPathIdx = lowestInfo.idx;
52
+ // the lowermost path must be an outer path, so if its orientation is negative,
53
+ // then flag that the whole group is 'reversed' (will negate delta etc.)
54
+ // as this is much more efficient than reversing every path.
55
+ this.pathsReversed = (this.lowestPathIdx >= 0) && lowestInfo.isNegArea;
56
+ } else {
57
+ this.lowestPathIdx = -1;
58
+ this.pathsReversed = false;
59
+ }
60
+ }
61
+ }
62
+
63
+ export class ClipperOffset {
64
+ private static readonly Tolerance = 1.0E-12;
65
+
66
+ // Clipper2 approximates arcs by using series of relatively short straight
67
+ //line segments. And logically, shorter line segments will produce better arc
68
+ // approximations. But very short segments can degrade performance, usually
69
+ // with little or no discernable improvement in curve quality. Very short
70
+ // segments can even detract from curve quality, due to the effects of integer
71
+ // rounding. Since there isn't an optimal number of line segments for any given
72
+ // arc radius (that perfectly balances curve approximation with performance),
73
+ // arc tolerance is user defined. Nevertheless, when the user doesn't define
74
+ // an arc tolerance (ie leaves alone the 0 default value), the calculated
75
+ // default arc tolerance (offset_radius / 500) generally produces good (smooth)
76
+ // arc approximations without producing excessively small segment lengths.
77
+ // See also: https://www.angusj.com/clipper2/Docs/Trigonometry.htm
78
+ private static readonly arc_const = 0.002; // <-- 1/500
79
+
80
+ private readonly groupList: Group[] = [];
81
+ private pathOut: Path64 = [];
82
+ private readonly normals: PathD = [];
83
+ private solution: Paths64 = [];
84
+ private solutionTree: PolyTree64 | null = null;
85
+
86
+ private groupDelta: number = 0; //*0.5 for open paths; *-1.0 for negative areas
87
+ private delta: number = 0;
88
+ private mitLimSqr: number = 0;
89
+ private stepsPerRad: number = 0;
90
+ private stepSin: number = 0;
91
+ private stepCos: number = 0;
92
+ private joinType: JoinType = JoinType.Bevel;
93
+ private endType: EndType = EndType.Polygon;
94
+
95
+ public arcTolerance: number = 0;
96
+ public mergeGroups: boolean = true;
97
+ public miterLimit: number = 2.0;
98
+ public preserveCollinear: boolean = false;
99
+ public reverseSolution: boolean = false;
100
+
101
+ public deltaCallback: ((path: Path64, pathNorms: PathD, currPt: number, prevPt: number) => number) | null = null;
102
+
103
+ constructor(
104
+ miterLimit: number = 2.0,
105
+ arcTolerance: number = 0.0,
106
+ preserveCollinear: boolean = false,
107
+ reverseSolution: boolean = false
108
+ ) {
109
+ this.miterLimit = miterLimit;
110
+ this.arcTolerance = arcTolerance;
111
+ this.mergeGroups = true;
112
+ this.preserveCollinear = preserveCollinear;
113
+ this.reverseSolution = reverseSolution;
114
+ }
115
+
116
+ public clear(): void {
117
+ this.groupList.length = 0;
118
+ }
119
+
120
+ public addPath(path: Path64, joinType: JoinType, endType: EndType): void {
121
+ if (path.length === 0) return;
122
+ const pp: Paths64 = [path];
123
+ this.addPaths(pp, joinType, endType);
124
+ }
125
+
126
+ public addPaths(paths: Paths64, joinType: JoinType, endType: EndType): void {
127
+ if (paths.length === 0) return;
128
+ this.groupList.push(new Group(paths, joinType, endType));
129
+ }
130
+
131
+ private calcSolutionCapacity(): number {
132
+ let result = 0;
133
+ for (const g of this.groupList) {
134
+ result += (g.endType === EndType.Joined) ? g.inPaths.length * 2 : g.inPaths.length;
135
+ }
136
+ return result;
137
+ }
138
+
139
+ private checkPathsReversed(): boolean {
140
+ let result = false;
141
+ for (const g of this.groupList) {
142
+ if (g.endType === EndType.Polygon) {
143
+ result = g.pathsReversed;
144
+ break;
145
+ }
146
+ }
147
+ return result;
148
+ }
149
+
150
+ private executeInternal(delta: number): void {
151
+ if (this.groupList.length === 0) return;
152
+
153
+ // make sure the offset delta is significant
154
+ if (Math.abs(delta) < 0.5) {
155
+ for (const group of this.groupList) {
156
+ for (const path of group.inPaths) {
157
+ this.solution.push(path);
158
+ }
159
+ }
160
+ return;
161
+ }
162
+
163
+ this.delta = delta;
164
+ this.mitLimSqr = (this.miterLimit <= 1 ?
165
+ 2.0 : 2.0 / ClipperOffset.sqr(this.miterLimit));
166
+
167
+ for (const group of this.groupList) {
168
+ this.doGroupOffset(group);
169
+ }
170
+
171
+ if (this.groupList.length === 0) return;
172
+
173
+ const pathsReversed = this.checkPathsReversed();
174
+ const fillRule = pathsReversed ? FillRule.Negative : FillRule.Positive;
175
+
176
+ // clean up self-intersections ...
177
+ const c = new Clipper64();
178
+ c.preserveCollinear = this.preserveCollinear;
179
+ c.reverseSolution = this.reverseSolution !== pathsReversed;
180
+ c.addSubject(this.solution);
181
+
182
+ if (this.solutionTree !== null) {
183
+ c.execute(ClipType.Union, fillRule, this.solutionTree);
184
+ } else {
185
+ c.execute(ClipType.Union, fillRule, this.solution);
186
+ }
187
+ }
188
+
189
+ public execute(delta: number, solution: Paths64): void;
190
+ public execute(delta: number, solutionTree: PolyTree64): void;
191
+ public execute(delta: number, solutionOrTree: Paths64 | PolyTree64): void {
192
+ if (Array.isArray(solutionOrTree)) {
193
+ // Paths64 version
194
+ const solution = solutionOrTree;
195
+ solution.length = 0;
196
+ this.solution = solution;
197
+ this.executeInternal(delta);
198
+ } else {
199
+ // PolyTree64 version
200
+ const solutionTree = solutionOrTree;
201
+ solutionTree.clear();
202
+ this.solutionTree = solutionTree;
203
+ this.solution = [];
204
+ this.executeInternal(delta);
205
+ }
206
+ }
207
+
208
+ public executeWithCallback(deltaCallback: (path: Path64, pathNorms: PathD, currPt: number, prevPt: number) => number, solution: Paths64): void {
209
+ this.deltaCallback = deltaCallback;
210
+ this.execute(1.0, solution);
211
+ }
212
+
213
+ public static getUnitNormal(pt1: Point64, pt2: Point64): PointD {
214
+ const dx = (pt2.x - pt1.x);
215
+ const dy = (pt2.y - pt1.y);
216
+ if ((dx === 0) && (dy === 0)) return { x: 0, y: 0 };
217
+
218
+ const f = 1.0 / Math.sqrt(dx * dx + dy * dy);
219
+ return {
220
+ x: dy * f,
221
+ y: -dx * f
222
+ };
223
+ }
224
+
225
+ public static getLowestPathInfo(paths: Paths64): { idx: number; isNegArea: boolean } {
226
+ let idx = -1;
227
+ let isNegArea = false;
228
+ let botPt: Point64 = { x: Number.MAX_SAFE_INTEGER, y: Number.MIN_SAFE_INTEGER };
229
+
230
+ for (let i = 0; i < paths.length; ++i) {
231
+ let a = Number.MAX_VALUE;
232
+ for (const pt of paths[i]) {
233
+ if ((pt.y < botPt.y) || ((pt.y === botPt.y) && (pt.x >= botPt.x))) continue;
234
+ if (a === Number.MAX_VALUE) {
235
+ a = ClipperOffset.area(paths[i]);
236
+ if (a === 0) break; // invalid closed path so break from inner loop
237
+ isNegArea = a < 0;
238
+ }
239
+ idx = i;
240
+ botPt.x = pt.x;
241
+ botPt.y = pt.y;
242
+ }
243
+ }
244
+ return { idx, isNegArea };
245
+ }
246
+
247
+ private static translatePoint(pt: PointD, dx: number, dy: number): PointD {
248
+ return { x: pt.x + dx, y: pt.y + dy };
249
+ }
250
+
251
+ private static reflectPoint(pt: PointD, pivot: PointD): PointD {
252
+ return { x: pivot.x + (pivot.x - pt.x), y: pivot.y + (pivot.y - pt.y) };
253
+ }
254
+
255
+ private static almostZero(value: number, epsilon: number = 0.001): boolean {
256
+ return Math.abs(value) < epsilon;
257
+ }
258
+
259
+ private static hypotenuse(x: number, y: number): number {
260
+ return Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2));
261
+ }
262
+
263
+ private static normalizeVector(vec: PointD): PointD {
264
+ const h = ClipperOffset.hypotenuse(vec.x, vec.y);
265
+ if (ClipperOffset.almostZero(h)) return { x: 0, y: 0 };
266
+ const inverseHypot = 1 / h;
267
+ return { x: vec.x * inverseHypot, y: vec.y * inverseHypot };
268
+ }
269
+
270
+ private static getAvgUnitVector(vec1: PointD, vec2: PointD): PointD {
271
+ return ClipperOffset.normalizeVector({ x: vec1.x + vec2.x, y: vec1.y + vec2.y });
272
+ }
273
+
274
+ private static intersectPoint(pt1a: PointD, pt1b: PointD, pt2a: PointD, pt2b: PointD): PointD {
275
+ if (InternalClipper.isAlmostZero(pt1a.x - pt1b.x)) { // vertical
276
+ if (InternalClipper.isAlmostZero(pt2a.x - pt2b.x)) return { x: 0, y: 0 };
277
+ const m2 = (pt2b.y - pt2a.y) / (pt2b.x - pt2a.x);
278
+ const b2 = pt2a.y - m2 * pt2a.x;
279
+ return { x: pt1a.x, y: m2 * pt1a.x + b2 };
280
+ }
281
+
282
+ if (InternalClipper.isAlmostZero(pt2a.x - pt2b.x)) { // vertical
283
+ const m1 = (pt1b.y - pt1a.y) / (pt1b.x - pt1a.x);
284
+ const b1 = pt1a.y - m1 * pt1a.x;
285
+ return { x: pt2a.x, y: m1 * pt2a.x + b1 };
286
+ } else {
287
+ const m1 = (pt1b.y - pt1a.y) / (pt1b.x - pt1a.x);
288
+ const b1 = pt1a.y - m1 * pt1a.x;
289
+ const m2 = (pt2b.y - pt2a.y) / (pt2b.x - pt2a.x);
290
+ const b2 = pt2a.y - m2 * pt2a.x;
291
+ if (InternalClipper.isAlmostZero(m1 - m2)) return { x: 0, y: 0 };
292
+ const x = (b2 - b1) / (m1 - m2);
293
+ return { x: x, y: m1 * x + b1 };
294
+ }
295
+ }
296
+
297
+ private getPerpendic(pt: Point64, norm: PointD): Point64 {
298
+ return {
299
+ x: Math.round(pt.x + norm.x * this.groupDelta),
300
+ y: Math.round(pt.y + norm.y * this.groupDelta)
301
+ };
302
+ }
303
+
304
+ private getPerpendicD(pt: Point64, norm: PointD): PointD {
305
+ return {
306
+ x: pt.x + norm.x * this.groupDelta,
307
+ y: pt.y + norm.y * this.groupDelta
308
+ };
309
+ }
310
+
311
+ private doBevel(path: Path64, j: number, k: number): void {
312
+ let pt1: Point64, pt2: Point64;
313
+ if (j === k) {
314
+ const absDelta = Math.abs(this.groupDelta);
315
+ pt1 = {
316
+ x: Math.round(path[j].x - absDelta * this.normals[j].x),
317
+ y: Math.round(path[j].y - absDelta * this.normals[j].y)
318
+ };
319
+ pt2 = {
320
+ x: Math.round(path[j].x + absDelta * this.normals[j].x),
321
+ y: Math.round(path[j].y + absDelta * this.normals[j].y)
322
+ };
323
+ } else {
324
+ pt1 = {
325
+ x: Math.round(path[j].x + this.groupDelta * this.normals[k].x),
326
+ y: Math.round(path[j].y + this.groupDelta * this.normals[k].y)
327
+ };
328
+ pt2 = {
329
+ x: Math.round(path[j].x + this.groupDelta * this.normals[j].x),
330
+ y: Math.round(path[j].y + this.groupDelta * this.normals[j].y)
331
+ };
332
+ }
333
+ this.pathOut.push(pt1);
334
+ this.pathOut.push(pt2);
335
+ }
336
+
337
+ private doSquare(path: Path64, j: number, k: number): void {
338
+ let vec: PointD;
339
+ if (j === k) {
340
+ vec = { x: this.normals[j].y, y: -this.normals[j].x };
341
+ } else {
342
+ vec = ClipperOffset.getAvgUnitVector(
343
+ { x: -this.normals[k].y, y: this.normals[k].x },
344
+ { x: this.normals[j].y, y: -this.normals[j].x }
345
+ );
346
+ }
347
+
348
+ const absDelta = Math.abs(this.groupDelta);
349
+
350
+ // now offset the original vertex delta units along unit vector
351
+ let ptQ: PointD = { x: path[j].x, y: path[j].y };
352
+ ptQ = ClipperOffset.translatePoint(ptQ, absDelta * vec.x, absDelta * vec.y);
353
+
354
+ // get perpendicular vertices
355
+ const pt1 = ClipperOffset.translatePoint(ptQ, this.groupDelta * vec.y, this.groupDelta * -vec.x);
356
+ const pt2 = ClipperOffset.translatePoint(ptQ, this.groupDelta * -vec.y, this.groupDelta * vec.x);
357
+ // get 2 vertices along one edge offset
358
+ const pt3 = this.getPerpendicD(path[k], this.normals[k]);
359
+
360
+ if (j === k) {
361
+ const pt4: PointD = {
362
+ x: pt3.x + vec.x * this.groupDelta,
363
+ y: pt3.y + vec.y * this.groupDelta
364
+ };
365
+ const pt = ClipperOffset.intersectPoint(pt1, pt2, pt3, pt4);
366
+ //get the second intersect point through reflecion
367
+ this.pathOut.push(Point64Utils.fromPointD(ClipperOffset.reflectPoint(pt, ptQ)));
368
+ this.pathOut.push(Point64Utils.fromPointD(pt));
369
+ } else {
370
+ const pt4 = this.getPerpendicD(path[j], this.normals[k]);
371
+ const pt = ClipperOffset.intersectPoint(pt1, pt2, pt3, pt4);
372
+ this.pathOut.push(Point64Utils.fromPointD(pt));
373
+ //get the second intersect point through reflecion
374
+ this.pathOut.push(Point64Utils.fromPointD(ClipperOffset.reflectPoint(pt, ptQ)));
375
+ }
376
+ }
377
+
378
+ private doMiter(path: Path64, j: number, k: number, cosA: number): void {
379
+ const q = this.groupDelta / (cosA + 1);
380
+ this.pathOut.push({
381
+ x: Math.round(path[j].x + (this.normals[k].x + this.normals[j].x) * q),
382
+ y: Math.round(path[j].y + (this.normals[k].y + this.normals[j].y) * q)
383
+ });
384
+ }
385
+
386
+ private doRound(path: Path64, j: number, k: number, angle: number): void {
387
+ if (this.deltaCallback !== null) {
388
+ // when deltaCallback is assigned, groupDelta won't be constant,
389
+ // so we'll need to do the following calculations for *every* vertex.
390
+ const absDelta = Math.abs(this.groupDelta);
391
+ const arcTol = this.arcTolerance > 0.01 ? this.arcTolerance : absDelta * ClipperOffset.arc_const;
392
+ const stepsPer360 = Math.PI / Math.acos(1 - arcTol / absDelta);
393
+ this.stepSin = Math.sin((2 * Math.PI) / stepsPer360);
394
+ this.stepCos = Math.cos((2 * Math.PI) / stepsPer360);
395
+ if (this.groupDelta < 0.0) this.stepSin = -this.stepSin;
396
+ this.stepsPerRad = stepsPer360 / (2 * Math.PI);
397
+ }
398
+
399
+ const pt = path[j];
400
+ let offsetVec: PointD = { x: this.normals[k].x * this.groupDelta, y: this.normals[k].y * this.groupDelta };
401
+ if (j === k) PointDUtils.negate(offsetVec);
402
+
403
+ this.pathOut.push({
404
+ x: Math.round(pt.x + offsetVec.x),
405
+ y: Math.round(pt.y + offsetVec.y)
406
+ });
407
+
408
+ const steps = Math.ceil(this.stepsPerRad * Math.abs(angle));
409
+ for (let i = 1; i < steps; i++) { // ie 1 less than steps
410
+ offsetVec = {
411
+ x: offsetVec.x * this.stepCos - this.stepSin * offsetVec.y,
412
+ y: offsetVec.x * this.stepSin + offsetVec.y * this.stepCos
413
+ };
414
+ this.pathOut.push({
415
+ x: Math.round(pt.x + offsetVec.x),
416
+ y: Math.round(pt.y + offsetVec.y)
417
+ });
418
+ }
419
+ this.pathOut.push(this.getPerpendic(path[j], this.normals[j]));
420
+ }
421
+
422
+ private buildNormals(path: Path64): void {
423
+ const cnt = path.length;
424
+ this.normals.length = 0;
425
+ if (cnt === 0) return;
426
+
427
+ for (let i = 0; i < cnt - 1; i++) {
428
+ this.normals.push(ClipperOffset.getUnitNormal(path[i], path[i + 1]));
429
+ }
430
+ this.normals.push(ClipperOffset.getUnitNormal(path[cnt - 1], path[0]));
431
+ }
432
+
433
+ private offsetPoint(group: Group, path: Path64, j: number, k: number): void {
434
+ if (Point64Utils.equals(path[j], path[k])) return;
435
+
436
+ // Let A = change in angle where edges join
437
+ // A == 0: ie no change in angle (flat join)
438
+ // A == PI: edges 'spike'
439
+ // sin(A) < 0: right turning
440
+ // cos(A) < 0: change in angle is more than 90 degree
441
+ let sinA = InternalClipper.crossProductD(this.normals[j], this.normals[k]);
442
+ const cosA = InternalClipper.dotProductD(this.normals[j], this.normals[k]);
443
+ if (sinA > 1.0) sinA = 1.0;
444
+ else if (sinA < -1.0) sinA = -1.0;
445
+
446
+ if (this.deltaCallback !== null) {
447
+ this.groupDelta = this.deltaCallback(path, this.normals, j, k);
448
+ if (group.pathsReversed) this.groupDelta = -this.groupDelta;
449
+ }
450
+ if (Math.abs(this.groupDelta) < ClipperOffset.Tolerance) {
451
+ this.pathOut.push(path[j]);
452
+ return;
453
+ }
454
+
455
+ if (cosA > -0.999 && (sinA * this.groupDelta < 0)) { // test for concavity first (#593)
456
+ // is concave
457
+ // by far the simplest way to construct concave joins, especially those joining very
458
+ // short segments, is to insert 3 points that produce negative regions. These regions
459
+ // will be removed later by the finishing union operation. This is also the best way
460
+ // to ensure that path reversals (ie over-shrunk paths) are removed.
461
+ this.pathOut.push(this.getPerpendic(path[j], this.normals[k]));
462
+ this.pathOut.push(path[j]); // (#405, #873, #916)
463
+ this.pathOut.push(this.getPerpendic(path[j], this.normals[j]));
464
+ } else if ((cosA > 0.999) && (this.joinType !== JoinType.Round)) {
465
+ // almost straight - less than 2.5 degree (#424, #482, #526 & #724)
466
+ this.doMiter(path, j, k, cosA);
467
+ } else {
468
+ switch (this.joinType) {
469
+ // miter unless the angle is sufficiently acute to exceed ML
470
+ case JoinType.Miter:
471
+ if (cosA > this.mitLimSqr - 1) {
472
+ this.doMiter(path, j, k, cosA);
473
+ } else {
474
+ this.doSquare(path, j, k);
475
+ }
476
+ break;
477
+ case JoinType.Round:
478
+ this.doRound(path, j, k, Math.atan2(sinA, cosA));
479
+ break;
480
+ case JoinType.Bevel:
481
+ this.doBevel(path, j, k);
482
+ break;
483
+ default:
484
+ this.doSquare(path, j, k);
485
+ break;
486
+ }
487
+ }
488
+ }
489
+
490
+ private offsetPolygon(group: Group, path: Path64): void {
491
+ this.pathOut = [];
492
+ const cnt = path.length;
493
+ let prev = cnt - 1;
494
+ for (let i = 0; i < cnt; i++) {
495
+ this.offsetPoint(group, path, i, prev);
496
+ prev = i;
497
+ }
498
+ this.solution.push([...this.pathOut]);
499
+ }
500
+
501
+ private offsetOpenJoined(group: Group, path: Path64): void {
502
+ this.offsetPolygon(group, path);
503
+ const reversePath = [...path].reverse();
504
+ this.buildNormals(reversePath);
505
+ this.offsetPolygon(group, reversePath);
506
+ }
507
+
508
+ private offsetOpenPath(group: Group, path: Path64): void {
509
+ this.pathOut = [];
510
+ const highI = path.length - 1;
511
+
512
+ if (this.deltaCallback !== null) {
513
+ this.groupDelta = this.deltaCallback(path, this.normals, 0, 0);
514
+ }
515
+
516
+ // do the line start cap
517
+ if (Math.abs(this.groupDelta) < ClipperOffset.Tolerance) {
518
+ this.pathOut.push(path[0]);
519
+ } else {
520
+ switch (this.endType) {
521
+ case EndType.Butt:
522
+ this.doBevel(path, 0, 0);
523
+ break;
524
+ case EndType.Round:
525
+ this.doRound(path, 0, 0, Math.PI);
526
+ break;
527
+ default:
528
+ this.doSquare(path, 0, 0);
529
+ break;
530
+ }
531
+ }
532
+
533
+ // offset the left side going forward
534
+ for (let i = 1, k = 0; i < highI; i++) {
535
+ this.offsetPoint(group, path, i, k);
536
+ k = i;
537
+ }
538
+
539
+ // reverse normals ...
540
+ for (let i = highI; i > 0; i--) {
541
+ this.normals[i] = { x: -this.normals[i - 1].x, y: -this.normals[i - 1].y };
542
+ }
543
+ this.normals[0] = this.normals[highI];
544
+
545
+ if (this.deltaCallback !== null) {
546
+ this.groupDelta = this.deltaCallback(path, this.normals, highI, highI);
547
+ }
548
+
549
+ // do the line end cap
550
+ if (Math.abs(this.groupDelta) < ClipperOffset.Tolerance) {
551
+ this.pathOut.push(path[highI]);
552
+ } else {
553
+ switch (this.endType) {
554
+ case EndType.Butt:
555
+ this.doBevel(path, highI, highI);
556
+ break;
557
+ case EndType.Round:
558
+ this.doRound(path, highI, highI, Math.PI);
559
+ break;
560
+ default:
561
+ this.doSquare(path, highI, highI);
562
+ break;
563
+ }
564
+ }
565
+
566
+ // offset the left side going back
567
+ for (let i = highI - 1, k = highI; i > 0; i--) {
568
+ this.offsetPoint(group, path, i, k);
569
+ k = i;
570
+ }
571
+
572
+ this.solution.push([...this.pathOut]);
573
+ }
574
+
575
+ private doGroupOffset(group: Group): void {
576
+ if (group.endType === EndType.Polygon) {
577
+ // a straight path (2 points) can now also be 'polygon' offset
578
+ // where the ends will be treated as (180 deg.) joins
579
+ if (group.lowestPathIdx < 0) this.delta = Math.abs(this.delta);
580
+ this.groupDelta = group.pathsReversed ? -this.delta : this.delta;
581
+ } else {
582
+ this.groupDelta = Math.abs(this.delta);
583
+ }
584
+
585
+ const absDelta = Math.abs(this.groupDelta);
586
+
587
+ this.joinType = group.joinType;
588
+ this.endType = group.endType;
589
+
590
+ if (group.joinType === JoinType.Round || group.endType === EndType.Round) {
591
+ const arcTol = this.arcTolerance > 0.01 ? this.arcTolerance : absDelta * ClipperOffset.arc_const;
592
+ const stepsPer360 = Math.PI / Math.acos(1 - arcTol / absDelta);
593
+ this.stepSin = Math.sin((2 * Math.PI) / stepsPer360);
594
+ this.stepCos = Math.cos((2 * Math.PI) / stepsPer360);
595
+ if (this.groupDelta < 0.0) this.stepSin = -this.stepSin;
596
+ this.stepsPerRad = stepsPer360 / (2 * Math.PI);
597
+ }
598
+
599
+ for (const pathIn of group.inPaths) {
600
+ this.pathOut = [];
601
+ const cnt = pathIn.length;
602
+
603
+ if (cnt === 1) {
604
+ // single point
605
+ const pt = pathIn[0];
606
+
607
+ if (this.deltaCallback !== null) {
608
+ this.groupDelta = this.deltaCallback(pathIn, this.normals, 0, 0);
609
+ if (group.pathsReversed) this.groupDelta = -this.groupDelta;
610
+ }
611
+
612
+ // single vertex so build a circle or square ...
613
+ if (group.endType === EndType.Round) {
614
+ const steps = Math.ceil(this.stepsPerRad * 2 * Math.PI);
615
+ this.pathOut = ClipperOffset.ellipse(pt, Math.abs(this.groupDelta), Math.abs(this.groupDelta), steps);
616
+ } else {
617
+ const d = Math.ceil(Math.abs(this.groupDelta));
618
+ const r = { left: pt.x - d, top: pt.y - d, right: pt.x + d, bottom: pt.y + d };
619
+ this.pathOut = [
620
+ { x: r.left, y: r.top },
621
+ { x: r.right, y: r.top },
622
+ { x: r.right, y: r.bottom },
623
+ { x: r.left, y: r.bottom }
624
+ ];
625
+ }
626
+ this.solution.push([...this.pathOut]);
627
+ continue; // end of offsetting a single point
628
+ }
629
+
630
+ if (cnt === 2 && group.endType === EndType.Joined) {
631
+ this.endType = (group.joinType === JoinType.Round) ?
632
+ EndType.Round :
633
+ EndType.Square;
634
+ }
635
+
636
+ this.buildNormals(pathIn);
637
+ switch (this.endType) {
638
+ case EndType.Polygon:
639
+ this.offsetPolygon(group, pathIn);
640
+ break;
641
+ case EndType.Joined:
642
+ this.offsetOpenJoined(group, pathIn);
643
+ break;
644
+ default:
645
+ this.offsetOpenPath(group, pathIn);
646
+ break;
647
+ }
648
+ }
649
+ }
650
+
651
+ public static stripDuplicates(path: Path64, isClosedPath: boolean): Path64 {
652
+ const cnt = path.length;
653
+ const result: Path64 = [];
654
+ if (cnt === 0) return result;
655
+
656
+ let lastPt = path[0];
657
+ result.push(lastPt);
658
+ for (let i = 1; i < cnt; i++) {
659
+ if (!Point64Utils.equals(lastPt, path[i])) {
660
+ lastPt = path[i];
661
+ result.push(lastPt);
662
+ }
663
+ }
664
+ if (isClosedPath && Point64Utils.equals(lastPt, result[0])) {
665
+ result.pop();
666
+ }
667
+ return result;
668
+ }
669
+
670
+ public static area(path: Path64): number {
671
+ // https://en.wikipedia.org/wiki/Shoelace_formula
672
+ let a = 0.0;
673
+ const cnt = path.length;
674
+ if (cnt < 3) return 0.0;
675
+ let prevPt = path[cnt - 1];
676
+ for (const pt of path) {
677
+ a += (prevPt.y + pt.y) * (prevPt.x - pt.x);
678
+ prevPt = pt;
679
+ }
680
+ return a * 0.5;
681
+ }
682
+
683
+ public static sqr(val: number): number {
684
+ return val * val;
685
+ }
686
+
687
+ public static ellipse(center: Point64, radiusX: number, radiusY: number = 0, steps: number = 0): Path64 {
688
+ if (radiusX <= 0) return [];
689
+ if (radiusY <= 0) radiusY = radiusX;
690
+ if (steps <= 2) {
691
+ steps = Math.ceil(Math.PI * Math.sqrt((radiusX + radiusY) / 2));
692
+ }
693
+
694
+ const si = Math.sin(2 * Math.PI / steps);
695
+ const co = Math.cos(2 * Math.PI / steps);
696
+ let dx = co;
697
+ let dy = si;
698
+ const result: Path64 = [{ x: Math.round(center.x + radiusX), y: center.y }];
699
+
700
+ for (let i = 1; i < steps; ++i) {
701
+ result.push({
702
+ x: Math.round(center.x + radiusX * dx),
703
+ y: Math.round(center.y + radiusY * dy)
704
+ });
705
+ const x = dx * co - dy * si;
706
+ dy = dy * co + dx * si;
707
+ dx = x;
708
+ }
709
+ return result;
710
+ }
711
+ }