@js-draw/math
Version:
A math library for js-draw.
1,211 lines (1,210 loc) • 55.8 kB
JavaScript
import LineSegment2 from './LineSegment2.mjs';
import Rect2 from './Rect2.mjs';
import { Vec2 } from '../Vec2.mjs';
import CubicBezier from './CubicBezier.mjs';
import QuadraticBezier from './QuadraticBezier.mjs';
import PointShape2D from './PointShape2D.mjs';
import toRoundedString from '../rounding/toRoundedString.mjs';
import toStringOfSamePrecision from '../rounding/toStringOfSamePrecision.mjs';
import convexHull2Of from '../utils/convexHull2Of.mjs';
/** Identifiers for different path commands. These commands can make up a {@link Path}. */
export var PathCommandType;
(function (PathCommandType) {
PathCommandType[PathCommandType["LineTo"] = 0] = "LineTo";
PathCommandType[PathCommandType["MoveTo"] = 1] = "MoveTo";
PathCommandType[PathCommandType["CubicBezierTo"] = 2] = "CubicBezierTo";
PathCommandType[PathCommandType["QuadraticBezierTo"] = 3] = "QuadraticBezierTo";
})(PathCommandType || (PathCommandType = {}));
/** Returns a positive number if `a` comes after `b`, 0 if equal, and negative otherwise. */
export const compareCurveIndices = (a, b) => {
const indexCompare = a.curveIndex - b.curveIndex;
if (indexCompare === 0) {
return a.parameterValue - b.parameterValue;
}
else {
return indexCompare;
}
};
/**
* Returns a version of `index` with its parameter value incremented by `stepBy`
* (which can be either positive or negative).
*/
export const stepCurveIndexBy = (index, stepBy) => {
if (index.parameterValue + stepBy > 1) {
return { curveIndex: index.curveIndex + 1, parameterValue: index.parameterValue + stepBy - 1 };
}
if (index.parameterValue + stepBy < 0) {
if (index.curveIndex === 0) {
return { curveIndex: 0, parameterValue: 0 };
}
return { curveIndex: index.curveIndex - 1, parameterValue: index.parameterValue + stepBy + 1 };
}
return { curveIndex: index.curveIndex, parameterValue: index.parameterValue + stepBy };
};
/**
* Represents a union of lines and curves.
*
* To create a path from a string, see {@link fromString}.
*
* @example
* ```ts,runnable,console
* import {Path, Mat33, Vec2, LineSegment2} from '@js-draw/math';
*
* // Creates a path from an SVG path string.
* // In this case,
* // 1. Move to (0,0)
* // 2. Line to (100,0)
* const path = Path.fromString('M0,0 L100,0');
*
* // Logs the distance from (10,0) to the curve 1 unit
* // away from path. This curve forms a stroke with the path at
* // its center.
* const strokeRadius = 1;
* console.log(path.signedDistance(Vec2.of(10,0), strokeRadius));
*
* // Log a version of the path that's scaled by a factor of 4.
* console.log(path.transformedBy(Mat33.scaling2D(4)).toString());
*
* // Log all intersections of a stroked version of the path with
* // a vertical line segment.
* // (Try removing the `strokeRadius` parameter).
* const segment = new LineSegment2(Vec2.of(5, -100), Vec2.of(5, 100));
* console.log(path.intersection(segment, strokeRadius).map(i => i.point));
* ```
*/
export class Path {
/**
* Creates a new `Path` that starts at `startPoint` and is made up of the path commands,
* `parts`.
*
* See also {@link fromString}
*/
constructor(startPoint, parts) {
this.startPoint = startPoint;
this.cachedGeometry = null;
this.cachedPolylineApproximation = null;
this.cachedStringVersion = null;
this.parts = parts;
// Initial bounding box contains one point: the start point.
this.bbox = Rect2.bboxOf([startPoint]);
// Convert into a representation of the geometry (cache for faster intersection
// calculation)
for (const part of this.parts) {
this.bbox = this.bbox.union(Path.computeBBoxForSegment(startPoint, part));
}
}
/**
* Computes and returns the full bounding box for this path.
*
* If a slight over-estimate of a path's bounding box is sufficient, use
* {@link bbox} instead.
*/
getExactBBox() {
const bboxes = [];
for (const part of this.geometry) {
bboxes.push(part.getTightBoundingBox());
}
return Rect2.union(...bboxes);
}
// Lazy-loads and returns this path's geometry
get geometry() {
if (this.cachedGeometry) {
return this.cachedGeometry;
}
let startPoint = this.startPoint;
const geometry = [];
for (const part of this.parts) {
let exhaustivenessCheck;
switch (part.kind) {
case PathCommandType.CubicBezierTo:
geometry.push(new CubicBezier(startPoint, part.controlPoint1, part.controlPoint2, part.endPoint));
startPoint = part.endPoint;
break;
case PathCommandType.QuadraticBezierTo:
geometry.push(new QuadraticBezier(startPoint, part.controlPoint, part.endPoint));
startPoint = part.endPoint;
break;
case PathCommandType.LineTo:
geometry.push(new LineSegment2(startPoint, part.point));
startPoint = part.point;
break;
case PathCommandType.MoveTo:
geometry.push(new PointShape2D(part.point));
startPoint = part.point;
break;
default:
exhaustivenessCheck = part;
return exhaustivenessCheck;
}
}
this.cachedGeometry = geometry;
return this.cachedGeometry;
}
/**
* Iterates through the start/end points of each component in this path.
*
* If a start point is equivalent to the end point of the previous segment,
* the point is **not** emitted twice.
*/
*startEndPoints() {
yield this.startPoint;
for (const part of this.parts) {
let exhaustivenessCheck;
switch (part.kind) {
case PathCommandType.CubicBezierTo:
yield part.endPoint;
break;
case PathCommandType.QuadraticBezierTo:
yield part.endPoint;
break;
case PathCommandType.LineTo:
yield part.point;
break;
case PathCommandType.MoveTo:
yield part.point;
break;
default:
exhaustivenessCheck = part;
return exhaustivenessCheck;
}
}
}
// Approximates this path with a group of line segments.
polylineApproximation() {
if (this.cachedPolylineApproximation) {
return this.cachedPolylineApproximation;
}
const points = [];
for (const part of this.parts) {
switch (part.kind) {
case PathCommandType.CubicBezierTo:
points.push(part.controlPoint1, part.controlPoint2, part.endPoint);
break;
case PathCommandType.QuadraticBezierTo:
points.push(part.controlPoint, part.endPoint);
break;
case PathCommandType.MoveTo:
case PathCommandType.LineTo:
points.push(part.point);
break;
}
}
const result = [];
let prevPoint = this.startPoint;
for (const point of points) {
result.push(new LineSegment2(prevPoint, point));
prevPoint = point;
}
return result;
}
static computeBBoxForSegment(startPoint, part) {
const points = [startPoint];
let exhaustivenessCheck;
switch (part.kind) {
case PathCommandType.MoveTo:
case PathCommandType.LineTo:
points.push(part.point);
break;
case PathCommandType.CubicBezierTo:
points.push(part.controlPoint1, part.controlPoint2, part.endPoint);
break;
case PathCommandType.QuadraticBezierTo:
points.push(part.controlPoint, part.endPoint);
break;
default:
exhaustivenessCheck = part;
return exhaustivenessCheck;
}
return Rect2.bboxOf(points);
}
/**
* Returns the signed distance between `point` and a curve `strokeRadius` units
* away from this path.
*
* This returns the **signed distance**, which means that points inside this shape
* have their distance negated. For example,
* ```ts,runnable,console
* import {Path, Vec2} from '@js-draw/math';
* console.log(Path.fromString('m0,0 L100,0').signedDistance(Vec2.zero, 1));
* ```
* would print `-1` because (0,0) is on `m0,0 L100,0` and thus one unit away from its boundary.
*
* **Note**: `strokeRadius = strokeWidth / 2`
*/
signedDistance(point, strokeRadius) {
let minDist = Infinity;
for (const part of this.geometry) {
const currentDist = part.signedDistance(point) - strokeRadius;
if (currentDist < minDist) {
minDist = currentDist;
}
}
return minDist;
}
/**
* Let `S` be a closed path a distance `strokeRadius` from this path.
*
* @returns Approximate intersections of `line` with `S` using ray marching, starting from
* both end points of `line` and each point in `additionalRaymarchStartPoints`.
*/
raymarchIntersectionWith(line, strokeRadius, additionalRaymarchStartPoints = []) {
// No intersection between bounding boxes: No possible intersection
// of the interior.
if (!line.bbox.intersects(this.bbox.grownBy(strokeRadius))) {
return [];
}
const lineLength = line.length;
const partDistFunctionRecords = [];
// Determine distance functions for all parts that the given line could possibly intersect with
for (const part of this.geometry) {
const bbox = part.getTightBoundingBox().grownBy(strokeRadius);
if (!bbox.intersects(line.bbox)) {
continue;
}
// Signed distance function
const partDist = (point) => part.signedDistance(point);
// Part signed distance function (negative result implies `point` is
// inside the shape).
const partSdf = (point) => partDist(point) - strokeRadius;
// If the line can't possibly intersect the part,
if (partSdf(line.p1) > lineLength && partSdf(line.p2) > lineLength) {
continue;
}
partDistFunctionRecords.push({
part,
distFn: partDist,
bbox,
});
}
// If no distance functions, there are no intersections.
if (partDistFunctionRecords.length === 0) {
return [];
}
// Returns the minimum distance to a part in this stroke, where only parts that the given
// line could intersect are considered.
const sdf = (point) => {
let minDist = Infinity;
let minDistPart = null;
const uncheckedDistFunctions = [];
// First pass: only curves for which the current point is inside
// the bounding box.
for (const distFnRecord of partDistFunctionRecords) {
const { part, distFn, bbox } = distFnRecord;
// Check later if the current point isn't in the bounding box.
if (!bbox.containsPoint(point)) {
uncheckedDistFunctions.push(distFnRecord);
continue;
}
const currentDist = distFn(point);
if (currentDist <= minDist) {
minDist = currentDist;
minDistPart = part;
}
}
// Second pass: Everything else
for (const { part, distFn, bbox } of uncheckedDistFunctions) {
// Skip if impossible for the distance to the target to be lesser than
// the current minimum.
if (isFinite(minDist) && !bbox.grownBy(minDist).containsPoint(point)) {
continue;
}
const currentDist = distFn(point);
if (currentDist <= minDist) {
minDist = currentDist;
minDistPart = part;
}
}
return [minDistPart, minDist - strokeRadius];
};
// Raymarch:
const maxRaymarchSteps = 8;
// Start raymarching from each of these points. This allows detection of multiple
// intersections.
const startPoints = [line.p1, ...additionalRaymarchStartPoints, line.p2];
// Converts a point ON THE LINE to a parameter
const pointToParameter = (point) => {
// Because line.direction is a unit vector, this computes the length
// of the projection of the vector(line.p1->point) onto line.direction.
//
// Note that this can be negative if the given point is outside of the given
// line segment.
return point.minus(line.p1).dot(line.direction);
};
// Sort start points by parameter on the line.
// This allows us to determine whether the current value of a parameter
// drops down to a value already tested.
startPoints.sort((a, b) => {
const t_a = pointToParameter(a);
const t_b = pointToParameter(b);
// Sort in increasing order
return t_a - t_b;
});
const result = [];
const stoppingThreshold = strokeRadius / 1000;
// Returns the maximum parameter value explored
const raymarchFrom = (startPoint,
// Direction to march in (multiplies line.direction)
directionMultiplier,
// Terminate if the current point corresponds to a parameter
// below this.
minimumLineParameter) => {
let currentPoint = startPoint;
let [lastPart, lastDist] = sdf(currentPoint);
let lastParameter = pointToParameter(currentPoint);
if (lastDist > lineLength) {
return lastParameter;
}
const direction = line.direction.times(directionMultiplier);
for (let i = 0; i < maxRaymarchSteps; i++) {
// Step in the direction of the edge of the shape.
const step = lastDist;
currentPoint = currentPoint.plus(direction.times(step));
lastParameter = pointToParameter(currentPoint);
// If we're below the minimum parameter, stop. We've already tried
// this.
if (lastParameter <= minimumLineParameter) {
return lastParameter;
}
const [currentPart, signedDist] = sdf(currentPoint);
// Ensure we're stepping in the correct direction.
// Note that because we could start with a negative distance and work towards a
// positive distance, we need absolute values here.
if (Math.abs(signedDist) > Math.abs(lastDist)) {
// If not, stop.
return null;
}
lastDist = signedDist;
lastPart = currentPart;
// Is the distance close enough that we can stop early?
if (Math.abs(lastDist) < stoppingThreshold) {
break;
}
}
// Ensure that the point we ended with is on the line.
const isOnLineSegment = lastParameter >= 0 && lastParameter <= lineLength;
if (lastPart && isOnLineSegment && Math.abs(lastDist) < stoppingThreshold) {
result.push({
point: currentPoint,
parameterValue: lastPart.nearestPointTo(currentPoint).parameterValue,
curve: lastPart,
curveIndex: this.geometry.indexOf(lastPart),
});
// Slightly increase the parameter value to prevent the same point from being
// added to the results twice.
const parameterIncrease = strokeRadius / 20 / line.length;
lastParameter += isFinite(parameterIncrease) ? parameterIncrease : 0;
}
return lastParameter;
};
// The maximum value of the line's parameter explored so far (0 corresponds to
// line.p1)
let maxLineT = 0;
// Raymarch for each start point.
//
// Use a for (i from 0 to length) loop because startPoints may be added
// during iteration.
for (let i = 0; i < startPoints.length; i++) {
const startPoint = startPoints[i];
// Try raymarching in both directions.
maxLineT = Math.max(maxLineT, raymarchFrom(startPoint, 1, maxLineT) ?? maxLineT);
maxLineT = Math.max(maxLineT, raymarchFrom(startPoint, -1, maxLineT) ?? maxLineT);
}
return result;
}
/**
* Returns a list of intersections with this path. If `strokeRadius` is given,
* intersections are approximated with the surface `strokeRadius` away from this.
*
* If `strokeRadius > 0`, the resultant `parameterValue` has no defined value.
*
* **Note**: `strokeRadius` is half of a stroke's width.
*/
intersection(line, strokeRadius) {
let result = [];
// Is any intersection between shapes within the bounding boxes impossible?
if (!line.bbox.intersects(this.bbox.grownBy(strokeRadius ?? 0))) {
return [];
}
if (this.parts.length === 0) {
return new Path(this.startPoint, [
{ kind: PathCommandType.MoveTo, point: this.startPoint },
]).intersection(line, strokeRadius);
}
let index = 0;
for (const part of this.geometry) {
const intersections = part.argIntersectsLineSegment(line);
for (const intersection of intersections) {
result.push({
curve: part,
curveIndex: index,
point: part.at(intersection),
parameterValue: intersection,
});
}
index++;
}
// If given a non-zero strokeWidth, attempt to raymarch.
// Even if raymarching, we need to collect starting points.
// We use the above-calculated intersections for this.
const doRaymarching = strokeRadius && strokeRadius > 1e-8;
if (doRaymarching) {
// Starting points for raymarching (in addition to the end points of the line).
const startPoints = result.map((intersection) => intersection.point);
result = this.raymarchIntersectionWith(line, strokeRadius, startPoints);
}
return result;
}
/**
* @returns the nearest point on this path to the given `point`.
*/
nearestPointTo(point) {
// Find the closest point on this
let closestSquareDist = Infinity;
let closestPartIndex = 0;
let closestParameterValue = 0;
let closestPoint = this.startPoint;
for (let i = 0; i < this.geometry.length; i++) {
const current = this.geometry[i];
const nearestPoint = current.nearestPointTo(point);
const sqareDist = nearestPoint.point.squareDistanceTo(point);
if (i === 0 || sqareDist < closestSquareDist) {
closestPartIndex = i;
closestSquareDist = sqareDist;
closestParameterValue = nearestPoint.parameterValue;
closestPoint = nearestPoint.point;
}
}
return {
curve: this.geometry[closestPartIndex],
curveIndex: closestPartIndex,
parameterValue: closestParameterValue,
point: closestPoint,
};
}
at(index) {
if (index.curveIndex === 0 && index.parameterValue === 0) {
return this.startPoint;
}
return this.geometry[index.curveIndex].at(index.parameterValue);
}
tangentAt(index) {
return this.geometry[index.curveIndex].tangentAt(index.parameterValue);
}
/** Splits this path in two near the given `point`. */
splitNear(point, options) {
const nearest = this.nearestPointTo(point);
return this.splitAt(nearest, options);
}
/**
* Returns a copy of this path with `deleteFrom` until `deleteUntil` replaced with `insert`.
*
* This method is analogous to {@link Array.toSpliced}.
*/
spliced(deleteFrom, deleteTo, insert, options) {
const isBeforeOrEqual = (a, b) => {
return (a.curveIndex < b.curveIndex ||
(a.curveIndex === b.curveIndex && a.parameterValue <= b.parameterValue));
};
if (isBeforeOrEqual(deleteFrom, deleteTo)) {
// deleteFrom deleteTo
// <---------| |-------------->
// x x
// startPoint endPoint
const firstSplit = this.splitAt(deleteFrom, options);
const secondSplit = this.splitAt(deleteTo, options);
const before = firstSplit[0];
const after = secondSplit[secondSplit.length - 1];
return insert ? before.union(insert).union(after) : before.union(after);
}
else {
// In this case, we need to handle wrapping at the start/end.
// deleteTo deleteFrom
// <---------| keep |-------------->
// x x
// startPoint endPoint
const splitAtFrom = this.splitAt([deleteFrom], options);
const beforeFrom = splitAtFrom[0];
// We need splitNear, rather than splitAt, because beforeFrom does not have
// the same indexing as this.
const splitAtTo = beforeFrom.splitNear(this.at(deleteTo), options);
const betweenBoth = splitAtTo[splitAtTo.length - 1];
return insert ? betweenBoth.union(insert) : betweenBoth;
}
}
// @internal
splitAt(splitAt, options) {
if (!Array.isArray(splitAt)) {
splitAt = [splitAt];
}
splitAt = [...splitAt];
splitAt.sort(compareCurveIndices);
//
// Bounds checking & reversal.
//
while (splitAt.length > 0 &&
splitAt[splitAt.length - 1].curveIndex >= this.parts.length - 1 &&
splitAt[splitAt.length - 1].parameterValue >= 1) {
splitAt.pop();
}
splitAt.reverse(); // .reverse() <-- We're `.pop`ing from the end
while (splitAt.length > 0 &&
splitAt[splitAt.length - 1].curveIndex <= 0 &&
splitAt[splitAt.length - 1].parameterValue <= 0) {
splitAt.pop();
}
if (splitAt.length === 0 || this.parts.length === 0) {
return [this];
}
const expectedSplitCount = splitAt.length + 1;
const mapNewPoint = options?.mapNewPoint ?? ((p) => p);
const result = [];
let currentStartPoint = this.startPoint;
let currentPath = [];
//
// Splitting
//
let { curveIndex, parameterValue } = splitAt.pop();
for (let i = 0; i < this.parts.length; i++) {
if (i !== curveIndex) {
currentPath.push(this.parts[i]);
}
else {
let part = this.parts[i];
let geom = this.geometry[i];
while (i === curveIndex) {
let newPathStart;
const newPath = [];
switch (part.kind) {
case PathCommandType.MoveTo:
currentPath.push({
kind: part.kind,
point: part.point,
});
newPathStart = part.point;
break;
case PathCommandType.LineTo:
{
const split = geom.splitAt(parameterValue);
currentPath.push({
kind: part.kind,
point: mapNewPoint(split[0].p2),
});
newPathStart = split[0].p2;
if (split.length > 1) {
console.assert(split.length === 2);
newPath.push({
kind: part.kind,
// Don't map: For lines, the end point of the split is
// the same as the end point of the original:
point: split[1].p2,
});
geom = split[1];
}
}
break;
case PathCommandType.QuadraticBezierTo:
case PathCommandType.CubicBezierTo:
{
const split = geom.splitAt(parameterValue);
let isFirstPart = split.length === 2;
for (const segment of split) {
geom = segment;
const targetArray = isFirstPart ? currentPath : newPath;
const controlPoints = segment.getPoints();
if (part.kind === PathCommandType.CubicBezierTo) {
targetArray.push({
kind: part.kind,
controlPoint1: mapNewPoint(controlPoints[1]),
controlPoint2: mapNewPoint(controlPoints[2]),
endPoint: mapNewPoint(controlPoints[3]),
});
}
else {
targetArray.push({
kind: part.kind,
controlPoint: mapNewPoint(controlPoints[1]),
endPoint: mapNewPoint(controlPoints[2]),
});
}
// We want the start of the new path to match the start of the
// FIRST Bézier in the NEW path.
if (!isFirstPart) {
newPathStart = controlPoints[0];
}
isFirstPart = false;
}
}
break;
default: {
const exhaustivenessCheck = part;
return exhaustivenessCheck;
}
}
result.push(new Path(currentStartPoint, [...currentPath]));
currentStartPoint = mapNewPoint(newPathStart);
console.assert(!!currentStartPoint, 'should have a start point');
currentPath = newPath;
part = newPath[newPath.length - 1] ?? part;
const nextSplit = splitAt.pop();
if (!nextSplit) {
break;
}
else {
curveIndex = nextSplit.curveIndex;
if (i === curveIndex) {
const originalPoint = this.at(nextSplit);
parameterValue = geom.nearestPointTo(originalPoint).parameterValue;
currentPath = [];
}
else {
parameterValue = nextSplit.parameterValue;
}
}
}
}
}
result.push(new Path(currentStartPoint, currentPath));
console.assert(result.length === expectedSplitCount, `should split into splitAt.length + 1 splits (was ${result.length}, expected ${expectedSplitCount})`);
return result;
}
/**
* Replaces all `MoveTo` commands with `LineTo` commands and connects the end point of this
* path to the start point.
*/
asClosed() {
const newParts = [];
let hasChanges = false;
for (const part of this.parts) {
if (part.kind === PathCommandType.MoveTo) {
newParts.push({
kind: PathCommandType.LineTo,
point: part.point,
});
hasChanges = true;
}
else {
newParts.push(part);
}
}
if (!this.getEndPoint().eq(this.startPoint)) {
newParts.push({
kind: PathCommandType.LineTo,
point: this.startPoint,
});
hasChanges = true;
}
if (!hasChanges) {
return this;
}
const result = new Path(this.startPoint, newParts);
console.assert(result.getEndPoint().eq(result.startPoint));
return result;
}
static mapPathCommand(part, mapping) {
switch (part.kind) {
case PathCommandType.MoveTo:
case PathCommandType.LineTo:
return {
kind: part.kind,
point: mapping(part.point),
};
break;
case PathCommandType.CubicBezierTo:
return {
kind: part.kind,
controlPoint1: mapping(part.controlPoint1),
controlPoint2: mapping(part.controlPoint2),
endPoint: mapping(part.endPoint),
};
break;
case PathCommandType.QuadraticBezierTo:
return {
kind: part.kind,
controlPoint: mapping(part.controlPoint),
endPoint: mapping(part.endPoint),
};
break;
}
const exhaustivenessCheck = part;
return exhaustivenessCheck;
}
mapPoints(mapping) {
const startPoint = mapping(this.startPoint);
const newParts = [];
for (const part of this.parts) {
newParts.push(Path.mapPathCommand(part, mapping));
}
return new Path(startPoint, newParts);
}
transformedBy(affineTransfm) {
if (affineTransfm.isIdentity()) {
return this;
}
return this.mapPoints((point) => affineTransfm.transformVec2(point));
}
/**
* @internal -- TODO: This method may have incorrect output in some cases.
*/
closedContainsPoint(point) {
const bbox = this.getExactBBox();
if (!bbox.containsPoint(point)) {
return false;
}
const pointOutside = point.plus(Vec2.of(bbox.width, 0));
const asClosed = this.asClosed();
const lineToOutside = new LineSegment2(point, pointOutside);
const intersections = asClosed.intersection(lineToOutside);
const filteredIntersections = intersections.filter((intersection, index) => {
if (index === 0)
return true; // No previous
const previousIntersection = intersections[index - 1];
const isRepeatedIntersection = previousIntersection.parameterValue >= 1 && intersection.parameterValue <= 0;
return !isRepeatedIntersection;
});
return filteredIntersections.length % 2 === 1;
}
/**
* @returns `true` if this path (interpreted as a closed path) contains the given rectangle.
*/
closedContainsRect(rect) {
if (!this.bbox.containsRect(rect))
return false;
if (!rect.corners.every((corner) => this.closedContainsPoint(corner)))
return false;
for (const edge of rect.getEdges()) {
if (this.intersection(edge).length) {
return false;
}
}
return true;
}
// Creates a new path by joining [other] to the end of this path
union(other,
// allowReverse: true iff reversing other or this is permitted if it means
// no moveTo command is necessary when unioning the paths.
options = { allowReverse: true }) {
if (!other) {
return this;
}
if (Array.isArray(other)) {
return new Path(this.startPoint, [...this.parts, ...other]);
}
const thisEnd = this.getEndPoint();
let newParts = [];
if (thisEnd.eq(other.startPoint)) {
newParts = this.parts.concat(other.parts);
}
else if (options.allowReverse && this.startPoint.eq(other.getEndPoint())) {
return other.union(this, { allowReverse: false });
}
else if (options.allowReverse && this.startPoint.eq(other.startPoint)) {
return this.union(other.reversed(), { allowReverse: false });
}
else {
newParts = [
...this.parts,
{
kind: PathCommandType.MoveTo,
point: other.startPoint,
},
...other.parts,
];
}
return new Path(this.startPoint, newParts);
}
/**
* @returns a version of this path with the direction reversed.
*
* Example:
* ```ts,runnable,console
* import {Path} from '@js-draw/math';
* console.log(Path.fromString('m0,0l1,1').reversed()); // -> M1,1 L0,0
* ```
*/
reversed() {
const newStart = this.getEndPoint();
const newParts = [];
let lastPoint = this.startPoint;
for (const part of this.parts) {
switch (part.kind) {
case PathCommandType.LineTo:
case PathCommandType.MoveTo:
newParts.push({
kind: part.kind,
point: lastPoint,
});
lastPoint = part.point;
break;
case PathCommandType.CubicBezierTo:
newParts.push({
kind: part.kind,
controlPoint1: part.controlPoint2,
controlPoint2: part.controlPoint1,
endPoint: lastPoint,
});
lastPoint = part.endPoint;
break;
case PathCommandType.QuadraticBezierTo:
newParts.push({
kind: part.kind,
controlPoint: part.controlPoint,
endPoint: lastPoint,
});
lastPoint = part.endPoint;
break;
default: {
const exhaustivenessCheck = part;
return exhaustivenessCheck;
}
}
}
newParts.reverse();
return new Path(newStart, newParts);
}
/** Computes and returns the end point of this path */
getEndPoint() {
if (this.parts.length === 0) {
return this.startPoint;
}
const lastPart = this.parts[this.parts.length - 1];
if (lastPart.kind === PathCommandType.QuadraticBezierTo ||
lastPart.kind === PathCommandType.CubicBezierTo) {
return lastPart.endPoint;
}
else {
return lastPart.point;
}
}
/**
* Like {@link closedRoughlyIntersects} except takes stroke width into account.
*
* This is intended to be a very fast and rough approximation. Use {@link intersection}
* and {@link signedDistance} for more accurate (but much slower) intersection calculations.
*
* **Note**: Unlike other methods, this accepts `strokeWidth` (and not `strokeRadius`).
*
* `strokeRadius` is half of `strokeWidth`.
*/
roughlyIntersects(rect, strokeWidth = 0) {
if (this.parts.length === 0) {
return rect.containsPoint(this.startPoint);
}
const isClosed = this.startPoint.eq(this.getEndPoint());
if (isClosed && strokeWidth === 0) {
return this.closedRoughlyIntersects(rect);
}
if (rect.containsRect(this.bbox)) {
return true;
}
// Does the rectangle intersect the bounding boxes of any of this' parts?
let startPoint = this.startPoint;
for (const part of this.parts) {
const bbox = Path.computeBBoxForSegment(startPoint, part).grownBy(strokeWidth);
if (part.kind === PathCommandType.LineTo || part.kind === PathCommandType.MoveTo) {
startPoint = part.point;
}
else {
startPoint = part.endPoint;
}
if (rect.intersects(bbox)) {
return true;
}
}
return false;
}
/**
* Treats this as a closed path and returns true if part of `rect` is *roughly* within
* this path's interior.
*
* **Note**: Assumes that this is a closed, non-self-intersecting path.
*/
closedRoughlyIntersects(rect) {
if (rect.containsRect(this.bbox)) {
return true;
}
// Choose a point outside of the path.
const startPt = this.bbox.topLeft.minus(Vec2.of(1, 1));
const testPts = rect.corners;
const polygon = this.polylineApproximation();
for (const point of testPts) {
const testLine = new LineSegment2(point, startPt);
let intersectionCount = 0;
for (const line of polygon) {
if (line.intersects(testLine)) {
intersectionCount++;
}
}
// Odd? The point is within the polygon!
if (intersectionCount % 2 === 1) {
return true;
}
}
// Grow the rectangle for possible additional precision.
const grownRect = rect.grownBy(Math.min(rect.size.x, rect.size.y));
const edges = [];
for (const subrect of grownRect.divideIntoGrid(4, 4)) {
edges.push(...subrect.getEdges());
}
for (const edge of edges) {
for (const line of polygon) {
if (edge.intersects(line)) {
return true;
}
}
}
// Even? Probably no intersection.
return false;
}
/** @returns true if all points on this are equivalent to the points on `other` */
eq(other, tolerance) {
if (other.parts.length !== this.parts.length) {
return false;
}
for (let i = 0; i < this.parts.length; i++) {
const part1 = this.parts[i];
const part2 = other.parts[i];
switch (part1.kind) {
case PathCommandType.LineTo:
case PathCommandType.MoveTo:
if (part1.kind !== part2.kind) {
return false;
}
else if (!part1.point.eq(part2.point, tolerance)) {
return false;
}
break;
case PathCommandType.CubicBezierTo:
if (part1.kind !== part2.kind) {
return false;
}
else if (!part1.controlPoint1.eq(part2.controlPoint1, tolerance) ||
!part1.controlPoint2.eq(part2.controlPoint2, tolerance) ||
!part1.endPoint.eq(part2.endPoint, tolerance)) {
return false;
}
break;
case PathCommandType.QuadraticBezierTo:
if (part1.kind !== part2.kind) {
return false;
}
else if (!part1.controlPoint.eq(part2.controlPoint, tolerance) ||
!part1.endPoint.eq(part2.endPoint, tolerance)) {
return false;
}
break;
default: {
const exhaustivenessCheck = part1;
return exhaustivenessCheck;
}
}
}
return true;
}
/**
* Returns a path that outlines `rect`.
*
* If `lineWidth` is given, the resultant path traces a `lineWidth` thick
* border around `rect`. Otherwise, the resultant path is just the border
* of `rect`.
*/
static fromRect(rect, lineWidth = null) {
const commands = [];
let corners;
let startPoint;
if (lineWidth !== null) {
// Vector from the top left corner or bottom right corner to the edge of the
// stroked region.
const cornerToEdge = Vec2.of(lineWidth, lineWidth).times(0.5);
const innerRect = Rect2.fromCorners(rect.topLeft.plus(cornerToEdge), rect.bottomRight.minus(cornerToEdge));
const outerRect = Rect2.fromCorners(rect.topLeft.minus(cornerToEdge), rect.bottomRight.plus(cornerToEdge));
corners = [innerRect.corners[3], ...innerRect.corners, ...outerRect.corners.reverse()];
startPoint = outerRect.corners[3];
}
else {
corners = rect.corners.slice(1);
startPoint = rect.corners[0];
}
for (const corner of corners) {
commands.push({
kind: PathCommandType.LineTo,
point: corner,
});
}
// Close the shape
commands.push({
kind: PathCommandType.LineTo,
point: startPoint,
});
return new Path(startPoint, commands);
}
/**
* Convert to an [SVG path representation](https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths).
*
* If `useNonAbsCommands` is given, relative path commands (e.g. `l10,0`) are to be used instead of
* absolute commands (e.g. `L10,0`).
*
* See also {@link fromString}.
*/
toString(useNonAbsCommands, ignoreCache = false) {
if (this.cachedStringVersion && !ignoreCache) {
return this.cachedStringVersion;
}
if (useNonAbsCommands === undefined) {
// Hueristic: Try to determine whether converting absolute to relative commands is worth it.
useNonAbsCommands = Math.abs(this.bbox.topLeft.x) > 10 && Math.abs(this.bbox.topLeft.y) > 10;
}
const result = Path.toString(this.startPoint, this.parts, !useNonAbsCommands);
this.cachedStringVersion = result;
return result;
}
serialize() {
return this.toString();
}
// @param onlyAbsCommands - True if we should avoid converting absolute coordinates to relative offsets -- such
// conversions can lead to smaller output strings, but also take time.
static toString(startPoint, parts, onlyAbsCommands) {
const result = [];
let prevPoint;
const addCommand = (command, ...points) => {
const absoluteCommandParts = [];
const relativeCommandParts = [];
const makeAbsCommand = !prevPoint || onlyAbsCommands;
const roundedPrevX = prevPoint ? toRoundedString(prevPoint.x) : '';
const roundedPrevY = prevPoint ? toRoundedString(prevPoint.y) : '';
for (const point of points) {
const xComponent = toRoundedString(point.x);
const yComponent = toRoundedString(point.y);
// Relative commands are often shorter as strings than absolute commands.
if (!makeAbsCommand) {
const xComponentRelative = toStringOfSamePrecision(point.x - prevPoint.x, xComponent, roundedPrevX, roundedPrevY);
const yComponentRelative = toStringOfSamePrecision(point.y - prevPoint.y, yComponent, roundedPrevX, roundedPrevY);
// No need for an additional separator if it starts with a '-'
if (yComponentRelative.charAt(0) === '-') {
relativeCommandParts.push(`${xComponentRelative}${yComponentRelative}`);
}
else {
relativeCommandParts.push(`${xComponentRelative},${yComponentRelative}`);
}
}
else {
absoluteCommandParts.push(`${xComponent},${yComponent}`);
}
}
let commandString;
if (makeAbsCommand) {
commandString = `${command}${absoluteCommandParts.join(' ')}`;
}
else {
commandString = `${command.toLowerCase()}${relativeCommandParts.join(' ')}`;
}
// Don't add no-ops.
if (commandString === 'l0,0' || commandString === 'm0,0') {
return;
}
result.push(commandString);
if (points.length > 0) {
prevPoint = points[points.length - 1];
}
};
// Don't add two moveTos in a row (this can happen if
// the start point corresponds to a moveTo _and_ the first command is
// also a moveTo)
if (parts[0]?.kind !== PathCommandType.MoveTo) {
addCommand('M', startPoint);
}
let exhaustivenessCheck;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
switch (part.kind) {
case PathCommandType.MoveTo:
addCommand('M', part.point);
break;
case PathCommandType.LineTo:
addCommand('L', part.point);
break;
case PathCommandType.CubicBezierTo:
addCommand('C', part.controlPoint1, part.controlPoint2, part.endPoint);
break;
case PathCommandType.QuadraticBezierTo:
addCommand('Q', part.controlPoint, part.endPoint);
break;
default:
exhaustivenessCheck = part;
return exhaustivenessCheck;
}
}
return result.join('');
}
/**
* Create a `Path` from a subset of the SVG path specification.
*
* Currently, this does not support elliptical arcs or `s` and `t` command
* shorthands. See https://github.com/personalizedrefrigerator/js-draw/pull/19.
*
* @example
* ```ts,runnable,console
* import { Path } from '@js-draw/math';
*
* const path = Path.fromString('m0,0l100,100');
* console.log(path.toString(true)); // true: Prefer relative to absolute path commands
* ```
*/
static fromString(pathString) {
// TODO: Support elliptical arcs, and the `s`, `t` command shorthands.
//
// See the MDN reference:
// https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d
// and
// https://www.w3.org/TR/SVG2/paths.html
// Remove linebreaks
pathString = pathString.split('\n').join(' ');
let lastPos = Vec2.zero;
let firstPos = null;
let startPos = null;
let isFirstCommand = true;
const commands = [];
const moveTo = (point) => {
// The first moveTo/lineTo is already handled by the [startPoint] parameter of the Path constructor.
if (isFirstCommand) {
isFirstCommand = false;
return;
}
commands.push({
kind: PathCommandType.MoveTo,
point,
});
};
const lineTo = (point) => {
if (isFirstCommand) {
isFirstCommand = false;
return;
}
commands.push({
kind: PathCommandType.LineTo,
point,
});
};
const cubicBezierTo = (cp1, cp2, end) => {
commands.push({
kind: PathCommandType.CubicBezierTo,
controlPoint1: cp1,
controlPoint2: cp2,
endPoint: end,
});
};
const quadraticBeierTo = (controlPoint, endPoint) => {
commands.push({
kind: PathCommandType.QuadraticBezierTo,
controlPoint,
endPoint,
});
};
const commandArgCounts = {
m: 1,
l: 1,
c: 3,
q: 2,
z: 0,
h: 1,
v: 1,
};