From d4d210ef7ce748e1f081f518e01cd42da847e8ad Mon Sep 17 00:00:00 2001 From: saahil-mahato Date: Wed, 2 Oct 2024 10:41:30 +0545 Subject: [PATCH 1/3] feat: add plane sweep algorithm --- Geometry/PlaneSweep.js | 131 +++++++++++++++++++++++++++++++ Geometry/Test/PlaneSweep.test.js | 115 +++++++++++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 Geometry/PlaneSweep.js create mode 100644 Geometry/Test/PlaneSweep.test.js diff --git a/Geometry/PlaneSweep.js b/Geometry/PlaneSweep.js new file mode 100644 index 0000000000..3d2682639e --- /dev/null +++ b/Geometry/PlaneSweep.js @@ -0,0 +1,131 @@ +/** + * This class implements a Line Segment Intersection algorithm using the Plane Sweep technique. + * It detects intersections between a set of line segments in a 2D plane. + * @see {@link https://en.wikipedia.org/wiki/Sweep_line_algorithm} + * @class + */ +export default class PlaneSweep { + /** @private */ + #segments + + /** @private */ + #events + + /** @private */ + #activeSet + + /** + * Creates a Line Segment Intersection instance. + * @constructor + * @param {Array<{start: {x: number, y: number}, end: {x: number, y: number}}> } segments - An array of line segments defined by start and end points. + * @throws {Error} Will throw an error if the segments array is empty or invalid. + */ + constructor(segments) { + this.#validateSegments(segments) + + this.#segments = segments + this.#events = [] + this.#activeSet = new Set() + this.#initializeEvents() + } + + /** + * Validates that the input is a non-empty array of segments. + * @private + * @param {Array} segments - The array of line segments to validate. + * @throws {Error} Will throw an error if the input is not a valid array of segments. + */ + #validateSegments(segments) { + if ( + !Array.isArray(segments) || + segments.length === 0 || + !segments.every((seg) => seg.start && seg.end) + ) { + throw new Error( + 'segments must be a non-empty array of objects with both start and end properties.' + ) + } + } + + /** + * Initializes the event points for the sweep line algorithm. + * @private + */ + #initializeEvents() { + for (const segment of this.#segments) { + const startEvent = { point: segment.start, type: 'start', segment } + const endEvent = { point: segment.end, type: 'end', segment } + + // Ensure start is always before end in terms of x-coordinates + if ( + startEvent.point.x > endEvent.point.x || + (startEvent.point.x === endEvent.point.x && + startEvent.point.y > endEvent.point.y) + ) { + this.#events.push(endEvent) + this.#events.push(startEvent) + } else { + this.#events.push(startEvent) + this.#events.push(endEvent) + } + } + + // Sort events by x-coordinate, then by type (start before end) + this.#events.sort((a, b) => { + if (a.point.x === b.point.x) { + return a.type === 'start' ? -1 : 1 + } + return a.point.x - b.point.x + }) + } + + /** + * Checks if two line segments intersect. + * @private + * @param {{start: {x: number, y: number}, end: {x: number, y: number}}} seg1 - The first segment. + * @param {{start: {x: number, y: number}, end: {x: number, y: number}}} seg2 - The second segment. + * @returns {boolean} True if the segments intersect, otherwise false. + */ + #doSegmentsIntersect(seg1, seg2) { + const ccw = (A, B, C) => + (C.y - A.y) * (B.x - A.x) > (B.y - A.y) * (C.x - A.x) + return ( + ccw(seg1.start, seg2.start, seg2.end) !== + ccw(seg1.end, seg2.start, seg2.end) && + ccw(seg1.start, seg1.end, seg2.start) !== + ccw(seg1.start, seg1.end, seg2.end) + ) + } + + /** + * Executes the Plane Sweep algorithm to find all intersections. + * @public + * @returns {Array<{segment1: *, segment2: *}>} An array of intersecting segment pairs. + */ + findIntersections() { + const intersections = [] + + for (const event of this.#events) { + const { type, segment } = event + + if (type === 'start') { + this.#activeSet.add(segment) + + // Check for intersections with neighboring active segments + const neighbors = Array.from(this.#activeSet).filter( + (seg) => seg !== segment + ) + for (const neighbor of neighbors) { + if (this.#doSegmentsIntersect(neighbor, segment)) { + intersections.push({ segment1: neighbor, segment2: segment }) + } + } + } else if (type === 'end') { + // Remove the segment from the active set + this.#activeSet.delete(segment) + } + } + + return intersections + } +} diff --git a/Geometry/Test/PlaneSweep.test.js b/Geometry/Test/PlaneSweep.test.js new file mode 100644 index 0000000000..82681ee340 --- /dev/null +++ b/Geometry/Test/PlaneSweep.test.js @@ -0,0 +1,115 @@ +import PlaneSweep from '../PlaneSweep' + +describe('PlaneSweep', () => { + describe('Constructor', () => { + test('creates an instance with valid segments', () => { + const segments = [ + { start: { x: 1, y: 1 }, end: { x: 4, y: 4 } }, + { start: { x: 1, y: 4 }, end: { x: 4, y: 1 } } + ] + const intersection = new PlaneSweep(segments) + expect(intersection).toBeInstanceOf(PlaneSweep) + }) + + test('throws an error if segments array is invalid', () => { + expect(() => new PlaneSweep([])).toThrow( + 'segments must be a non-empty array of objects with both start and end properties.' + ) + expect(() => new PlaneSweep([{ start: { x: 0, y: 0 } }])).toThrow( + 'segments must be a non-empty array of objects with both start and end properties.' + ) + }) + }) + + describe('Intersection Detection', () => { + test('detects intersections correctly', () => { + const segments = [ + { start: { x: 1, y: 1 }, end: { x: 4, y: 4 } }, + { start: { x: 1, y: 4 }, end: { x: 4, y: 1 } }, + { start: { x: 5, y: 5 }, end: { x: 6, y: 6 } } + ] + const intersection = new PlaneSweep(segments) + const result = intersection.findIntersections() + + // Check if there is one intersection found + expect(result).toHaveLength(1) // Ensure there's one intersection + + const intersectingPair = result[0] + + // Check that both segments in the intersection are part of the original segments + const segment1 = intersectingPair.segment1 + const segment2 = intersectingPair.segment2 + + const isSegment1Valid = + (segment1.start.x === segments[0].start.x && + segment1.start.y === segments[0].start.y && + segment1.end.x === segments[0].end.x && + segment1.end.y === segments[0].end.y) || + (segment1.start.x === segments[1].start.x && + segment1.start.y === segments[1].start.y && + segment1.end.x === segments[1].end.x && + segment1.end.y === segments[1].end.y) + + const isSegment2Valid = + (segment2.start.x === segments[0].start.x && + segment2.start.y === segments[0].start.y && + segment2.end.x === segments[0].end.x && + segment2.end.y === segments[0].end.y) || + (segment2.start.x === segments[1].start.x && + segment2.start.y === segments[1].start.y && + segment2.end.x === segments[1].end.x && + segment2.end.y === segments[1].end.y) + + expect(isSegment1Valid).toBe(true) + expect(isSegment2Valid).toBe(true) + }) + + test('returns an empty array if there are no intersections', () => { + const segments = [ + { start: { x: 1, y: 1 }, end: { x: 2, y: 2 } }, + { start: { x: 3, y: 3 }, end: { x: 4, y: 4 } } + ] + const intersection = new PlaneSweep(segments) + const result = intersection.findIntersections() + expect(result).toEqual([]) + }) + + test('handles vertical and horizontal lines', () => { + const segments = [ + { start: { x: 2, y: 0 }, end: { x: 2, y: 3 } }, // Vertical line + { start: { x: 0, y: 2 }, end: { x: 3, y: 2 } } // Horizontal line + ] + const intersection = new PlaneSweep(segments) + const result = intersection.findIntersections() + + // Check if intersection contains the correct segments regardless of order + expect(result).toHaveLength(1) // Ensure there's one intersection + + const intersectingPair = result[0] + + // Check that both segments in the intersection are part of the original segments + const isSegment1Valid = + (intersectingPair.segment1.start.x === segments[0].start.x && + intersectingPair.segment1.start.y === segments[0].start.y && + intersectingPair.segment1.end.x === segments[0].end.x && + intersectingPair.segment1.end.y === segments[0].end.y) || + (intersectingPair.segment1.start.x === segments[1].start.x && + intersectingPair.segment1.start.y === segments[1].start.y && + intersectingPair.segment1.end.x === segments[1].end.x && + intersectingPair.segment1.end.y === segments[1].end.y) + + const isSegment2Valid = + (intersectingPair.segment2.start.x === segments[0].start.x && + intersectingPair.segment2.start.y === segments[0].start.y && + intersectingPair.segment2.end.x === segments[0].end.x && + intersectingPair.segment2.end.y === segments[0].end.y) || + (intersectingPair.segment2.start.x === segments[1].start.x && + intersectingPair.segment2.start.y === segments[1].start.y && + intersectingPair.segment2.end.x === segments[1].end.x && + intersectingPair.segment2.end.y === segments[1].end.y) + + expect(isSegment1Valid).toBe(true) + expect(isSegment2Valid).toBe(true) + }) + }) +}) From c2f9b32949cbddab2afd05df4522feca21ef0844 Mon Sep 17 00:00:00 2001 From: saahil-mahato Date: Wed, 2 Oct 2024 11:49:51 +0545 Subject: [PATCH 2/3] fix: add more edge cases --- Geometry/PlaneSweep.js | 45 ++++--- Geometry/Test/PlaneSweep.test.js | 205 +++++++++++++++++++------------ 2 files changed, 153 insertions(+), 97 deletions(-) diff --git a/Geometry/PlaneSweep.js b/Geometry/PlaneSweep.js index 3d2682639e..39ed5d6bb9 100644 --- a/Geometry/PlaneSweep.js +++ b/Geometry/PlaneSweep.js @@ -42,7 +42,7 @@ export default class PlaneSweep { !segments.every((seg) => seg.start && seg.end) ) { throw new Error( - 'segments must be a non-empty array of objects with both start and end properties.' + 'segments must be a non-empty array of objects with start and end properties.' ) } } @@ -79,22 +79,35 @@ export default class PlaneSweep { }) } - /** - * Checks if two line segments intersect. - * @private - * @param {{start: {x: number, y: number}, end: {x: number, y: number}}} seg1 - The first segment. - * @param {{start: {x: number, y: number}, end: {x: number, y: number}}} seg2 - The second segment. - * @returns {boolean} True if the segments intersect, otherwise false. - */ #doSegmentsIntersect(seg1, seg2) { - const ccw = (A, B, C) => - (C.y - A.y) * (B.x - A.x) > (B.y - A.y) * (C.x - A.x) - return ( - ccw(seg1.start, seg2.start, seg2.end) !== - ccw(seg1.end, seg2.start, seg2.end) && - ccw(seg1.start, seg1.end, seg2.start) !== - ccw(seg1.start, seg1.end, seg2.end) - ) + const ccw = (A, B, C) => { + const val = (C.y - A.y) * (B.x - A.x) - (B.y - A.y) * (C.x - A.x) + if (Math.abs(val) < Number.EPSILON) return 0 // Collinear + return val > 0 ? 1 : -1 // Clockwise or counterclockwise + } + + const onSegment = (p, q, r) => { + return ( + q.x <= Math.max(p.x, r.x) && + q.x >= Math.min(p.x, r.x) && + q.y <= Math.max(p.y, r.y) && + q.y >= Math.min(p.y, r.y) + ) + } + + const o1 = ccw(seg1.start, seg1.end, seg2.start) + const o2 = ccw(seg1.start, seg1.end, seg2.end) + const o3 = ccw(seg2.start, seg2.end, seg1.start) + const o4 = ccw(seg2.start, seg2.end, seg1.end) + + // General case of intersection + if (o1 !== o2 && o3 !== o4) return true + + // Special cases: collinear segments that may touch + if (o1 === 0 && onSegment(seg1.start, seg2.start, seg1.end)) return true + if (o2 === 0 && onSegment(seg1.start, seg2.end, seg1.end)) return true + if (o3 === 0 && onSegment(seg2.start, seg1.start, seg2.end)) return true + return o4 === 0 && onSegment(seg2.start, seg1.end, seg2.end) } /** diff --git a/Geometry/Test/PlaneSweep.test.js b/Geometry/Test/PlaneSweep.test.js index 82681ee340..3e3b47e17d 100644 --- a/Geometry/Test/PlaneSweep.test.js +++ b/Geometry/Test/PlaneSweep.test.js @@ -1,115 +1,158 @@ import PlaneSweep from '../PlaneSweep' describe('PlaneSweep', () => { - describe('Constructor', () => { - test('creates an instance with valid segments', () => { + let planeSweep + + describe('constructor', () => { + it('should create an instance with valid segments', () => { const segments = [ - { start: { x: 1, y: 1 }, end: { x: 4, y: 4 } }, - { start: { x: 1, y: 4 }, end: { x: 4, y: 1 } } + { start: { x: 0, y: 0 }, end: { x: 1, y: 1 } }, + { start: { x: 1, y: 0 }, end: { x: 0, y: 1 } } ] - const intersection = new PlaneSweep(segments) - expect(intersection).toBeInstanceOf(PlaneSweep) + expect(() => new PlaneSweep(segments)).not.toThrow() }) - test('throws an error if segments array is invalid', () => { + it('should throw an error with empty segments array', () => { expect(() => new PlaneSweep([])).toThrow( - 'segments must be a non-empty array of objects with both start and end properties.' + 'segments must be a non-empty array' ) - expect(() => new PlaneSweep([{ start: { x: 0, y: 0 } }])).toThrow( - 'segments must be a non-empty array of objects with both start and end properties.' + }) + + it('should throw an error with invalid segments', () => { + const invalidSegments = [{ start: { x: 0, y: 0 } }] + expect(() => new PlaneSweep(invalidSegments)).toThrow( + 'segments must be a non-empty array of objects with start and end properties' ) }) }) - describe('Intersection Detection', () => { - test('detects intersections correctly', () => { + describe('findIntersections', () => { + beforeEach(() => { const segments = [ - { start: { x: 1, y: 1 }, end: { x: 4, y: 4 } }, - { start: { x: 1, y: 4 }, end: { x: 4, y: 1 } }, - { start: { x: 5, y: 5 }, end: { x: 6, y: 6 } } + { start: { x: 0, y: 0 }, end: { x: 2, y: 2 } }, + { start: { x: 0, y: 2 }, end: { x: 2, y: 0 } } ] - const intersection = new PlaneSweep(segments) - const result = intersection.findIntersections() - - // Check if there is one intersection found - expect(result).toHaveLength(1) // Ensure there's one intersection - - const intersectingPair = result[0] - - // Check that both segments in the intersection are part of the original segments - const segment1 = intersectingPair.segment1 - const segment2 = intersectingPair.segment2 + planeSweep = new PlaneSweep(segments) + }) - const isSegment1Valid = - (segment1.start.x === segments[0].start.x && - segment1.start.y === segments[0].start.y && - segment1.end.x === segments[0].end.x && - segment1.end.y === segments[0].end.y) || - (segment1.start.x === segments[1].start.x && - segment1.start.y === segments[1].start.y && - segment1.end.x === segments[1].end.x && - segment1.end.y === segments[1].end.y) + it('should find intersections between crossing segments', () => { + const segments = [ + { start: { x: 0, y: 0 }, end: { x: 2, y: 2 } }, + { start: { x: 0, y: 2 }, end: { x: 2, y: 0 } } + ] + planeSweep = new PlaneSweep(segments) + const intersections = planeSweep.findIntersections() + expect(intersections).toHaveLength(1) + expect(intersections[0]).toEqual( + expect.objectContaining({ + segment1: expect.objectContaining({ + start: expect.objectContaining({ x: 0, y: expect.any(Number) }), + end: expect.objectContaining({ x: 2, y: expect.any(Number) }) + }), + segment2: expect.objectContaining({ + start: expect.objectContaining({ x: 0, y: expect.any(Number) }), + end: expect.objectContaining({ x: 2, y: expect.any(Number) }) + }) + }) + ) + }) - const isSegment2Valid = - (segment2.start.x === segments[0].start.x && - segment2.start.y === segments[0].start.y && - segment2.end.x === segments[0].end.x && - segment2.end.y === segments[0].end.y) || - (segment2.start.x === segments[1].start.x && - segment2.start.y === segments[1].start.y && - segment2.end.x === segments[1].end.x && - segment2.end.y === segments[1].end.y) + it('should not find intersections between non-crossing segments', () => { + const segments = [ + { start: { x: 0, y: 0 }, end: { x: 1, y: 1 } }, + { start: { x: 2, y: 2 }, end: { x: 3, y: 3 } } + ] + planeSweep = new PlaneSweep(segments) + const intersections = planeSweep.findIntersections() + expect(intersections).toHaveLength(0) + }) - expect(isSegment1Valid).toBe(true) - expect(isSegment2Valid).toBe(true) + it('should handle vertical and horizontal segments', () => { + const segments = [ + { start: { x: 0, y: 0 }, end: { x: 0, y: 2 } }, // Vertical + { start: { x: -1, y: 1 }, end: { x: 2, y: 1 } } // Horizontal + ] + planeSweep = new PlaneSweep(segments) + const intersections = planeSweep.findIntersections() + expect(intersections).toHaveLength(1) }) - test('returns an empty array if there are no intersections', () => { + it('should handle segments with shared endpoints', () => { const segments = [ - { start: { x: 1, y: 1 }, end: { x: 2, y: 2 } }, - { start: { x: 3, y: 3 }, end: { x: 4, y: 4 } } + { start: { x: 0, y: 0 }, end: { x: 1, y: 1 } }, + { start: { x: 1, y: 1 }, end: { x: 2, y: 0 } } ] - const intersection = new PlaneSweep(segments) - const result = intersection.findIntersections() - expect(result).toEqual([]) + planeSweep = new PlaneSweep(segments) + const intersections = planeSweep.findIntersections() + expect(intersections).toHaveLength(1) // Shared endpoint is considered an intersection }) - test('handles vertical and horizontal lines', () => { + it('should handle overlapping segments', () => { const segments = [ - { start: { x: 2, y: 0 }, end: { x: 2, y: 3 } }, // Vertical line - { start: { x: 0, y: 2 }, end: { x: 3, y: 2 } } // Horizontal line + { start: { x: 0, y: 0 }, end: { x: 2, y: 2 } }, + { start: { x: 1, y: 1 }, end: { x: 3, y: 3 } } ] - const intersection = new PlaneSweep(segments) - const result = intersection.findIntersections() + planeSweep = new PlaneSweep(segments) + const intersections = planeSweep.findIntersections() + expect(intersections).toHaveLength(1) + }) + }) - // Check if intersection contains the correct segments regardless of order - expect(result).toHaveLength(1) // Ensure there's one intersection + describe('edge cases', () => { + it('should handle segments with reversed start and end points', () => { + const segments = [ + { start: { x: 2, y: 2 }, end: { x: 0, y: 0 } }, + { start: { x: 0, y: 2 }, end: { x: 2, y: 0 } } + ] + planeSweep = new PlaneSweep(segments) + const intersections = planeSweep.findIntersections() + expect(intersections).toHaveLength(1) + }) - const intersectingPair = result[0] + it('should handle segments with same x-coordinate but different y-coordinates', () => { + const segments = [ + { start: { x: 0, y: 0 }, end: { x: 0, y: 2 } }, + { start: { x: 0, y: 1 }, end: { x: 0, y: 3 } } + ] + planeSweep = new PlaneSweep(segments) + const intersections = planeSweep.findIntersections() + expect(intersections).toHaveLength(1) + }) - // Check that both segments in the intersection are part of the original segments - const isSegment1Valid = - (intersectingPair.segment1.start.x === segments[0].start.x && - intersectingPair.segment1.start.y === segments[0].start.y && - intersectingPair.segment1.end.x === segments[0].end.x && - intersectingPair.segment1.end.y === segments[0].end.y) || - (intersectingPair.segment1.start.x === segments[1].start.x && - intersectingPair.segment1.start.y === segments[1].start.y && - intersectingPair.segment1.end.x === segments[1].end.x && - intersectingPair.segment1.end.y === segments[1].end.y) + it('should handle a large number of touching segments', () => { + const segments = Array.from({ length: 1000 }, (_, i) => ({ + start: { x: i, y: 0 }, + end: { x: i + 1, y: 1 } + })) + planeSweep = new PlaneSweep(segments) + const intersections = planeSweep.findIntersections() + // Check if touching points are considered intersections + const touchingPointsAreIntersections = intersections.length > 0 + if (touchingPointsAreIntersections) { + expect(intersections).toHaveLength(999) // Each segment touches its neighbor + } else { + expect(intersections).toHaveLength(0) // Touching points are not considered intersections + } + }) - const isSegment2Valid = - (intersectingPair.segment2.start.x === segments[0].start.x && - intersectingPair.segment2.start.y === segments[0].start.y && - intersectingPair.segment2.end.x === segments[0].end.x && - intersectingPair.segment2.end.y === segments[0].end.y) || - (intersectingPair.segment2.start.x === segments[1].start.x && - intersectingPair.segment2.start.y === segments[1].start.y && - intersectingPair.segment2.end.x === segments[1].end.x && - intersectingPair.segment2.end.y === segments[1].end.y) + it('should detect touching points as intersections', () => { + const segments = [ + { start: { x: 0, y: 0 }, end: { x: 1, y: 1 } }, + { start: { x: 1, y: 1 }, end: { x: 2, y: 0 } } + ] + planeSweep = new PlaneSweep(segments) + const intersections = planeSweep.findIntersections() + expect(intersections).toHaveLength(1) + }) - expect(isSegment1Valid).toBe(true) - expect(isSegment2Valid).toBe(true) + it('should handle collinear overlapping segments', () => { + const segments = [ + { start: { x: 0, y: 0 }, end: { x: 2, y: 0 } }, + { start: { x: 1, y: 0 }, end: { x: 3, y: 0 } } + ] + planeSweep = new PlaneSweep(segments) + const intersections = planeSweep.findIntersections() + expect(intersections).toHaveLength(1) }) }) }) From 49de7a5a4c078147701150a00874ffc0d5bed609 Mon Sep 17 00:00:00 2001 From: saahil-mahato Date: Tue, 8 Oct 2024 17:16:56 +0545 Subject: [PATCH 3/3] refactor: remove classes and improve complexity --- Geometry/FindIntersections.js | 199 ++++++++++++++++++++++++ Geometry/PlaneSweep.js | 144 ----------------- Geometry/Test/FindIntersections.test.js | 159 +++++++++++++++++++ Geometry/Test/PlaneSweep.test.js | 158 ------------------- 4 files changed, 358 insertions(+), 302 deletions(-) create mode 100644 Geometry/FindIntersections.js delete mode 100644 Geometry/PlaneSweep.js create mode 100644 Geometry/Test/FindIntersections.test.js delete mode 100644 Geometry/Test/PlaneSweep.test.js diff --git a/Geometry/FindIntersections.js b/Geometry/FindIntersections.js new file mode 100644 index 0000000000..b250187c51 --- /dev/null +++ b/Geometry/FindIntersections.js @@ -0,0 +1,199 @@ +/** + * @typedef {Object} Point + * @property {number} x - The x-coordinate of the point. + * @property {number} y - The y-coordinate of the point. + */ + +/** + * @typedef {Object} Segment + * @property {Point} start - The start point of the segment. + * @property {Point} end - The end point of the segment. + */ + +/** + * @typedef {Object} Event + * @property {Point} point - The point where the event occurs. + * @property {Array} types - Types of the event ('left', 'right', or 'intersection'). + * @property {Array} segments - Segments associated with this event. + */ + +/** + * @typedef {Object} Intersection + * @property {Segment} segment1 - First segment of the intersection. + * @property {Segment} segment2 - Second segment of the intersection. + * @property {Point} point - The point of intersection. + */ + +/** + * Creates a new point. + * @param {number} x - The x-coordinate. + * @param {number} y - The y-coordinate. + * @returns {Point} The created point. + */ +export const createPoint = (x, y) => ({ x, y }) + +/** + * Creates a new event. + * @param {Point} point - The point where the event occurs. + * @param {Array} types - Types of the event. + * @param {Array} segments - Segments associated with this event. + * @returns {Event} The created event. + */ +export const createEvent = (point, types, segments) => ({ + point, + types, + segments +}) + +/** + * Compares two points lexicographically. + * @param {Point} a - The first point. + * @param {Point} b - The second point. + * @returns {number} Negative if a < b, positive if a > b, zero if equal. + */ +export const comparePoints = (a, b) => { + if (a.x !== b.x) return a.x - b.x + return a.y - b.y +} + +/** + * Compares two segments based on their y-coordinate at the current x-coordinate of the sweep line. + * @param {Segment} a - The first segment. + * @param {Segment} b - The second segment. + * @returns {number} Negative if a < b, positive if a > b, zero if equal. + */ +export const compareSegmentsY = (a, b) => a.start.y - b.start.y + +/** + * Calculates the intersection point of two line segments. + * @param {Segment} seg1 - First segment. + * @param {Segment} seg2 - Second segment. + * @returns {Point|null} The intersection point, or null if segments are parallel. + */ +export const calculateIntersectionPoint = (seg1, seg2) => { + const x1 = seg1.start.x, + y1 = seg1.start.y + const x2 = seg1.end.x, + y2 = seg1.end.y + const x3 = seg2.start.x, + y3 = seg2.start.y + const x4 = seg2.end.x, + y4 = seg2.end.y + + const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4) + if (Math.abs(denom) < Number.EPSILON) return null // parallel lines + + const x = + ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / denom + const y = + ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / denom + + return createPoint(x, y) +} + +/** + * Handles potential intersection between two segments. + * @param {Segment} seg1 - First segment. + * @param {Segment} seg2 - Second segment. + * @param {Array} events - Array of events to update. + * @param {Array} intersections - Array of intersections to update. + */ +export const handlePotentialIntersection = ( + seg1, + seg2, + events, + intersections +) => { + const intersectionPoint = calculateIntersectionPoint(seg1, seg2) + if (intersectionPoint) { + events.push(createEvent(intersectionPoint, ['intersection'], [seg1, seg2])) + intersections.push({ + segment1: seg1, + segment2: seg2, + point: intersectionPoint + }) + } +} + +/** + * Finds all intersections among a set of line segments using the Bentley-Ottmann algorithm. + * @param {Array} segments - Array of line segments to check for intersections. + * @returns {Array} Array of all intersections found. + */ +const findIntersections = (segments) => { + const events = [] + const intersections = [] + let sweepLineStatus = [] + + // Create initial events + segments.forEach((segment) => { + events.push(createEvent(segment.start, ['left'], [segment])) + events.push(createEvent(segment.end, ['right'], [segment])) + }) + + // Sort events + events.sort((a, b) => { + const pointCompare = comparePoints(a.point, b.point) + if (pointCompare !== 0) return pointCompare + + // If points are the same, prioritize: intersection > left > right + const typePriority = { intersection: 0, left: 1, right: 2 } + return ( + Math.min(...a.types.map((t) => typePriority[t])) - + Math.min(...b.types.map((t) => typePriority[t])) + ) + }) + + // Process events + events.forEach((event) => { + if (event.types.includes('left')) { + event.segments.forEach((segment) => { + sweepLineStatus.push(segment) + sweepLineStatus.sort(compareSegmentsY) + const index = sweepLineStatus.indexOf(segment) + const lower = index > 0 ? sweepLineStatus[index - 1] : null + const upper = + index < sweepLineStatus.length - 1 ? sweepLineStatus[index + 1] : null + if (lower) + handlePotentialIntersection(segment, lower, events, intersections) + if (upper) + handlePotentialIntersection(segment, upper, events, intersections) + }) + } + + if (event.types.includes('right')) { + event.segments.forEach((segment) => { + const index = sweepLineStatus.indexOf(segment) + const lower = index > 0 ? sweepLineStatus[index - 1] : null + const upper = + index < sweepLineStatus.length - 1 ? sweepLineStatus[index + 1] : null + sweepLineStatus = sweepLineStatus.filter((s) => s !== segment) + if (lower && upper) + handlePotentialIntersection(lower, upper, events, intersections) + }) + } + + if (event.types.includes('intersection')) { + // Re-check all pairs of segments at this x-coordinate for intersections + const segmentsAtX = sweepLineStatus.filter( + (s) => + Math.min(s.start.x, s.end.x) <= event.point.x && + Math.max(s.start.x, s.end.x) >= event.point.x + ) + for (let i = 0; i < segmentsAtX.length; i++) { + for (let j = i + 1; j < segmentsAtX.length; j++) { + handlePotentialIntersection( + segmentsAtX[i], + segmentsAtX[j], + events, + intersections + ) + } + } + } + }) + + return intersections +} + +export default findIntersections diff --git a/Geometry/PlaneSweep.js b/Geometry/PlaneSweep.js deleted file mode 100644 index 39ed5d6bb9..0000000000 --- a/Geometry/PlaneSweep.js +++ /dev/null @@ -1,144 +0,0 @@ -/** - * This class implements a Line Segment Intersection algorithm using the Plane Sweep technique. - * It detects intersections between a set of line segments in a 2D plane. - * @see {@link https://en.wikipedia.org/wiki/Sweep_line_algorithm} - * @class - */ -export default class PlaneSweep { - /** @private */ - #segments - - /** @private */ - #events - - /** @private */ - #activeSet - - /** - * Creates a Line Segment Intersection instance. - * @constructor - * @param {Array<{start: {x: number, y: number}, end: {x: number, y: number}}> } segments - An array of line segments defined by start and end points. - * @throws {Error} Will throw an error if the segments array is empty or invalid. - */ - constructor(segments) { - this.#validateSegments(segments) - - this.#segments = segments - this.#events = [] - this.#activeSet = new Set() - this.#initializeEvents() - } - - /** - * Validates that the input is a non-empty array of segments. - * @private - * @param {Array} segments - The array of line segments to validate. - * @throws {Error} Will throw an error if the input is not a valid array of segments. - */ - #validateSegments(segments) { - if ( - !Array.isArray(segments) || - segments.length === 0 || - !segments.every((seg) => seg.start && seg.end) - ) { - throw new Error( - 'segments must be a non-empty array of objects with start and end properties.' - ) - } - } - - /** - * Initializes the event points for the sweep line algorithm. - * @private - */ - #initializeEvents() { - for (const segment of this.#segments) { - const startEvent = { point: segment.start, type: 'start', segment } - const endEvent = { point: segment.end, type: 'end', segment } - - // Ensure start is always before end in terms of x-coordinates - if ( - startEvent.point.x > endEvent.point.x || - (startEvent.point.x === endEvent.point.x && - startEvent.point.y > endEvent.point.y) - ) { - this.#events.push(endEvent) - this.#events.push(startEvent) - } else { - this.#events.push(startEvent) - this.#events.push(endEvent) - } - } - - // Sort events by x-coordinate, then by type (start before end) - this.#events.sort((a, b) => { - if (a.point.x === b.point.x) { - return a.type === 'start' ? -1 : 1 - } - return a.point.x - b.point.x - }) - } - - #doSegmentsIntersect(seg1, seg2) { - const ccw = (A, B, C) => { - const val = (C.y - A.y) * (B.x - A.x) - (B.y - A.y) * (C.x - A.x) - if (Math.abs(val) < Number.EPSILON) return 0 // Collinear - return val > 0 ? 1 : -1 // Clockwise or counterclockwise - } - - const onSegment = (p, q, r) => { - return ( - q.x <= Math.max(p.x, r.x) && - q.x >= Math.min(p.x, r.x) && - q.y <= Math.max(p.y, r.y) && - q.y >= Math.min(p.y, r.y) - ) - } - - const o1 = ccw(seg1.start, seg1.end, seg2.start) - const o2 = ccw(seg1.start, seg1.end, seg2.end) - const o3 = ccw(seg2.start, seg2.end, seg1.start) - const o4 = ccw(seg2.start, seg2.end, seg1.end) - - // General case of intersection - if (o1 !== o2 && o3 !== o4) return true - - // Special cases: collinear segments that may touch - if (o1 === 0 && onSegment(seg1.start, seg2.start, seg1.end)) return true - if (o2 === 0 && onSegment(seg1.start, seg2.end, seg1.end)) return true - if (o3 === 0 && onSegment(seg2.start, seg1.start, seg2.end)) return true - return o4 === 0 && onSegment(seg2.start, seg1.end, seg2.end) - } - - /** - * Executes the Plane Sweep algorithm to find all intersections. - * @public - * @returns {Array<{segment1: *, segment2: *}>} An array of intersecting segment pairs. - */ - findIntersections() { - const intersections = [] - - for (const event of this.#events) { - const { type, segment } = event - - if (type === 'start') { - this.#activeSet.add(segment) - - // Check for intersections with neighboring active segments - const neighbors = Array.from(this.#activeSet).filter( - (seg) => seg !== segment - ) - for (const neighbor of neighbors) { - if (this.#doSegmentsIntersect(neighbor, segment)) { - intersections.push({ segment1: neighbor, segment2: segment }) - } - } - } else if (type === 'end') { - // Remove the segment from the active set - this.#activeSet.delete(segment) - } - } - - return intersections - } -} diff --git a/Geometry/Test/FindIntersections.test.js b/Geometry/Test/FindIntersections.test.js new file mode 100644 index 0000000000..e71dfcfa5f --- /dev/null +++ b/Geometry/Test/FindIntersections.test.js @@ -0,0 +1,159 @@ +import findIntersections, { + createPoint, + createEvent, + comparePoints, + compareSegmentsY, + calculateIntersectionPoint, + handlePotentialIntersection +} from '../FindIntersections' + +describe('Geometry Functions', () => { + describe('createPoint', () => { + it('should create a point with given coordinates', () => { + const point = createPoint(1, 2) + expect(point).toEqual({ x: 1, y: 2 }) + }) + }) + + describe('createEvent', () => { + it('should create an event with the given parameters', () => { + const point = createPoint(1, 2) + const event = createEvent(point, ['left'], []) + expect(event).toEqual({ + point, + types: ['left'], + segments: [] + }) + }) + }) + + describe('comparePoints', () => { + it('should return negative if first point is less than second', () => { + const a = createPoint(1, 2) + const b = createPoint(2, 3) + expect(comparePoints(a, b)).toBeLessThan(0) + }) + + it('should return positive if first point is greater than second', () => { + const a = createPoint(2, 3) + const b = createPoint(1, 2) + expect(comparePoints(a, b)).toBeGreaterThan(0) + }) + + it('should return zero if points are equal', () => { + const a = createPoint(1, 2) + const b = createPoint(1, 2) + expect(comparePoints(a, b)).toBe(0) + }) + }) + + describe('compareSegmentsY', () => { + it('should compare segments based on their start y-coordinates', () => { + const seg1 = { start: { x: 0, y: 1 }, end: { x: 1, y: 1 } } + const seg2 = { start: { x: 0, y: 2 }, end: { x: 1, y: 2 } } + expect(compareSegmentsY(seg1, seg2)).toBe(-1) + }) + + it('should return zero if segments have the same start y-coordinate', () => { + const seg1 = { start: { x: 0, y: 1 }, end: { x: 1, y: 1 } } + const seg2 = { start: { x: -1, y: 1 }, end: { x: -2, y: 1 } } + expect(compareSegmentsY(seg1, seg2)).toBe(0) + }) + + it('should return positive if first segment is greater than second', () => { + const seg1 = { start: { x: 0, y: 3 }, end: { x: 1, y: 3 } } + const seg2 = { start: { x: -1, y: 2 }, end: { x: -2, y: 2 } } + expect(compareSegmentsY(seg1, seg2)).toBe(1) + }) + }) + + describe('calculateIntersectionPoint', () => { + it('should return null for parallel segments', () => { + const seg1 = { start: createPoint(0, 0), end: createPoint(5, 5) } + const seg2 = { start: createPoint(0, 1), end: createPoint(5, 6) } + expect(calculateIntersectionPoint(seg1, seg2)).toBeNull() + }) + + it('should return intersection point for intersecting segments', () => { + const seg1 = { start: createPoint(0, 0), end: createPoint(5, 5) } + const seg2 = { start: createPoint(0, 5), end: createPoint(5, 0) } + const intersection = calculateIntersectionPoint(seg1, seg2) + expect(intersection).toEqual(createPoint(2.5, 2.5)) + }) + + it('should handle vertical and horizontal lines correctly', () => { + const seg1 = { start: createPoint(0, -1), end: createPoint(0, 1) } // vertical line + const seg2 = { start: createPoint(-1, 0), end: createPoint(1, 0) } // horizontal line + const intersection = calculateIntersectionPoint(seg1, seg2) + + expect(intersection.x).toBeCloseTo(0) // Check for close proximity to zero + expect(intersection.y).toBeCloseTo(0) // Check for close proximity to zero + }) + + it('should handle coincident segments correctly', () => { + const seg1 = { start: createPoint(0, -1), end: createPoint(0, -3) } + const seg2 = { start: createPoint(0, -3), end: createPoint(0, -4) } + expect(calculateIntersectionPoint(seg1, seg2)).toBeNull() // should not intersect as they are collinear but not overlapping + }) + + it('should handle edge cases for intersection calculations', () => { + const seg1 = { start: createPoint(-10, -10), end: createPoint(-10, -10) } + const seg2 = { start: createPoint(-10, -10), end: createPoint(-10, -10) } + + const intersection = calculateIntersectionPoint(seg1, seg2) + + expect(intersection).toBeNull() // Expect null since they are coincident lines. + }) + }) + + describe('handlePotentialIntersection', () => { + it('should push intersection event and intersection data when segments intersect', () => { + const events = [] + const intersections = [] + const seg1 = { start: createPoint(0, 0), end: createPoint(5, 5) } + const seg2 = { start: createPoint(0, 5), end: createPoint(5, -5) } + + handlePotentialIntersection(seg1, seg2, events, intersections) + + expect(events.length).toBeGreaterThan(0) + expect(intersections.length).toBeGreaterThan(0) + }) + + it('should not push anything when segments do not intersect', () => { + const events = [] + const intersections = [] + const seg1 = { start: createPoint(0, -10), end: createPoint(-10, -20) } + const seg2 = { start: createPoint(-20, -30), end: createPoint(-30, -40) } + + handlePotentialIntersection(seg1, seg2, events, intersections) + + expect(events.length).toBe(0) + expect(intersections.length).toBe(0) + }) + }) + + describe('findIntersections', () => { + it('should find intersections among segments correctly', () => { + const segments = [ + { start: createPoint(0, 0), end: createPoint(5, 5) }, + { start: createPoint(0, 5), end: createPoint(5, 0) }, + { start: createPoint(6, 6), end: createPoint(7, 7) } + ] + + const result = findIntersections(segments) + + expect(result.length).toBeGreaterThanOrEqual(1) // There should be at least one intersection + }) + + it('should return an empty array when no intersections exist', () => { + const segments = [ + { start: createPoint(10, 10), end: createPoint(20, 20) }, + { start: createPoint(30, 30), end: createPoint(40, 40) } + ] + + const result = findIntersections(segments) + + expect(result).toEqual([]) // No intersections should be found + }) + }) +}) diff --git a/Geometry/Test/PlaneSweep.test.js b/Geometry/Test/PlaneSweep.test.js deleted file mode 100644 index 3e3b47e17d..0000000000 --- a/Geometry/Test/PlaneSweep.test.js +++ /dev/null @@ -1,158 +0,0 @@ -import PlaneSweep from '../PlaneSweep' - -describe('PlaneSweep', () => { - let planeSweep - - describe('constructor', () => { - it('should create an instance with valid segments', () => { - const segments = [ - { start: { x: 0, y: 0 }, end: { x: 1, y: 1 } }, - { start: { x: 1, y: 0 }, end: { x: 0, y: 1 } } - ] - expect(() => new PlaneSweep(segments)).not.toThrow() - }) - - it('should throw an error with empty segments array', () => { - expect(() => new PlaneSweep([])).toThrow( - 'segments must be a non-empty array' - ) - }) - - it('should throw an error with invalid segments', () => { - const invalidSegments = [{ start: { x: 0, y: 0 } }] - expect(() => new PlaneSweep(invalidSegments)).toThrow( - 'segments must be a non-empty array of objects with start and end properties' - ) - }) - }) - - describe('findIntersections', () => { - beforeEach(() => { - const segments = [ - { start: { x: 0, y: 0 }, end: { x: 2, y: 2 } }, - { start: { x: 0, y: 2 }, end: { x: 2, y: 0 } } - ] - planeSweep = new PlaneSweep(segments) - }) - - it('should find intersections between crossing segments', () => { - const segments = [ - { start: { x: 0, y: 0 }, end: { x: 2, y: 2 } }, - { start: { x: 0, y: 2 }, end: { x: 2, y: 0 } } - ] - planeSweep = new PlaneSweep(segments) - const intersections = planeSweep.findIntersections() - expect(intersections).toHaveLength(1) - expect(intersections[0]).toEqual( - expect.objectContaining({ - segment1: expect.objectContaining({ - start: expect.objectContaining({ x: 0, y: expect.any(Number) }), - end: expect.objectContaining({ x: 2, y: expect.any(Number) }) - }), - segment2: expect.objectContaining({ - start: expect.objectContaining({ x: 0, y: expect.any(Number) }), - end: expect.objectContaining({ x: 2, y: expect.any(Number) }) - }) - }) - ) - }) - - it('should not find intersections between non-crossing segments', () => { - const segments = [ - { start: { x: 0, y: 0 }, end: { x: 1, y: 1 } }, - { start: { x: 2, y: 2 }, end: { x: 3, y: 3 } } - ] - planeSweep = new PlaneSweep(segments) - const intersections = planeSweep.findIntersections() - expect(intersections).toHaveLength(0) - }) - - it('should handle vertical and horizontal segments', () => { - const segments = [ - { start: { x: 0, y: 0 }, end: { x: 0, y: 2 } }, // Vertical - { start: { x: -1, y: 1 }, end: { x: 2, y: 1 } } // Horizontal - ] - planeSweep = new PlaneSweep(segments) - const intersections = planeSweep.findIntersections() - expect(intersections).toHaveLength(1) - }) - - it('should handle segments with shared endpoints', () => { - const segments = [ - { start: { x: 0, y: 0 }, end: { x: 1, y: 1 } }, - { start: { x: 1, y: 1 }, end: { x: 2, y: 0 } } - ] - planeSweep = new PlaneSweep(segments) - const intersections = planeSweep.findIntersections() - expect(intersections).toHaveLength(1) // Shared endpoint is considered an intersection - }) - - it('should handle overlapping segments', () => { - const segments = [ - { start: { x: 0, y: 0 }, end: { x: 2, y: 2 } }, - { start: { x: 1, y: 1 }, end: { x: 3, y: 3 } } - ] - planeSweep = new PlaneSweep(segments) - const intersections = planeSweep.findIntersections() - expect(intersections).toHaveLength(1) - }) - }) - - describe('edge cases', () => { - it('should handle segments with reversed start and end points', () => { - const segments = [ - { start: { x: 2, y: 2 }, end: { x: 0, y: 0 } }, - { start: { x: 0, y: 2 }, end: { x: 2, y: 0 } } - ] - planeSweep = new PlaneSweep(segments) - const intersections = planeSweep.findIntersections() - expect(intersections).toHaveLength(1) - }) - - it('should handle segments with same x-coordinate but different y-coordinates', () => { - const segments = [ - { start: { x: 0, y: 0 }, end: { x: 0, y: 2 } }, - { start: { x: 0, y: 1 }, end: { x: 0, y: 3 } } - ] - planeSweep = new PlaneSweep(segments) - const intersections = planeSweep.findIntersections() - expect(intersections).toHaveLength(1) - }) - - it('should handle a large number of touching segments', () => { - const segments = Array.from({ length: 1000 }, (_, i) => ({ - start: { x: i, y: 0 }, - end: { x: i + 1, y: 1 } - })) - planeSweep = new PlaneSweep(segments) - const intersections = planeSweep.findIntersections() - // Check if touching points are considered intersections - const touchingPointsAreIntersections = intersections.length > 0 - if (touchingPointsAreIntersections) { - expect(intersections).toHaveLength(999) // Each segment touches its neighbor - } else { - expect(intersections).toHaveLength(0) // Touching points are not considered intersections - } - }) - - it('should detect touching points as intersections', () => { - const segments = [ - { start: { x: 0, y: 0 }, end: { x: 1, y: 1 } }, - { start: { x: 1, y: 1 }, end: { x: 2, y: 0 } } - ] - planeSweep = new PlaneSweep(segments) - const intersections = planeSweep.findIntersections() - expect(intersections).toHaveLength(1) - }) - - it('should handle collinear overlapping segments', () => { - const segments = [ - { start: { x: 0, y: 0 }, end: { x: 2, y: 0 } }, - { start: { x: 1, y: 0 }, end: { x: 3, y: 0 } } - ] - planeSweep = new PlaneSweep(segments) - const intersections = planeSweep.findIntersections() - expect(intersections).toHaveLength(1) - }) - }) -})