diff --git a/config/changelog.yaml b/config/changelog.yaml
index 496b6ca6c22..ad2170cd8d2 100644
--- a/config/changelog.yaml
+++ b/config/changelog.yaml
@@ -2,6 +2,7 @@ Unreleased:
Added:
core:
- The `Path.rotate()` method was added to the core API.
+ - The `Path.circleSegment()` method was added to the core API.
Changed:
brian:
diff --git a/designs/sandy/src/curved-waistband.mjs b/designs/sandy/src/curved-waistband.mjs
index dd16c5a3541..44c23224e81 100644
--- a/designs/sandy/src/curved-waistband.mjs
+++ b/designs/sandy/src/curved-waistband.mjs
@@ -89,11 +89,11 @@ export function draftCurvedWaistband({
)
points.centerNotch = new Path()
.move(points.ex1Rotated)
- .curve(points.ex1cFlippedRotated, points.ex2cFlippedRotated, points.ex2FlippedRotated)
+ .circleSegment(-(an + anExtra), points.center)
.shiftAlong(store.get('waistbandOverlap') / 2)
points.buttonNotch = new Path()
.move(points.ex2Rotated)
- .curve(points.ex2cRotated, points.ex1cRotated, points.ex1Rotated)
+ .circleSegment(an + anExtra, points.center)
.shiftAlong(store.get('waistbandOverlap'))
macro('sprinkle', {
snippet: 'notch',
diff --git a/markdown/dev/reference/api/path/circlesegment/en.md b/markdown/dev/reference/api/path/circlesegment/en.md
new file mode 100644
index 00000000000..00369994f6c
--- /dev/null
+++ b/markdown/dev/reference/api/path/circlesegment/en.md
@@ -0,0 +1,52 @@
+---
+title: Path.circleSegment()
+---
+
+The `Path.circleSegment()` method draws a circle segment
+starting from the current endpoint of the path around the given origin with a given angle.
+
+A positive angle results in a counter-clockwise arc.
+
+A negative angle results in a clockwise arc.
+
+
+The new endpoint of this path is the same point
+that
+```js
+path.end().rotate(deg, origin)
+```
+would return.
+
+
+## Signature
+
+```js
+Path path.circleSegment(deg, origin)
+```
+
+This method is chainable as it returns the `Path` object
+
+## Example
+
+
+```js
+({ Point, points, Path, paths, part }) => {
+
+points.from = new Point(10, 20)
+points.origin = new Point(40, 0)
+
+paths.line = new Path()
+.move(points.from)
+.circleSegment(90, points.origin)
+.setText("→ Path.circleSegment() →", "text-sm center fill-note")
+
+paths.helper = new Path()
+.move(paths.line.start())
+.line(points.origin)
+.line(paths.line.end())
+.setClass('dotted stroke-sm')
+
+return part
+}
+```
+
diff --git a/packages/core/src/path.mjs b/packages/core/src/path.mjs
index 3ffdd4b0981..774885f308c 100644
--- a/packages/core/src/path.mjs
+++ b/packages/core/src/path.mjs
@@ -2,6 +2,7 @@ import { Attributes } from './attributes.mjs'
import { Point } from './point.mjs'
import { Bezier } from 'bezier-js'
import {
+ deg2rad,
linesIntersect,
lineIntersectsCurve,
curvesIntersect,
@@ -200,6 +201,43 @@ Path.prototype.bbox = function () {
return __bbbbox(bbs)
}
+/**
+ * Adds a circle section to this path.
+ * Positive angles create a counter-clockwise arc starting from the current point,
+ * negative angles create a clockwise arc.
+ *
+ * Note: This is unrelated to SVG arc segments in paths, we approximate the circle segment using
+ * standard cubic Bézier curves
+ *
+ * @param {number} deg span of the new circle section in degrees.
+ * @param {Point} origin center point of the circle (rotation origin)
+ * @returns {Path} this
+ */
+Path.prototype.circleSegment = function (deg, origin) {
+ const radius = this.end().dist(origin)
+ // ensure a full circle gets 4 segments for a good approximation
+ // We could use more, but this is not necessary
+ const steps = Math.ceil(Math.abs(deg / 90))
+ const stepAngle = deg / steps
+ const stepAngleRad = deg2rad(stepAngle)
+
+ // magic formula, calculate distance of control points for a good circle segment approximation
+ const distance = radius * (4.0 / 3.0) * Math.tan(stepAngleRad / 4)
+
+ // Append individual segments for arc approximation
+ // steps is usually between 1 and 4
+ for (let i = 0; i < steps; i++) {
+ const startPoint = this.end()
+ const endPoint = startPoint.rotate(stepAngle, origin)
+ const startAngle = startPoint.angle(origin) - 90
+ const endAngle = endPoint.angle(origin) + 90
+ const cp1 = startPoint.shift(startAngle, distance)
+ const cp2 = endPoint.shift(endAngle, distance)
+ this.curve(cp1, cp2, endPoint)
+ }
+ return this
+}
+
/**
* Returns this after cleaning out in-place path operations
*
diff --git a/packages/core/tests/path.test.mjs b/packages/core/tests/path.test.mjs
index 8eefcda4fd1..accf8fc66a7 100644
--- a/packages/core/tests/path.test.mjs
+++ b/packages/core/tests/path.test.mjs
@@ -3,6 +3,60 @@ import { round, Path, Point } from '../src/index.mjs'
import { pathsProxy } from '../src/path.mjs'
describe('Path', () => {
+ describe('circleSegment', () => {
+ it('Should draw a circleSegment', () => {
+ const points = {}
+ points.origin = new Point(10, 20)
+ // radius = 100
+ points.start = points.origin.shift(77, 100)
+
+ const test = new Path().move(points.start).circleSegment(90, points.origin)
+
+ const endPoint = test.end()
+ const expectedEndPoint = points.start.rotate(90, points.origin)
+
+ expect(round(endPoint.x)).to.equal(round(expectedEndPoint.x))
+ expect(round(endPoint.y)).to.equal(round(expectedEndPoint.y))
+ expect(endPoint.sitsOn(expectedEndPoint)).to.equal(true)
+ expect(Math.round(test.length())).to.equal(Math.round(Math.PI * 50))
+ })
+
+ it('Should draw a circleSegment with negative angle', () => {
+ const points = {}
+ points.origin = new Point(10, 20)
+ // radius = 100
+ points.start = points.origin.shift(-122, 100)
+
+ const test = new Path().move(points.start).circleSegment(-45, points.origin)
+
+ const endPoint = test.end()
+ const expectedEndPoint = points.start.rotate(-45, points.origin)
+
+ expect(round(endPoint.x)).to.equal(round(expectedEndPoint.x))
+ expect(round(endPoint.y)).to.equal(round(expectedEndPoint.y))
+ expect(endPoint.sitsOn(expectedEndPoint)).to.equal(true)
+ expect(Math.round(test.length())).to.equal(Math.round(Math.PI * 25))
+ })
+
+ it('Should draw a full circle', () => {
+ const points = {}
+
+ points.origin = new Point(0, 0)
+ // radius = 100
+ points.start = points.origin.shift(0, 100)
+
+ const test = new Path().move(points.start).circleSegment(360, points.origin)
+
+ const endPoint = test.end()
+
+ expect(round(endPoint.x)).to.equal(round(points.start.x))
+ expect(round(endPoint.y)).to.equal(round(points.start.y))
+ expect(endPoint.sitsOn(points.start)).to.equal(true)
+ expect(Math.round(test.length())).to.equal(Math.round(Math.PI * 200))
+ expect(test.ops.length).to.equal(5) // 1 move + 4 cubic curves
+ })
+ })
+
describe('smurve', () => {
it('Should draw a smurve', () => {
const points = {}
diff --git a/plugins/plugin-ringsector/src/index.mjs b/plugins/plugin-ringsector/src/index.mjs
index 0567c86c556..bf5f9fe253a 100644
--- a/plugins/plugin-ringsector/src/index.mjs
+++ b/plugins/plugin-ringsector/src/index.mjs
@@ -10,35 +10,10 @@ export const getIds = (keys, id) => {
return ids
}
-/*
- * Helper method to calculate the arc
- */
-const roundExtended = (radius, angle = 90, utils) => {
- const arg = utils.deg2rad(angle / 2)
-
- return (radius * 4 * (1 - Math.cos(arg))) / Math.sin(arg) / 3
-}
-
/*
* Short IDs
*/
-const keys = [
- 'center',
- 'in1',
- 'in1c',
- 'in2',
- 'in2c',
- 'ex1',
- 'ex1c',
- 'ex2',
- 'ex2c',
- 'in2Flipped',
- 'in2cFlipped',
- 'in1cFlipped',
- 'ex1cFlipped',
- 'ex2cFlipped',
- 'ex2Flipped',
-]
+const keys = ['center', 'in1', 'in2', 'ex1', 'ex2', 'in2Flipped', 'ex2Flipped']
/*
* The plugin object itself
@@ -52,7 +27,7 @@ export const plugin = {
for (const id of Object.values(store.get([...storeRoot, 'paths']))) delete paths[id]
for (const id of Object.values(store.get([...storeRoot, 'points']))) delete points[id]
},
- ringsector: function (mc, { utils, Point, points, Path, paths, store }) {
+ ringsector: function (mc, { Point, points, Path, paths, store }) {
const {
angle,
insideRadius,
@@ -68,64 +43,30 @@ export const plugin = {
const ids = getIds(keys, id)
const pathIds = getIds(['path'], id)
- /**
- * Calculates the distance of the control point for the internal
- * and external arcs using bezierCircleExtended
- */
- const distIn = roundExtended(insideRadius, angle / 2, utils)
- const distEx = roundExtended(outsideRadius, angle / 2, utils)
// The centre of the circles
points[ids.center] = center.copy()
- /**
- * This function is expected to draft ring sectors for
- * angles up to 180%. Since roundExtended works
- * best for angles until 90º, we generate the ring
- * sector using the half angle and then duplicate it
- */
-
/**
* The first point of the internal arc, situated at
* a insideRadius distance below the centre
*/
points[ids.in1] = points[ids.center].shift(-90, insideRadius)
- /**
- * The control point for 'in1'. It's situated at a
- * distance $distIn calculated with bezierCircleExtended
- * and the line between it and 'in1' is perpendicular to
- * the line between 'in1' and the centre, so it's
- * shifted in the direction 0º
- */
- points[ids.in1c] = points[ids.in1].shift(0, distIn)
-
/**
* The second point of the internal arc, situated at
* a $insideRadius distance of the centre in the direction
* $angle/2 - 90º
*/
points[ids.in2] = points[ids.center].shift(angle / 2 - 90, insideRadius)
-
- /**
- * The control point for 'in2'. It's situated at a
- * distance $distIn calculated with bezierCircleExtended
- * and the line between it and 'in2' is perpendicular to
- * the line between 'in2' and the centre, so it's
- * shifted in the direction $angle/2 + 180º
- */
- points[ids.in2c] = points[ids.in2].shift(angle / 2 + 180, distIn)
-
/**
* The points for the external arc are generated in the
* same way, using $outsideRadius and $distEx instead
*/
points[ids.ex1] = points[ids.center].shift(-90, outsideRadius)
- points[ids.ex1c] = points[ids.ex1].shift(0, distEx)
points[ids.ex2] = points[ids.center].shift(angle / 2 - 90, outsideRadius)
- points[ids.ex2c] = points[ids.ex2].shift(angle / 2 + 180, distEx)
// Flip all the points to generate the full ring sector
- for (const id of ['in2', 'in2c', 'in1c', 'ex1c', 'ex2c', 'ex2']) {
+ for (const id of ['in2', 'ex2']) {
points[ids[id + 'Flipped']] = points[ids[id]].flipX(center)
}
@@ -148,12 +89,9 @@ export const plugin = {
// Construct the path of the full ring sector
paths[pathIds.path] = new Path()
.move(points[ids.ex2Flipped])
- .curve(points[ids.ex2cFlipped], points[ids.ex1cFlipped], points[ids.ex1])
- .curve(points[ids.ex1c], points[ids.ex2c], points[ids.ex2])
+ .circleSegment(angle, points[ids.center])
.line(points[ids.in2])
- .curve(points[ids.in2c], points[ids.in1c], points[ids.in1])
- .curve(points[ids.in1cFlipped], points[ids.in2cFlipped], points[ids.in2Flipped])
- .line(points[ids.ex2Flipped])
+ .circleSegment(-angle, points[ids.center])
.close()
/*