diff --git a/markdown/dev/reference/api/path/angleat/en.md b/markdown/dev/reference/api/path/angleat/en.md
new file mode 100644
index 00000000000..7068802c39c
--- /dev/null
+++ b/markdown/dev/reference/api/path/angleat/en.md
@@ -0,0 +1,54 @@
+---
+title: Path.angleAt()
+---
+
+The `Path.angleAt()` method returns the (tangent) angle of a path at a specific point.
+
+If the given point is a sharp corner, this method prefers returning the angle directly before the corner.
+
+If the given point does not lie (approximately) on the path, this method returns `false`.
+
+## Signature
+
+```js
+number|false path.angleAt(Point point)
+```
+
+## Example
+
+
+```js
+({ Point, points, Path, paths, snippets, Snippet, part }) => {
+
+points.A = new Point(45, 60)
+points.B = new Point(10, 30)
+points.BCp2 = new Point(40, 20)
+points.C = new Point(90, 30)
+points.CCp1 = new Point(50, -30)
+points.D = new Point(50, 80)
+points.DCp1 = new Point(70, 30)
+
+paths.demo = new Path()
+.move(points.D)
+.curve(points.DCp1, points.DCp1, points.C)
+.curve(points.CCp1, points.BCp2, points.B)
+.line(points.A)
+
+points.testPoint = paths.demo.shiftFractionAlong(0.55)
+snippets.point = new Snippet("notch", points.testPoint)
+
+let angle = paths.demo.angleAt(points.testPoint)
+//draw a tangent path
+paths.tangent = new Path()
+ .move(points.testPoint.shift(angle, -30))
+ .line(points.testPoint.shift(angle, 30))
+ .attr("class", "lining dashed")
+
+return part
+}
+```
+
+
+## Notes
+
+Keep in mind that calculations with Bézier curves are often approximations.
diff --git a/markdown/dev/reference/api/utils/curveparameterfrompoint/en.md b/markdown/dev/reference/api/utils/curveparameterfrompoint/en.md
new file mode 100644
index 00000000000..b1346a3d342
--- /dev/null
+++ b/markdown/dev/reference/api/utils/curveparameterfrompoint/en.md
@@ -0,0 +1,77 @@
+---
+title: utils.curveParameterFromPoint()
+---
+
+The `utils.curveParameterFromPoint()` function calculates where the point `check` lies on a
+curve described by points `start`, `cp1`, `cp2`, and `end`.
+
+For example a return value of 0 indicates that the given point is the start of the curve, a return value
+of 1 indicated that the given point is identical to the end of the curve.
+
+A return value of 0.5 indicates that the start point and the first control point had the same influence
+as the end point and the second control point, to create the checked point, but this doesn't necessarily mean
+that the point lies exactly half-way on the curve.
+
+This method returns `false` if the point isn't (approximately) located on the curve.
+
+## Signature
+
+```js
+number|false utils.curveParameterFromPoint(
+ Point start,
+ Point cp1,
+ Point cp2,
+ Point end,
+ Point check
+)
+```
+
+## Example
+
+
+```js
+({ Point, points, Path, paths, Snippet, snippets, getId, utils, part }) => {
+
+ points.start = new Point(10, 10)
+ points.cp1 = new Point(90, 30)
+ points.cp2 = new Point(10, 40)
+ points.end = new Point(90, 60)
+
+ const scatter = []
+ for (let i = 1; i < 19; i++) {
+ for (let j = 1; j < 14; j++) {
+ scatter.push(new Point(i * 10, j * 10))
+ }
+ }
+ let snippet
+ for (let point of scatter) {
+ let t = utils.curveParameterFromPoint(
+ points.start,
+ points.cp1,
+ points.cp2,
+ points.end,
+ point
+ )
+ if(t !== false) {
+ points[getId()] = point.addText(` ${Math.round(t * 100) / 100}`, 'text-sm')
+ snippets[getId()] = new Snippet('notch', point)
+ }
+ }
+ paths.curve = new Path()
+ .move(points.start)
+ .curve(points.cp1, points.cp2, points.end)
+ .addClass("fabric stroke-lg")
+
+ return part
+}
+```
+
+
+
+## Notes
+
+Keep in mind that calculations with Bézier curves are often approximations.
+
+This method is mostly used as internal building block for methods like
+`utils.pointOnCurve()`, `Path.split()` or `Path.angleAt()` and probably is not very relevant
+for direct usage from pattern code.
diff --git a/packages/core/src/path.mjs b/packages/core/src/path.mjs
index 8e9287cd4e9..08a4972fce1 100644
--- a/packages/core/src/path.mjs
+++ b/packages/core/src/path.mjs
@@ -6,7 +6,7 @@ import {
lineIntersectsCurve,
curvesIntersect,
pointOnLine,
- pointOnCurve,
+ curveParameterFromPoint,
curveEdge,
round,
__addNonEnumProp,
@@ -875,7 +875,7 @@ Path.prototype.smurve_ = function (to) {
}
/**
- * Splits path on point, and retuns both halves as Path instances
+ * Splits path on point, and returns both halves as Path instances
*
* @param {Point} point - The Point to split this Path on
* @return {Array} halves - An array holding the two Path instances that make the split halves
@@ -907,7 +907,13 @@ Path.prototype.split = function (point) {
break
}
} else if (path.ops[1].type === 'curve') {
- let t = pointOnCurve(path.ops[0].to, path.ops[1].cp1, path.ops[1].cp2, path.ops[1].to, point)
+ let t = curveParameterFromPoint(
+ path.ops[0].to,
+ path.ops[1].cp1,
+ path.ops[1].cp2,
+ path.ops[1].to,
+ point
+ )
if (t !== false) {
let curve = new Bezier(
{ x: path.ops[0].to.x, y: path.ops[0].to.y },
@@ -953,6 +959,51 @@ Path.prototype.split = function (point) {
return [firstHalf, secondHalf]
}
+/**
+ * Determines the angle (tangent) of this path at the given point. If the given point is a sharp corner of this path,
+ * this method returns the angle directly before the point.
+ *
+ * @param {Point} point - The Point to determine the angle of relative to this Path
+ * @return {number|false} the angle of degrees at that point or false if the given Point doesn't lie on this Path
+ */
+Path.prototype.angleAt = function (point) {
+ if (!(point instanceof Point))
+ this.log.error('Called `Path.angleAt(point)` but `point` is not a `Point` object')
+ let divided = this.divide()
+ for (let pi = 0; pi < divided.length; pi++) {
+ let path = divided[pi]
+ if (path.ops[1].type === 'line') {
+ if (pointOnLine(path.ops[0].to, path.ops[1].to, point)) {
+ return path.ops[0].to.angle(path.ops[1].to)
+ }
+ } else if (path.ops[1].type === 'curve') {
+ let t = curveParameterFromPoint(
+ path.ops[0].to,
+ path.ops[1].cp1,
+ path.ops[1].cp2,
+ path.ops[1].to,
+ point
+ )
+ if (t !== false) {
+ const curve = new Bezier(
+ { x: path.ops[0].to.x, y: path.ops[0].to.y },
+ { x: path.ops[1].cp1.x, y: path.ops[1].cp1.y },
+ { x: path.ops[1].cp2.x, y: path.ops[1].cp2.y },
+ { x: path.ops[1].to.x, y: path.ops[1].to.y }
+ )
+
+ let normal = curve.normal(t)
+
+ // atan2's first parameter is y, but we're swapping them because
+ // we're interested in the tangent angle, not normal
+ return (Math.atan2(normal.x, normal.y) / Math.PI) * 180
+ }
+ }
+ }
+
+ return false
+}
+
/**
* Returns the startpoint of this path
*
@@ -1018,7 +1069,13 @@ Path.prototype.trim = function () {
{ x: ops[1].cp2.x, y: ops[1].cp2.y },
{ x: ops[1].to.x, y: ops[1].to.y }
)
- let t = pointOnCurve(ops[0].to, ops[1].cp1, ops[1].cp2, ops[1].to, intersection)
+ let t = curveParameterFromPoint(
+ ops[0].to,
+ ops[1].cp1,
+ ops[1].cp2,
+ ops[1].to,
+ intersection
+ )
let split = curve.split(t)
let side
if (first) side = split.left
diff --git a/packages/core/src/utils.mjs b/packages/core/src/utils.mjs
index 22bc34fb6d5..6d8c8b559c6 100644
--- a/packages/core/src/utils.mjs
+++ b/packages/core/src/utils.mjs
@@ -541,16 +541,36 @@ export function pointOnBeam(from, to, check, precision = 1e6) {
/**
* Finds out whether a Point lies on a (cubic) Bezier curve
*
- * @param {Point} from - Start of the curve
+ * @param {Point} start - Start of the curve
* @param {Point} cp1 - Control point at the start of the curve
- * @param {Point} cp1 - Control point at the end of the curve
+ * @param {Point} cp2 - Control point at the end of the curve
* @param {Point} end - End of the curve
* @param {Point} check - Point to check
- * @return {bool} result - True of the Point is on the curve, false when not
+ * @return {boolean} result - True of the Point is on the curve, false when not
*/
export function pointOnCurve(start, cp1, cp2, end, check) {
- if (start.sitsOn(check)) return true
- if (end.sitsOn(check)) return true
+ return curveParameterFromPoint(start, cp1, cp2, end, check) !== false
+}
+
+/**
+ * Finds where a Point lies on a (cubic) Bezier curve and returns the curve parameter t of this position.
+ * For example a return value of 0 indicates that the given point is the start of the curve, a return value
+ * of 1 indicated that the given point is identical to the end of the curve.
+ *
+ * A return value of 0.5 indicates that the start point and the first control point had the same influence
+ * as the end point and the second control point, to create the point, but this doesn't necessarily mean
+ * that the point lies exactly half-way on the curve.
+ *
+ * @param {Point} start - Start of the curve
+ * @param {Point} cp1 - Control point at the start of the curve
+ * @param {Point} cp2 - Control point at the end of the curve
+ * @param {Point} end - End of the curve
+ * @param {Point} check - Point to check
+ * @return {false|number} result - relative position on the curve (value between 0 and 1), false when not on curve
+ */
+export function curveParameterFromPoint(start, cp1, cp2, end, check) {
+ if (start.sitsOn(check)) return 0
+ if (end.sitsOn(check)) return 1
let curve = new Bezier(
{ x: start.x, y: start.y },
{ x: cp1.x, y: cp1.y },
diff --git a/packages/core/tests/path.test.mjs b/packages/core/tests/path.test.mjs
index 4c57a7d7bd1..50562bd6f47 100644
--- a/packages/core/tests/path.test.mjs
+++ b/packages/core/tests/path.test.mjs
@@ -680,6 +680,33 @@ describe('Path', () => {
expect(halves[1].ops[0].to.y).to.equal(30)
})
+ it('Should determine the angle on a path', () => {
+ const a = new Point(0, 0)
+ const b = new Point(0, 40)
+ const c = new Point(40, 40)
+ const d = new Point(100, 40)
+ const e = new Point(100, 0)
+
+ const linePoint = new Point(80, 40)
+ const curvePoint = new Point(5, 35)
+
+ const path = new Path().move(a).curve(b, b, c).line(d).line(e)
+
+ let angleAtStart = path.angleAt(a)
+ let angleOnCurve = path.angleAt(curvePoint)
+ let angleOnJoint = path.angleAt(c)
+ let angleOnLine = path.angleAt(linePoint)
+ let angleOnCorner = path.angleAt(d)
+ let angleOnEnd = path.angleAt(e)
+
+ expect(angleAtStart).to.equal(-90)
+ expect(angleOnCurve).to.equal(-45)
+ expect(angleOnJoint).to.equal(0)
+ expect(angleOnLine).to.equal(0)
+ expect(angleOnCorner).to.equal(0)
+ expect(angleOnEnd).to.equal(90)
+ })
+
it('Should trim a path when lines overlap', () => {
const A = new Point(0, 0)
const B = new Point(100, 100)