UNPKG

@js-draw/math

Version:
1,211 lines (1,210 loc) 55.8 kB
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, };