@js-draw/math
Advanced tools
Comparing version 1.17.0 to 1.18.0
@@ -20,4 +20,5 @@ /** | ||
export { LineSegment2 } from './shapes/LineSegment2'; | ||
export { Path, IntersectionResult as PathIntersectionResult, CurveIndexRecord as PathCurveIndex, PathCommandType, PathCommand, LinePathCommand, MoveToPathCommand, QuadraticBezierPathCommand, CubicBezierPathCommand, } from './shapes/Path'; | ||
export { Path, IntersectionResult as PathIntersectionResult, CurveIndexRecord as PathCurveIndex, stepCurveIndexBy as stepPathIndexBy, compareCurveIndices as comparePathIndices, PathCommandType, PathCommand, LinePathCommand, MoveToPathCommand, QuadraticBezierPathCommand, CubicBezierPathCommand, } from './shapes/Path'; | ||
export { Rect2 } from './shapes/Rect2'; | ||
export { Parameterized2DShape } from './shapes/Parameterized2DShape'; | ||
export { QuadraticBezier } from './shapes/QuadraticBezier'; | ||
@@ -24,0 +25,0 @@ export { Abstract2DShape } from './shapes/Abstract2DShape'; |
@@ -35,3 +35,3 @@ "use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.Color4 = exports.Vec3 = exports.Vec2 = exports.Mat33 = exports.Abstract2DShape = exports.QuadraticBezier = exports.Rect2 = exports.PathCommandType = exports.Path = exports.LineSegment2 = void 0; | ||
exports.Color4 = exports.Vec3 = exports.Vec2 = exports.Mat33 = exports.Abstract2DShape = exports.QuadraticBezier = exports.Parameterized2DShape = exports.Rect2 = exports.PathCommandType = exports.comparePathIndices = exports.stepPathIndexBy = exports.Path = exports.LineSegment2 = void 0; | ||
var LineSegment2_1 = require("./shapes/LineSegment2"); | ||
@@ -41,5 +41,9 @@ Object.defineProperty(exports, "LineSegment2", { enumerable: true, get: function () { return LineSegment2_1.LineSegment2; } }); | ||
Object.defineProperty(exports, "Path", { enumerable: true, get: function () { return Path_1.Path; } }); | ||
Object.defineProperty(exports, "stepPathIndexBy", { enumerable: true, get: function () { return Path_1.stepCurveIndexBy; } }); | ||
Object.defineProperty(exports, "comparePathIndices", { enumerable: true, get: function () { return Path_1.compareCurveIndices; } }); | ||
Object.defineProperty(exports, "PathCommandType", { enumerable: true, get: function () { return Path_1.PathCommandType; } }); | ||
var Rect2_1 = require("./shapes/Rect2"); | ||
Object.defineProperty(exports, "Rect2", { enumerable: true, get: function () { return Rect2_1.Rect2; } }); | ||
var Parameterized2DShape_1 = require("./shapes/Parameterized2DShape"); | ||
Object.defineProperty(exports, "Parameterized2DShape", { enumerable: true, get: function () { return Parameterized2DShape_1.Parameterized2DShape; } }); | ||
var QuadraticBezier_1 = require("./shapes/QuadraticBezier"); | ||
@@ -46,0 +50,0 @@ Object.defineProperty(exports, "QuadraticBezier", { enumerable: true, get: function () { return QuadraticBezier_1.QuadraticBezier; } }); |
@@ -343,3 +343,7 @@ "use strict"; | ||
const parseArguments = (argumentString) => { | ||
return argumentString.split(/[, \t\n]+/g).map(argString => { | ||
const parsed = argumentString.split(/[, \t\n]+/g).map(argString => { | ||
// Handle trailing spaces/commands | ||
if (argString.trim() === '') { | ||
return null; | ||
} | ||
let isPercentage = false; | ||
@@ -365,2 +369,3 @@ if (argString.endsWith('%')) { | ||
}); | ||
return parsed.filter(n => n !== null); | ||
}; | ||
@@ -367,0 +372,0 @@ const keywordToAction = { |
@@ -44,4 +44,8 @@ import { Bezier } from 'bezier-js'; | ||
}; | ||
intersectsBezier(other: BezierJSWrapper): { | ||
parameterValue: number; | ||
point: import("../Vec3").Vec3; | ||
}[]; | ||
toString(): string; | ||
} | ||
export default BezierJSWrapper; |
@@ -21,2 +21,3 @@ "use strict"; | ||
const Vec2_1 = require("../Vec2"); | ||
const LineSegment2_1 = __importDefault(require("./LineSegment2")); | ||
const Rect2_1 = __importDefault(require("./Rect2")); | ||
@@ -88,2 +89,13 @@ const Parameterized2DShape_1 = __importDefault(require("./Parameterized2DShape")); | ||
argIntersectsLineSegment(line) { | ||
// Bezier-js has a bug when all control points of a Bezier curve lie on | ||
// a line. Our solution involves converting the Bezier into a line, then | ||
// finding the parameter value that produced the intersection. | ||
// | ||
// TODO: This is unnecessarily slow. A better solution would be to fix | ||
// the bug upstream. | ||
const asLine = LineSegment2_1.default.ofSmallestContainingPoints(this.getPoints()); | ||
if (asLine) { | ||
const intersection = asLine.intersectsLineSegment(line); | ||
return intersection.map(p => this.nearestPointTo(p).parameterValue); | ||
} | ||
const bezier = this.getBezier(); | ||
@@ -171,2 +183,4 @@ return bezier.intersects(line).map(t => { | ||
const slope = secondDerivativeAt(t); | ||
if (slope === 0) | ||
return; | ||
// We intersect a line through the point on f'(t) at t with the x-axis: | ||
@@ -195,2 +209,23 @@ // y = m(x - x₀) + y₀ | ||
} | ||
intersectsBezier(other) { | ||
const intersections = this.getBezier().intersects(other.getBezier()); | ||
if (!intersections || intersections.length === 0) { | ||
return []; | ||
} | ||
const result = []; | ||
for (const intersection of intersections) { | ||
// From http://pomax.github.io/bezierjs/#intersect-curve, | ||
// .intersects returns an array of 't1/t2' pairs, where curve1.at(t1) gives the point. | ||
const match = /^([-0-9.eE]+)\/([-0-9.eE]+)$/.exec(intersection); | ||
if (!match) { | ||
throw new Error(`Incorrect format returned by .intersects: ${intersections} should be array of "number/number"!`); | ||
} | ||
const t = parseFloat(match[1]); | ||
result.push({ | ||
parameterValue: t, | ||
point: this.at(t), | ||
}); | ||
} | ||
return result; | ||
} | ||
toString() { | ||
@@ -197,0 +232,0 @@ return `Bézier(${this.getPoints().map(point => point.toString()).join(', ')})`; |
@@ -28,2 +28,13 @@ import Mat33 from '../Mat33'; | ||
constructor(point1: Point2, point2: Point2); | ||
/** | ||
* Returns the smallest line segment that contains all points in `points`, or `null` | ||
* if no such line segment exists. | ||
* | ||
* @example | ||
* ```ts,runnable | ||
* import {LineSegment2, Vec2} from '@js-draw/math'; | ||
* console.log(LineSegment2.ofSmallestContainingPoints([Vec2.of(1, 0), Vec2.of(0, 1)])); | ||
* ``` | ||
*/ | ||
static ofSmallestContainingPoints(points: readonly Point2[]): LineSegment2 | null; | ||
/** Alias for `point1`. */ | ||
@@ -30,0 +41,0 @@ get p1(): Point2; |
@@ -25,2 +25,24 @@ "use strict"; | ||
} | ||
/** | ||
* Returns the smallest line segment that contains all points in `points`, or `null` | ||
* if no such line segment exists. | ||
* | ||
* @example | ||
* ```ts,runnable | ||
* import {LineSegment2, Vec2} from '@js-draw/math'; | ||
* console.log(LineSegment2.ofSmallestContainingPoints([Vec2.of(1, 0), Vec2.of(0, 1)])); | ||
* ``` | ||
*/ | ||
static ofSmallestContainingPoints(points) { | ||
if (points.length <= 1) | ||
return null; | ||
const sorted = [...points].sort((a, b) => a.x !== b.x ? a.x - b.x : a.y - b.y); | ||
const line = new LineSegment2(sorted[0], sorted[sorted.length - 1]); | ||
for (const point of sorted) { | ||
if (!line.containsPoint(point)) { | ||
return null; | ||
} | ||
} | ||
return line; | ||
} | ||
// Accessors to make LineSegment2 compatible with bezier-js's | ||
@@ -108,3 +130,6 @@ // interface | ||
let resultPoint, resultT; | ||
if (this.direction.x === 0) { | ||
// Consider very-near-vertical lines to be vertical --- not doing so can lead to | ||
// precision error when dividing by this.direction.x. | ||
const small = 4e-13; | ||
if (Math.abs(this.direction.x) < small) { | ||
// Vertical line: Where does the other have x = this.point1.x? | ||
@@ -111,0 +136,0 @@ // x = o₁ₓ = o₂ₓ + d₂ₓ · (y - o₂ᵧ) / d₂ᵧ |
import { Point2, Vec2 } from '../Vec2'; | ||
import Abstract2DShape from './Abstract2DShape'; | ||
import LineSegment2 from './LineSegment2'; | ||
/** A 2-dimensional path with parameter interval $t \in [0, 1]$. */ | ||
/** | ||
* A 2-dimensional path with parameter interval $t \in [0, 1]$. | ||
* | ||
* **Note:** Avoid extending this class outside of `js-draw` --- new abstract methods | ||
* may be added between minor versions. | ||
*/ | ||
export declare abstract class Parameterized2DShape extends Abstract2DShape { | ||
@@ -6,0 +11,0 @@ /** Returns this at a given parameter. $t \in [0, 1]$ */ |
@@ -8,3 +8,8 @@ "use strict"; | ||
const Abstract2DShape_1 = __importDefault(require("./Abstract2DShape")); | ||
/** A 2-dimensional path with parameter interval $t \in [0, 1]$. */ | ||
/** | ||
* A 2-dimensional path with parameter interval $t \in [0, 1]$. | ||
* | ||
* **Note:** Avoid extending this class outside of `js-draw` --- new abstract methods | ||
* may be added between minor versions. | ||
*/ | ||
class Parameterized2DShape extends Abstract2DShape_1.default { | ||
@@ -11,0 +16,0 @@ intersectsLineSegment(line) { |
@@ -35,7 +35,15 @@ import LineSegment2 from './LineSegment2'; | ||
curveIndex: number; | ||
/** Parameter value for the closest point **on** the path to the intersection. @internal @deprecated */ | ||
parameterValue?: number; | ||
/** Parameter value for the closest point **on** the path to the intersection. @internal */ | ||
parameterValue: number; | ||
/** Point at which the intersection occured. */ | ||
point: Point2; | ||
} | ||
/** Options for {@link Path.splitNear} and {@link Path.splitAt} */ | ||
export interface PathSplitOptions { | ||
/** | ||
* Allows mapping points on newly added segments. This is useful, for example, | ||
* to round points to prevent long decimals when later saving. | ||
*/ | ||
mapNewPoint?: (point: Point2) => Point2; | ||
} | ||
/** | ||
@@ -50,4 +58,39 @@ * Allows indexing a particular part of a path. | ||
} | ||
/** Returns a positive number if `a` comes after `b`, 0 if equal, and negative otherwise. */ | ||
export declare const compareCurveIndices: (a: CurveIndexRecord, b: CurveIndexRecord) => number; | ||
/** | ||
* Returns a version of `index` with its parameter value incremented by `stepBy` | ||
* (which can be either positive or negative). | ||
*/ | ||
export declare const stepCurveIndexBy: (index: CurveIndexRecord, stepBy: number) => CurveIndexRecord; | ||
/** | ||
* 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)); | ||
* ``` | ||
*/ | ||
@@ -71,2 +114,8 @@ export declare class Path { | ||
constructor(startPoint: Point2, parts: Readonly<PathCommand>[]); | ||
/** | ||
* 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(): Rect2; | ||
@@ -85,3 +134,16 @@ private cachedGeometry; | ||
static computeBBoxForSegment(startPoint: Point2, part: PathCommand): Rect2; | ||
/** **Note**: `strokeRadius = strokeWidth / 2` */ | ||
/** | ||
* 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: Point2, strokeRadius: number): number; | ||
@@ -106,5 +168,2 @@ /** | ||
* @returns the nearest point on this path to the given `point`. | ||
* | ||
* @internal | ||
* @beta | ||
*/ | ||
@@ -114,6 +173,25 @@ nearestPointTo(point: Point2): IntersectionResult; | ||
tangentAt(index: CurveIndexRecord): import("../Vec3").Vec3; | ||
/** Splits this path in two near the given `point`. */ | ||
splitNear(point: Point2, options?: PathSplitOptions): [Path] | [Path, Path]; | ||
/** | ||
* Returns a copy of this path with `deleteFrom` until `deleteUntil` replaced with `insert`. | ||
* | ||
* This method is analogous to {@link Array.toSpliced}. | ||
*/ | ||
spliced(deleteFrom: CurveIndexRecord, deleteTo: CurveIndexRecord, insert: Path | undefined, options?: PathSplitOptions): Path; | ||
splitAt(at: CurveIndexRecord, options?: PathSplitOptions): [Path] | [Path, Path]; | ||
splitAt(at: CurveIndexRecord[], options?: PathSplitOptions): Path[]; | ||
/** | ||
* Replaces all `MoveTo` commands with `LineTo` commands and connects the end point of this | ||
* path to the start point. | ||
*/ | ||
asClosed(): Path; | ||
private static mapPathCommand; | ||
mapPoints(mapping: (point: Point2) => Point2): Path; | ||
transformedBy(affineTransfm: Mat33): Path; | ||
union(other: Path | null, options?: { | ||
/** | ||
* @internal | ||
*/ | ||
closedContainsPoint(point: Point2): boolean; | ||
union(other: Path | PathCommand[] | null, options?: { | ||
allowReverse?: boolean; | ||
@@ -131,3 +209,4 @@ }): Path; | ||
reversed(): Path; | ||
private getEndPoint; | ||
/** Computes and returns the end point of this path */ | ||
getEndPoint(): import("../Vec3").Vec3; | ||
/** | ||
@@ -144,2 +223,8 @@ * Like {@link closedRoughlyIntersects} except takes stroke width into account. | ||
roughlyIntersects(rect: Rect2, strokeWidth?: number): boolean; | ||
/** | ||
* 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: Rect2): boolean; | ||
@@ -171,6 +256,4 @@ /** @returns true if all points on this are equivalent to the points on `other` */ | ||
* | ||
* ## To-do | ||
* - TODO: Support a larger subset of SVG paths | ||
* - Elliptical arcs are currently unsupported. | ||
* - TODO: Support `s`,`t` commands shorthands. | ||
* Currently, this does not support elliptical arcs or `s` and `t` command | ||
* shorthands. See https://github.com/personalizedrefrigerator/js-draw/pull/19. | ||
* | ||
@@ -186,4 +269,5 @@ * @example | ||
static fromString(pathString: string): Path; | ||
static fromConvexHullOf(points: Point2[]): Path; | ||
static empty: Path; | ||
} | ||
export default Path; |
@@ -6,3 +6,3 @@ "use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.Path = exports.PathCommandType = void 0; | ||
exports.Path = exports.stepCurveIndexBy = exports.compareCurveIndices = exports.PathCommandType = void 0; | ||
const LineSegment2_1 = __importDefault(require("./LineSegment2")); | ||
@@ -16,2 +16,3 @@ const Rect2_1 = __importDefault(require("./Rect2")); | ||
const toStringOfSamePrecision_1 = __importDefault(require("../rounding/toStringOfSamePrecision")); | ||
const convexHull2Of_1 = __importDefault(require("../utils/convexHull2Of")); | ||
var PathCommandType; | ||
@@ -24,4 +25,60 @@ (function (PathCommandType) { | ||
})(PathCommandType || (exports.PathCommandType = PathCommandType = {})); | ||
/** Returns a positive number if `a` comes after `b`, 0 if equal, and negative otherwise. */ | ||
const compareCurveIndices = (a, b) => { | ||
const indexCompare = a.curveIndex - b.curveIndex; | ||
if (indexCompare === 0) { | ||
return a.parameterValue - b.parameterValue; | ||
} | ||
else { | ||
return indexCompare; | ||
} | ||
}; | ||
exports.compareCurveIndices = compareCurveIndices; | ||
/** | ||
* Returns a version of `index` with its parameter value incremented by `stepBy` | ||
* (which can be either positive or negative). | ||
*/ | ||
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 }; | ||
}; | ||
exports.stepCurveIndexBy = stepCurveIndexBy; | ||
/** | ||
* 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)); | ||
* ``` | ||
*/ | ||
@@ -49,2 +106,8 @@ class Path { | ||
} | ||
/** | ||
* 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() { | ||
@@ -168,3 +231,16 @@ const bboxes = []; | ||
} | ||
/** **Note**: `strokeRadius = strokeWidth / 2` */ | ||
/** | ||
* 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) { | ||
@@ -256,3 +332,3 @@ let minDist = Infinity; | ||
// Raymarch: | ||
const maxRaymarchSteps = 7; | ||
const maxRaymarchSteps = 8; | ||
// Start raymarching from each of these points. This allows detection of multiple | ||
@@ -327,3 +403,3 @@ // intersections. | ||
point: currentPoint, | ||
parameterValue: NaN, // lastPart.nearestPointTo(currentPoint).parameterValue, | ||
parameterValue: lastPart.nearestPointTo(currentPoint).parameterValue, | ||
curve: lastPart, | ||
@@ -368,2 +444,5 @@ curveIndex: this.geometry.indexOf(lastPart), | ||
} | ||
if (this.parts.length === 0) { | ||
return new Path(this.startPoint, [{ kind: PathCommandType.MoveTo, point: this.startPoint }]).intersection(line, strokeRadius); | ||
} | ||
let index = 0; | ||
@@ -395,5 +474,2 @@ for (const part of this.geometry) { | ||
* @returns the nearest point on this path to the given `point`. | ||
* | ||
* @internal | ||
* @beta | ||
*/ | ||
@@ -425,2 +501,5 @@ nearestPointTo(point) { | ||
at(index) { | ||
if (index.curveIndex === 0 && index.parameterValue === 0) { | ||
return this.startPoint; | ||
} | ||
return this.geometry[index.curveIndex].at(index.parameterValue); | ||
@@ -431,2 +510,211 @@ } | ||
} | ||
/** 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(exports.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) { | ||
@@ -474,2 +762,15 @@ switch (part.kind) { | ||
} | ||
/** | ||
* @internal | ||
*/ | ||
closedContainsPoint(point) { | ||
const bbox = this.getExactBBox(); | ||
if (!bbox.containsPoint(point)) { | ||
return false; | ||
} | ||
const pointOutside = point.plus(Vec2_1.Vec2.of(bbox.width, 0)); | ||
const asClosed = this.asClosed(); | ||
const lineToOutside = new LineSegment2_1.default(point, pointOutside); | ||
return asClosed.intersection(lineToOutside).length % 2 === 1; | ||
} | ||
// Creates a new path by joining [other] to the end of this path | ||
@@ -483,2 +784,5 @@ union(other, | ||
} | ||
if (Array.isArray(other)) { | ||
return new Path(this.startPoint, [...this.parts, ...other]); | ||
} | ||
const thisEnd = this.getEndPoint(); | ||
@@ -557,2 +861,3 @@ let newParts = []; | ||
} | ||
/** Computes and returns the end point of this path */ | ||
getEndPoint() { | ||
@@ -607,6 +912,8 @@ if (this.parts.length === 0) { | ||
} | ||
// 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. | ||
/** | ||
* 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) { | ||
@@ -837,6 +1144,4 @@ if (rect.containsRect(this.bbox)) { | ||
* | ||
* ## To-do | ||
* - TODO: Support a larger subset of SVG paths | ||
* - Elliptical arcs are currently unsupported. | ||
* - TODO: Support `s`,`t` commands shorthands. | ||
* Currently, this does not support elliptical arcs or `s` and `t` command | ||
* shorthands. See https://github.com/personalizedrefrigerator/js-draw/pull/19. | ||
* | ||
@@ -852,2 +1157,4 @@ * @example | ||
static fromString(pathString) { | ||
// TODO: Support elliptical arcs, and the `s`, `t` command shorthands. | ||
// | ||
// See the MDN reference: | ||
@@ -1020,2 +1327,18 @@ // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d | ||
} | ||
static fromConvexHullOf(points) { | ||
if (points.length === 0) { | ||
return Path.empty; | ||
} | ||
const hull = (0, convexHull2Of_1.default)(points); | ||
const commands = hull.slice(1).map((p) => ({ | ||
kind: PathCommandType.LineTo, | ||
point: p, | ||
})); | ||
// Close -- connect back to the start | ||
commands.push({ | ||
kind: PathCommandType.LineTo, | ||
point: hull[0], | ||
}); | ||
return new Path(hull[0], commands); | ||
} | ||
} | ||
@@ -1022,0 +1345,0 @@ exports.Path = Path; |
@@ -5,6 +5,5 @@ import { Point2, Vec2 } from '../Vec2'; | ||
/** | ||
* A wrapper around `bezier-js`'s quadratic Bézier. | ||
* Represents a 2D Bézier curve. | ||
* | ||
* This wrappper lazy-loads `bezier-js`'s Bézier and can perform some operations | ||
* without loading it at all (e.g. `normal`, `at`, and `approximateDistance`). | ||
* **Note**: Many Bézier operations use `bezier-js`'s. | ||
*/ | ||
@@ -11,0 +10,0 @@ export declare class QuadraticBezier extends BezierJSWrapper { |
@@ -12,6 +12,5 @@ "use strict"; | ||
/** | ||
* A wrapper around `bezier-js`'s quadratic Bézier. | ||
* Represents a 2D Bézier curve. | ||
* | ||
* This wrappper lazy-loads `bezier-js`'s Bézier and can perform some operations | ||
* without loading it at all (e.g. `normal`, `at`, and `approximateDistance`). | ||
* **Note**: Many Bézier operations use `bezier-js`'s. | ||
*/ | ||
@@ -18,0 +17,0 @@ class QuadraticBezier extends BezierJSWrapper_1.default { |
@@ -6,3 +6,3 @@ import LineSegment2 from './LineSegment2'; | ||
import Vec3 from '../Vec3'; | ||
/** An object that can be converted to a Rect2. */ | ||
/** An object that can be converted to a {@link Rect2}. */ | ||
export interface RectTemplate { | ||
@@ -16,2 +16,7 @@ x: number; | ||
} | ||
/** | ||
* Represents a rectangle in 2D space, parallel to the XY axes. | ||
* | ||
* `invariant: w ≥ 0, h ≥ 0, immutable` | ||
*/ | ||
export declare class Rect2 extends Abstract2DShape { | ||
@@ -18,0 +23,0 @@ readonly x: number; |
@@ -10,3 +10,7 @@ "use strict"; | ||
const Abstract2DShape_1 = __importDefault(require("./Abstract2DShape")); | ||
// invariant: w ≥ 0, h ≥ 0, immutable | ||
/** | ||
* Represents a rectangle in 2D space, parallel to the XY axes. | ||
* | ||
* `invariant: w ≥ 0, h ≥ 0, immutable` | ||
*/ | ||
class Rect2 extends Abstract2DShape_1.default { | ||
@@ -13,0 +17,0 @@ constructor(x, y, w, h) { |
@@ -137,3 +137,4 @@ /** | ||
* @example | ||
* ``` | ||
* ```ts,runnable,console | ||
* import { Vec3 } from '@js-draw/math'; | ||
* console.log(Vec3.of(1, 2, 3).map(val => val + 1)); // → Vec(2, 3, 4) | ||
@@ -140,0 +141,0 @@ * ``` |
@@ -209,3 +209,4 @@ "use strict"; | ||
* @example | ||
* ``` | ||
* ```ts,runnable,console | ||
* import { Vec3 } from '@js-draw/math'; | ||
* console.log(Vec3.of(1, 2, 3).map(val => val + 1)); // → Vec(2, 3, 4) | ||
@@ -234,8 +235,5 @@ * ``` | ||
eq(other, fuzz = 1e-10) { | ||
for (let i = 0; i < 3; i++) { | ||
if (Math.abs(other.at(i) - this.at(i)) > fuzz) { | ||
return false; | ||
} | ||
} | ||
return true; | ||
return (Math.abs(other.x - this.x) <= fuzz | ||
&& Math.abs(other.y - this.y) <= fuzz | ||
&& Math.abs(other.z - this.z) <= fuzz); | ||
} | ||
@@ -242,0 +240,0 @@ toString() { |
@@ -20,4 +20,5 @@ /** | ||
export { LineSegment2 } from './shapes/LineSegment2'; | ||
export { Path, IntersectionResult as PathIntersectionResult, CurveIndexRecord as PathCurveIndex, PathCommandType, PathCommand, LinePathCommand, MoveToPathCommand, QuadraticBezierPathCommand, CubicBezierPathCommand, } from './shapes/Path'; | ||
export { Path, IntersectionResult as PathIntersectionResult, CurveIndexRecord as PathCurveIndex, stepCurveIndexBy as stepPathIndexBy, compareCurveIndices as comparePathIndices, PathCommandType, PathCommand, LinePathCommand, MoveToPathCommand, QuadraticBezierPathCommand, CubicBezierPathCommand, } from './shapes/Path'; | ||
export { Rect2 } from './shapes/Rect2'; | ||
export { Parameterized2DShape } from './shapes/Parameterized2DShape'; | ||
export { QuadraticBezier } from './shapes/QuadraticBezier'; | ||
@@ -24,0 +25,0 @@ export { Abstract2DShape } from './shapes/Abstract2DShape'; |
@@ -44,4 +44,8 @@ import { Bezier } from 'bezier-js'; | ||
}; | ||
intersectsBezier(other: BezierJSWrapper): { | ||
parameterValue: number; | ||
point: import("../Vec3").Vec3; | ||
}[]; | ||
toString(): string; | ||
} | ||
export default BezierJSWrapper; |
@@ -28,2 +28,13 @@ import Mat33 from '../Mat33'; | ||
constructor(point1: Point2, point2: Point2); | ||
/** | ||
* Returns the smallest line segment that contains all points in `points`, or `null` | ||
* if no such line segment exists. | ||
* | ||
* @example | ||
* ```ts,runnable | ||
* import {LineSegment2, Vec2} from '@js-draw/math'; | ||
* console.log(LineSegment2.ofSmallestContainingPoints([Vec2.of(1, 0), Vec2.of(0, 1)])); | ||
* ``` | ||
*/ | ||
static ofSmallestContainingPoints(points: readonly Point2[]): LineSegment2 | null; | ||
/** Alias for `point1`. */ | ||
@@ -30,0 +41,0 @@ get p1(): Point2; |
import { Point2, Vec2 } from '../Vec2'; | ||
import Abstract2DShape from './Abstract2DShape'; | ||
import LineSegment2 from './LineSegment2'; | ||
/** A 2-dimensional path with parameter interval $t \in [0, 1]$. */ | ||
/** | ||
* A 2-dimensional path with parameter interval $t \in [0, 1]$. | ||
* | ||
* **Note:** Avoid extending this class outside of `js-draw` --- new abstract methods | ||
* may be added between minor versions. | ||
*/ | ||
export declare abstract class Parameterized2DShape extends Abstract2DShape { | ||
@@ -6,0 +11,0 @@ /** Returns this at a given parameter. $t \in [0, 1]$ */ |
@@ -35,7 +35,15 @@ import LineSegment2 from './LineSegment2'; | ||
curveIndex: number; | ||
/** Parameter value for the closest point **on** the path to the intersection. @internal @deprecated */ | ||
parameterValue?: number; | ||
/** Parameter value for the closest point **on** the path to the intersection. @internal */ | ||
parameterValue: number; | ||
/** Point at which the intersection occured. */ | ||
point: Point2; | ||
} | ||
/** Options for {@link Path.splitNear} and {@link Path.splitAt} */ | ||
export interface PathSplitOptions { | ||
/** | ||
* Allows mapping points on newly added segments. This is useful, for example, | ||
* to round points to prevent long decimals when later saving. | ||
*/ | ||
mapNewPoint?: (point: Point2) => Point2; | ||
} | ||
/** | ||
@@ -50,4 +58,39 @@ * Allows indexing a particular part of a path. | ||
} | ||
/** Returns a positive number if `a` comes after `b`, 0 if equal, and negative otherwise. */ | ||
export declare const compareCurveIndices: (a: CurveIndexRecord, b: CurveIndexRecord) => number; | ||
/** | ||
* Returns a version of `index` with its parameter value incremented by `stepBy` | ||
* (which can be either positive or negative). | ||
*/ | ||
export declare const stepCurveIndexBy: (index: CurveIndexRecord, stepBy: number) => CurveIndexRecord; | ||
/** | ||
* 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)); | ||
* ``` | ||
*/ | ||
@@ -71,2 +114,8 @@ export declare class Path { | ||
constructor(startPoint: Point2, parts: Readonly<PathCommand>[]); | ||
/** | ||
* 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(): Rect2; | ||
@@ -85,3 +134,16 @@ private cachedGeometry; | ||
static computeBBoxForSegment(startPoint: Point2, part: PathCommand): Rect2; | ||
/** **Note**: `strokeRadius = strokeWidth / 2` */ | ||
/** | ||
* 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: Point2, strokeRadius: number): number; | ||
@@ -106,5 +168,2 @@ /** | ||
* @returns the nearest point on this path to the given `point`. | ||
* | ||
* @internal | ||
* @beta | ||
*/ | ||
@@ -114,6 +173,25 @@ nearestPointTo(point: Point2): IntersectionResult; | ||
tangentAt(index: CurveIndexRecord): import("../Vec3").Vec3; | ||
/** Splits this path in two near the given `point`. */ | ||
splitNear(point: Point2, options?: PathSplitOptions): [Path] | [Path, Path]; | ||
/** | ||
* Returns a copy of this path with `deleteFrom` until `deleteUntil` replaced with `insert`. | ||
* | ||
* This method is analogous to {@link Array.toSpliced}. | ||
*/ | ||
spliced(deleteFrom: CurveIndexRecord, deleteTo: CurveIndexRecord, insert: Path | undefined, options?: PathSplitOptions): Path; | ||
splitAt(at: CurveIndexRecord, options?: PathSplitOptions): [Path] | [Path, Path]; | ||
splitAt(at: CurveIndexRecord[], options?: PathSplitOptions): Path[]; | ||
/** | ||
* Replaces all `MoveTo` commands with `LineTo` commands and connects the end point of this | ||
* path to the start point. | ||
*/ | ||
asClosed(): Path; | ||
private static mapPathCommand; | ||
mapPoints(mapping: (point: Point2) => Point2): Path; | ||
transformedBy(affineTransfm: Mat33): Path; | ||
union(other: Path | null, options?: { | ||
/** | ||
* @internal | ||
*/ | ||
closedContainsPoint(point: Point2): boolean; | ||
union(other: Path | PathCommand[] | null, options?: { | ||
allowReverse?: boolean; | ||
@@ -131,3 +209,4 @@ }): Path; | ||
reversed(): Path; | ||
private getEndPoint; | ||
/** Computes and returns the end point of this path */ | ||
getEndPoint(): import("../Vec3").Vec3; | ||
/** | ||
@@ -144,2 +223,8 @@ * Like {@link closedRoughlyIntersects} except takes stroke width into account. | ||
roughlyIntersects(rect: Rect2, strokeWidth?: number): boolean; | ||
/** | ||
* 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: Rect2): boolean; | ||
@@ -171,6 +256,4 @@ /** @returns true if all points on this are equivalent to the points on `other` */ | ||
* | ||
* ## To-do | ||
* - TODO: Support a larger subset of SVG paths | ||
* - Elliptical arcs are currently unsupported. | ||
* - TODO: Support `s`,`t` commands shorthands. | ||
* Currently, this does not support elliptical arcs or `s` and `t` command | ||
* shorthands. See https://github.com/personalizedrefrigerator/js-draw/pull/19. | ||
* | ||
@@ -186,4 +269,5 @@ * @example | ||
static fromString(pathString: string): Path; | ||
static fromConvexHullOf(points: Point2[]): Path; | ||
static empty: Path; | ||
} | ||
export default Path; |
@@ -5,6 +5,5 @@ import { Point2, Vec2 } from '../Vec2'; | ||
/** | ||
* A wrapper around `bezier-js`'s quadratic Bézier. | ||
* Represents a 2D Bézier curve. | ||
* | ||
* This wrappper lazy-loads `bezier-js`'s Bézier and can perform some operations | ||
* without loading it at all (e.g. `normal`, `at`, and `approximateDistance`). | ||
* **Note**: Many Bézier operations use `bezier-js`'s. | ||
*/ | ||
@@ -11,0 +10,0 @@ export declare class QuadraticBezier extends BezierJSWrapper { |
@@ -6,3 +6,3 @@ import LineSegment2 from './LineSegment2'; | ||
import Vec3 from '../Vec3'; | ||
/** An object that can be converted to a Rect2. */ | ||
/** An object that can be converted to a {@link Rect2}. */ | ||
export interface RectTemplate { | ||
@@ -16,2 +16,7 @@ x: number; | ||
} | ||
/** | ||
* Represents a rectangle in 2D space, parallel to the XY axes. | ||
* | ||
* `invariant: w ≥ 0, h ≥ 0, immutable` | ||
*/ | ||
export declare class Rect2 extends Abstract2DShape { | ||
@@ -18,0 +23,0 @@ readonly x: number; |
@@ -137,3 +137,4 @@ /** | ||
* @example | ||
* ``` | ||
* ```ts,runnable,console | ||
* import { Vec3 } from '@js-draw/math'; | ||
* console.log(Vec3.of(1, 2, 3).map(val => val + 1)); // → Vec(2, 3, 4) | ||
@@ -140,0 +141,0 @@ * ``` |
{ | ||
"name": "@js-draw/math", | ||
"version": "1.17.0", | ||
"version": "1.18.0", | ||
"description": "A math library for js-draw. ", | ||
@@ -48,3 +48,3 @@ "types": "./dist/mjs/lib.d.ts", | ||
], | ||
"gitHead": "d0eff585750ab5670af3acda8ddff090e8825bd3" | ||
"gitHead": "73c0d802a8439b5d408ba1e60f91be029db7e402" | ||
} |
@@ -26,2 +26,4 @@ /** | ||
CurveIndexRecord as PathCurveIndex, | ||
stepCurveIndexBy as stepPathIndexBy, | ||
compareCurveIndices as comparePathIndices, | ||
PathCommandType, | ||
@@ -35,2 +37,3 @@ PathCommand, | ||
export { Rect2 } from './shapes/Rect2'; | ||
export { Parameterized2DShape } from './shapes/Parameterized2DShape'; | ||
export { QuadraticBezier } from './shapes/QuadraticBezier'; | ||
@@ -37,0 +40,0 @@ export { Abstract2DShape } from './shapes/Abstract2DShape'; |
@@ -447,4 +447,9 @@ import { Point2, Vec2 } from './Vec2'; | ||
const parseArguments = (argumentString: string) => { | ||
return argumentString.split(/[, \t\n]+/g).map(argString => { | ||
const parseArguments = (argumentString: string): number[] => { | ||
const parsed = argumentString.split(/[, \t\n]+/g).map(argString => { | ||
// Handle trailing spaces/commands | ||
if (argString.trim() === '') { | ||
return null; | ||
} | ||
let isPercentage = false; | ||
@@ -480,2 +485,3 @@ if (argString.endsWith('%')) { | ||
}); | ||
return parsed.filter(n => n !== null) as number[]; | ||
}; | ||
@@ -482,0 +488,0 @@ |
@@ -90,2 +90,14 @@ import { Bezier } from 'bezier-js'; | ||
public override argIntersectsLineSegment(line: LineSegment2): number[] { | ||
// Bezier-js has a bug when all control points of a Bezier curve lie on | ||
// a line. Our solution involves converting the Bezier into a line, then | ||
// finding the parameter value that produced the intersection. | ||
// | ||
// TODO: This is unnecessarily slow. A better solution would be to fix | ||
// the bug upstream. | ||
const asLine = LineSegment2.ofSmallestContainingPoints(this.getPoints()); | ||
if (asLine) { | ||
const intersection = asLine.intersectsLineSegment(line); | ||
return intersection.map(p => this.nearestPointTo(p).parameterValue); | ||
} | ||
const bezier = this.getBezier(); | ||
@@ -190,2 +202,4 @@ | ||
const slope = secondDerivativeAt(t); | ||
if (slope === 0) return; | ||
// We intersect a line through the point on f'(t) at t with the x-axis: | ||
@@ -216,2 +230,29 @@ // y = m(x - x₀) + y₀ | ||
public intersectsBezier(other: BezierJSWrapper) { | ||
const intersections = this.getBezier().intersects(other.getBezier()) as (string[] | null | undefined); | ||
if (!intersections || intersections.length === 0) { | ||
return []; | ||
} | ||
const result = []; | ||
for (const intersection of intersections) { | ||
// From http://pomax.github.io/bezierjs/#intersect-curve, | ||
// .intersects returns an array of 't1/t2' pairs, where curve1.at(t1) gives the point. | ||
const match = /^([-0-9.eE]+)\/([-0-9.eE]+)$/.exec(intersection); | ||
if (!match) { | ||
throw new Error( | ||
`Incorrect format returned by .intersects: ${intersections} should be array of "number/number"!` | ||
); | ||
} | ||
const t = parseFloat(match[1]); | ||
result.push({ | ||
parameterValue: t, | ||
point: this.at(t), | ||
}); | ||
} | ||
return result; | ||
} | ||
public override toString() { | ||
@@ -218,0 +259,0 @@ return `Bézier(${this.getPoints().map(point => point.toString()).join(', ')})`; |
@@ -77,2 +77,10 @@ import LineSegment2 from './LineSegment2'; | ||
it('(9.559000000000001, 11.687)->(9.559, 11.67673) should intersect (9.56069, 11.68077)->(9.55719, 11.68077)', () => { | ||
// Points taken from an issue observed in the editor. | ||
const l1 = new LineSegment2(Vec2.of(9.559000000000001, 11.687), Vec2.of(9.559, 11.67673)); | ||
const l2 = new LineSegment2(Vec2.of(9.56069, 11.68077), Vec2.of(9.55719, 11.68077)); | ||
expect(l2.intersects(l1)).toBe(true); | ||
expect(l1.intersects(l2)).toBe(true); | ||
}); | ||
it('Closest point to (0,0) on the line x = 1 should be (1,0)', () => { | ||
@@ -134,2 +142,20 @@ const line = new LineSegment2(Vec2.of(1, 100), Vec2.of(1, -100)); | ||
}); | ||
it('should support creating from a collection of points', () => { | ||
expect(LineSegment2.ofSmallestContainingPoints([])).toBeNull(); | ||
expect(LineSegment2.ofSmallestContainingPoints([Vec2.of(1, 1)])).toBeNull(); | ||
expect(LineSegment2.ofSmallestContainingPoints( | ||
[Vec2.of(1, 1), Vec2.of(1, 2), Vec2.of(3, 3)] | ||
)).toBeNull(); | ||
expect(LineSegment2.ofSmallestContainingPoints( | ||
[Vec2.of(1, 1), Vec2.of(1, 2)] | ||
)).objEq(new LineSegment2(Vec2.of(1, 1), Vec2.of(1, 2))); | ||
expect(LineSegment2.ofSmallestContainingPoints( | ||
[Vec2.of(1, 1), Vec2.of(2, 2), Vec2.of(3, 3)] | ||
)).objEq(new LineSegment2(Vec2.of(1, 1), Vec2.of(3, 3))); | ||
expect(LineSegment2.ofSmallestContainingPoints( | ||
[Vec2.of(3, 3), Vec2.of(2, 2), Vec2.of(2.4, 2.4), Vec2.of(3, 3)] | ||
)).objEq(new LineSegment2(Vec2.of(2, 2), Vec2.of(3, 3))); | ||
}); | ||
}); |
@@ -49,2 +49,27 @@ import Mat33 from '../Mat33'; | ||
/** | ||
* Returns the smallest line segment that contains all points in `points`, or `null` | ||
* if no such line segment exists. | ||
* | ||
* @example | ||
* ```ts,runnable | ||
* import {LineSegment2, Vec2} from '@js-draw/math'; | ||
* console.log(LineSegment2.ofSmallestContainingPoints([Vec2.of(1, 0), Vec2.of(0, 1)])); | ||
* ``` | ||
*/ | ||
public static ofSmallestContainingPoints(points: readonly Point2[]) { | ||
if (points.length <= 1) return null; | ||
const sorted = [...points].sort((a, b) => a.x !== b.x ? a.x - b.x : a.y - b.y); | ||
const line = new LineSegment2(sorted[0], sorted[sorted.length - 1]); | ||
for (const point of sorted) { | ||
if (!line.containsPoint(point)) { | ||
return null; | ||
} | ||
} | ||
return line; | ||
} | ||
// Accessors to make LineSegment2 compatible with bezier-js's | ||
@@ -143,3 +168,7 @@ // interface | ||
let resultPoint, resultT; | ||
if (this.direction.x === 0) { | ||
// Consider very-near-vertical lines to be vertical --- not doing so can lead to | ||
// precision error when dividing by this.direction.x. | ||
const small = 4e-13; | ||
if (Math.abs(this.direction.x) < small) { | ||
// Vertical line: Where does the other have x = this.point1.x? | ||
@@ -189,2 +218,3 @@ // x = o₁ₓ = o₂ₓ + d₂ₓ · (y - o₂ᵧ) / d₂ᵧ | ||
const resultToP4 = resultPoint.distanceTo(other.point2); | ||
if (resultToP1 > this.length | ||
@@ -191,0 +221,0 @@ || resultToP2 > this.length |
@@ -5,3 +5,8 @@ import { Point2, Vec2 } from '../Vec2'; | ||
/** A 2-dimensional path with parameter interval $t \in [0, 1]$. */ | ||
/** | ||
* A 2-dimensional path with parameter interval $t \in [0, 1]$. | ||
* | ||
* **Note:** Avoid extending this class outside of `js-draw` --- new abstract methods | ||
* may be added between minor versions. | ||
*/ | ||
export abstract class Parameterized2DShape extends Abstract2DShape { | ||
@@ -8,0 +13,0 @@ /** Returns this at a given parameter. $t \in [0, 1]$ */ |
import LineSegment2 from './LineSegment2'; | ||
import Path, { PathCommandType } from './Path'; | ||
import Path, { CurveIndexRecord, PathCommandType } from './Path'; | ||
import Rect2 from './Rect2'; | ||
import { Vec2 } from '../Vec2'; | ||
import { Point2, Vec2 } from '../Vec2'; | ||
import CubicBezier from './CubicBezier'; | ||
@@ -241,5 +241,3 @@ import QuadraticBezier from './QuadraticBezier'; | ||
[new LineSegment2(Vec2.of(43.5,-12.5), Vec2.of(40.5,24.5)), 0], | ||
// TODO: The below case is failing. It seems to be a Bezier-js bug though... | ||
// (The Bézier.js method returns an empty array). | ||
//[new LineSegment2(Vec2.of(35.5,19.5), Vec2.of(38.5,-17.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) => { | ||
@@ -249,2 +247,13 @@ const bezier = Path.fromString('M0,0 Q50,0 100,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); | ||
}); | ||
}); | ||
@@ -353,2 +362,142 @@ | ||
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: | ||
// <path d="m1,1 C1,2 2,10 4,4 C5,0 9,3 7,7" fill="none" stroke="#ff0000"/> | ||
// <path d="M2,6C3,6 3,6 4,4C5,0 9,3 7,7" fill="none" stroke="#00ff0080"/> | ||
// 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([ | ||
@@ -372,2 +521,21 @@ [ 'm0,0 L1,1', 'M1,1 L0,0' ], | ||
}); | ||
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); | ||
}); | ||
}); |
@@ -11,2 +11,4 @@ import LineSegment2 from './LineSegment2'; | ||
import Parameterized2DShape from './Parameterized2DShape'; | ||
import BezierJSWrapper from './BezierJSWrapper'; | ||
import convexHull2Of from '../utils/convexHull2Of'; | ||
@@ -51,4 +53,4 @@ export enum PathCommandType { | ||
/** Parameter value for the closest point **on** the path to the intersection. @internal @deprecated */ | ||
parameterValue?: number; | ||
/** Parameter value for the closest point **on** the path to the intersection. @internal */ | ||
parameterValue: number; | ||
@@ -59,2 +61,11 @@ /** Point at which the intersection occured. */ | ||
/** Options for {@link Path.splitNear} and {@link Path.splitAt} */ | ||
export interface PathSplitOptions { | ||
/** | ||
* Allows mapping points on newly added segments. This is useful, for example, | ||
* to round points to prevent long decimals when later saving. | ||
*/ | ||
mapNewPoint?: (point: Point2)=>Point2; | ||
} | ||
/** | ||
@@ -70,4 +81,60 @@ * Allows indexing a particular part of a path. | ||
/** Returns a positive number if `a` comes after `b`, 0 if equal, and negative otherwise. */ | ||
export const compareCurveIndices = (a: CurveIndexRecord, b: CurveIndexRecord) => { | ||
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: CurveIndexRecord, stepBy: number): CurveIndexRecord => { | ||
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)); | ||
* ``` | ||
*/ | ||
@@ -107,2 +174,8 @@ export class Path { | ||
/** | ||
* 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. | ||
*/ | ||
public getExactBBox(): Rect2 { | ||
@@ -257,3 +330,16 @@ const bboxes: Rect2[] = []; | ||
/** **Note**: `strokeRadius = strokeWidth / 2` */ | ||
/** | ||
* 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` | ||
*/ | ||
public signedDistance(point: Point2, strokeRadius: number) { | ||
@@ -376,3 +462,3 @@ let minDist = Infinity; | ||
// Raymarch: | ||
const maxRaymarchSteps = 7; | ||
const maxRaymarchSteps = 8; | ||
@@ -468,3 +554,3 @@ // Start raymarching from each of these points. This allows detection of multiple | ||
point: currentPoint, | ||
parameterValue: NaN,// lastPart.nearestPointTo(currentPoint).parameterValue, | ||
parameterValue: lastPart.nearestPointTo(currentPoint).parameterValue, | ||
curve: lastPart, | ||
@@ -518,2 +604,6 @@ curveIndex: this.geometry.indexOf(lastPart), | ||
if (this.parts.length === 0) { | ||
return new Path(this.startPoint, [{ kind: PathCommandType.MoveTo, point: this.startPoint }]).intersection(line, strokeRadius); | ||
} | ||
let index = 0; | ||
@@ -550,5 +640,2 @@ for (const part of this.geometry) { | ||
* @returns the nearest point on this path to the given `point`. | ||
* | ||
* @internal | ||
* @beta | ||
*/ | ||
@@ -583,2 +670,5 @@ public nearestPointTo(point: Point2): IntersectionResult { | ||
public at(index: CurveIndexRecord) { | ||
if (index.curveIndex === 0 && index.parameterValue === 0) { | ||
return this.startPoint; | ||
} | ||
return this.geometry[index.curveIndex].at(index.parameterValue); | ||
@@ -591,2 +681,242 @@ } | ||
/** Splits this path in two near the given `point`. */ | ||
public splitNear(point: Point2, options?: PathSplitOptions) { | ||
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}. | ||
*/ | ||
public spliced(deleteFrom: CurveIndexRecord, deleteTo: CurveIndexRecord, insert: Path|undefined, options?: PathSplitOptions): Path { | ||
const isBeforeOrEqual = (a: CurveIndexRecord, b: CurveIndexRecord) => { | ||
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; | ||
} | ||
} | ||
public splitAt(at: CurveIndexRecord, options?: PathSplitOptions): [Path]|[Path, Path]; | ||
public splitAt(at: CurveIndexRecord[], options?: PathSplitOptions): Path[]; | ||
// @internal | ||
public splitAt(splitAt: CurveIndexRecord[]|CurveIndexRecord, options?: PathSplitOptions): Path[] { | ||
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: Point2)=>p); | ||
const result: Path[] = []; | ||
let currentStartPoint = this.startPoint; | ||
let currentPath: PathCommand[] = []; | ||
// | ||
// 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: Point2; | ||
const newPath: PathCommand[] = []; | ||
switch (part.kind) { | ||
case PathCommandType.MoveTo: | ||
currentPath.push({ | ||
kind: part.kind, | ||
point: part.point, | ||
}); | ||
newPathStart = part.point; | ||
break; | ||
case PathCommandType.LineTo: | ||
{ | ||
const split = (geom as LineSegment2).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 as BezierJSWrapper).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: never = 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. | ||
*/ | ||
public asClosed() { | ||
const newParts: PathCommand[] = []; | ||
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; | ||
} | ||
private static mapPathCommand(part: PathCommand, mapping: (point: Point2)=> Point2): PathCommand { | ||
@@ -641,5 +971,21 @@ switch (part.kind) { | ||
/** | ||
* @internal | ||
*/ | ||
public closedContainsPoint(point: Point2) { | ||
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); | ||
return asClosed.intersection(lineToOutside).length % 2 === 1; | ||
} | ||
// Creates a new path by joining [other] to the end of this path | ||
public union( | ||
other: Path|null, | ||
other: Path|PathCommand[]|null, | ||
@@ -653,2 +999,5 @@ // allowReverse: true iff reversing other or this is permitted if it means | ||
} | ||
if (Array.isArray(other)) { | ||
return new Path(this.startPoint, [...this.parts, ...other]); | ||
} | ||
@@ -728,3 +1077,4 @@ const thisEnd = this.getEndPoint(); | ||
private getEndPoint() { | ||
/** Computes and returns the end point of this path */ | ||
public getEndPoint() { | ||
if (this.parts.length === 0) { | ||
@@ -784,6 +1134,8 @@ return this.startPoint; | ||
// 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. | ||
/** | ||
* 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. | ||
*/ | ||
public closedRoughlyIntersects(rect: Rect2): boolean { | ||
@@ -1054,6 +1406,4 @@ if (rect.containsRect(this.bbox)) { | ||
* | ||
* ## To-do | ||
* - TODO: Support a larger subset of SVG paths | ||
* - Elliptical arcs are currently unsupported. | ||
* - TODO: Support `s`,`t` commands shorthands. | ||
* Currently, this does not support elliptical arcs or `s` and `t` command | ||
* shorthands. See https://github.com/personalizedrefrigerator/js-draw/pull/19. | ||
* | ||
@@ -1069,2 +1419,4 @@ * @example | ||
public static fromString(pathString: string): Path { | ||
// TODO: Support elliptical arcs, and the `s`, `t` command shorthands. | ||
// | ||
// See the MDN reference: | ||
@@ -1257,2 +1609,22 @@ // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d | ||
public static fromConvexHullOf(points: Point2[]) { | ||
if (points.length === 0) { | ||
return Path.empty; | ||
} | ||
const hull = convexHull2Of(points); | ||
const commands = hull.slice(1).map((p): LinePathCommand => ({ | ||
kind: PathCommandType.LineTo, | ||
point: p, | ||
})); | ||
// Close -- connect back to the start | ||
commands.push({ | ||
kind: PathCommandType.LineTo, | ||
point: hull[0], | ||
}); | ||
return new Path(hull[0], commands); | ||
} | ||
// @internal TODO: At present, this isn't really an empty path. | ||
@@ -1259,0 +1631,0 @@ public static empty: Path = new Path(Vec2.zero, []); |
@@ -40,2 +40,5 @@ import { Vec2 } from '../Vec2'; | ||
[ new QuadraticBezier(Vec2.zero, Vec2.of(0, 0.5), Vec2.unitY), Vec2.of(0, 1000), 1 ], | ||
// Edge case -- just a point | ||
[ new QuadraticBezier(Vec2.zero, Vec2.zero, Vec2.zero), Vec2.of(0, 1000), 0 ], | ||
])('nearestPointTo should return the nearest point and parameter value on %s to %s', (bezier, point, expectedParameter) => { | ||
@@ -68,2 +71,20 @@ const nearest = bezier.nearestPointTo(point); | ||
}); | ||
test.each([ | ||
new QuadraticBezier(Vec2.zero, Vec2.unitY, Vec2.unitY.times(2)), | ||
new QuadraticBezier(Vec2.zero, Vec2.unitX, Vec2.unitY), | ||
new QuadraticBezier(Vec2.zero, Vec2.unitY, Vec2.unitX), | ||
])('.derivativeAt should return a derivative vector with the correct direction (curve: %s)', (curve) => { | ||
for (let t = 0; t < 1; t += 0.1) { | ||
const derivative = curve.derivativeAt(t); | ||
const derivativeApprox = curve.at(t + 0.001).minus(curve.at(t - 0.001)); | ||
expect(derivativeApprox.normalized()).objEq(derivative.normalized(), 0.01); | ||
} | ||
}); | ||
test('should support Bezier-Bezier intersections', () => { | ||
const b1 = new QuadraticBezier(Vec2.zero, Vec2.unitX, Vec2.unitY); | ||
const b2 = new QuadraticBezier(Vec2.of(-1, 0.5), Vec2.of(0, 0.6), Vec2.of(1, 0.4)); | ||
expect(b1.intersectsBezier(b2)).toHaveLength(1); | ||
}); | ||
}); |
@@ -7,6 +7,5 @@ import { Point2, Vec2 } from '../Vec2'; | ||
/** | ||
* A wrapper around `bezier-js`'s quadratic Bézier. | ||
* Represents a 2D Bézier curve. | ||
* | ||
* This wrappper lazy-loads `bezier-js`'s Bézier and can perform some operations | ||
* without loading it at all (e.g. `normal`, `at`, and `approximateDistance`). | ||
* **Note**: Many Bézier operations use `bezier-js`'s. | ||
*/ | ||
@@ -13,0 +12,0 @@ export class QuadraticBezier extends BezierJSWrapper { |
@@ -7,3 +7,3 @@ import LineSegment2 from './LineSegment2'; | ||
/** An object that can be converted to a Rect2. */ | ||
/** An object that can be converted to a {@link Rect2}. */ | ||
export interface RectTemplate { | ||
@@ -18,3 +18,7 @@ x: number; | ||
// invariant: w ≥ 0, h ≥ 0, immutable | ||
/** | ||
* Represents a rectangle in 2D space, parallel to the XY axes. | ||
* | ||
* `invariant: w ≥ 0, h ≥ 0, immutable` | ||
*/ | ||
export class Rect2 extends Abstract2DShape { | ||
@@ -21,0 +25,0 @@ // Derived state: |
@@ -60,2 +60,3 @@ | ||
{ from: Vec3.of(-1, -10, 0), to: Vec3.of(1, 2, 0), expected: 148 }, | ||
{ from: Vec3.of(-1, -10, 0), to: Vec3.of(1, 2, 0), expected: 148 }, | ||
])( | ||
@@ -71,2 +72,17 @@ '.squareDistanceTo and .distanceTo should return correct square and euclidean distances (%j)', | ||
); | ||
test.each([ | ||
{ a: Vec3.of(1, 2, 3), b: Vec3.of(4, 5, 6), tolerance: 0.1, eq: false }, | ||
{ a: Vec3.of(1, 2, 3), b: Vec3.of(4, 5, 6), tolerance: 10, eq: true }, | ||
{ a: Vec3.of(1, 2, 3), b: Vec3.of(1, 2, 3), tolerance: 0, eq: true }, | ||
{ a: Vec3.of(1, 2, 3), b: Vec3.of(1, 2, 4), tolerance: 0, eq: false }, | ||
{ a: Vec3.of(1, 2, 3), b: Vec3.of(1, 4, 3), tolerance: 0, eq: false }, | ||
{ a: Vec3.of(1, 2, 3), b: Vec3.of(4, 2, 3), tolerance: 0, eq: false }, | ||
{ a: Vec3.of(1, 2, 3.0001), b: Vec3.of(1, 2, 3), tolerance: 1e-12, eq: false }, | ||
{ a: Vec3.of(1, 2, 3.0001), b: Vec3.of(1, 2, 3), tolerance: 1e-3, eq: true }, | ||
{ a: Vec3.of(1, 2.00001, 3.0001), b: Vec3.of(1.00001, 2, 3), tolerance: 1e-3, eq: true }, | ||
])('.eq should support tolerance (case %#)', ({ a, b, tolerance, eq }) => { | ||
expect(a.eq(b, tolerance)).toBe(eq); | ||
expect(b.eq(a, tolerance)).toBe(eq); | ||
}); | ||
}); |
@@ -247,3 +247,4 @@ | ||
* @example | ||
* ``` | ||
* ```ts,runnable,console | ||
* import { Vec3 } from '@js-draw/math'; | ||
* console.log(Vec3.of(1, 2, 3).map(val => val + 1)); // → Vec(2, 3, 4) | ||
@@ -276,9 +277,7 @@ * ``` | ||
public eq(other: Vec3, fuzz: number = 1e-10): boolean { | ||
for (let i = 0; i < 3; i++) { | ||
if (Math.abs(other.at(i) - this.at(i)) > fuzz) { | ||
return false; | ||
} | ||
} | ||
return true; | ||
return ( | ||
Math.abs(other.x - this.x) <= fuzz | ||
&& Math.abs(other.y - this.y) <= fuzz | ||
&& Math.abs(other.z - this.z) <= fuzz | ||
); | ||
} | ||
@@ -285,0 +284,0 @@ |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
610113
176
16382