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() /*