@immugio/three-math-extensions 0.3.4 → 0.3.6

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/cjs/Line2D.js +90 -13
  3. package/docs/classes/BoundingBox.md +121 -121
  4. package/docs/classes/Line2D.md +1366 -1366
  5. package/docs/classes/Line3D.md +831 -831
  6. package/docs/classes/Polygon.md +297 -297
  7. package/docs/classes/Rectangle.md +291 -291
  8. package/docs/classes/Size2.md +55 -55
  9. package/docs/classes/Vec2.md +282 -282
  10. package/docs/classes/Vec3.md +338 -338
  11. package/docs/interfaces/Point2.md +30 -30
  12. package/docs/interfaces/Point3.md +41 -41
  13. package/docs/modules.md +209 -209
  14. package/eslint.config.mjs +111 -111
  15. package/esm/Line2D.js +90 -13
  16. package/package.json +62 -62
  17. package/src/BoundingBox.ts +13 -13
  18. package/src/Line2D.ts +951 -857
  19. package/src/Line3D.ts +586 -586
  20. package/src/MathConstants.ts +1 -1
  21. package/src/Point2.ts +3 -3
  22. package/src/Point3.ts +4 -4
  23. package/src/Polygon.ts +286 -286
  24. package/src/Rectangle.ts +92 -92
  25. package/src/Size2.ts +3 -3
  26. package/src/Vec2.ts +124 -124
  27. package/src/Vec3.ts +167 -167
  28. package/src/containsPoint.ts +65 -65
  29. package/src/directions.ts +9 -9
  30. package/src/directions2d.ts +7 -7
  31. package/src/ensurePolygonClockwise.ts +9 -9
  32. package/src/extendOrTrimPolylinesAtIntersections.ts +10 -10
  33. package/src/getPolygonArea.ts +21 -21
  34. package/src/index.ts +24 -24
  35. package/src/isContinuousClosedShape.ts +24 -24
  36. package/src/isPointInPolygon.ts +23 -23
  37. package/src/isPolygonClockwise.ts +15 -15
  38. package/src/normalizeAngleDegrees.ts +6 -6
  39. package/src/normalizeAngleRadians.ts +14 -14
  40. package/src/offsetPolyline.ts +26 -26
  41. package/src/polygonPerimeter.ts +13 -13
  42. package/src/sortLinesByConnections.ts +45 -45
  43. package/types/Line2D.d.ts +12 -5
