1
0
Fork 0

feat(core): Allow plugins to provide their own packing implementation

This commit is contained in:
Joost De Cock 2023-10-15 15:12:10 +02:00
parent e9135f707a
commit b83ab5df74
20 changed files with 590 additions and 5 deletions

View file

@ -106,8 +106,18 @@
- Added support for notes in flags
### plugin-bin-pack
#### Added
- First release of the plugin providing the default packing implementation
### core
#### Added
- Allow plugins to provide their own packing implementation
#### Fixed
- Fix order in mergeOptions method so user settings take precendence over defaults

View file

@ -1,4 +1,9 @@
Unreleased:
Added:
core:
- Allow plugins to provide their own packing implementation
plugin-bin-pack:
- First release of the plugin providing the default packing implementation
Changed:
aaron:
- Rephrased flag message when expand is off to avoid confusion about included seam allowance. Fixes #5057

View file

@ -109,6 +109,7 @@ core-plugins:
'@freesewing/plugin-mirror': *freesewing
'@freesewing/plugin-round': *freesewing
'@freesewing/plugin-sprinkle': *freesewing
'@freesewing/plugin-bin-pack': *freesewing
plugintest:
peer:
'@freesewing/plugin-annotations': *freesewing

View file

@ -1,6 +1,7 @@
{
"core-plugins": "An umbrella package of essential plugins that are bundled with FreeSewing's core library",
"plugin-annotations": "A FreeSewing plugin that provides pattern annotations",
"plugin-bin-pack": "A FreeSewing plugin that adds a bin-pack algorithm to the core library",
"plugin-bust": "A FreeSewing plugin that helps with bust-adjusting menswear patterns",
"plugin-flip": "A FreeSewing plugin to flip parts horizontally",
"plugin-gore": "A FreeSewing plugin to generate gores for a semi-sphere or dome",

View file

@ -261,6 +261,18 @@ Pattern.prototype.__applySettings = function (sets) {
return this
}
/**
* Populates the pattern store with methods set by plugins
*
* @private
* @return {Store} store - The pattern-wide store populated with relevant data/methods
*/
Pattern.prototype.__extendPatternStore = function () {
this.store.extend([...this.plugins.__storeMethods])
return this.store
}
/**
* Creates a store for a set (of settings)
*

View file

@ -21,6 +21,9 @@ PatternDrafter.prototype.draft = function () {
// Keep container for drafted parts fresh
this.pattern.parts = []
// Extend pattern-wide store with methods from plugins
this.pattern.__extendPatternStore()
// Iterate over the provided sets of settings (typically just one)
for (const set in this.pattern.settings) {
this.pattern.setStores[set] = this.pattern.__createSetStore()

View file

@ -147,7 +147,6 @@ PatternPlugins.prototype.__loadPluginStoreMethods = function (plugin) {
for (const method of plugin.store) this.__storeMethods.add(method)
} else this.store.log.warn(`Plugin store methods should be an Array`)
// console.log('store', plugin, this.__storeMethods)
return this
}

View file

@ -1,6 +1,5 @@
import { Svg } from '../svg.mjs'
import { Stack } from '../stack.mjs'
import pack from 'bin-pack-with-constraints'
/**
* A class for handling layout and rendering for a pattern
@ -107,8 +106,8 @@ PatternRenderer.prototype.__pack = function () {
}
}
if (settings[activeSet].layout === true) {
// some plugins will add a width constraint to the settings, but we can safely pass undefined if not
let size = pack(bins, { inPlace: true, maxWidth: settings[0].maxWidth })
// store.pack is provided by a plugin
const size = bins.length > 0 ? this.pattern.store.pack(bins, this) : { width: 0, height: 0 }
this.autoLayout.width = size.width
this.autoLayout.height = size.height

View file

@ -50,6 +50,9 @@ export function Store(methods = []) {
} else set(this, path, method)
}
// Fallback packing algorithm
this.pack = fallbackPacker
return this
}
@ -156,3 +159,22 @@ Store.prototype.unset = function (path) {
return this
}
/**
* The default pack method comes from a plugin, typically
* plugin-bin-back which is part of core plugins.
* However, when a pattern is loaded without plugins
* we stil don't want it work even when no pack method
* is available, so this is the fallback default pack method.
*/
function fallbackPacker(items, pattern) {
console.log({ items, pattern })
let w = 0
let h = 0
for (const item of items) {
if (item.width > w) w = item.width
if (item.height > w) w = item.height
}
return { w, h }
}

View file

@ -5,6 +5,7 @@ import { Design, Attributes } from '../src/index.mjs'
import { Defs } from '../src/defs.mjs'
import { version } from '../data.mjs'
import render from './fixtures/render.mjs'
import { binpackPlugin } from '../../../plugins/plugin-bin-pack/src/index.mjs'
chai.use(chaiString)
const expect = chai.expect
@ -12,6 +13,7 @@ const expect = chai.expect
const getPattern = (settings = {}, draft = false) => {
const part = {
name: 'test',
plugins: binpackPlugin,
draft: draft
? draft
: ({ paths, Path, Point, part }) => {

View file

@ -58,7 +58,8 @@
"@freesewing/plugin-annotations": "3.0.0",
"@freesewing/plugin-mirror": "3.0.0",
"@freesewing/plugin-round": "3.0.0",
"@freesewing/plugin-sprinkle": "3.0.0"
"@freesewing/plugin-sprinkle": "3.0.0",
"@freesewing/plugin-bin-pack": "3.0.0"
},
"files": [
"dist/*",

View file

@ -3,6 +3,7 @@ import { measurementsPlugin } from '../../plugin-measurements/src/index.mjs'
import { mirrorPlugin } from '../../plugin-mirror/src/index.mjs'
import { roundPlugin } from '../../plugin-round/src/index.mjs'
import { sprinklePlugin } from '../../plugin-sprinkle/src/index.mjs'
import { binpackPlugin } from '../../plugin-bin-pack/src/index.mjs'
import { name, version } from '../data.mjs'
const bundledPlugins = [
@ -11,6 +12,7 @@ const bundledPlugins = [
mirrorPlugin,
roundPlugin,
sprinklePlugin,
binpackPlugin,
]
const hooks = {}

View file

@ -0,0 +1,17 @@
# Change log for: @freesewing/plugin-bin-pack
## 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.

View file

@ -0,0 +1,143 @@
![FreeSewing](https://static.freesewing.org/banner.png)
<p align='center'><a
href="https://www.npmjs.com/package/@freesewing/plugin-bin-pack"
title="@freesewing/plugin-bin-pack on NPM"
><img src="https://img.shields.io/npm/v/@freesewing/plugin-bin-pack.svg"
alt="@freesewing/plugin-bin-pack on NPM"/>
</a><a
href="https://opensource.org/licenses/MIT"
title="License: MIT"
><img src="https://img.shields.io/npm/l/@freesewing/plugin-bin-pack.svg?label=License"
alt="License: MIT"/>
</a><a
href="https://deepscan.io/dashboard#view=project&tid=2114&pid=2993&bid=23256"
title="Code quality on DeepScan"
><img src="https://deepscan.io/api/teams/2114/projects/2993/branches/23256/badge/grade.svg"
alt="Code quality on DeepScan"/>
</a><a
href="https://github.com/freesewing/freesewing/issues?q=is%3Aissue+is%3Aopen+label%3Apkg%3Aplugin-bin-pack"
title="Open issues tagged pkg:plugin-bin-pack"
><img src="https://img.shields.io/github/issues/freesewing/freesewing/pkg:plugin-bin-pack.svg?label=Issues"
alt="Open issues tagged pkg:plugin-bin-pack"/>
</a><a
href="#contributors-"
title="All Contributors"
><img src="https://img.shields.io/badge/all_contributors-111-pink.svg"
alt="All Contributors"/>
</a></p><p align='center'><a
href="https://twitter.com/freesewing_org"
title="Follow @freesewing_org on Twitter"
><img src="https://img.shields.io/badge/%F3%A0%80%A0-Follow%20us-blue.svg?logo=twitter&logoColor=white&logoWidth=15"
alt="Follow @freesewing_org on Twitter"/>
</a><a
href="https://chat.freesewing.org"
title="Chat with us on Discord"
><img src="https://img.shields.io/discord/698854858052075530?label=Chat%20on%20Discord"
alt="Chat with us on Discord"/>
</a><a
href="https://freesewing.org/patrons/join"
title="Become a FreeSewing Patron"
><img src="https://img.shields.io/badge/%F3%A0%80%A0-Support%20us-blueviolet.svg?logo=cash-app&logoColor=white&logoWidth=15"
alt="Become a FreeSewing Patron"/>
</a><a
href="https://instagram.com/freesewing_org"
title="Follow @freesewing_org on Twitter"
><img src="https://img.shields.io/badge/%F3%A0%80%A0-Follow%20us-E4405F.svg?logo=instagram&logoColor=white&logoWidth=15"
alt="Follow @freesewing_org on Twitter"/>
</a></p>
# @freesewing/plugin-bin-pack
A FreeSewing plugin that adds a bin-pack algorithm to the core library
## 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-bin-pack
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:
>
> [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/freesewing/freesewing)
>
> We recommend that you fork our repository and then
> put `gitpod.io/#<entire-url-of-your-fork` into a browser
> 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).

View file

@ -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()

View file

@ -0,0 +1,4 @@
// This file is auto-generated | All changes you make will be overwritten.
export const name = '@freesewing/plugin-bin-pack'
export const version = '3.0.0'
export const data = { name, version }

View file

@ -0,0 +1,71 @@
{
"name": "@freesewing/plugin-bin-pack",
"version": "3.0.0",
"description": "A FreeSewing plugin that adds a bin-pack algorithm to the core library",
"author": "Joost De Cock <joost@joost.at> (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"
}
}

View file

@ -0,0 +1,239 @@
/******************************************************************************
This is a binary tree based bin packing algorithm that is more complex than
the simple Packer (packer.js). Instead of starting off with a fixed width and
height, it starts with the width and height of the first block passed and then
grows as necessary to accomodate each subsequent block.
The default behavior as it grows is that it attempts
to maintain a roughly square ratio by making 'smart' choices about whether to
grow right or down.
If either a maxWidth or a maxHeight is set, it will try not to grow beyond that
limit, and choose instead to grow in the other direction. If the first block is
larger than the given limit, however, that block's dimension will replace the
limit.
When growing, the algorithm can only grow to the right OR down. Therefore, if
the new block is BOTH wider and taller than the current target then it will be
rejected. This makes it very important to initialize with a sensible starting
width and height. If you are providing sorted input (largest first) then this
will not be an issue.
A potential way to solve this limitation would be to allow growth in BOTH
directions at once, but this requires maintaining a more complex tree
with 3 children (down, right and center) and that complexity can be avoided
by simply chosing a sensible starting block.
Best results occur when the input blocks are sorted by height, or even better
when sorted by max(width,height).
Construction:
------------
{
maxWidth = Infinity: set a max width to constrain the growth in that direction
maxHeight = Infinity: set a max height to constrain the growth in that direction
strictMax = false: require wider-than-max blocks to start at left boundary
PREVIOUS INTENDED TODO strictMax = false: reject blocks that are larger than the max
}
Inputs:
------
blocks: array of any objects that have .w and .h attributes
Outputs:
-------
marks each block that fits with a .fit attribute pointing to a
node with .x and .y coordinates
Example:
-------
let blocks = [
{ w: 100, h: 100 },
{ w: 100, h: 100 },
{ w: 80, h: 80 },
{ w: 80, h: 80 },
etc
etc
];
let packer = new GrowingPacker();
packer.fit(blocks);
for(let n = 0 ; n < blocks.length ; n++) {
let block = blocks[n]
if (block.fit) {
Draw(block.fit.x, block.fit.y, block.w, block.h)
}
}
******************************************************************************/
const GrowingPacker = function ({ maxWidth = Infinity, maxHeight = Infinity, strictMax = false }) {
this.maxWidth = maxWidth
this.maxHeight = maxHeight
this.strictMax = strictMax
this.ensureSquare = maxWidth === Infinity && maxHeight === Infinity
}
GrowingPacker.prototype = {
fit: function (blocks) {
let len = blocks.length
if (len === 0) {
return
}
let n, node, block, fit
// Require wider-than-max blocks to start at the left boundary, so
// they encroach past the right boundary minimally.
let width =
this.strictMax && this.maxWidth < Infinity
? Math.max(blocks[0].width, this.maxWidth)
: blocks[0].width
let height = this.strictMax && this.maxHeight < Infinity ? this.maxHeight : blocks[0].height
this.root = { x: 0, y: 0, width, height }
for (n = 0; n < len; n++) {
block = blocks[n]
if ((node = this.findNode(this.root, block.width, block.height))) {
fit = this.splitNode(node, block.width, block.height)
block.x = fit.x
block.y = fit.y
} else {
fit = this.growNode(block.width, block.height)
block.x = fit.x
block.y = fit.y
}
}
},
findNode: function (root, width, height) {
if (root.used)
return this.findNode(root.right, width, height) || this.findNode(root.down, width, height)
else if (width <= root.width && height <= root.height) return root
else return null
},
splitNode: function (node, width, height) {
node.used = true
const downY = node.y + height
node.down = {
x: node.x,
y: downY,
width: node.width,
height: Math.min(node.height - height, this.maxHeight - downY),
}
const rightX = node.x + width
node.right = {
x: rightX,
y: node.y,
width: Math.min(node.width - width, this.maxWidth - rightX),
height: height,
}
return node
},
growNode: function (width, height) {
let canGrowDown = width <= this.root.width
let canGrowRight = height <= this.root.height
const proposedNewWidth = this.root.width + width
let shouldGrowRight =
canGrowRight && (this.ensureSquare ? this.root.height : this.maxWidth) >= proposedNewWidth // attempt to keep square-ish by growing right when height is much greater than width
const proposedNewHeight = this.root.height + height
let shouldGrowDown =
canGrowDown && (this.ensureSquare ? this.root.width : this.maxHeight) >= proposedNewHeight // attempt to keep square-ish by growing down when width is much greater than height
if (shouldGrowRight) return this.growRight(width, height)
else if (shouldGrowDown) return this.growDown(width, height)
else if (canGrowRight) return this.growRight(width, height)
else if (canGrowDown) return this.growDown(width, height)
else return null // need to ensure sensible root starting size to avoid this happening
},
growRight: function (width, height) {
this.root = {
used: true,
x: 0,
y: 0,
width: this.root.width + width,
height: this.root.height,
down: this.root,
right: { x: this.root.width, y: 0, width: width, height: this.root.height },
}
let node
if ((node = this.findNode(this.root, width, height))) return this.splitNode(node, width, height)
else return null
},
growDown: function (width, height) {
this.root = {
used: true,
x: 0,
y: 0,
width: this.root.width,
height: this.root.height + height,
down: { x: 0, y: this.root.height, width: this.root.width, height: height },
right: this.root,
}
let node
if ((node = this.findNode(this.root, width, height))) return this.splitNode(node, width, height)
else return null
},
}
const defaultOptions = { inPlace: true }
export const pack = (store, items, pattern) => {
const options = {
...defaultOptions,
...pattern.pattern.settings[0],
}
const packer = new GrowingPacker(options)
const inPlace = options.inPlace || false
// Clone the items.
let newItems = items.map(function (item) {
return inPlace ? item : { width: item.width, height: item.height, item: item }
})
// We need to know whether the widest item exceeds the maximum width.
// The optimal sorting strategy changes depending on this.
const widestWidth = newItems.sort(function (a, b) {
return b.width - a.width
})[0].width
const widerThanMax = options.maxWidth && widestWidth > options.maxWidth
newItems = newItems.sort(function (a, b) {
if (options.maxWidth && !options.maxHeight && widerThanMax) return b.width - a.width
if (options.maxWidth && !options.maxHeight) return b.height - a.height
if (options.maxHeight) return b.height - a.height
// TODO: check that each actually HAS a width and a height.
// Sort based on the size (area) of each block.
return b.width * b.height - a.width * a.height
})
packer.fit(newItems)
const w = newItems.reduce(function (curr, item) {
return Math.max(curr, item.x + item.width)
}, 0)
const h = newItems.reduce(function (curr, item) {
return Math.max(curr, item.y + item.height)
}, 0)
const ret = {
width: w,
height: h,
}
if (!inPlace) ret.items = newItems
return ret
}

View file

@ -0,0 +1,13 @@
import { name, version } from '../data.mjs'
import { pack } from './growing-packer.mjs'
export const plugin = {
name,
version,
store: [['pack', pack]],
}
// More specifically named exports
export const packPlugin = plugin
export const binPackPlugin = plugin
export const binpackPlugin = plugin

View file

@ -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)