diff --git a/CHANGELOG.md b/CHANGELOG.md
index af3db810a15..4f7953442e8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -87,6 +87,7 @@
#### Changed
- Rephrased flag message when expand is off to avoid confusion about included seam allowance. Fixes
+ - The skirt and curved waistband are now constructed with the ringsector macro
### shin
@@ -124,6 +125,12 @@
- First release of the plugin providing the default packing implementation
+### plugin-ringsector
+
+#### Added
+
+ - First release of the plugin facilitating drafting a ring sector
+
### core
#### Added
diff --git a/config/changelog.yaml b/config/changelog.yaml
index 71fc4067c02..af9c4c82c67 100644
--- a/config/changelog.yaml
+++ b/config/changelog.yaml
@@ -4,6 +4,8 @@ Unreleased:
- Allow plugins to provide their own packing implementation
plugin-bin-pack:
- First release of the plugin providing the default packing implementation
+ plugin-ringsector:
+ - First release of the plugin facilitating drafting a ring sector
Changed:
aaron:
@@ -33,6 +35,7 @@ Unreleased:
- Rephrased flag message when expand is off to avoid confusion about included seam allowance. Fixes #5057
sandy:
- Rephrased flag message when expand is off to avoid confusion about included seam allowance. Fixes #5057
+ - The skirt and curved waistband are now constructed with the ringsector macro
shin:
- Rephrased flag message when expand is off to avoid confusion about included seam allowance. Fixes #5057
sven:
diff --git a/config/software/plugins.json b/config/software/plugins.json
index a785f1c13c4..91eb1942069 100644
--- a/config/software/plugins.json
+++ b/config/software/plugins.json
@@ -8,6 +8,7 @@
"plugin-i18n": "A FreeSewing plugin for pattern translation",
"plugin-measurements": "A FreeSewing plugin that adds additional measurements that can be calculated from existing ones",
"plugin-mirror": "A FreeSewing plugin to mirror points or paths",
+ "plugin-ringsector": "A FreeSewing plugin to draft a ring sector",
"plugin-round": "A FreeSewing plugin to round corners",
"plugin-sprinkle": "A FreeSewing plugin to bulk-add snippets to your pattern",
"plugin-svgattr": "A FreeSewing plugin to set SVG attributes",
diff --git a/designs/sandy/src/curved-waistband.mjs b/designs/sandy/src/curved-waistband.mjs
index 45ab5bee9e0..60c500163ff 100644
--- a/designs/sandy/src/curved-waistband.mjs
+++ b/designs/sandy/src/curved-waistband.mjs
@@ -1,5 +1,3 @@
-import { draftRingSector } from './shared.mjs'
-
export function draftCurvedWaistband({
utils,
store,
@@ -32,18 +30,35 @@ export function draftCurvedWaistband({
store.get('waistbandOverlap') / (rad + absoluteOptions.waistbandWidth)
)
- // The curved waistband is shown with no rotation
- const rot = 0
- // Call draftRingSector to draft the part
- paths.seam = draftRingSector(
- part,
- rot,
- an + anExtra,
- rad,
- rad + absoluteOptions.waistbandWidth
- ).attr('class', 'fabric')
+ // Call the RingSector macro to draft the waistband
+ macro('ringsector', {
+ angle: an + anExtra,
+ insideRadius: rad,
+ outsideRadius: rad + absoluteOptions.waistbandWidth,
+ })
+ const storeRoot = [
+ 'parts',
+ part.name,
+ 'macros',
+ '@freesewing/plugin-ringsector',
+ 'ids',
+ 'ringsector',
+ ]
+ const pathId = store.get([...storeRoot, 'paths', 'path'])
+ paths.seam = paths[pathId].clone().addClass('fabric')
+ paths[pathId].hide()
- if (sa) paths.sa = paths.seam.offset(sa * -1).attr('class', 'fabric sa')
+ /*
+ * Macros ensure they can be used more than once in a part, and will generate unique (and complex)
+ * point names. Since we're only calling the macro once here, we will simplify these names
+ */
+ for (const [shortId, uid] of Object.entries(store.get([...storeRoot, 'points']))) {
+ points[shortId] = points[uid].copy()
+ // Some points are rotated, we need those too
+ if (points[uid + 'Rotated']) points[shortId + 'Rotated'] = points[uid + 'Rotated'].copy()
+ }
+
+ if (sa) paths.sa = paths.seam.offset(sa * -1).addClass('fabric sa')
/*
* Annotations
@@ -55,7 +70,7 @@ export function draftCurvedWaistband({
points.gridAnchor = points.in1.clone()
// Title
- points.title = points.in1Rotated.shiftFractionTowards(points.ex1Rotated, 0.5).shift(0, 25)
+ points.title = points.ex2Flipped.shiftFractionTowards(points.ex2, 0.5)
macro('title', {
at: points.title,
nr: 2,
@@ -64,42 +79,33 @@ export function draftCurvedWaistband({
})
// Grainline
- points.grainlineFrom = utils.curveIntersectsY(
- points.ex2FlippedRotated,
- points.ex2CFlippedRotated,
- points.ex1CFlippedRotated,
- points.ex1Rotated,
- points.title.y
- )
- points.grainlineTo = points.grainlineFrom.flipX()
macro('grainline', {
- from: points.grainlineFrom,
- to: points.grainlineTo,
+ from: points.ex2Flipped,
+ to: points.ex2,
})
// Buttons / Notches
if (store.get('waistbandOverlap') >= options.minimumOverlap) {
- points.pivot = points.in2Rotated.shiftFractionTowards(points.ex2Rotated, 0.5)
+ points.pivot = points.in2.shiftFractionTowards(points.ex2, 0.5)
points.button = points.pivot
- .shiftTowards(points.ex2Rotated, store.get('waistbandOverlap') / 2)
+ .shiftTowards(points.ex2, store.get('waistbandOverlap') / 2)
.rotate(-90, points.pivot)
points.buttonhole = points.button.flipX()
snippets.button = new Snippet('button', points.button)
- snippets.buttonhole = new Snippet('buttonhole', points.buttonhole).attr(
- 'data-rotate',
- -1 * points.ex2FlippedRotated.angle(points.in2FlippedRotated)
+ snippets.buttonhole = new Snippet('buttonhole', points.buttonhole).rotate(
+ points.in2.angle(points.ex2)
)
points.centerNotch = new Path()
.move(points.ex1Rotated)
- .curve(points.ex1CFlippedRotated, points.ex2CFlippedRotated, points.ex2FlippedRotated)
+ .curve(points.ex1cFlippedRotated, points.ex2cFlippedRotated, points.ex2FlippedRotated)
.shiftAlong(store.get('waistbandOverlap') / 2)
points.buttonNotch = new Path()
.move(points.ex2Rotated)
- .curve(points.ex2CRotated, points.ex1CRotated, points.ex1Rotated)
+ .curve(points.ex2cRotated, points.ex1cRotated, points.ex1Rotated)
.shiftAlong(store.get('waistbandOverlap'))
macro('sprinkle', {
snippet: 'notch',
- on: ['centerNotch', 'buttonNotch', 'ex2FlippedRotated'],
+ on: ['centerNotch', 'buttonNotch', 'ex2Flipped'],
})
}
@@ -107,25 +113,25 @@ export function draftCurvedWaistband({
macro('hd', {
id: 'wTop',
from: points.in2FlippedRotated,
- to: points.in2Rotated,
- y: points.in2Rotated.y - sa - 15,
+ to: points.in2,
+ y: points.in2.y - sa - 15,
})
macro('hd', {
from: points.ex2FlippedRotated,
id: 'wFull',
- to: points.ex2Rotated,
- y: points.in2Rotated.y - sa - 30,
+ to: points.ex2,
+ y: points.in2.y - sa - 30,
})
macro('vd', {
id: 'hFull',
- from: points.ex1Rotated,
- to: points.in2Rotated,
- x: points.in2Rotated.x + sa + 30,
+ from: points.ex1,
+ to: points.in2,
+ x: points.in2.x + sa + 40,
})
macro('ld', {
id: 'lWidth',
- from: points.ex2Rotated,
- to: points.in2Rotated,
+ from: points.ex2,
+ to: points.in2,
d: -1 * sa - 15,
})
diff --git a/designs/sandy/src/shared.mjs b/designs/sandy/src/shared.mjs
deleted file mode 100644
index ee13eb7f5b2..00000000000
--- a/designs/sandy/src/shared.mjs
+++ /dev/null
@@ -1,105 +0,0 @@
-export const draftRingSector = (part, rot, an, radIn, radEx, rotate = false) => {
- const { utils, Point, points, Path } = part.shorthand()
-
- const roundExtended = (radius, angle = 90) => {
- const arg = utils.deg2rad(angle / 2)
-
- return (radius * 4 * (1 - Math.cos(arg))) / Math.sin(arg) / 3
- }
-
- /**
- * Calculates the distance of the control point for the internal
- * and external arcs using bezierCircleExtended
- */
- const distIn = roundExtended(radIn, an / 2)
- const distEx = roundExtended(radEx, an / 2)
- // The centre of the circles
- points.center = new Point(0, 0)
-
- /**
- * 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 radIn distance below the centre
- */
- points.in1 = points.center.shift(-90, radIn)
-
- /**
- * 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.in1C = points.in1.shift(0, distIn)
-
- /**
- * The second point of the internal arc, situated at
- * a $radIn distance of the centre in the direction
- * $an/2 - 90º
- */
- points.in2 = points.center.shift(an / 2 - 90, radIn)
-
- /**
- * 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 $an/2 + 180º
- */
- points.in2C = points.in2.shift(an / 2 + 180, distIn)
-
- /**
- * The points for the external arc are generated in the
- * same way, using $radEx and $distEx instead
- */
- points.ex1 = points.center.shift(-90, radEx)
- points.ex1C = points.ex1.shift(0, distEx)
- points.ex2 = points.center.shift(an / 2 - 90, radEx)
- points.ex2C = points.ex2.shift(an / 2 + 180, distEx)
-
- // Flip all the points to generate the full ring sector
- for (const id of ['in2', 'in2C', 'in1C', 'ex1C', 'ex2C', 'ex2'])
- points[id + 'Flipped'] = points[id].flipX()
-
- // Rotate all the points an angle rot
- for (const id of [
- 'in1',
- 'in1C',
- 'in2',
- 'in2C',
- 'ex1',
- 'ex1C',
- 'ex2',
- 'ex2C',
- 'in2Flipped',
- 'in2CFlipped',
- 'in1CFlipped',
- 'ex1CFlipped',
- 'ex2CFlipped',
- 'ex2Flipped',
- ])
- points[id + 'Rotated'] = points[id].rotate(rot, points.center)
-
- if (rotate) {
- // Rotate all points so the line from in1Rotated to ex1Rotated is vertical
- const deg = 270 - points.in2Flipped.angle(points.ex2Flipped)
- for (const id in points) {
- points[id] = points[id].rotate(deg, points.in2Flipped)
- }
- }
- // Return the path of the full ring sector
- return new Path()
- .move(points.in2Flipped)
- .curve(points.in2CFlipped, points.in1CFlipped, points.in1)
- .curve(points.in1C, points.in2C, points.in2)
- .line(points.ex2)
- .curve(points.ex2C, points.ex1C, points.ex1)
- .curve(points.ex1CFlipped, points.ex2CFlipped, points.ex2Flipped)
- .close()
-}
diff --git a/designs/sandy/src/skirt.mjs b/designs/sandy/src/skirt.mjs
index 52d3c95095b..5d12d36e3ec 100644
--- a/designs/sandy/src/skirt.mjs
+++ b/designs/sandy/src/skirt.mjs
@@ -1,6 +1,6 @@
-import { draftRingSector } from './shared.mjs'
import { pctBasedOn } from '@freesewing/core'
import { elastics } from '@freesewing/snapseries'
+import { ringsectorPlugin } from '@freesewing/plugin-ringsector'
function sandySkirt({
utils,
@@ -85,29 +85,50 @@ function sandySkirt({
const radiusHem =
radiusWaist + store.get('fullLength') * options.lengthBonus - absoluteOptions.waistbandWidth
- /**
- * The ring sector will be rotated an angle an/2 so we
- * display the part with one edge of the skirt vertical
- */
- const rot = an / 2
+ // Call the RingSector macro to draft the part
+ macro('ringsector', {
+ angle: an,
+ insideRadius: radiusWaist,
+ outsideRadius: radiusHem,
+ rotate: true,
+ })
+ const storeRoot = [
+ 'parts',
+ part.name,
+ 'macros',
+ '@freesewing/plugin-ringsector',
+ 'ids',
+ 'ringsector',
+ ]
+ const pathId = store.get([...storeRoot, 'paths', 'path'])
+ paths.seam = paths[pathId].clone().addClass('fabric')
+ paths[pathId].hide()
- // Call draftRingSector to draft the part
- paths.seam = draftRingSector(part, rot, an, radiusWaist, radiusHem, true).attr('class', 'fabric')
+ /*
+ * Macros ensure they can be used more than once in a part, and will generate unique (and complex)
+ * point names. Since we're only calling the macro once here, we will simplify these names
+ */
+ for (const [shortId, uid] of Object.entries(store.get([...storeRoot, 'points']))) {
+ points[shortId] = points[uid].copy()
+ // Some points are rotated, we need those too
+ if (points[uid + 'Rotated']) points[shortId + 'Rotated'] = points[uid + 'Rotated'].copy()
+ }
// Anchor samples to the centre of the waist
points.gridAnchor = points.in2Flipped.clone()
if (sa) {
paths.hemBase = new Path()
- .move(points.ex1Rotated)
- .curve(points.ex1CFlippedRotated, points.ex2CFlippedRotated, points.ex2FlippedRotated)
- .curve(points.ex1CFlipped, points.ex2CFlipped, points.ex2Flipped)
+ .move(points.ex2)
+ .curve(points.ex2c, points.ex1c, points.ex1)
+ .curve(points.ex1cFlipped, points.ex2cFlipped, points.ex2Flipped)
.offset(store.get('fullLength') * options.lengthBonus * options.hemWidth * -1)
paths.saBase = new Path()
.move(points.in2Flipped)
- .curve(points.in2CFlipped, points.in1CFlipped, points.in2FlippedRotated)
- .curve(points.in2CFlippedRotated, points.in1CFlippedRotated, points.in1Rotated)
- if (!options.seamlessFullCircle) paths.saBase = paths.saBase.line(points.ex1Rotated)
+ .curve(points.in2cFlipped, points.in1cFlipped, points.in1)
+ .curve(points.in1c, points.in2c, points.in2)
+
+ if (!options.seamlessFullCircle) paths.saBase = paths.saBase.line(points.ex2)
paths.saBase = paths.saBase.offset(sa * -1)
paths.hemBase.hide()
@@ -118,8 +139,8 @@ function sandySkirt({
.move(points.in2Flipped)
.line(paths.saBase.start())
.join(paths.saBase)
- .line(points.in1Rotated)
- .move(points.ex1Rotated)
+ .line(points.in2)
+ .move(points.ex2)
.line(paths.hemBase.start())
.join(paths.hemBase)
.line(points.ex2Flipped)
@@ -150,9 +171,10 @@ function sandySkirt({
})
if (options.seamlessFullCircle) {
macro('cutonfold', {
- from: points.ex1Rotated,
- to: points.in1Rotated,
+ from: points.ex2,
+ to: points.in2,
id: 'double',
+ reverse: true,
})
}
@@ -171,7 +193,7 @@ function sandySkirt({
// Notches
macro('sprinkle', {
snippet: 'notch',
- on: ['in1Rotated', 'gridAnchor'],
+ on: ['in2', 'gridAnchor'],
})
snippets.center = new Snippet('bnotch', points.center)
@@ -244,5 +266,6 @@ export const skirt = {
menu: 'fit',
},
},
+ plugins: ringsectorPlugin,
draft: sandySkirt,
}
diff --git a/markdown/dev/reference/macros/ringsector/en.md b/markdown/dev/reference/macros/ringsector/en.md
new file mode 100644
index 00000000000..a637abb3f46
--- /dev/null
+++ b/markdown/dev/reference/macros/ringsector/en.md
@@ -0,0 +1,92 @@
+---
+title: ringsector
+---
+
+The `ringsector` macro drafts a ring sector, which is like a part of a donut
+with an inside and outside radius. It is particularly useful for drafting
+curved waistbands, circle skirts, and so on.
+
+It is provided by the [ringsector plugin](/reference/plugins/ringsector).
+
+
+##### Not a core-plugins macro
+
+The `ringsector` macro is not provided by the [core-plugins](/reference/plugins/core),
+so you need to load the [ringsector plugin](/reference/plugins/ringsector) explicitly
+if you want to use it.
+
+
+## Signature
+
+```js
+macro('ringsector', {
+ Point center = new Point(0,0),
+ Number angle,
+ Number insideRadius,
+ Number outsideRadius,
+ Boolean rotate = false,
+ String id='ringsector',
+})
+```
+
+## Example
+
+
+```js
+({ Point, macro, Path, paths, part }) => {
+
+ macro('ringsector', {
+ angle: 60,
+ insideRadius: 30,
+ outsideRadius: 45,
+ })
+
+ return part
+}
+```
+
+
+## Configuration
+
+| Property | Default | Type | Description |
+|---------------:|-------------------|------------|-------------|
+| `center` | `new Point(0,0)` | [Point][1] | The center point of the ring sector |
+| `angle` | | Number | The angle the ring sector should cover |
+| `insideRadius` | | Number | The inside radius of the ring sector |
+| `outsideRadius` | | Number | The outside radius of the ring sector |
+| `rotate` | `false` | Boolean | Whether or not to rotate the ringsector so one of its sides is vertical (see [example below](#example-when-rotatetrue)) |
+| `id` | `ringsector` | String | The id to use in auto-generate macro points and paths |
+
+[1]: /reference/api/point
+
+## Notes
+
+### Nodes generated by this macro
+
+This macro will add points and a single path to your part.
+Their IDs will be saved in store under:
+`parts.{part.name}.macros.@freesewing/plugin-ringsector.ids.{id}`
+
+### Removing a ring sector
+
+If you inherit a part with a ring sector drafted by this macro and you'd like to remove it,
+
+you can do so with [the rmringsector macro](/reference/macros/rmringsector).
+### Example when rotate=true
+
+
+```js
+({ Point, macro, Path, paths, part }) => {
+
+ macro('ringsector', {
+ angle: 60,
+ insideRadius: 30,
+ outsideRadius: 45,
+ rotate: true,
+ })
+
+ return part
+}
+```
+
+
diff --git a/markdown/dev/reference/macros/rmringsector/en.md b/markdown/dev/reference/macros/rmringsector/en.md
new file mode 100644
index 00000000000..b4f428e6db2
--- /dev/null
+++ b/markdown/dev/reference/macros/rmringsector/en.md
@@ -0,0 +1,56 @@
+---
+title: rmringsector
+---
+
+The `rmringsector` macro removes the nodes added by [the ringsector macro](/reference/macros/ringsector).
+It is the recommended way to remove (the effects of) a `ringsector` macro.
+
+It is provided by the [ringsector plugin](/reference/plugins/ringsector).
+
+
+##### Not a core-plugins macro
+
+The `rmringsector` macro is not provided by the [core-plugins](/reference/plugins/core),
+so you need to load the [ringsector plugin](/reference/plugins/ringsector) explicitly
+if you want to use it.
+
+
+## Signature
+
+```js
+macro('rmringsector', String id = 'ringsector')
+```
+
+## Example
+
+
+```js
+({ Point, macro, Path, paths, part }) => {
+
+ macro('ringsector', {
+ angle: 60,
+ insideRadius: 30,
+ outsideRadius: 45,
+ })
+ macro('rmringsector')
+
+ return part
+}
+```
+
+
+## Configuration
+
+| Property | Default | Type | Description |
+|---------:|--------------|--------|-------------|
+| `id` | `ringsector` | String | The id of the ringsector macro to remove |
+
+## Notes
+
+### Nodes removed by this macro
+
+This macro will remove points and a single path from your part.
+Their IDs have been saved in store under:
+`parts.{part.name}.macros.@freesewing/plugin-ringsector.ids.{id}`
+by the [the ringsector macro](/reference/macros/ringsector).
+
diff --git a/markdown/dev/reference/plugins/ringsector/en.md b/markdown/dev/reference/plugins/ringsector/en.md
new file mode 100644
index 00000000000..e6cbeaaa907
--- /dev/null
+++ b/markdown/dev/reference/plugins/ringsector/en.md
@@ -0,0 +1,30 @@
+---
+title: plugin-ringsector
+---
+
+Published as [@freesewing/plugin-ringsector][1], this plugin facilitates
+drafting a ring sector (like a part of a donut).
+It is particularly usefor for drafting curved waistband, circle skirts, and so on.
+
+## Provides
+
+- [The ringsector macro](/reference/macros/ringsector)
+- [The rmringsector macro](/reference/macros/rmringsector)
+
+## Installation
+
+```sh
+npm install @freesewing/plugin-ringsector
+```
+
+## Usage
+
+You should [add it as a part plugin](/reference/api/part/config/plugins).
+Refer to the documentation of [the provided macros](#provides) for details on how to use them.
+
+To import the plugin for use:
+```js
+import { plugin as ringsectorPlugin } from '@freesewing/plugin-ringsector'
+```
+
+[1]: https://www.npmjs.com/package/@freesewing/plugin-ringsector
diff --git a/plugins/plugin-ringsector/CHANGELOG.md b/plugins/plugin-ringsector/CHANGELOG.md
new file mode 100644
index 00000000000..a5e10ff0f7a
--- /dev/null
+++ b/plugins/plugin-ringsector/CHANGELOG.md
@@ -0,0 +1,17 @@
+# Change log for: @freesewing/plugin-ringsector
+
+
+## 3.0.0 (2022-09-30)
+
+### Changed
+
+ - All FreeSewing pacakges are now ESM only.
+ - All FreeSewing pacakges now use named exports.
+ - Dropped support for NodeJS 14. NodeJS 18 (LTS/hydrogen) or more recent is now required.
+
+
+This is the **initial release**, and the start of this change log.
+
+> Prior to version 2, FreeSewing was not a JavaScript project.
+> As such, that history is out of scope for this change log.
+
diff --git a/plugins/plugin-ringsector/README.md b/plugins/plugin-ringsector/README.md
new file mode 100644
index 00000000000..fa0e078df04
--- /dev/null
+++ b/plugins/plugin-ringsector/README.md
@@ -0,0 +1,143 @@
+
+
+
+
+
+
+
+
+
+
+
+
+# @freesewing/plugin-ringsector
+
+A FreeSewing plugin to draft a ring sector
+
+
+
+
+## What am I looking at? 🤔
+
+This repository is the FreeSewing *monorepo* holding all FreeSewing's websites, documentation, designs, plugins, and other NPM packages.
+
+This folder holds: @freesewing/plugin-ringsector
+
+If you're not entirely sure what to do or how to start, type this command:
+
+```
+npm run tips
+```
+
+> If you don't want to set up a dev environment, you can run it in your browser:
+>
+> [](https://gitpod.io/#https://github.com/freesewing/freesewing)
+>
+> We recommend that you fork our repository and then
+> put `gitpod.io/# to start up a browser-based dev environment of your own.
+
+## About FreeSewing 💀
+
+Where the world of makers and developers collide, that's where you'll find FreeSewing.
+
+If you're a maker, checkout [freesewing.org](https://freesewing.org/) where you can generate
+sewing patterns adapted to your measurements.
+
+If you're a developer, the FreeSewing documentation lives at [freesewing.dev](https://freesewing.dev/).
+The FreeSewing [core library](https://freesewing.dev/reference/api/) is a *batteries-included* toolbox
+for parametric design of sewing patterns. But FreeSewing also provides a range
+of [plugins](https://freesewing.dev/reference/plugins/) that further extend the
+functionality of the platform.
+
+If you have NodeJS installed, you can try it right now by running:
+
+```bash
+npx create-freesewing-pattern
+```
+
+Getting started guides are available for:
+- [Linux](https://freesewing.dev/tutorials/getting-started-linux/)
+- [MacOS](https://freesewing.dev/tutorials/getting-started-mac/)
+- [Windows](https://freesewing.dev/tutorials/getting-started-windows/)
+
+The [pattern design tutorial](https://freesewing.dev/tutorials/pattern-design/) will
+show you how to create your first parametric design.
+
+## Support FreeSewing: Become a patron 🥰
+
+FreeSewing is an open source project maintained by Joost De Cock and financially supported by the FreeSewing patrons.
+
+If you feel FreeSewing is worthwhile, and you can spend a few coind without
+hardship, then you should [join us and become a patron](https://freesewing.org/community/join).
+
+## Links 👩💻
+
+**Official channels**
+
+ - 💻 Makers website: [FreeSewing.org](https://freesewing.org)
+ - 💻 Developers website: [FreeSewing.dev](https://freesewing.dev)
+ - ✅ [Support](https://github.com/freesewing/freesewing/issues/new/choose),
+ [Issues](https://github.com/freesewing/freesewing/issues) &
+ [Discussions](https://github.com/freesewing/freesewing/discussions) on
+ [GitHub](https://github.com/freesewing/freesewing)
+
+**Social media**
+
+ - 🐦 Twitter: [@freesewing_org](https://twitter.com/freesewing_org)
+ - 📷 Instagram: [@freesewing_org](https://instagram.com/freesewing_org)
+
+**Places the FreeSewing community hangs out**
+
+ - 💬 [Discord](https://discord.freesewing.org/)
+ - 💬 [Facebook](https://www.facebook.com/groups/627769821272714/)
+ - 💬 [Reddit](https://www.reddit.com/r/freesewing/)
+
+## License: MIT 🤓
+
+© [Joost De Cock](https://github.com/joostdecock).
+See [the license file](https://github.com/freesewing/freesewing/blob/develop/LICENSE) for details.
+
+## Where to get help 🤯
+
+For [Support](https://github.com/freesewing/freesewing/issues/new/choose),
+please use the [Issues](https://github.com/freesewing/freesewing/issues) &
+[Discussions](https://github.com/freesewing/freesewing/discussions) on
+[GitHub](https://github.com/freesewing/freesewing).
+
diff --git a/plugins/plugin-ringsector/build.mjs b/plugins/plugin-ringsector/build.mjs
new file mode 100644
index 00000000000..99ace216bc8
--- /dev/null
+++ b/plugins/plugin-ringsector/build.mjs
@@ -0,0 +1,35 @@
+/* This script will build the package with esbuild */
+import esbuild from 'esbuild'
+import pkg from './package.json' assert { type: 'json' }
+
+// Create banner based on package info
+const banner = `/**
+ * ${pkg.name} | v${pkg.version}
+ * ${pkg.description}
+ * (c) ${new Date().getFullYear()} ${pkg.author}
+ * @license ${pkg.license}
+ */`
+
+// Shared esbuild options
+const options = {
+ banner: { js: banner },
+ bundle: true,
+ entryPoints: ['src/index.mjs'],
+ format: 'esm',
+ outfile: 'dist/index.mjs',
+ external: ['@freesewing'],
+ metafile: process.env.VERBOSE ? true : false,
+ minify: process.env.NO_MINIFY ? false : true,
+ sourcemap: true,
+}
+
+// Let esbuild generate the build
+const build = async () => {
+ const result = await esbuild.build(options).catch(() => process.exit(1))
+
+ if (process.env.VERBOSE) {
+ const info = await esbuild.analyzeMetafile(result.metafile)
+ console.log(info)
+ }
+}
+build()
diff --git a/plugins/plugin-ringsector/data.mjs b/plugins/plugin-ringsector/data.mjs
new file mode 100644
index 00000000000..f376317ed3d
--- /dev/null
+++ b/plugins/plugin-ringsector/data.mjs
@@ -0,0 +1,4 @@
+// This file is auto-generated | All changes you make will be overwritten.
+export const name = '@freesewing/plugin-ringsector'
+export const version = '3.0.0'
+export const data = { name, version }
diff --git a/plugins/plugin-ringsector/package.json b/plugins/plugin-ringsector/package.json
new file mode 100644
index 00000000000..3b08465f7ed
--- /dev/null
+++ b/plugins/plugin-ringsector/package.json
@@ -0,0 +1,71 @@
+{
+ "name": "@freesewing/plugin-ringsector",
+ "version": "3.0.0",
+ "description": "A FreeSewing plugin to draft a ring sector",
+ "author": "Joost De Cock (https://github.com/joostdecock)",
+ "homepage": "https://freesewing.org/",
+ "repository": "github:freesewing/freesewing",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/freesewing/freesewing/issues"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://freesewing.org/patrons/join"
+ },
+ "keywords": [
+ "freesewing",
+ "plugin",
+ "sewing pattern",
+ "sewing",
+ "design",
+ "parametric design",
+ "made to measure",
+ "diy",
+ "fashion"
+ ],
+ "type": "module",
+ "module": "dist/index.mjs",
+ "exports": {
+ ".": {
+ "internal": "./src/index.mjs",
+ "default": "./dist/index.mjs"
+ }
+ },
+ "scripts": {
+ "build": "node build.mjs",
+ "build:all": "yarn build",
+ "clean": "rimraf dist",
+ "mbuild": "NO_MINIFY=1 node build.mjs",
+ "symlink": "mkdir -p ./node_modules/@freesewing && cd ./node_modules/@freesewing && ln -s -f ../../../* . && cd -",
+ "test": "npx mocha tests/*.test.mjs",
+ "vbuild": "VERBOSE=1 node build.mjs",
+ "lab": "cd ../../sites/lab && yarn start",
+ "tips": "node ../../scripts/help.mjs",
+ "lint": "npx eslint 'src/**' 'tests/*.mjs'",
+ "prettier": "npx prettier --write 'src/*.mjs' 'tests/*.mjs'",
+ "testci": "NODE_OPTIONS=\"--conditions=internal\" npx mocha tests/*.test.mjs --reporter ../../tests/reporters/terse.js",
+ "wbuild": "node build.mjs",
+ "wbuild:all": "yarn wbuild"
+ },
+ "peerDependencies": {
+ "@freesewing/core": "3.0.0"
+ },
+ "dependencies": {},
+ "devDependencies": {
+ "mocha": "10.2.0",
+ "chai": "4.3.10"
+ },
+ "files": [
+ "dist/*",
+ "README.md"
+ ],
+ "publishConfig": {
+ "access": "public",
+ "tag": "latest"
+ },
+ "engines": {
+ "node": "18",
+ "npm": "9"
+ }
+}
diff --git a/plugins/plugin-ringsector/src/index.mjs b/plugins/plugin-ringsector/src/index.mjs
new file mode 100644
index 00000000000..03e13214a20
--- /dev/null
+++ b/plugins/plugin-ringsector/src/index.mjs
@@ -0,0 +1,171 @@
+import { name, version } from '../data.mjs'
+
+/*
+ * Helper method to get the various IDs for a macro
+ */
+export const getIds = (keys, id) => {
+ const ids = {}
+ for (const key of keys) ids[key] = `__macro_ringsector_${id}_${key}`
+
+ 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',
+]
+
+/*
+ * The plugin object itself
+ */
+export const plugin = {
+ name,
+ version,
+ macros: {
+ rmringsector: function (id = 'ringsector', { points, paths, store, part }) {
+ const storeRoot = ['parts', part.name, 'macros', '@freesewing/plugin-ringsector', 'ids', id]
+ 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, part }) {
+ const {
+ angle,
+ insideRadius,
+ outsideRadius,
+ rotate = false,
+ center = new Point(0, 0),
+ id = 'ringsector',
+ } = mc
+
+ /*
+ * Get the list of IDs
+ */
+ const ids = getIds(keys, id)
+ const pathId = getIds(['path'], id).path
+
+ /**
+ * 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']) {
+ points[ids[id + 'Flipped']] = points[ids[id]].flipX()
+ }
+
+ // Rotate all the points angle/2
+ for (const id of keys) {
+ points[ids[id] + 'Rotated'] = points[ids[id]].rotate(angle / 2, points[ids.center])
+ // Also add this to the ids so we can save them later
+ ids[id + 'Rotated'] = ids[id] + 'Rotated'
+ }
+
+ // (optionally) Rotate all points so the line from in1Rotated to ex1Rotated is vertical
+ if (rotate) {
+ const deg = 270 - points[ids.in2Flipped].angle(points[ids.ex2Flipped])
+ for (const id of keys) {
+ points[ids[id]] = points[ids[id]].rotate(deg, points[ids.in2Flipped])
+ // We need the rotated points too
+ points[ids[id] + 'Rotated'].rotate(deg, points[ids.in2Flipped])
+ }
+ }
+ // Return the path of the full ring sector
+ paths[pathId] = new Path()
+ .move(points[ids.in2Flipped])
+ .curve(points[ids.in2cFlipped], points[ids.in1cFlipped], points[ids.in1])
+ .curve(points[ids.in1c], points[ids.in2c], points[ids.in2])
+ .line(points[ids.ex2])
+ .curve(points[ids.ex2c], points[ids.ex1c], points[ids.ex1])
+ .curve(points[ids.ex1cFlipped], points[ids.ex2cFlipped], points[ids.ex2Flipped])
+ .close()
+
+ /*
+ * Store all IDs in the store so we can remove this macro with rmringsector
+ */
+ store.set(['parts', part.name, 'macros', name, 'ids', id, 'paths'], { path: pathId })
+ store.set(['parts', part.name, 'macros', name, 'ids', id, 'points'], ids)
+ },
+ },
+}
+
+// More specifically named exports
+export const ringsectorPlugin = plugin
+export const ringSectorPlugin = plugin
+export const pluginRingSector = plugin
+export const pluginRingsector = plugin
diff --git a/plugins/plugin-ringsector/tests/shared.test.mjs b/plugins/plugin-ringsector/tests/shared.test.mjs
new file mode 100644
index 00000000000..7bf4f668398
--- /dev/null
+++ b/plugins/plugin-ringsector/tests/shared.test.mjs
@@ -0,0 +1,6 @@
+// This file is auto-generated | Any changes you make will be overwritten.
+import { plugin } from '../src/index.mjs'
+import { sharedPluginTests } from '../../../tests/plugins/shared.mjs'
+
+// Run shared tests
+sharedPluginTests(plugin)
diff --git a/sites/shared/components/mdx/tabbed-example.mjs b/sites/shared/components/mdx/tabbed-example.mjs
index a7500845877..38d4db42482 100644
--- a/sites/shared/components/mdx/tabbed-example.mjs
+++ b/sites/shared/components/mdx/tabbed-example.mjs
@@ -2,6 +2,7 @@ import { Tab, Tabs } from '../tabs.mjs'
import { Mdx } from 'shared/components/mdx/dynamic.mjs'
import { pluginFlip } from '@freesewing/plugin-flip'
import { pluginGore } from '@freesewing/plugin-gore'
+import { pluginRingsector } from '@freesewing/plugin-ringsector'
import { Design } from '@freesewing/core'
import yaml from 'js-yaml'
import { Pattern, PatternXray } from 'pkgs/react-components/src/index.mjs'
@@ -48,7 +49,7 @@ const buildPattern = (children, settings = { margin: 5 }, tutorial = false, pape
lengthRatio: { pct: 75, min: 55, max: 85, menu: 'style' },
}
: {},
- plugins: [pluginFlip, pluginGore],
+ plugins: [pluginFlip, pluginGore, pluginRingsector],
}
const design = new Design({
parts: [part],