package/src/Line3D.ts CHANGED
@@ -1,587 +1,587 @@
1
- import { Line3, Vector3 } from "three";
2
- import { Vec3 } from "./Vec3";
3
- import { Point3 } from "./Point3";
4
- import { Line2D } from "./Line2D";
5
-
6
- export class Line3D extends Line3 {
7
-
8
- public declare start: Vec3;
9
- public declare end: Vec3;
10
-
11
- readonly #target: Vec3;
12
-
13
- constructor(start: Vec3, end: Vec3, public index: number = 0) {
14
- super(start, end);
15
- this.#target = new Vec3();
16
- }
17
-
18
- public static fromPoints(start: Point3, end: Point3, index: number = 0): Line3D {
19
- return new Line3D(new Vec3(start.x, start.y, start.z), new Vec3(end.x, end.y, end.z), index);
20
- }
21
-
22
- /**
23
- * Creates a polygon formed by an array of lines from points provided.
24
- * The polygon will only be closed if either
25
- * 1) the first and last points are the same or 2) `forceClosedPolygon` is true.
26
- */
27
- public static fromPolygon(polygon: Point3[], forceClosedPolygon: boolean = false): Line3D[] {
28
- if (!polygon || polygon.length < 2) {
29
- return [];
30
- }
31
-
32
- if (forceClosedPolygon && (polygon[0].x !== polygon.at(-1).x || polygon[0].y !== polygon.at(-1).y || polygon[0].z !== polygon.at(-1).z)) {
33
- polygon = [...polygon, polygon[0]];
34
- }
35
-
36
- const lines: Line3D[] = [];
37
- for (let i = 0; i < polygon.length - 1; i++) {
38
- lines.push(Line3D.fromPoints(polygon[i], polygon[i + 1], i));
39
- }
40
-
41
- return lines;
42
- }
43
-
44
- /**
45
- * Returns lines that are the result of clipping this line by the @other line.
46
- * Clips must be parallel to this line.
47
- * Clones the line, does not modify this.
48
- * @param other
49
- * @param parallelTolerance
50
- */
51
- public clipLine(other: Line3D, parallelTolerance: number = Number.EPSILON): Line3D[] {
52
- other = this.getParallelLineInTheSameDirection(other, parallelTolerance);
53
-
54
- // 1) Lines aren't parallel
55
- if (!other) {
56
- return [this.clone()];
57
- }
58
-
59
- const left = this.clone();
60
- left.end.copy(other.start);
61
-
62
- const right = this.clone();
63
- right.start.copy(other.end);
64
-
65
- return [left, right].filter(x => x.direction.manhattanDistanceTo(this.direction) <= parallelTolerance);
66
- }
67
-
68
- /**
69
- * Returns lines that are the result of clipping this line by the @clips.
70
- * Clips must be parallel to this line.
71
- * Clones the line, does not modify this.
72
- * @param clips
73
- * @param distanceTolerance
74
- * @param parallelTolerance
75
- */
76
- public clipLines(clips: Line3D[], distanceTolerance: number = 0, parallelTolerance: number = Number.EPSILON): Line3D[] {
77
- const free: Line3D[] = [];
78
- const sources: Line3D[] = [this.clone()];
79
-
80
- while (sources.length > 0) {
81
-
82
- let isFree = true;
83
-
84
- const tested = sources.pop();
85
-
86
- for (const clip of clips) {
87
-
88
- if (tested.overlaps(clip, distanceTolerance, parallelTolerance)) {
89
- isFree = false;
90
- const subtracted = tested.clipLine(clip, parallelTolerance);
91
- sources.push(...subtracted);
92
- break;
93
- }
94
- }
95
-
96
- if (isFree) free.push(tested);
97
- }
98
-
99
- return free;
100
- }
101
-
102
- /**
103
- * Joins a copy of this line with the @other line.
104
- * Other must be parallel to this line.
105
- * Returns null if there is no overlap
106
- * Clones the line, does not modify this.
107
- * @param other
108
- * @param distanceTolerance
109
- * @param parallelTolerance
110
- */
111
- public joinLine(other: Line3D, distanceTolerance: number = 0, parallelTolerance: number = Number.EPSILON): Line3D {
112
- // 6 possible cases:
113
- const otherParallel = this.getParallelLineInTheSameDirection(other, parallelTolerance);
114
-
115
- // 1) Lines aren't parallel
116
- if (!otherParallel) {
117
- return null;
118
- }
119
-
120
- const thisContainsOtherStartPoint = this.containsPoint(otherParallel.start, distanceTolerance);
121
- const thisContainsOtherEndPoint = this.containsPoint(otherParallel.end, distanceTolerance);
122
- const otherContainsThisStartPoint = otherParallel.containsPoint(this.start, distanceTolerance);
123
- const otherContainsThisEndPoint = otherParallel.containsPoint(this.end, distanceTolerance);
124
-
125
- // 2) Lines don't overlap at all
126
- if (
127
- !thisContainsOtherStartPoint &&
128
- !thisContainsOtherEndPoint &&
129
- !otherContainsThisStartPoint &&
130
- !otherContainsThisEndPoint
131
- ) {
132
- return null;
133
- }
134
-
135
- // 3) This line entirely covers the other line
136
- if (thisContainsOtherStartPoint && thisContainsOtherEndPoint) {
137
- return this.clone();
138
- }
139
-
140
- // 4) The other line entirely covers this line
141
- if (otherContainsThisStartPoint && otherContainsThisEndPoint) {
142
- return otherParallel.clone();
143
- }
144
-
145
- // 5) This line is overlapped by the start of the other line
146
- if (thisContainsOtherStartPoint && !thisContainsOtherEndPoint) {
147
- return new Line3D(this.start, otherParallel.end);
148
- }
149
-
150
- // 6) This line is overlapped by the end of the other line
151
- if (thisContainsOtherEndPoint && !thisContainsOtherStartPoint) {
152
- return new Line3D(otherParallel.start, this.end);
153
- }
154
-
155
- return null;
156
- }
157
-
158
- /**
159
- * Joins provided lines into several joined lines.
160
- * Lines must be parallel for joining.
161
- * @param lines
162
- * @param distanceTolerance
163
- * @param parallelTolerance
164
- */
165
- public static joinLines(lines: Line3D[], distanceTolerance: number = 0, parallelTolerance: number = Number.EPSILON): Line3D[] {
166
- if (lines.length < 2) {
167
- return lines.map(x => x.clone());
168
- }
169
-
170
- const toProcess = lines.map((line, index) => ({ line: line.clone(), index }));
171
- const result: { line: Line3D, index: number }[] = [];
172
-
173
- while (toProcess.length > 0) {
174
- const current = toProcess.pop();
175
- let joinedLine: Line3D;
176
-
177
- for (let i = 0; i < result.length; i++) {
178
- const other = result[i];
179
- joinedLine = current.line.joinLine(other.line, distanceTolerance, parallelTolerance);
180
- if (joinedLine) {
181
- result.splice(i, 1);
182
- toProcess.push({ line: joinedLine, index: current.index });
183
- break;
184
- }
185
- }
186
-
187
- if (!joinedLine) {
188
- result.push(current);
189
- }
190
- }
191
-
192
- // Sort the result based on the original indices
193
- result.sort((a, b) => a.index - b.index);
194
-
195
- return result.map(item => item.line);
196
- }
197
-
198
- /**
199
- * Returns true if this line section completely overlaps the @other line section.
200
- * @param other
201
- * @param tolerance
202
- */
203
- public covers(other: Line3D, tolerance: number = 0): boolean {
204
- return this.containsPoint(other.start, tolerance) && this.containsPoint(other.end, tolerance);
205
- }
206
-
207
- /**
208
- * Returns true if there is any overlap between this line and the @other line section.
209
- * @param other
210
- * @param distanceTolerance
211
- * @param parallelTolerance
212
- */
213
- public overlaps(other: Line3D, distanceTolerance: number = 0, parallelTolerance: number = Number.EPSILON): boolean {
214
- // Special case
215
- if (this.equals(other, distanceTolerance)) {
216
- return true;
217
- }
218
-
219
- // Always have to be parallel
220
- if (this.isParallelTo(other, parallelTolerance)) {
221
- // To pass as overlapping, at least one point has to be within the other line, but they mush not be equal - "touching" is not considered overlapping
222
-
223
- const otherStartEqualsToAnyOfThisPoint = other.start.distanceTo(this.start) <= distanceTolerance || other.start.distanceTo(this.end) <= distanceTolerance;
224
- if (this.containsPoint(other.start, distanceTolerance) && !otherStartEqualsToAnyOfThisPoint) {
225
- return true;
226
- }
227
-
228
- const otherEndEqualsToAnyOfThisPoint = other.end.distanceTo(this.start) <= distanceTolerance || other.end.distanceTo(this.end) <= distanceTolerance;
229
- if (this.containsPoint(other.end, distanceTolerance) && !otherEndEqualsToAnyOfThisPoint) {
230
- return true;
231
- }
232
-
233
- const thisStartEqualsToAnyOfOtherPoint = this.start.distanceTo(other.start) <= distanceTolerance || this.start.distanceTo(other.end) <= distanceTolerance;
234
- if (other.containsPoint(this.start, distanceTolerance) && !thisStartEqualsToAnyOfOtherPoint) {
235
- return true;
236
- }
237
-
238
- const thisEndEqualsToAnyOfOtherPoint = this.end.distanceTo(other.start) <= distanceTolerance || this.end.distanceTo(other.end) <= distanceTolerance;
239
- if (other.containsPoint(this.end, distanceTolerance) && !thisEndEqualsToAnyOfOtherPoint) {
240
- return true;
241
- }
242
- }
243
-
244
- return false;
245
- }
246
-
247
- /**
248
- * Returns this line's length.
249
- */
250
- public get length(): number {
251
- return this.start.distanceTo(this.end);
252
- }
253
-
254
- /**
255
- * Set the length of this line. Center and direction remain unchanged.
256
- * @param length
257
- */
258
- public setLength(length: number): this {
259
- const diff = length - this.length;
260
- return this.resize(diff);
261
- }
262
-
263
- /**
264
- * Returns the direction of this line.
265
- */
266
- public get direction(): Vec3 {
267
- return this.end.clone().sub(this.start).normalize();
268
- }
269
-
270
- /**
271
- * Returns the center of this line
272
- */
273
- public get center(): Vec3 {
274
- return this.getCenter(new Vec3()) as Vec3;
275
- }
276
-
277
- /**
278
- * Set the center of the line to the provided point. Length and direction remain unchanged.
279
- * @param value
280
- */
281
- public setCenter(value: Vector3): this {
282
- const current = this.center;
283
- const diffX = current.x - value.x;
284
- const diffY = current.y - value.y;
285
- const diffZ = current.z - value.z;
286
- this.start.x -= diffX;
287
- this.start.y -= diffY;
288
- this.start.z -= diffZ;
289
- this.end.x -= diffX;
290
- this.end.y -= diffY;
291
- this.end.z -= diffZ;
292
- return this;
293
- }
294
-
295
- /** Returns the start and end points of the line as an array. */
296
- public get endpoints(): Vec3[] {
297
- return [this.start, this.end];
298
- }
299
-
300
- /**
301
- * Check that this line section contains provided point.
302
- * @param p
303
- * @param tolerance
304
- */
305
- public containsPoint(p: Vector3, tolerance: number = 0): boolean {
306
- const closestPointToPoint = this.closestPointToPoint(p, true, this.#target);
307
- return closestPointToPoint.distanceTo(p) <= tolerance;
308
- }
309
-
310
- /**
311
- * Distance from this line to the provided point.
312
- * @param p
313
- * @param clampToLine
314
- */
315
- public distanceToPoint(p: Vector3, clampToLine: boolean = true): number {
316
- const closestPointToPoint = this.closestPointToPoint(p, clampToLine, this.#target);
317
- return closestPointToPoint.distanceTo(p);
318
- }
319
-
320
- /**
321
- * Returns a copy of @other line, the direction of @other is reversed if needed.
322
- * Returns null if lines are not parallel.
323
- * @param other
324
- * @param tolerance
325
- */
326
- public getParallelLineInTheSameDirection(other: Line3D, tolerance: number = Number.EPSILON): Line3D {
327
- const direction = this.direction;
328
-
329
- const areTheSameDirection = direction.manhattanDistanceTo(other.direction) < tolerance;
330
- if (areTheSameDirection) {
331
- return other.clone();
332
- }
333
-
334
- const otherLineOppositeDirection = new Line3D(other.end, other.start);
335
- if (otherLineOppositeDirection.direction.manhattanDistanceTo(direction) < tolerance) {
336
- return otherLineOppositeDirection;
337
- }
338
-
339
- return null;
340
- }
341
-
342
- /**
343
- * Check if @other is parallel to this line.
344
- * @param other
345
- * @param angleTolerance
346
- */
347
- public isParallelTo(other: Line3D, angleTolerance: number = Number.EPSILON): boolean {
348
- const direction = this.direction;
349
- const otherDirection = other.direction;
350
-
351
- const areTheSameDirection = direction.angleTo(otherDirection) <= angleTolerance;
352
- if (areTheSameDirection) {
353
- return true;
354
- }
355
-
356
- return direction.negate().angleTo(otherDirection) < angleTolerance;
357
- }
358
-
359
- /*
360
- * Extends or reduces the line to the given length while keeping the center of the line constant.
361
- */
362
- public resize(amount: number): this {
363
- this.moveStartPoint(amount / 2);
364
- this.moveEndPoint(amount / 2);
365
- return this;
366
- }
367
-
368
- /*
369
- * Moves start on the line by the given amount. Plus values move the point further away from the center.
370
- */
371
- public moveStartPoint(amount: number): this {
372
- const start = this.movePointOnThisLine(this.start, amount);
373
- this.start.x = start.x;
374
- this.start.y = start.y;
375
- this.start.z = start.z;
376
-
377
- return this;
378
- }
379
-
380
- /*
381
- * Moves end on the line by the given amount in the current direction. Plus values move the point further away from the center.
382
- */
383
- public moveEndPoint(amount: number): this {
384
- const end = this.movePointOnThisLine(this.end, amount);
385
- this.end.x = end.x;
386
- this.end.y = end.y;
387
- this.end.z = end.z;
388
-
389
- return this;
390
- }
391
-
392
- /**
393
- * Returns a new line that is the projection of this line onto @other. Uses `closestPointToPoint` to find the projection.
394
- * @param other
395
- * @param clampToLine
396
- */
397
- public projectOn(other: Line3D, clampToLine: boolean): Line3D {
398
- const p1 = other.closestPointToPoint(this.start, clampToLine, new Vec3()) as Vec3;
399
- const p2 = other.closestPointToPoint(this.end, clampToLine, new Vec3()) as Vec3;
400
-
401
- return p1.distanceTo(this.start) < p2.distanceTo(this.start) ? new Line3D(p1, p2) : new Line3D(p2, p1);
402
- }
403
-
404
- /**
405
- * Divides the Line3D into a number of segments of the given length.
406
- * @param maxSegmentLength number
407
- */
408
- public chunk(maxSegmentLength: number): Line3D[] {
409
- const source = this.clone();
410
- const result: Line3D[] = [];
411
- while (source.length > maxSegmentLength) {
412
- const chunk = source.clone();
413
- chunk.moveEndPoint(-(chunk.length - maxSegmentLength));
414
- result.push(chunk);
415
- source.start.copy(chunk.end);
416
- }
417
- if (source.length > 0) {
418
- result.push(source);
419
- }
420
- return result;
421
- }
422
-
423
- /**
424
- * Note that this works well for moving the endpoints as it's currently used
425
- * If it were to be made public, it would need to handle the situation where the point to move is in the center of the line which would require a different approach
426
- */
427
- private movePointOnThisLine(point: Vec3, amount: number): Vec3 {
428
- const center = this.getCenter(this.#target);
429
- const vec = new Vec3(center.x - point.x, center.y - point.y, center.z - point.z);
430
- const length = vec.length();
431
- vec.normalize().multiplyScalar(length + amount);
432
-
433
- return new Vec3(
434
- center.x - vec.x,
435
- center.y - vec.y,
436
- center.z - vec.z
437
- );
438
- }
439
-
440
- /**
441
- * Move this line by the given vector.
442
- * @param p
443
- */
444
- public translate(p: Vector3): this {
445
- this.start.add(p);
446
- this.end.add(p);
447
- return this;
448
- }
449
-
450
- /**
451
- * Calculates the intersection between this and `other` line. The lines are assumed to be infinite.
452
- * In a lot of cases, an actual intersection cannot be calculated due to rounding errors.
453
- * Therefore, the intersection calculated by this method comes in the form of the shorted possible line segment connecting the two lines.
454
- * Sources:
455
- * http://paulbourke.net/geometry/pointlineplane/
456
- * https://stackoverflow.com/questions/2316490/the-algorithm-to-find-the-point-of-intersection-of-two-3d-line-segment/2316934#2316934
457
- * @param other
458
- */
459
- public intersect(other: Line3D): Line3D {
460
- const p1: Vec3 = this.start.clone();
461
- const p2: Vec3 = this.end.clone();
462
-
463
- const p3: Vec3 = other.start.clone();
464
- const p4: Vec3 = other.end.clone();
465
-
466
- const p13: Vec3 = p1.clone().sub(p3);
467
- const p43: Vec3 = p4.clone().sub(p3);
468
-
469
- if (p43.lengthSq() <= Number.EPSILON) {
470
- return null;
471
- }
472
-
473
- const p21 = p2.clone().sub(p1);
474
- if (p21.lengthSq() <= Number.EPSILON) {
475
- return null;
476
- }
477
-
478
- const d1343: number = p13.x * p43.x + p13.y * p43.y + p13.z * p43.z;
479
- const d4321: number = p43.x * p21.x + p43.y * p21.y + p43.z * p21.z;
480
- const d1321: number = p13.x * p21.x + p13.y * p21.y + p13.z * p21.z;
481
- const d4343: number = p43.x * p43.x + p43.y * p43.y + p43.z * p43.z;
482
- const d2121: number = p21.x * p21.x + p21.y * p21.y + p21.z * p21.z;
483
-
484
- const denominator: number = d2121 * d4343 - d4321 * d4321;
485
- if (Math.abs(denominator) <= Number.EPSILON) {
486
- return null;
487
- }
488
- const numerator: number = d1343 * d4321 - d1321 * d4343;
489
-
490
- const mua: number = numerator / denominator;
491
- const mub: number = (d1343 + d4321 * (mua)) / d4343;
492
-
493
- const resultSegmentPoint1 = new Vec3(
494
- (p1.x + mua * p21.x),
495
- (p1.y + mua * p21.y),
496
- (p1.z + mua * p21.z)
497
- );
498
-
499
- const resultSegmentPoint2 = new Vec3(
500
- (p3.x + mub * p43.x),
501
- (p3.y + mub * p43.y),
502
- (p3.z + mub * p43.z)
503
- );
504
-
505
- return new Line3D(resultSegmentPoint1, resultSegmentPoint2);
506
- }
507
-
508
- /**
509
- * Accepts an array of Line3D and groups them into arrays of connected lines
510
- * @param lines Lines to be grouped
511
- * @param tolerance Tolerance for considering lines as connected
512
- * @param breakpoints
513
- */
514
- public static groupConnectedLines(lines: Line3D[], tolerance: number = 0, breakpoints: Vec3[] = []): Line3D[][] {
515
- const visited: Set<Line3D> = new Set();
516
-
517
- // Use graph-based approach. Each line can be considered as an edge in the graph, and the endpoints of the lines can be considered as vertices.
518
- // Then use Depth-First Search (DFS) to find connected components in the graph.
519
- const dfs = (line: Line3D, group: Line3D[]) => {
520
- if (visited.has(line)) return;
521
- visited.add(line);
522
- group.push(line);
523
-
524
- lines.forEach(neighbor => {
525
- if (!visited.has(neighbor)) {
526
- if (
527
- line.connectsTo(neighbor, tolerance, breakpoints)
528
- ) {
529
- dfs(neighbor, group);
530
- }
531
- }
532
- });
533
- };
534
-
535
- const connectedLines: Line3D[][] = [];
536
-
537
- lines.forEach(line => {
538
- if (!visited.has(line)) {
539
- const group: Line3D[] = [];
540
- dfs(line, group);
541
- connectedLines.push(group);
542
- }
543
- });
544
-
545
- return connectedLines;
546
- }
547
-
548
- /**
549
- * Returns true if any endpoint of this line is within the tolerance of any @other line's endpoints.
550
- * @param other
551
- * @param tolerance
552
- * @param breakpoints
553
- */
554
- public connectsTo(other: Line3D, tolerance: number = 0, breakpoints: Vec3[] = []): boolean {
555
- return (
556
- (this.start.isNear(other.start, tolerance) && breakpoints.every(b => !b.isNear(this.start, tolerance))) ||
557
- (this.start.isNear(other.end, tolerance) && breakpoints.every(b => !b.isNear(this.start, tolerance))) ||
558
- (this.end.isNear(other.start, tolerance) && breakpoints.every(b => !b.isNear(this.end, tolerance))) ||
559
- (this.end.isNear(other.end, tolerance) && breakpoints.every(b => !b.isNear(this.end, tolerance)))
560
- );
561
- }
562
-
563
- /**
564
- * Project the line to 2D space, Y value is dropped
565
- */
566
- public onPlan(): Line2D {
567
- return new Line2D(this.start.onPlan(), this.end.onPlan());
568
- }
569
-
570
- /**
571
- * Equals with tolerance
572
- */
573
- public equals(other: Line3D, tolerance: number = 0): boolean {
574
- return !!other && this.start.distanceTo(other.start) <= tolerance && this.end.distanceTo(other.end) <= tolerance;
575
- }
576
-
577
- /**
578
- * Deep clone of this line
579
- */
580
- public clone(): this {
581
- return new Line3D(this.start.clone(), this.end.clone(), this.index) as this;
582
- }
583
-
584
- public toString(): string {
585
- return `Line3D { start: ${this.start.x}, ${this.start.y}, ${this.start.z}, end: ${this.end.x}, ${this.end.y}, ${this.end.z}}`;
586
- }
1
+ import { Line3, Vector3 } from "three";
2
+ import { Vec3 } from "./Vec3";
3
+ import { Point3 } from "./Point3";
4
+ import { Line2D } from "./Line2D";
5
+
6
+ export class Line3D extends Line3 {
7
+
8
+ public declare start: Vec3;
9
+ public declare end: Vec3;
10
+
11
+ readonly #target: Vec3;
12
+
13
+ constructor(start: Vec3, end: Vec3, public index: number = 0) {
14
+ super(start, end);
15
+ this.#target = new Vec3();
16
+ }
17
+
18
+ public static fromPoints(start: Point3, end: Point3, index: number = 0): Line3D {
19
+ return new Line3D(new Vec3(start.x, start.y, start.z), new Vec3(end.x, end.y, end.z), index);
20
+ }
21
+
22
+ /**
23
+ * Creates a polygon formed by an array of lines from points provided.
24
+ * The polygon will only be closed if either
25
+ * 1) the first and last points are the same or 2) `forceClosedPolygon` is true.
26
+ */
27
+ public static fromPolygon(polygon: Point3[], forceClosedPolygon: boolean = false): Line3D[] {
28
+ if (!polygon || polygon.length < 2) {
29
+ return [];
30
+ }
31
+
32
+ if (forceClosedPolygon && (polygon[0].x !== polygon.at(-1).x || polygon[0].y !== polygon.at(-1).y || polygon[0].z !== polygon.at(-1).z)) {
33
+ polygon = [...polygon, polygon[0]];
34
+ }
35
+
36
+ const lines: Line3D[] = [];
37
+ for (let i = 0; i < polygon.length - 1; i++) {
38
+ lines.push(Line3D.fromPoints(polygon[i], polygon[i + 1], i));
39
+ }
40
+
41
+ return lines;
42
+ }
43
+
44
+ /**
45
+ * Returns lines that are the result of clipping this line by the @other line.
46
+ * Clips must be parallel to this line.
47
+ * Clones the line, does not modify this.
48
+ * @param other
49
+ * @param parallelTolerance
50
+ */
51
+ public clipLine(other: Line3D, parallelTolerance: number = Number.EPSILON): Line3D[] {
52
+ other = this.getParallelLineInTheSameDirection(other, parallelTolerance);
53
+
54
+ // 1) Lines aren't parallel
55
+ if (!other) {
56
+ return [this.clone()];
57
+ }
58
+
59
+ const left = this.clone();
60
+ left.end.copy(other.start);
61
+
62
+ const right = this.clone();
63
+ right.start.copy(other.end);
64
+
65
+ return [left, right].filter(x => x.direction.manhattanDistanceTo(this.direction) <= parallelTolerance);
66
+ }
67
+
68
+ /**
69
+ * Returns lines that are the result of clipping this line by the @clips.
70
+ * Clips must be parallel to this line.
71
+ * Clones the line, does not modify this.
72
+ * @param clips
73
+ * @param distanceTolerance
74
+ * @param parallelTolerance
75
+ */
76
+ public clipLines(clips: Line3D[], distanceTolerance: number = 0, parallelTolerance: number = Number.EPSILON): Line3D[] {
77
+ const free: Line3D[] = [];
78
+ const sources: Line3D[] = [this.clone()];
79
+
80
+ while (sources.length > 0) {
81
+
82
+ let isFree = true;
83
+
84
+ const tested = sources.pop();
85
+
86
+ for (const clip of clips) {
87
+
88
+ if (tested.overlaps(clip, distanceTolerance, parallelTolerance)) {
89
+ isFree = false;
90
+ const subtracted = tested.clipLine(clip, parallelTolerance);
91
+ sources.push(...subtracted);
92
+ break;
93
+ }
94
+ }
95
+
96
+ if (isFree) free.push(tested);
97
+ }
98
+
99
+ return free;
100
+ }
101
+
102
+ /**
103
+ * Joins a copy of this line with the @other line.
104
+ * Other must be parallel to this line.
105
+ * Returns null if there is no overlap
106
+ * Clones the line, does not modify this.
107
+ * @param other
108
+ * @param distanceTolerance
109
+ * @param parallelTolerance
110
+ */
111
+ public joinLine(other: Line3D, distanceTolerance: number = 0, parallelTolerance: number = Number.EPSILON): Line3D {
112
+ // 6 possible cases:
113
+ const otherParallel = this.getParallelLineInTheSameDirection(other, parallelTolerance);
114
+
115
+ // 1) Lines aren't parallel
116
+ if (!otherParallel) {
117
+ return null;
118
+ }
119
+
120
+ const thisContainsOtherStartPoint = this.containsPoint(otherParallel.start, distanceTolerance);
121
+ const thisContainsOtherEndPoint = this.containsPoint(otherParallel.end, distanceTolerance);
122
+ const otherContainsThisStartPoint = otherParallel.containsPoint(this.start, distanceTolerance);
123
+ const otherContainsThisEndPoint = otherParallel.containsPoint(this.end, distanceTolerance);
124
+
125
+ // 2) Lines don't overlap at all
126
+ if (
127
+ !thisContainsOtherStartPoint &&
128
+ !thisContainsOtherEndPoint &&
129
+ !otherContainsThisStartPoint &&
130
+ !otherContainsThisEndPoint
131
+ ) {
132
+ return null;
133
+ }
134
+
135
+ // 3) This line entirely covers the other line
136
+ if (thisContainsOtherStartPoint && thisContainsOtherEndPoint) {
137
+ return this.clone();
138
+ }
139
+
140
+ // 4) The other line entirely covers this line
141
+ if (otherContainsThisStartPoint && otherContainsThisEndPoint) {
142
+ return otherParallel.clone();
143
+ }
144
+
145
+ // 5) This line is overlapped by the start of the other line
146
+ if (thisContainsOtherStartPoint && !thisContainsOtherEndPoint) {
147
+ return new Line3D(this.start, otherParallel.end);
148
+ }
149
+
150
+ // 6) This line is overlapped by the end of the other line
151
+ if (thisContainsOtherEndPoint && !thisContainsOtherStartPoint) {
152
+ return new Line3D(otherParallel.start, this.end);
153
+ }
154
+
155
+ return null;
156
+ }
157
+
158
+ /**
159
+ * Joins provided lines into several joined lines.
160
+ * Lines must be parallel for joining.
161
+ * @param lines
162
+ * @param distanceTolerance
163
+ * @param parallelTolerance
164
+ */
165
+ public static joinLines(lines: Line3D[], distanceTolerance: number = 0, parallelTolerance: number = Number.EPSILON): Line3D[] {
166
+ if (lines.length < 2) {
167
+ return lines.map(x => x.clone());
168
+ }
169
+
170
+ const toProcess = lines.map((line, index) => ({ line: line.clone(), index }));
171
+ const result: { line: Line3D, index: number }[] = [];
172
+
173
+ while (toProcess.length > 0) {
174
+ const current = toProcess.pop();
175
+ let joinedLine: Line3D;
176
+
177
+ for (let i = 0; i < result.length; i++) {
178
+ const other = result[i];
179
+ joinedLine = current.line.joinLine(other.line, distanceTolerance, parallelTolerance);
180
+ if (joinedLine) {
181
+ result.splice(i, 1);
182
+ toProcess.push({ line: joinedLine, index: current.index });
183
+ break;
184
+ }
185
+ }
186
+
187
+ if (!joinedLine) {
188
+ result.push(current);
189
+ }
190
+ }
191
+
192
+ // Sort the result based on the original indices
193
+ result.sort((a, b) => a.index - b.index);
194
+
195
+ return result.map(item => item.line);
196
+ }
197
+
198
+ /**
199
+ * Returns true if this line section completely overlaps the @other line section.
200
+ * @param other
201
+ * @param tolerance
202
+ */
203
+ public covers(other: Line3D, tolerance: number = 0): boolean {
204
+ return this.containsPoint(other.start, tolerance) && this.containsPoint(other.end, tolerance);
205
+ }
206
+
207
+ /**
208
+ * Returns true if there is any overlap between this line and the @other line section.
209
+ * @param other
210
+ * @param distanceTolerance
211
+ * @param parallelTolerance
212
+ */
213
+ public overlaps(other: Line3D, distanceTolerance: number = 0, parallelTolerance: number = Number.EPSILON): boolean {
214
+ // Special case
215
+ if (this.equals(other, distanceTolerance)) {
216
+ return true;
217
+ }
218
+
219
+ // Always have to be parallel
220
+ if (this.isParallelTo(other, parallelTolerance)) {
221
+ // To pass as overlapping, at least one point has to be within the other line, but they mush not be equal - "touching" is not considered overlapping
222
+
223
+ const otherStartEqualsToAnyOfThisPoint = other.start.distanceTo(this.start) <= distanceTolerance || other.start.distanceTo(this.end) <= distanceTolerance;
224
+ if (this.containsPoint(other.start, distanceTolerance) && !otherStartEqualsToAnyOfThisPoint) {
225
+ return true;
226
+ }
227
+
228
+ const otherEndEqualsToAnyOfThisPoint = other.end.distanceTo(this.start) <= distanceTolerance || other.end.distanceTo(this.end) <= distanceTolerance;
229
+ if (this.containsPoint(other.end, distanceTolerance) && !otherEndEqualsToAnyOfThisPoint) {
230
+ return true;
231
+ }
232
+
233
+ const thisStartEqualsToAnyOfOtherPoint = this.start.distanceTo(other.start) <= distanceTolerance || this.start.distanceTo(other.end) <= distanceTolerance;
234
+ if (other.containsPoint(this.start, distanceTolerance) && !thisStartEqualsToAnyOfOtherPoint) {
235
+ return true;
236
+ }
237
+
238
+ const thisEndEqualsToAnyOfOtherPoint = this.end.distanceTo(other.start) <= distanceTolerance || this.end.distanceTo(other.end) <= distanceTolerance;
239
+ if (other.containsPoint(this.end, distanceTolerance) && !thisEndEqualsToAnyOfOtherPoint) {
240
+ return true;
241
+ }
242
+ }
243
+
244
+ return false;
245
+ }
246
+
247
+ /**
248
+ * Returns this line's length.
249
+ */
250
+ public get length(): number {
251
+ return this.start.distanceTo(this.end);
252
+ }
253
+
254
+ /**
255
+ * Set the length of this line. Center and direction remain unchanged.
256
+ * @param length
257
+ */
258
+ public setLength(length: number): this {
259
+ const diff = length - this.length;
260
+ return this.resize(diff);
261
+ }
262
+
263
+ /**
264
+ * Returns the direction of this line.
265
+ */
266
+ public get direction(): Vec3 {
267
+ return this.end.clone().sub(this.start).normalize();
268
+ }
269
+
270
+ /**
271
+ * Returns the center of this line
272
+ */
273
+ public get center(): Vec3 {
274
+ return this.getCenter(new Vec3()) as Vec3;
275
+ }
276
+
277
+ /**
278
+ * Set the center of the line to the provided point. Length and direction remain unchanged.
279
+ * @param value
280
+ */
281
+ public setCenter(value: Vector3): this {
282
+ const current = this.center;
283
+ const diffX = current.x - value.x;
284
+ const diffY = current.y - value.y;
285
+ const diffZ = current.z - value.z;
286
+ this.start.x -= diffX;
287
+ this.start.y -= diffY;
288
+ this.start.z -= diffZ;
289
+ this.end.x -= diffX;
290
+ this.end.y -= diffY;
291
+ this.end.z -= diffZ;
292
+ return this;
293
+ }
294
+
295
+ /** Returns the start and end points of the line as an array. */
296
+ public get endpoints(): Vec3[] {
297
+ return [this.start, this.end];
298
+ }
299
+
300
+ /**
301
+ * Check that this line section contains provided point.
302
+ * @param p
303
+ * @param tolerance
304
+ */
305
+ public containsPoint(p: Vector3, tolerance: number = 0): boolean {
306
+ const closestPointToPoint = this.closestPointToPoint(p, true, this.#target);
307
+ return closestPointToPoint.distanceTo(p) <= tolerance;
308
+ }
309
+
310
+ /**
311
+ * Distance from this line to the provided point.
312
+ * @param p
313
+ * @param clampToLine
314
+ */
315
+ public distanceToPoint(p: Vector3, clampToLine: boolean = true): number {
316
+ const closestPointToPoint = this.closestPointToPoint(p, clampToLine, this.#target);
317
+ return closestPointToPoint.distanceTo(p);
318
+ }
319
+
320
+ /**
321
+ * Returns a copy of @other line, the direction of @other is reversed if needed.
322
+ * Returns null if lines are not parallel.
323
+ * @param other
324
+ * @param tolerance
325
+ */
326
+ public getParallelLineInTheSameDirection(other: Line3D, tolerance: number = Number.EPSILON): Line3D {
327
+ const direction = this.direction;
328
+
329
+ const areTheSameDirection = direction.manhattanDistanceTo(other.direction) < tolerance;
330
+ if (areTheSameDirection) {
331
+ return other.clone();
332
+ }
333
+
334
+ const otherLineOppositeDirection = new Line3D(other.end, other.start);
335
+ if (otherLineOppositeDirection.direction.manhattanDistanceTo(direction) < tolerance) {
336
+ return otherLineOppositeDirection;
337
+ }
338
+
339
+ return null;
340
+ }
341
+
342
+ /**
343
+ * Check if @other is parallel to this line.
344
+ * @param other
345
+ * @param angleTolerance
346
+ */
347
+ public isParallelTo(other: Line3D, angleTolerance: number = Number.EPSILON): boolean {
348
+ const direction = this.direction;
349
+ const otherDirection = other.direction;
350
+
351
+ const areTheSameDirection = direction.angleTo(otherDirection) <= angleTolerance;
352
+ if (areTheSameDirection) {
353
+ return true;
354
+ }
355
+
356
+ return direction.negate().angleTo(otherDirection) < angleTolerance;
357
+ }
358
+
359
+ /*
360
+ * Extends or reduces the line to the given length while keeping the center of the line constant.
361
+ */
362
+ public resize(amount: number): this {
363
+ this.moveStartPoint(amount / 2);
364
+ this.moveEndPoint(amount / 2);
365
+ return this;
366
+ }
367
+
368
+ /*
369
+ * Moves start on the line by the given amount. Plus values move the point further away from the center.
370
+ */
371
+ public moveStartPoint(amount: number): this {
372
+ const start = this.movePointOnThisLine(this.start, amount);
373
+ this.start.x = start.x;
374
+ this.start.y = start.y;
375
+ this.start.z = start.z;
376
+
377
+ return this;
378
+ }
379
+
380
+ /*
381
+ * Moves end on the line by the given amount in the current direction. Plus values move the point further away from the center.
382
+ */
383
+ public moveEndPoint(amount: number): this {
384
+ const end = this.movePointOnThisLine(this.end, amount);
385
+ this.end.x = end.x;
386
+ this.end.y = end.y;
387
+ this.end.z = end.z;
388
+
389
+ return this;
390
+ }
391
+
392
+ /**
393
+ * Returns a new line that is the projection of this line onto @other. Uses `closestPointToPoint` to find the projection.
394
+ * @param other
395
+ * @param clampToLine
396
+ */
397
+ public projectOn(other: Line3D, clampToLine: boolean): Line3D {
398
+ const p1 = other.closestPointToPoint(this.start, clampToLine, new Vec3()) as Vec3;
399
+ const p2 = other.closestPointToPoint(this.end, clampToLine, new Vec3()) as Vec3;
400
+
401
+ return p1.distanceTo(this.start) < p2.distanceTo(this.start) ? new Line3D(p1, p2) : new Line3D(p2, p1);
402
+ }
403
+
404
+ /**
405
+ * Divides the Line3D into a number of segments of the given length.
406
+ * @param maxSegmentLength number
407
+ */
408
+ public chunk(maxSegmentLength: number): Line3D[] {
409
+ const source = this.clone();
410
+ const result: Line3D[] = [];
411
+ while (source.length > maxSegmentLength) {
412
+ const chunk = source.clone();
413
+ chunk.moveEndPoint(-(chunk.length - maxSegmentLength));
414
+ result.push(chunk);
415
+ source.start.copy(chunk.end);
416
+ }
417
+ if (source.length > 0) {
418
+ result.push(source);
419
+ }
420
+ return result;
421
+ }
422
+
423
+ /**
424
+ * Note that this works well for moving the endpoints as it's currently used
425
+ * If it were to be made public, it would need to handle the situation where the point to move is in the center of the line which would require a different approach
426
+ */
427
+ private movePointOnThisLine(point: Vec3, amount: number): Vec3 {
428
+ const center = this.getCenter(this.#target);
429
+ const vec = new Vec3(center.x - point.x, center.y - point.y, center.z - point.z);
430
+ const length = vec.length();
431
+ vec.normalize().multiplyScalar(length + amount);
432
+
433
+ return new Vec3(
434
+ center.x - vec.x,
435
+ center.y - vec.y,
436
+ center.z - vec.z
437
+ );
438
+ }
439
+
440
+ /**
441
+ * Move this line by the given vector.
442
+ * @param p
443
+ */
444
+ public translate(p: Vector3): this {
445
+ this.start.add(p);
446
+ this.end.add(p);
447
+ return this;
448
+ }
449
+
450
+ /**
451
+ * Calculates the intersection between this and `other` line. The lines are assumed to be infinite.
452
+ * In a lot of cases, an actual intersection cannot be calculated due to rounding errors.
453
+ * Therefore, the intersection calculated by this method comes in the form of the shorted possible line segment connecting the two lines.
454
+ * Sources:
455
+ * http://paulbourke.net/geometry/pointlineplane/
456
+ * https://stackoverflow.com/questions/2316490/the-algorithm-to-find-the-point-of-intersection-of-two-3d-line-segment/2316934#2316934
457
+ * @param other
458
+ */
459
+ public intersect(other: Line3D): Line3D {
460
+ const p1: Vec3 = this.start.clone();
461
+ const p2: Vec3 = this.end.clone();
462
+
463
+ const p3: Vec3 = other.start.clone();
464
+ const p4: Vec3 = other.end.clone();
465
+
466
+ const p13: Vec3 = p1.clone().sub(p3);
467
+ const p43: Vec3 = p4.clone().sub(p3);
468
+
469
+ if (p43.lengthSq() <= Number.EPSILON) {
470
+ return null;
471
+ }
472
+
473
+ const p21 = p2.clone().sub(p1);
474
+ if (p21.lengthSq() <= Number.EPSILON) {
475
+ return null;
476
+ }
477
+
478
+ const d1343: number = p13.x * p43.x + p13.y * p43.y + p13.z * p43.z;
479
+ const d4321: number = p43.x * p21.x + p43.y * p21.y + p43.z * p21.z;
480
+ const d1321: number = p13.x * p21.x + p13.y * p21.y + p13.z * p21.z;
481
+ const d4343: number = p43.x * p43.x + p43.y * p43.y + p43.z * p43.z;
482
+ const d2121: number = p21.x * p21.x + p21.y * p21.y + p21.z * p21.z;
483
+
484
+ const denominator: number = d2121 * d4343 - d4321 * d4321;
485
+ if (Math.abs(denominator) <= Number.EPSILON) {
486
+ return null;
487
+ }
488
+ const numerator: number = d1343 * d4321 - d1321 * d4343;
489
+
490
+ const mua: number = numerator / denominator;
491
+ const mub: number = (d1343 + d4321 * (mua)) / d4343;
492
+
493
+ const resultSegmentPoint1 = new Vec3(
494
+ (p1.x + mua * p21.x),
495
+ (p1.y + mua * p21.y),
496
+ (p1.z + mua * p21.z)
497
+ );
498
+
499
+ const resultSegmentPoint2 = new Vec3(
500
+ (p3.x + mub * p43.x),
501
+ (p3.y + mub * p43.y),
502
+ (p3.z + mub * p43.z)
503
+ );
504
+
505
+ return new Line3D(resultSegmentPoint1, resultSegmentPoint2);
506
+ }
507
+
508
+ /**
509
+ * Accepts an array of Line3D and groups them into arrays of connected lines
510
+ * @param lines Lines to be grouped
511
+ * @param tolerance Tolerance for considering lines as connected
512
+ * @param breakpoints
513
+ */
514
+ public static groupConnectedLines(lines: Line3D[], tolerance: number = 0, breakpoints: Vec3[] = []): Line3D[][] {
515
+ const visited: Set<Line3D> = new Set();
516
+
517
+ // Use graph-based approach. Each line can be considered as an edge in the graph, and the endpoints of the lines can be considered as vertices.
518
+ // Then use Depth-First Search (DFS) to find connected components in the graph.
519
+ const dfs = (line: Line3D, group: Line3D[]) => {
520
+ if (visited.has(line)) return;
521
+ visited.add(line);
522
+ group.push(line);
523
+
524
+ lines.forEach(neighbor => {
525
+ if (!visited.has(neighbor)) {
526
+ if (
527
+ line.connectsTo(neighbor, tolerance, breakpoints)
528
+ ) {
529
+ dfs(neighbor, group);
530
+ }
531
+ }
532
+ });
533
+ };
534
+
535
+ const connectedLines: Line3D[][] = [];
536
+
537
+ lines.forEach(line => {
538
+ if (!visited.has(line)) {
539
+ const group: Line3D[] = [];
540
+ dfs(line, group);
541
+ connectedLines.push(group);
542
+ }
543
+ });
544
+
545
+ return connectedLines;
546
+ }
547
+
548
+ /**
549
+ * Returns true if any endpoint of this line is within the tolerance of any @other line's endpoints.
550
+ * @param other
551
+ * @param tolerance
552
+ * @param breakpoints
553
+ */
554
+ public connectsTo(other: Line3D, tolerance: number = 0, breakpoints: Vec3[] = []): boolean {
555
+ return (
556
+ (this.start.isNear(other.start, tolerance) && breakpoints.every(b => !b.isNear(this.start, tolerance))) ||
557
+ (this.start.isNear(other.end, tolerance) && breakpoints.every(b => !b.isNear(this.start, tolerance))) ||
558
+ (this.end.isNear(other.start, tolerance) && breakpoints.every(b => !b.isNear(this.end, tolerance))) ||
559
+ (this.end.isNear(other.end, tolerance) && breakpoints.every(b => !b.isNear(this.end, tolerance)))
560
+ );
561
+ }
562
+
563
+ /**
564
+ * Project the line to 2D space, Y value is dropped
565
+ */
566
+ public onPlan(): Line2D {
567
+ return new Line2D(this.start.onPlan(), this.end.onPlan());
568
+ }
569
+
570
+ /**
571
+ * Equals with tolerance
572
+ */
573
+ public equals(other: Line3D, tolerance: number = 0): boolean {
574
+ return !!other && this.start.distanceTo(other.start) <= tolerance && this.end.distanceTo(other.end) <= tolerance;
575
+ }
576
+
577
+ /**
578
+ * Deep clone of this line
579
+ */
580
+ public clone(): this {
581
+ return new Line3D(this.start.clone(), this.end.clone(), this.index) as this;
582
+ }
583
+
584
+ public toString(): string {
585
+ return `Line3D { start: ${this.start.x}, ${this.start.y}, ${this.start.z}, end: ${this.end.x}, ${this.end.y}, ${this.end.z}}`;
586
+ }
587
587
  }