import LineSegment2 from './LineSegment2';
import Path, { CurveIndexRecord, PathCommandType } from './Path';
import Rect2 from './Rect2';
import { Point2, Vec2 } from '../Vec2';
import CubicBezier from './CubicBezier';
import QuadraticBezier from './QuadraticBezier';
describe('Path', () => {
it('should instantiate Beziers from cubic and quatratic commands', () => {
const path = new Path(Vec2.zero, [
{
kind: PathCommandType.CubicBezierTo,
controlPoint1: Vec2.of(1, 1),
controlPoint2: Vec2.of(-1, -1),
endPoint: Vec2.of(3, 3),
},
{
kind: PathCommandType.QuadraticBezierTo,
controlPoint: Vec2.of(1, 1),
endPoint: Vec2.of(0, 0),
},
]);
expect(path.geometry.length).toBe(2);
const firstItem = path.geometry[0];
const secondItem = path.geometry[1];
expect(firstItem).toBeInstanceOf(CubicBezier);
expect(secondItem).toBeInstanceOf(QuadraticBezier);
// Force TypeScript to do type narrowing.
if (!(firstItem instanceof CubicBezier) || !(secondItem instanceof QuadraticBezier)) {
throw new Error('Invalid state! .toBeInstanceOf should have caused test to fail!');
}
// Make sure the control points (and start/end points) match what was set
expect(firstItem.getPoints()).toMatchObject([
{ x: 0, y: 0 },
{ x: 1, y: 1 },
{ x: -1, y: -1 },
{ x: 3, y: 3 },
]);
expect(secondItem.getPoints()).toMatchObject([
{ x: 3, y: 3 },
{ x: 1, y: 1 },
{ x: 0, y: 0 },
]);
});
it('should create LineSegments from line commands', () => {
const lineStart = Vec2.zero;
const lineEnd = Vec2.of(100, 100);
const path = new Path(lineStart, [
{
kind: PathCommandType.LineTo,
point: lineEnd,
},
]);
expect(path.geometry.length).toBe(1);
expect(path.geometry[0]).toBeInstanceOf(LineSegment2);
expect(path.geometry[0]).toMatchObject(new LineSegment2(lineStart, lineEnd));
});
it.each([
['m0,0 L1,1', 'M0,0 L1,1', true],
['m0,0 L1,1', 'M1,1 L0,0', false],
['m0,0 L1,1 Q2,3 4,5', 'M1,1 L0,0', false],
['m0,0 L1,1 Q2,3 4,5', 'M1,1 L0,0 Q2,3 4,5', false],
['m0,0 L1,1 Q2,3 4,5', 'M0,0 L1,1 Q2,3 4,5', true],
['m0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9', 'M0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9', true],
['m0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9Z', 'M0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9', false],
['m0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9', 'M0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9Z', false],
['m0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9', 'M0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9.01', false],
['m0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9', 'M0,0 L1,1 Q2,3 4,5 C4,5 6,7.01 8,9', false],
['m0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9', 'M0,0 L1,1 Q2,3 4,5 C4,5.01 6,7 8,9', false],
])('.eq should check equality', (path1Str, path2Str, shouldEqual) => {
expect(Path.fromString(path1Str)).objEq(Path.fromString(path1Str));
expect(Path.fromString(path2Str)).objEq(Path.fromString(path2Str));
expect(Path.fromString(path1Str).eq(Path.fromString(path2Str))).toBe(shouldEqual);
});
describe('intersection', () => {
it('should give all intersections for a path made up of lines', () => {
const lineStart = Vec2.of(100, 100);
const path = new Path(lineStart, [
{
kind: PathCommandType.LineTo,
point: Vec2.of(-100, 100),
},
{
kind: PathCommandType.LineTo,
point: Vec2.of(0, 0),
},
{
kind: PathCommandType.LineTo,
point: Vec2.of(100, -100),
},
]);
const intersections = path.intersection(
new LineSegment2(Vec2.of(-50, 200), Vec2.of(-50, -200)),
);
// Should only have intersections in quadrants II and III.
expect(intersections.length).toBe(2);
// First intersection should be with the first curve
const firstIntersection = intersections[0];
expect(firstIntersection.point.xy).toMatchObject({
x: -50,
y: 100,
});
});
it('should give all intersections for a stroked path', () => {
const lineStart = Vec2.of(100, 100);
// Create a path in this shape:
// + - - - -|- - - - +
// \ |
// \ |
// \ |
// \ |
// ---------------------
// | \
// | \
// | \
// | \
// | +
const path = new Path(lineStart, [
{
kind: PathCommandType.LineTo,
point: Vec2.of(-100, 100),
},
{
kind: PathCommandType.LineTo,
point: Vec2.of(0, 0),
},
{
kind: PathCommandType.LineTo,
point: Vec2.of(100, -100),
},
]);
const strokeWidth = 5;
let intersections = path.intersection(
new LineSegment2(Vec2.of(2000, 200), Vec2.of(2000, 400)),
strokeWidth,
);
expect(intersections.length).toBe(0);
// Test a line that only enters, but does not exit one of the strokes:
//
// * <- Line to test
// |
// _____|_____________
// |
// * Stroke
//
// ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
intersections = path.intersection(
new LineSegment2(Vec2.of(-50, 200), Vec2.of(-50, 100)),
strokeWidth,
);
expect(intersections.length).toBe(1);
expect(intersections[0].point.xy).toMatchObject({
x: -50,
y: 105,
});
// Changing the order of the end points on the line should not change the result
intersections = path.intersection(
new LineSegment2(Vec2.of(-50, 100), Vec2.of(-50, 200)),
strokeWidth,
);
expect(intersections.length).toBe(1);
expect(intersections[0].point.xy).toMatchObject({
x: -50,
y: 105,
});
// This line should intersect two of the strokes. Thus, there should be four
// intersections — one entering and one leaving for each intersection with the
// centers.
intersections = path.intersection(
new LineSegment2(Vec2.of(-50, 200), Vec2.of(-50, -200)),
strokeWidth,
);
expect(intersections.length).toBe(4);
// Intersections should be in increasing order away from the
// first point on the line.
expect(intersections[0].point.xy).toMatchObject({
x: -50,
y: 105,
});
expect(intersections[1].point.xy).toMatchObject({
x: -50,
y: 95,
});
});
it('should correctly report intersections for a simple Bézier curve path', () => {
const lineStart = Vec2.zero;
const path = new Path(lineStart, [
{
kind: PathCommandType.QuadraticBezierTo,
controlPoint: Vec2.unitX,
endPoint: Vec2.unitY,
},
]);
const strokeWidth = 5;
// Should be no intersections for a line contained entirely within the stroke
// (including stroke width).
let intersections = path.intersection(
new LineSegment2(Vec2.of(-1, 0.5), Vec2.of(2, 0.5)),
strokeWidth,
);
expect(intersections).toHaveLength(0);
// Should be an intersection when exiting/entering the edge of the stroke
intersections = path.intersection(
new LineSegment2(Vec2.of(0, 0.5), Vec2.of(8, 0.5)),
strokeWidth,
);
expect(intersections).toHaveLength(1);
});
it('should correctly report intersections near the cap of a line-like Bézier', () => {
const path = Path.fromString('M0,0Q14,0 27,0');
expect(
path.intersection(new LineSegment2(Vec2.of(0, -100), Vec2.of(0, 100)), 10),
// Should have intersections, despite being at the cap of the Bézier
// curve.
).toHaveLength(2);
});
it.each([
[new LineSegment2(Vec2.of(43.5, -12.5), Vec2.of(40.5, 24.5)), 0],
[new LineSegment2(Vec2.of(35.5, 19.5), Vec2.of(38.5, -17.5)), 0],
])(
'should correctly report positive intersections with a line-like Bézier',
(line, strokeRadius) => {
const bezier = Path.fromString('M0,0 Q50,0 100,0');
expect(bezier.intersection(line, strokeRadius).length).toBeGreaterThan(0);
},
);
it('should handle near-vertical lines', () => {
const intersections = Path.fromString('M0,0 Q50,0 100,0').intersection(
new LineSegment2(Vec2.of(44, -12), Vec2.of(39, 25)),
);
expect(intersections).toHaveLength(1);
});
it('should handle single-point strokes', () => {
const stroke = new Path(Vec2.zero, []);
expect(
stroke.intersection(new LineSegment2(Vec2.of(-2, -20), Vec2.of(-2, -1)), 1),
).toHaveLength(0);
expect(stroke.intersection(new LineSegment2(Vec2.of(-2, -2), Vec2.of(2, 2)), 1)).toHaveLength(
2,
);
});
});
describe('polylineApproximation', () => {
it('should approximate Bézier curves with polylines', () => {
const path = Path.fromString('m0,0 l4,4 Q 1,4 4,1z');
expect(path.polylineApproximation()).toMatchObject([
new LineSegment2(Vec2.of(0, 0), Vec2.of(4, 4)),
new LineSegment2(Vec2.of(4, 4), Vec2.of(1, 4)),
new LineSegment2(Vec2.of(1, 4), Vec2.of(4, 1)),
new LineSegment2(Vec2.of(4, 1), Vec2.of(0, 0)),
]);
});
});
describe('roughlyIntersectsClosed', () => {
it('small, line-only path', () => {
const path = Path.fromString('m0,0 l10,10 L0,10 z');
expect(path.closedRoughlyIntersects(Rect2.fromCorners(Vec2.zero, Vec2.of(20, 20)))).toBe(
true,
);
expect(path.closedRoughlyIntersects(Rect2.fromCorners(Vec2.zero, Vec2.of(2, 2)))).toBe(true);
expect(path.closedRoughlyIntersects(new Rect2(10, 1, 1, 1))).toBe(false);
expect(path.closedRoughlyIntersects(new Rect2(1, 5, 1, 1))).toBe(true);
});
it('path with Bézier curves', () => {
const path = Path.fromString(`
M1090,2560
L1570,2620
Q1710,1300 1380,720
Q980,100 -460,-640
L-680,-200
Q670,470 960,980
Q1230,1370 1090,2560
`);
expect(path.closedRoughlyIntersects(new Rect2(0, 0, 500, 500))).toBe(true);
expect(path.closedRoughlyIntersects(new Rect2(0, 0, 5, 5))).toBe(true);
expect(path.closedRoughlyIntersects(new Rect2(-10000, 0, 500, 500))).toBe(false);
});
});
describe('roughlyIntersects', () => {
it('should consider parts outside bbox of individual parts of a line as not intersecting', () => {
const path = Path.fromString(`
M10,10
L20,20
L100,21
`);
expect(path.roughlyIntersects(new Rect2(0, 0, 50, 50))).toBe(true);
expect(path.roughlyIntersects(new Rect2(0, 0, 5, 5))).toBe(false);
expect(path.roughlyIntersects(new Rect2(8, 22, 1, 1))).toBe(false);
expect(path.roughlyIntersects(new Rect2(21, 11, 1, 1))).toBe(false);
expect(path.roughlyIntersects(new Rect2(50, 19, 1, 2))).toBe(true);
});
});
describe('fromRect', () => {
const filledRect = Path.fromRect(Rect2.unitSquare);
const strokedRect = Path.fromRect(Rect2.unitSquare, 0.1);
it('filled should be closed shape', () => {
const lastSegment = filledRect.parts[filledRect.parts.length - 1];
if (lastSegment.kind !== PathCommandType.LineTo) {
throw new Error('Rectangles should only be made up of lines');
}
expect(filledRect.startPoint).objEq(lastSegment.point);
});
it('stroked should be closed shape', () => {
const lastSegment = strokedRect.parts[strokedRect.parts.length - 1];
if (lastSegment.kind !== PathCommandType.LineTo) {
throw new Error('Rectangles should only be made up of lines');
}
expect(strokedRect.startPoint).objEq(lastSegment.point);
});
});
describe('splitAt', () => {
it.each([2, 3, 4, 5])('should split a line into %d sections', (numSections) => {
const path = Path.fromString('m0,0 l1,0');
const splitIndices: CurveIndexRecord[] = [];
for (let i = 0; i < numSections; i++) {
splitIndices.push({ curveIndex: 0, parameterValue: (i + 1) / (numSections + 1) });
}
const split = path.splitAt(splitIndices);
expect(split).toHaveLength(numSections + 1);
expect(split[numSections].getEndPoint()).objEq(Vec2.unitX);
for (let i = 0; i < numSections; i++) {
expect(split[i].geometry).toHaveLength(1);
const geom = split[i].geometry[0] as LineSegment2;
expect(geom.p1.y).toBeCloseTo(0);
expect(geom.p1.x).toBeCloseTo(i / (numSections + 1));
expect(geom.p2.y).toBeCloseTo(0);
expect(geom.p2.x).toBeCloseTo((i + 1) / (numSections + 1));
}
});
it('should handle the case where the first division is at the beginning of the path', () => {
const path = Path.fromString('m0,0 l1,0');
const beginningSplit = path.splitAt({ curveIndex: 0, parameterValue: 0 });
expect(beginningSplit).toHaveLength(1);
const endSplit = path.splitAt({ curveIndex: 0, parameterValue: 1 });
expect(endSplit).toHaveLength(1);
expect(beginningSplit[0]).objEq(path);
expect(beginningSplit[0]).objEq(endSplit[0]);
});
});
describe('splitNear', () => {
it('should divide a line in half', () => {
const path = Path.fromString('m0,0l8,0');
const split = path.splitNear(Vec2.of(4, 0));
expect(split).toHaveLength(2);
expect(split[0].toString()).toBe('M0,0L4,0');
expect(split[1]!.toString()).toBe('M4,0L8,0');
});
it('should divide a polyline into parts', () => {
const path = Path.fromString('m0,0L8,0L8,8');
const split = path.splitNear(Vec2.of(8, 4));
expect(split).toHaveLength(2);
expect(split[0].toString()).toBe('M0,0L8,0L8,4');
expect(split[1]!.toString()).toBe('M8,4L8,8');
});
it('should divide a quadratic Bézier in half', () => {
const path = Path.fromString('m0,0 Q4,0 8,0');
const split = path.splitNear(Vec2.of(4, 0));
expect(split).toHaveLength(2);
expect(split[0].toString()).toBe('M0,0Q2,0 4,0');
expect(split[1]!.toString()).toBe('M4,0Q6,0 8,0');
});
it('should divide two quadratic Béziers half', () => {
const path = Path.fromString('m0,0 Q4,0 8,0 Q8,4 8,8');
const split = path.splitNear(Vec2.of(8, 4));
expect(split).toHaveLength(2);
expect(split[0].toString()).toBe('M0,0Q4,0 8,0Q8,2 8,4');
expect(split[1]!.toString()).toBe('M8,4Q8,6 8,8');
});
it.each([
{
original: 'm0,0 Q4,0 8,0 Q8,4 8,8',
near: Vec2.of(8, 4),
map: (p: Point2) => p.plus(Vec2.of(1, 1)),
expected: ['M0,0Q4,0 8,0Q9,3 9,5', 'M9,5Q9,7 9,9'],
},
{
original: 'm0,0 L0,10',
near: Vec2.of(0, 5),
map: (p: Point2) => p.plus(Vec2.of(100, 0)),
expected: ['M0,0L100,5', 'M100,5L0,10'],
},
{
// Tested using SVG data similar to:
//
//
// Because of the rounding, the fit path should be slightly off.
original: 'm1,1 C1,2 2,10 4,4 C5,0 9,3 7,7',
near: Vec2.of(3, 5),
map: (p: Point2) => Vec2.of(Math.round(p.x), Math.round(p.y)),
expected: ['M1,1C1,2 1,6 2,6', 'M2,6C3,6 3,6 4,4C5,0 9,3 7,7'],
},
])(
'should support mapping newly-added points while splitting (case %j)',
({ original, near, map, expected }) => {
const path = Path.fromString(original);
const split = path.splitNear(near, { mapNewPoint: map });
expect(split.map((p) => p.toString(false))).toMatchObject(expected);
},
);
});
describe('spliced', () => {
it.each([
// should support insertion splicing
{
curve: 'm0,0 l2,0',
from: { i: 0, t: 0.5 },
to: { i: 0, t: 0.5 },
insert: 'm1,0 l0,10 z',
expected: 'M0,0 L1,0 L1,10 L1,0 L2,0',
},
// should support removing a segment when splicing
{
curve: 'm0,0 l4,0',
from: { i: 0, t: 0.25 },
to: { i: 0, t: 0.75 },
insert: 'M1,0 L1,1 L3,1 L3,0',
expected: 'M0,0 L1,0 L1,1 L3,1 L3,0 L4,0',
},
// should support reverse splicing and reverse `insert` as necessary
{
curve: 'M0,0 l4,0',
from: { i: 0, t: 0.75 },
to: { i: 0, t: 0.25 },
insert: 'M1,0 L1,1 L3,1 L3,0',
expected: 'M1,0 L3,0 L3,1 L1,1 L1,0',
},
])(
'.spliced should support inserting paths inbetween other paths (case %#)',
({ curve, from, to, insert, expected }) => {
const originalCurve = Path.fromString(curve);
expect(
originalCurve.spliced(
{ curveIndex: from.i, parameterValue: from.t },
{ curveIndex: to.i, parameterValue: to.t },
Path.fromString(insert),
),
).objEq(Path.fromString(expected));
},
);
});
it.each([
['m0,0 L1,1', 'M1,1 L0,0'],
['m0,0 L1,1', 'M1,1 L0,0'],
['M0,0 L1,1 Q2,2 3,3', 'M3,3 Q2,2 1,1 L0,0'],
['M0,0 L1,1 Q4,2 5,3 C12,13 10,9 8,7', 'M8,7 C 10,9 12,13 5,3 Q 4,2 1,1 L 0,0'],
])('.reversed should reverse paths', (original, expected) => {
expect(Path.fromString(original).reversed()).objEq(Path.fromString(expected));
expect(Path.fromString(expected).reversed()).objEq(Path.fromString(original));
expect(Path.fromString(original).reversed().reversed()).objEq(Path.fromString(original));
});
it.each([
['m0,0 l1,0', Vec2.of(0, 0), Vec2.of(0, 0)],
['m0,0 l1,0', Vec2.of(0.5, 0), Vec2.of(0.5, 0)],
['m0,0 Q1,0 1,2', Vec2.of(1, 0), Vec2.of(0.6236, 0.299)],
])(
'.nearestPointTo should return the closest point on a path to the given parameter (case %#)',
(path, point, expectedClosest) => {
expect(Path.fromString(path).nearestPointTo(point).point).objEq(expectedClosest, 0.002);
},
);
it.each([
// Polyline
['m0,0 l1,0 l0,1', [0, 0.5], Vec2.of(1, 0)],
['m0,0 l1,0 l0,1', [0, 0.99], Vec2.of(1, 0)],
['m0,0 l1,0 l0,1', [1, 0], Vec2.of(0, 1)],
['m0,0 l1,0 l0,1', [1, 0.5], Vec2.of(0, 1)],
['m0,0 l1,0 l0,1', [1, 1], Vec2.of(0, 1)],
// Shape with quadratic Bézier curves
['M0,0 Q1,0 0,1', [0, 0], Vec2.of(1, 0)],
['M0,0 Q1,1 0,1', [0, 1], Vec2.of(-1, 0)],
['M0,0 Q1,0 1,1 Q0,1 0,2', [0, 1], Vec2.of(0, 1)],
['M0,0 Q1,0 1,1 Q0,1 0,2', [1, 1], Vec2.of(0, 1)],
])(
'.tangentAt should point in the direction of increasing parameter values, for curve %s at %j',
(pathString, evalAt, expected) => {
const at: CurveIndexRecord = { curveIndex: evalAt[0], parameterValue: evalAt[1] };
const path = Path.fromString(pathString);
expect(path.tangentAt(at)).objEq(expected);
},
);
it.each([
// A rectangle completely contained
['m0,0 l10,0 l0,10 l-10,0', new Rect2(3, 3, 3, 3), true],
// A rectangle partially contained
['m0,0 l10,0 l0,10 l-10,0', new Rect2(3, 3, 33, 3), false],
// A rectangle not contained
['m0,0 l10,0 l0,10 l-10,0', new Rect2(13, 3, 1, 1), false],
// More complicated path containing a rectangle
['M0,0 Q10,15 10,5', new Rect2(5, 5, 1, 1), true],
['M0,0 Q10,15 10,5', new Rect2(15, 5, 1, 1), false],
])(
'.closedContainsRect should return whether a rectangle is contained within a path (case %#: path(%s), rect(%s))',
(pathString, rect, expected) => {
expect(Path.fromString(pathString).closedContainsRect(rect)).toBe(expected);
},
);
});