diff --git a/.changeset/little-paws-play.md b/.changeset/little-paws-play.md new file mode 100644 index 0000000000..7048c63794 --- /dev/null +++ b/.changeset/little-paws-play.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +Internal: refactor and test `segmentsIntersect` function diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/components/angle-indicators.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/components/angle-indicators.tsx index 4ea675d603..c316dcc72e 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/components/angle-indicators.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/components/angle-indicators.tsx @@ -3,7 +3,7 @@ import {vec} from "mafs"; import * as React from "react"; import {clockwise} from "../../../../util/geometry"; -import {findAngle} from "../../math"; +import {findAngle, segmentsIntersect} from "../../math"; import {getIntersectionOfRayWithBox as getRangeIntersectionVertex} from "../utils"; import {MafsCssTransformWrapper} from "./css-transform-wrapper"; @@ -324,7 +324,7 @@ export const shouldDrawArcOutside = ( polygonLines.forEach( (line) => - linesIntersect([vertex, rangeIntersectionPoint], line) && + segmentsIntersect([vertex, rangeIntersectionPoint], line) && lineIntersectionCount++, ); @@ -332,22 +332,6 @@ export const shouldDrawArcOutside = ( return !isEven(lineIntersectionCount); }; -// https://stackoverflow.com/a/24392281/7347484 -// The "intersects" function in geometry doesn't seem to work for this use case -const linesIntersect = ( - [[a, b], [c, d]]: CollinearTuple, - [[p, q], [r, s]]: CollinearTuple, -) => { - const determinant = (c - a) * (s - q) - (r - p) * (d - b); - if (determinant === 0) { - return false; - } else { - const lambda = ((s - q) * (r - a) + (p - r) * (s - b)) / determinant; - const gamma = ((b - d) * (r - a) + (c - a) * (s - b)) / determinant; - return 0 < lambda && lambda < 1 && 0 < gamma && gamma < 1; - } -}; - const isEven = (n: number) => n % 2 === 0; // This function calculates the bisector point of an angle formed by two points @@ -420,10 +404,10 @@ function calculateBisectorPoint( } // Scale the bisector direction by the radius - const bisectorPoint = [ + const bisectorPoint: vec.Vector2 = [ bisectorDirection[0] * radius, bisectorDirection[1] * radius, - ] satisfies vec.Vector2; + ]; // Add the vertex to the bisector point to get the final position // to ensure that the angle label moves with the interactive element diff --git a/packages/perseus/src/widgets/interactive-graphs/math/geometry.test.ts b/packages/perseus/src/widgets/interactive-graphs/math/geometry.test.ts new file mode 100644 index 0000000000..8189f04750 --- /dev/null +++ b/packages/perseus/src/widgets/interactive-graphs/math/geometry.test.ts @@ -0,0 +1,178 @@ +import {segmentsIntersect} from "./geometry"; + +import type {Segment} from "./geometry"; + +describe("segmentsIntersect", () => { + it("returns false when segments have zero length", () => { + const segment1: Segment = [ + [0, 0], + [0, 0], + ]; + const segment2: Segment = [ + [1, 1], + [1, 1], + ]; + expect(segmentsIntersect(segment1, segment2)).toBe(false); + }); + + it("returns false when segments are the same", () => { + // intersecting segments must have a SINGLE intersection point. + const segment1: Segment = [ + [0, 0], + [1, 1], + ]; + const segment2: Segment = [ + [0, 0], + [1, 1], + ]; + expect(segmentsIntersect(segment1, segment2)).toBe(false); + }); + + it("returns false when an endpoint touches the other segment (lambda = 1)", () => { + // We treat segments as open (exclusive of their endpoints). + const segment1: Segment = [ + [0, 0], + [1, 1], + ]; + const segment2: Segment = [ + [0, 2], + [2, 0], + ]; + expect(segmentsIntersect(segment1, segment2)).toBe(false); + }); + + it("returns false when an endpoint touches the other segment (lambda = 0)", () => { + // We treat segments as open (exclusive of their endpoints). + const segment1: Segment = [ + [0, 0], + [1, 1], + ]; + const segment2: Segment = [ + [-1, 1], + [1, -1], + ]; + expect(segmentsIntersect(segment1, segment2)).toBe(false); + }); + + it("returns false when endpoints touch (gamma = 0)", () => { + // We treat segments as open (exclusive of their endpoints). + const segment1: Segment = [ + [0, 0], + [1, 1], + ]; + const segment2: Segment = [ + [2, 1], + [1, 1], + ]; + expect(segmentsIntersect(segment1, segment2)).toBe(false); + }); + + it("returns false when endpoints touch (gamma = 1)", () => { + // We treat segments as open (exclusive of their endpoints). + const segment1: Segment = [ + [0, 0], + [1, 1], + ]; + const segment2: Segment = [ + [1, 1], + [2, 1], + ]; + expect(segmentsIntersect(segment1, segment2)).toBe(false); + }); + + it("returns false given two horizontal segments", () => { + const segment1: Segment = [ + [0, 0], + [1, 0], + ]; + const segment2: Segment = [ + [0, 1], + [1, 1], + ]; + expect(segmentsIntersect(segment1, segment2)).toBe(false); + }); + + it("returns false given two vertical segments", () => { + const segment1: Segment = [ + [0, 0], + [0, 1], + ]; + const segment2: Segment = [ + [1, 0], + [1, 1], + ]; + expect(segmentsIntersect(segment1, segment2)).toBe(false); + }); + + it("returns false given two parallel diagonal segments", () => { + const segment1: Segment = [ + [0, 0], + [1, 1], + ]; + const segment2: Segment = [ + [0, 1], + [1, 2], + ]; + expect(segmentsIntersect(segment1, segment2)).toBe(false); + }); + + it("returns true given intersecting segments", () => { + const segment1: Segment = [ + [0, 0], + [1, 1], + ]; + const segment2: Segment = [ + [1, 0], + [0, 1], + ]; + expect(segmentsIntersect(segment1, segment2)).toBe(true); + }); + + it("returns false when segments are not parallel but do not intersect (lambda > 1)", () => { + const segment1: Segment = [ + [0, 0], + [1, 1], + ]; + const segment2: Segment = [ + [9, 0], + [0, 9], + ]; + expect(segmentsIntersect(segment1, segment2)).toBe(false); + }); + + it("returns false when segments are not parallel but do not intersect (lambda < 0)", () => { + const segment1: Segment = [ + [0, 0], + [1, 1], + ]; + const segment2: Segment = [ + [-9, 0], + [0, -9], + ]; + expect(segmentsIntersect(segment1, segment2)).toBe(false); + }); + + it("returns false when segments are not parallel but do not intersect (gamma > 1)", () => { + const segment1: Segment = [ + [-9, 0], + [0, -9], + ]; + const segment2: Segment = [ + [0, 0], + [1, 1], + ]; + expect(segmentsIntersect(segment1, segment2)).toBe(false); + }); + + it("returns false when segments are not parallel but do not intersect (gamma < 0)", () => { + const segment1: Segment = [ + [9, 0], + [0, 9], + ]; + const segment2: Segment = [ + [0, 0], + [1, 1], + ]; + expect(segmentsIntersect(segment1, segment2)).toBe(false); + }); +}); diff --git a/packages/perseus/src/widgets/interactive-graphs/math/geometry.ts b/packages/perseus/src/widgets/interactive-graphs/math/geometry.ts new file mode 100644 index 0000000000..bda3e75074 --- /dev/null +++ b/packages/perseus/src/widgets/interactive-graphs/math/geometry.ts @@ -0,0 +1,21 @@ +import type {vec} from "mafs"; + +export type Segment = [vec.Vector2, vec.Vector2]; + +// Determines whether two line segments have exactly one intersection point. +// The segments are treated as "open" - that is, not inclusive of their +// endpoints. +// Algorithm from https://stackoverflow.com/a/24392281/7347484 +export const segmentsIntersect = ( + [[a, b], [c, d]]: Segment, + [[p, q], [r, s]]: Segment, +): boolean => { + const determinant = (c - a) * (s - q) - (r - p) * (d - b); + if (determinant === 0) { + return false; + } else { + const lambda = ((s - q) * (r - a) + (p - r) * (s - b)) / determinant; + const gamma = ((b - d) * (r - a) + (c - a) * (s - b)) / determinant; + return 0 < lambda && lambda < 1 && 0 < gamma && gamma < 1; + } +}; diff --git a/packages/perseus/src/widgets/interactive-graphs/math/index.ts b/packages/perseus/src/widgets/interactive-graphs/math/index.ts index a240ee4ae2..ba2c6ec56b 100644 --- a/packages/perseus/src/widgets/interactive-graphs/math/index.ts +++ b/packages/perseus/src/widgets/interactive-graphs/math/index.ts @@ -4,6 +4,7 @@ export {inset, clampToBox} from "./box"; export {X, Y} from "./coordinates"; export {MIN, MAX, size} from "./interval"; export {lerp} from "./interpolation"; +export {segmentsIntersect} from "./geometry"; export { calculateAngleInDegrees, convertDegreesToRadians,