diff --git a/packages/core/src/design.mjs b/packages/core/src/design.mjs index 6d62ed70a1a..aa5b9caefbc 100644 --- a/packages/core/src/design.mjs +++ b/packages/core/src/design.mjs @@ -1,4 +1,4 @@ -import { Pattern } from './pattern.mjs' +import { Pattern } from './pattern/index.mjs' import { __loadDesignDefaults } from './config.mjs' ////////////////////////////////////////////// diff --git a/packages/core/src/index.mjs b/packages/core/src/index.mjs index 98224d8e5ea..38657c84c0d 100644 --- a/packages/core/src/index.mjs +++ b/packages/core/src/index.mjs @@ -1,13 +1,13 @@ import { Bezier } from 'bezier-js' import { Attributes } from './attributes.mjs' import { Design } from './design.mjs' -import { Pattern } from './pattern.mjs' +import { Pattern } from './pattern/index.mjs' import { Part } from './part.mjs' import { Point } from './point.mjs' import { Path } from './path.mjs' import { Snippet } from './snippet.mjs' import { Store } from './store.mjs' -import { hidePresets } from './pattern-config.mjs' +import { hidePresets } from './pattern/pattern-config.mjs' import { beamIntersectsCircle, beamIntersectsX, diff --git a/packages/core/src/pattern.mjs b/packages/core/src/pattern.mjs deleted file mode 100644 index d4d881d500d..00000000000 --- a/packages/core/src/pattern.mjs +++ /dev/null @@ -1,1101 +0,0 @@ -import { Attributes } from './attributes.mjs' -import pack from 'bin-pack-with-constraints' -import { __addNonEnumProp, __macroName } from './utils.mjs' -import { Part } from './part.mjs' -import { Stack } from './stack.mjs' -import { Point } from './point.mjs' -import { Path } from './path.mjs' -import { Snippet } from './snippet.mjs' -import { Svg } from './svg.mjs' -import { Store } from './store.mjs' -import { Hooks } from './hooks.mjs' -import { version } from '../data.mjs' -import { __loadPatternDefaults } from './config.mjs' -import { PatternConfig, getPluginName } from './pattern-config.mjs' -import { PatternDraftQueue } from './pattern-draft-queue.mjs' -import cloneDeep from 'lodash.clonedeep' - -////////////////////////////////////////////// -// CONSTRUCTOR // -////////////////////////////////////////////// - -/** - * Constructor for a Pattern - * - * @constructor - * @param {object} config - The Design config - * @return {object} this - The Pattern instance - */ -export function Pattern(designConfig = {}) { - // Enumerable properties - this.designConfig = cloneDeep(designConfig) // The design configuration (unresolved) - this.config = {} // Will hold the resolved pattern after calling __init() - this.store = new Store() // Pattern-wide store - this.setStores = [] // Per-set stores - - // Non-enumerable properties - __addNonEnumProp(this, 'plugins', {}) - __addNonEnumProp(this, 'width', 0) - __addNonEnumProp(this, 'height', 0) - __addNonEnumProp(this, 'autoLayout', { stacks: {} }) - __addNonEnumProp(this, 'is', '') - __addNonEnumProp(this, 'hooks', new Hooks()) - __addNonEnumProp(this, 'Point', Point) - __addNonEnumProp(this, 'Path', Path) - __addNonEnumProp(this, 'Snippet', Snippet) - __addNonEnumProp(this, 'Attributes', Attributes) - __addNonEnumProp(this, 'macros', {}) - __addNonEnumProp(this, '__initialized', false) - __addNonEnumProp(this, 'config.parts', {}) - __addNonEnumProp(this, 'config.resolvedDependencies', {}) - __addNonEnumProp(this, '__storeMethods', new Set()) - __addNonEnumProp(this, '__configResolver', new PatternConfig(this)) // handles config resolution during __init() as well as runtime part adding - - return this -} - -////////////////////////////////////////////// -// PUBLIC METHODS // -////////////////////////////////////////////// - -/** - * Allows adding parts to the config at runtime - * - * @param {object} part - The part to add - * @param {boolean} resolveImmediately - Should the part be resolved now, or wait until the next call to {@link __init()}? - * It is useful to resolve immediately if one part is being added at runtime - * It might be useful to not resolve immediately if a number of parts will be added over multiple calls - * @return {object} this - The Pattern instance - */ -Pattern.prototype.addPart = function (part, resolveImmediately = true) { - if ( - this.__configResolver.isPartValid(part) && - !this.designConfig.parts.find((p) => p.name == part.name) - ) { - this.store.log.debug(`Adding Part \`${part.name}\` at runtime`) - this.designConfig.parts.push(part) - if (resolveImmediately) { - if (this.__configResolver.addPart(part) && typeof this.draftQueue !== 'undefined') - this.draftQueue.addPart(part.name) - } else this.__initialized = false - } - return this -} - -/** - * Drafts this pattern, aka the raison d'etre of FreeSewing - * - * @return {object} this - The Pattern instance - */ -Pattern.prototype.draft = function () { - this.__init() - this.draftQueue = new PatternDraftQueue(this) - this.__runHooks('preDraft') - // Keep container for drafted parts fresh - this.parts = [] - - // Iterate over the provided sets of settings (typically just one) - for (const set in this.settings) { - this.activeSet = set - this.setStores[set] = this.__createSetStore() - this.setStores[set].log.debug(`Initialized store for set ${set}`) - this.__runHooks('preSetDraft') - this.setStores[set].log.debug(`📐 Drafting pattern for set ${set}`) - - // Create parts container - this.parts[set] = {} - - // Handle snap for pct options - this.__loadAbsoluteOptionsSet(set) - - this.draftQueue.start() - while (this.draftQueue.hasNext()) { - this.createPartForSet(this.draftQueue.next(), set) - } - this.__runHooks('postSetDraft') - } - this.__runHooks('postDraft') - - return this -} - -Pattern.prototype.createPartForSet = function (partName, set = 0) { - // gotta protect against attacks - if (set === '__proto__') { - throw new Error('malicious attempt at altering Object.prototype. Stopping action') - } - // Create parts - this.setStores[set].log.debug(`📦 Creating part \`${partName}\` (set ${set})`) - this.parts[set][partName] = this.__createPartWithContext(partName, set) - - // Handle inject/inheritance - if (typeof this.config.inject[partName] === 'string') { - this.setStores[set].log.debug( - `Creating part \`${partName}\` from part \`${this.config.inject[partName]}\`` - ) - try { - this.parts[set][partName].__inject(this.parts[set][this.config.inject[partName]]) - } catch (err) { - this.setStores[set].log.error([ - `Could not inject part \`${this.config.inject[partName]}\` into part \`${partName}\``, - err, - ]) - } - } - if (this.__needs(partName, set)) { - // Draft part - const result = this.draftPartForSet(partName, set) - if (typeof result !== 'undefined') this.parts[set][partName] = result - // FIXME: THis won't work not that this is immutable - // But is it still needed? - // this.parts[set][partName].hidden === true ? true : !this.__wants(partName, set) - } else { - this.setStores[set].log.debug( - `Part \`${partName}\` is not needed. Skipping draft and setting hidden to \`true\`` - ) - this.parts[set][partName].hidden = true - } -} - -Pattern.prototype.draftPartForSet = function (partName, set) { - if (typeof this.config.parts?.[partName]?.draft === 'function') { - this.activePart = partName - this.setStores[set].set('activePart', partName) - try { - this.__runHooks('prePartDraft') - const result = this.config.parts[partName].draft(this.parts[set][partName].shorthand()) - if (!this.__wants(partName, set)) { - result.hide() - } - this.__runHooks('postPartDraft') - if (typeof result === 'undefined') { - this.setStores[set].log.error( - `Result of drafting part ${partName} was undefined. Did you forget to return the part?` - ) - } - return result - } catch (err) { - this.setStores[set].log.error([`Unable to draft part \`${partName}\` (set ${set})`, err]) - } - } else - this.setStores[set].log.error( - `Unable to draft pattern part __${partName}__. Part.draft() is not callable` - ) -} - -/** - * Return the initialized configuration - * - * @return {object} config - The initialized config - */ -Pattern.prototype.getConfig = function () { - return this.__init().config -} - -/** Returns props required to render this pattern through - * an external renderer (eg. a React component) - * - * @return {object} this - The Pattern instance - */ -Pattern.prototype.getRenderProps = function () { - this.store.log.info('Gathering render props') - // Run pre-render hook - let svg = new Svg(this) - svg.hooks = this.hooks - - this.__pack() - svg.__runHooks('preRender') - - let props = { svg } - props.width = this.width - props.height = this.height - props.autoLayout = this.autoLayout - props.settings = this.settings - props.parts = [] - for (const set of this.parts) { - const setParts = {} - for (let p in set) { - if (!set[p].hidden) { - setParts[p] = { - ...set[p].asProps(), - store: this.setStores[set[p].set], - } - } else if (this.setStores[set.set]) { - this.setStores[set.set].log.info( - `Part${p} is hidden in set ${set.set}. Not adding to render props` - ) - } - } - props.parts.push(setParts) - } - props.stacks = {} - for (let s in this.stacks) { - if (!this.__isStackHidden(s)) { - props.stacks[s] = this.stacks[s].asProps() - } else this.store.log.info(`Stack ${s} is hidden. Skipping in render props.`) - } - props.logs = { - pattern: this.store.logs, - sets: this.setStores.map((store) => ({ - debug: store.logs.debug, - info: store.logs.info, - error: store.logs.error, - warning: store.logs.warning, - })), - } - - svg.__runHooks('postRender') - return props -} - -/** - * Handles pattern sampling - * - * @return {object} this - The Pattern instance - */ -Pattern.prototype.sample = function () { - this.__init() - if (this.settings[0].sample.type === 'option') { - return this.sampleOption(this.settings[0].sample.option) - } else if (this.settings[0].sample.type === 'measurement') { - return this.sampleMeasurement(this.settings[0].sample.measurement) - } else if (this.settings[0].sample.type === 'models') { - return this.sampleModels(this.settings[0].sample.models, this.settings[0].sample.focus || false) - } -} - -/** - * Handles measurement sampling - * - * @return {object} this - The Pattern instance - */ -Pattern.prototype.sampleMeasurement = function (measurementName) { - this.store.log.debug(`Sampling measurement \`${measurementName}\``) - this.__runHooks('preSample') - this.__applySettings(this.__measurementSets(measurementName)) - this.__init() - this.__runHooks('postSample') - - return this.draft() -} - -/** - * Handles models sampling - * - * @return {object} this - The Pattern instance - */ -Pattern.prototype.sampleModels = function (models, focus = false) { - this.store.log.debug(`Sampling models \`${Object.keys(models).join(', ')}\``) - this.__runHooks('preSample') - this.__applySettings(this.__modelSets(models, focus)) - this.__init() - this.__runHooks('postSample') - - return this.draft() -} - -/** - * Handles option sampling - * - * @return {object} this - The Pattern instance - */ -Pattern.prototype.sampleOption = function (optionName) { - this.store.log.debug(`Sampling option \`${optionName}\``) - this.__runHooks('preSample') - this.__applySettings(this.__optionSets(optionName)) - this.__init() - this.__runHooks('postSample') - - return this.draft() -} - -/** - * Adds a lifecycle hook method to the pattern - * - * @param {string} hook - Name of the lifecycle hook - * @param {function} method - The method to run - * @param {object} data - Any data to pass to the hook method - * @return {object} this - The Pattern instance - */ -Pattern.prototype.on = function (hook, method, data) { - for (const added of this.hooks[hook]) { - // Don't add it twice - if (added.method === method) return this - } - this.hooks[hook].push({ method, data }) - - return this -} - -/** - * Renders the pattern to SVG - * - * @return {string} svg - The rendered SVG - */ -Pattern.prototype.render = function () { - this.svg = new Svg(this) - this.svg.hooks = this.hooks - - return this.__pack().svg.render() -} - -/** - * Loads a plugin - * - * @param {object} plugin - The plugin to load - * @param {object} data - Any data to pass to the plugin - * @return {object} this - The Pattern instance - */ -Pattern.prototype.use = function (plugin, data) { - const name = getPluginName(plugin) - if (!this.plugins?.[name]) - return plugin.plugin && plugin.condition - ? this.__useIf(plugin, data) // Conditional plugin - : this.__loadPlugin(plugin, data) // Regular plugin - - this.store.log.info(`Plugin \`${name}\` was requested, but it's already loaded. Skipping.`) - - return this -} - -////////////////////////////////////////////// -// PRIVATE METHODS // -////////////////////////////////////////////// - -/** - * Creates a store for a set (of settings) - * - * @private - * @return {Store} store - A new store populated with relevant data/methods - */ -Pattern.prototype.__createSetStore = function () { - const store = new Store() - store.set('data', this.store.data) - store.extend([...this.__storeMethods]) - - return store -} - -/** - * Merges (sets of) settings with the default settings - * - * @private - * @param {Array} sets - An array of settings objects - * @return {object} this - The Pattern instance - */ -Pattern.prototype.__applySettings = function (sets) { - if (!Array.isArray(sets)) throw 'Sets should be an array of settings objects' - if (sets.length === 0) sets.push({}) // Required to load default settings - this.settings = [] - for (let i = 0; i < sets.length; i++) { - // Don't mutate the input itself - const set = { ...sets[i] } - if (!set.options) set.options = {} - if (!set.measurements) set.measurements = {} - this.settings.push({ - ...__loadPatternDefaults(), - ...set, - // Force creation of a new objects - // so we don't reference the original - options: { ...set.options }, - measurements: { ...set.measurements }, - }) - } - - return this -} - -/** - * Instantiates a new Part instance and populates it with the pattern context - * - * @private - * @param {string} name - The name of the part - * @param {int} set - The index of the settings set in the list of sets - * @return {Part} part - The instantiated Part - */ -Pattern.prototype.__createPartWithContext = function (name, set) { - // Context object to add to Part closure - const part = new Part() - part.name = name - part.set = set - part.stack = this.config.parts[name]?.stack || name - part.context = { - parts: this.parts[set], - config: this.config, - settings: this.settings[set], - store: this.setStores[set], - macros: this.macros, - } - - if (this.settings[set]?.partClasses) { - part.attr('class', this.settings[set].partClasses) - } - - for (const macro in this.macros) { - part[__macroName(macro)] = this.macros[macro] - } - - return part -} - -/** - * Instantiates a new Stack instance and populates it with the pattern context - * - * @private - * @param {string} name - The name of the stack - * @return {Stack} stack - The instantiated Stack - */ -Pattern.prototype.__createStackWithContext = function (name) { - // Context object to add to Stack closure - const stack = new Stack() - stack.name = name - stack.context = { - config: this.config, - settings: this.settings, - setStores: this.setStores, - } - - return stack -} - -/** - * Initializes the pattern coniguration and settings - * - * @return {object} this - The Pattern instance - */ -Pattern.prototype.__init = function () { - if (this.__initialized) return this - - this.__runHooks('preInit') - // Say hello - this.store.log.info( - `New \`${this.designConfig?.data?.name || 'No Name'}:` + - `${this.designConfig?.data?.version || 'No version'}\` ` + - `pattern using \`@freesewing/core:${version}\`` - ) - - /* - * We allow late-stage updating of the design config (adding parts for example) - * so we need to do the things we used to do in the contructor at a later stage. - * This methods does that, and resolves the design config + user settings - */ - this.__resolveParts() // Resolves parts - .__resolveConfig() // Gets the config from the resolver - .__loadOptionDefaults() // Merges default options with user provided ones - .__loadPlugins() // Loads plugins - .__loadConfigData() // Makes config data available in store - - this.store.log.info(`Pattern initialized. Draft order is: ${this.config.draftOrder.join(', ')}`) - this.__runHooks('postInit') - - this.__initialized = true - - return this -} - -/** - * Checks whether a part is hidden in the config - * - * @private - * @param {string} partName - Name of the part to check - * @return {bool} hidden - true if the part is hidden, or false if not - */ -Pattern.prototype.__isPartHidden = function (partName) { - const partHidden = this.parts?.[this.activeSet]?.[partName]?.hidden || false - if (Array.isArray(this.settings[this.activeSet || 0].only)) { - if (this.settings[this.activeSet || 0].only.includes(partName)) return partHidden - } - if (this.config.partHide?.[partName]) return true - - return partHidden -} - -/** - * Checks whether a stack is hidden in the config - * - * @private - * @param {string} stackName - Name of the stack to check - * @return {bool} hidden - true if the part is hidden, or false if not - */ -Pattern.prototype.__isStackHidden = function (stackName) { - if (!this.stacks[stackName]) return true - const parts = this.stacks[stackName].getPartNames() - for (const partName of parts) { - if (!this.__isPartHidden(partName)) return false - } - - return true -} - -/** - * Generates an array of settings.options objects for sampling a list or boolean option - * - * @private - * @param {string} optionName - Name of the option to sample - * @return {Array} sets - The list of settings objects - */ -Pattern.prototype.__listBoolOptionSets = function (optionName) { - let option = this.config.options[optionName] - const base = this.__setBase() - const sets = [] - let run = 1 - if (typeof option.bool !== 'undefined') option = { list: [false, true] } - for (const choice of option.list) { - const settings = { - ...base, - options: { - ...base.options, - }, - idPrefix: `sample-${run}`, - partClasses: `sample-${run}`, - } - settings.options[optionName] = choice - sets.push(settings) - run++ - } - - return sets -} - -/** - * Generates an array of settings.absoluteOptions objects for sampling a list option - * - * @private - * @param {string} optionName - Name of the option to sample - * @return {Array} sets - The list of settings objects - */ -Pattern.prototype.__loadAbsoluteOptionsSet = function (set) { - for (const optionName in this.settings[set].options) { - const option = this.config.options[optionName] - if ( - typeof option !== 'undefined' && - typeof option.snap !== 'undefined' && - option.toAbs instanceof Function - ) { - this.settings[set].absoluteOptions[optionName] = this.__snappedPercentageOption( - optionName, - set - ) - this.setStores[set].log.debug( - `🧲 Snapped __${optionName}__ to \`${this.settings[set].absoluteOptions[optionName]}\` for set __${set}__` - ) - } - } - - return this -} - -/** - * Loads data from the design config into the store - * - * @private - * @return {Pattern} this - The Pattern instance - */ -Pattern.prototype.__loadConfigData = function () { - if (this.designConfig.data) this.store.set('data', this.designConfig.data) - - return this -} - -/** - * Merges defaults for options with user-provided options - * - * @private - * @return {Pattern} this - The Pattern instance - */ -Pattern.prototype.__loadOptionDefaults = function () { - if (!this.config.options) this.config.options = {} - if (Object.keys(this.config.options).length < 1) return this - for (const i in this.settings) { - for (const [name, option] of Object.entries(this.config.options)) { - // Don't overwrite user-provided settings.options - if (typeof this.settings[i].options[name] === 'undefined') { - if (typeof option === 'object') { - if (typeof option.pct !== 'undefined') this.settings[i].options[name] = option.pct / 100 - else if (typeof option.mm !== 'undefined') this.settings[i].options[name] = option.mm - else if (typeof option.deg !== 'undefined') this.settings[i].options[name] = option.deg - else if (typeof option.count !== 'undefined') - this.settings[i].options[name] = option.count - else if (typeof option.bool !== 'undefined') this.settings[i].options[name] = option.bool - else if (typeof option.dflt !== 'undefined') this.settings[i].options[name] = option.dflt - else { - let err = 'Unknown option type: ' + JSON.stringify(option) - this.store.log.error(err) - throw new Error(err) - } - } else this.settings[i].options[name] = option - } - } - } - - return this -} - -/** - * Loads a plugin - * - * @private - * @param {object} plugin - The plugin object, or an object with `plugin` and `condition` keys - * @param {object} data - Any plugin data to load - * @return {Pattern} this - The Pattern instance - */ -Pattern.prototype.__loadPlugin = function (plugin, data) { - this.plugins[plugin.name] = plugin - if (plugin.hooks) this.__loadPluginHooks(plugin, data) - if (plugin.macros) this.__loadPluginMacros(plugin) - if (plugin.store) this.__loadPluginStoreMethods(plugin) - this.store.log.info(`Loaded plugin \`${plugin.name}:${plugin.version}\``) - - return this -} - -/** - * Loads a plugin's hooks - * - * @private - * @param {object} plugin - The plugin object - * @param {object} data - Any plugin data to load - * @return {Pattern} this - The Pattern instance - */ -Pattern.prototype.__loadPluginHooks = function (plugin, data) { - for (let hook of Object.keys(this.hooks)) { - if (typeof plugin.hooks[hook] === 'function') { - this.on(hook, plugin.hooks[hook], data) - } else if (Array.isArray(plugin.hooks[hook])) { - for (let method of plugin.hooks[hook]) { - this.on(hook, method, data) - } - } - } - - return this -} - -/** - * Loads a plugin's macros - * - * @private - * @param {object} plugin - The plugin object - * @return {Pattern} this - The Pattern instance - */ -Pattern.prototype.__loadPluginMacros = function (plugin) { - for (let macro in plugin.macros) { - if (typeof plugin.macros[macro] === 'function') { - this.__macro(macro, plugin.macros[macro]) - } - } -} - -/** - * Loads the plugins that are part of the config - * - * @private - * @return {Pattern} this - The Pattern instance - */ -Pattern.prototype.__loadPlugins = function () { - if (!this.config.plugins) return this - for (const plugin in this.config.plugins) - this.use(this.config.plugins[plugin], this.config.plugins[plugin]?.data) - - return this -} - -/** - * Loads a plugin's store methods - * - * @private - * @param {object} plugin - The plugin object - * @return {Pattern} this - The Pattern instance - */ -Pattern.prototype.__loadPluginStoreMethods = function (plugin) { - if (Array.isArray(plugin.store)) { - for (const method of plugin.store) this.__storeMethods.add(method) - } else this.store.log.warning(`Plugin store methods should be an Array`) - - return this -} - -/** - * Sets a method for a macro - * - * @private - * @param {string} macro - Name of the macro to run - * @param {function} method - The macro method - * @return {object} this - The Pattern instance - */ -Pattern.prototype.__macro = function (key, method) { - this.macros[key] = method - - return this -} - -/** - * Generates an array of settings objects for sampling a measurement - * - * @private - * @param {string} measurementName - The name of the measurement to sample - * @return {Array} sets - The list of settings objects - */ -Pattern.prototype.__measurementSets = function (measurementName) { - let val = this.settings[0].measurements[measurementName] - if (val === undefined) - this.store.log.error( - `Cannot sample measurement \`${measurementName}\` because it's \`undefined\`` - ) - let step = val / 50 - val = val * 0.9 - const sets = [] - const base = this.__setBase() - for (let run = 1; run < 11; run++) { - const settings = { - ...base, - measurements: { - ...base.measurements, - }, - idPrefix: `sample-${run}`, - partClasses: `sample-${run}`, - } - settings.measurements[measurementName] = val - sets.push(settings) - val += step - } - - return sets -} - -/** - * Generates an array of settings objects for sampling a list of models - * - * @private - * @param {object} models - The models to sample - * @param {string} focus - The ID of the model that should be highlighted - * @return {Array} sets - The list of settings objects - */ -Pattern.prototype.__modelSets = function (models, focus = false) { - const sets = [] - const base = this.__setBase() - let run = 1 - // If there's a focus, do it first so it's at the bottom of the SVG - if (focus) { - sets.push({ - ...base, - measurements: models[focus], - idPrefix: `sample-${run}`, - partClasses: `sample-${run} sample-focus`, - }) - run++ - delete models[focus] - } - for (const measurements of Object.values(models)) { - sets.push({ - ...base, - measurements, - idPrefix: `sample-${run}`, - partClasses: `sample-${run}`, - }) - } - - return sets -} - -/** - * Determines whether a part is needed, depending on the 'only' setting and the configured dependencies - * - * @private - * @param {string} partName - Name of the part - * @param {int} set - The index of the set of settings - * @return {bool} needs - true if the part is needed, or false if not - */ -Pattern.prototype.__needs = function (partName, set = 0) { - // If only is unset, all parts are needed - if ( - typeof this.settings[set].only === 'undefined' || - this.settings[set].only === false || - (Array.isArray(this.settings[set].only) && this.settings[set].only.length === 0) - ) - return true - - // Make only to always be an array - const only = - typeof this.settings[set].only === 'string' - ? [this.settings[set].only] - : this.settings[set].only - - // Walk the only parts, checking each one for a match in its dependencies - for (const part of only) { - if (part === partName) return true - if (this.config.resolvedDependencies[part]?.indexOf(partName) !== -1) return true - } - - return false -} - -/** - * Generates an array of settings objects for sampling an option - * - * @private - * @param {string} optionName - The name of the option to sample - * @return {Array} sets - The list of settings objects - */ -Pattern.prototype.__optionSets = function (optionName) { - const sets = [] - if (!(optionName in this.config.options)) return sets - let option = this.config.options[optionName] - if (typeof option.list === 'object' || typeof option.bool !== 'undefined') - return this.__listBoolOptionSets(optionName) - let factor = 1 - let step, val - let numberRuns = 10 - let stepFactor = numberRuns - 1 - if (typeof option.min === 'undefined' || typeof option.max === 'undefined') { - const min = option * 0.9 - const max = option * 1.1 - option = { min, max } - } - if (typeof option.pct !== 'undefined') factor = 100 - val = option.min / factor - if (typeof option.count !== 'undefined' || typeof option.mm !== 'undefined') { - const numberOfCounts = option.max - option.min + 1 - if (numberOfCounts < 10) { - numberRuns = numberOfCounts - stepFactor = Math.max(numberRuns - 1, 1) - } - } - step = (option.max / factor - val) / stepFactor - const base = this.__setBase() - for (let run = 1; run <= numberRuns; run++) { - const settings = { - ...base, - options: { - ...base.options, - }, - idPrefix: `sample-${run}`, - partClasses: `sample-${run}`, - } - settings.options[optionName] = val - sets.push(settings) - val += step - if (typeof option.count !== 'undefined' || typeof option.mm !== 'undefined') - val = Math.round(val) - } - - return sets -} - -/** - * Packs stacks in a 2D space and sets pattern size - * - * @private - * @return {Pattern} this - The Pattern instance - */ -Pattern.prototype.__pack = function () { - this.__runHooks('preLayout') - for (const set in this.settings) { - if (this.setStores[set].logs.error.length > 0) { - this.setStores[set].log.warning(`One or more errors occured. Not packing pattern parts`) - return this - } - } - // First, create all stacks - this.stacks = {} - for (const set in this.settings) { - for (const [name, part] of Object.entries(this.parts[set])) { - const stackName = - this.settings[set].stackPrefix + - (typeof part.stack === 'function' ? part.stack(this.settings[set], name) : part.stack) - if (typeof this.stacks[stackName] === 'undefined') - this.stacks[stackName] = this.__createStackWithContext(stackName, set) - this.stacks[stackName].addPart(part) - } - } - - let bins = [] - for (const [key, stack] of Object.entries(this.stacks)) { - // Avoid multiple render calls to cause addition of transforms - stack.attributes.remove('transform') - if (!this.__isStackHidden(key)) { - stack.home() - if (this.settings[0].layout === true) - bins.push({ id: key, width: stack.width, height: stack.height }) - else { - if (this.width < stack.width) this.width = stack.width - if (this.height < stack.height) this.height = stack.height - } - } - } - if (this.settings[0].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: this.settings[0].maxWidth }) - for (let bin of bins) { - this.autoLayout.stacks[bin.id] = { move: {} } - let stack = this.stacks[bin.id] - if (bin.x !== 0 || bin.y !== 0) { - stack.attr('transform', `translate(${bin.x}, ${bin.y})`) - } - this.autoLayout.stacks[bin.id].move = { - x: bin.x + stack.layout.move.x, - y: bin.y + stack.layout.move.y, - } - } - this.width = size.width - this.height = size.height - } else if (typeof this.settings[0].layout === 'object') { - this.width = this.settings[0].layout.width - this.height = this.settings[0].layout.height - for (let stackId of Object.keys(this.settings[0].layout.stacks)) { - // Some parts are added by late-stage plugins - if (this.stacks[stackId]) { - let transforms = this.settings[this.activeStack || 0].layout.stacks[stackId] - this.stacks[stackId].generateTransform(transforms) - } - } - } - - this.__runHooks('postLayout') - return this -} - -/** - * Gets the configuration for the config resolver and sets it on the pattern - * @private - * @return {Pattern} this - The Pattern instance - */ -Pattern.prototype.__resolveConfig = function () { - this.config = this.__configResolver.asConfig() - return this -} - -/** - * Resolves parts and their dependencies - * - * @private - * @return {Pattern} this - The Pattern instance - */ -Pattern.prototype.__resolveParts = function () { - this.designConfig.parts.forEach((p) => this.__configResolver.addPart(p)) - - // Print final part distances. - this.__configResolver.logPartDistances() - - return this -} - -/** - * Runs subscriptions to a given lifecycle hook - * - * @private - * @param {string} hookName - Name of the lifecycle hook - * @param {obhect} data - Any data to pass to the hook method - * @return {Pattern} this - The Pattern instance - */ -Pattern.prototype.__runHooks = function (hookName, data = false) { - if (data === false) data = this - let hooks = this.hooks[hookName] - if (hooks.length > 0) { - this.store.log.debug(`Running \`${hookName}\` hooks`) - for (let hook of hooks) { - hook.method(data, hook.data) - } - } -} - -/** - * Returns the base/defaults to generate a set of settings - * - * @private - * @return {object} settings - The settings object - */ -Pattern.prototype.__setBase = function () { - return { - ...this.settings[0], - measurements: { ...(this.settings[0].measurements || {}) }, - options: { ...(this.settings[0].options || {}) }, - } -} - -/** - * Returns the absolute value of a snapped percentage option - * - * @private - * @param {string} optionName - The name of the option - * @param {int} set - The index of the set in the list of settings - * @return {float} abs - The absolute value of the snapped option - */ -Pattern.prototype.__snappedPercentageOption = function (optionName, set) { - const conf = this.config.options[optionName] - const abs = conf.toAbs(this.settings[set].options[optionName], this.settings[set]) - // Handle units-specific config - Side-step immutability for the snap conf - let snapConf = conf.snap - if (!Array.isArray(snapConf) && snapConf.metric && snapConf.imperial) - snapConf = snapConf[this.settings[set].units] - // Simple steps - if (typeof snapConf === 'number') return Math.ceil(abs / snapConf) * snapConf - // List of snaps - if (Array.isArray(snapConf) && snapConf.length > 1) { - for (const snap of snapConf - .sort((a, b) => a - b) - .map((snap, i) => { - const margin = - i < snapConf.length - 1 - ? (snapConf[Number(i) + 1] - snap) / 2 // Look forward - : (snap - snapConf[i - 1]) / 2 // Final snap, look backward - - return { - min: snap - margin, - max: snap + Number(margin), - snap, - } - })) - if (abs <= snap.max && abs >= snap.min) return snap.snap - } - - return abs -} - -/** - * Loads a conditional plugin - * - * @private - * @param {object} plugin - An object with `plugin` and `condition` keys - * @return {Pattern} this - The Pattern instance - */ -Pattern.prototype.__useIf = function (plugin) { - let load = 0 - for (const set of this.settings) { - if (plugin.condition(set)) load++ - } - if (load > 0) { - this.store.log.info( - `Condition met: Loaded plugin \`${plugin.plugin.name}:${plugin.plugin.version}\`` - ) - this.__loadPlugin(plugin.plugin, plugin.data) - } else { - this.store.log.info( - `Condition not met: Skipped loading plugin \`${plugin.plugin.name}:${plugin.plugin.version}\`` - ) - } - - return this -} - -/** - * Determines whether a part is wanted, depending on the 'only' setting and the configured dependencies - * - * @private - * @param {string} partName - Name of the part - * @param {int} set - The index of the set of settings - * @return {bool} wants - true if the part is wanted, or false if not - */ -Pattern.prototype.__wants = function (partName, set = 0) { - // Hidden parts are not wanted - if (this.__isPartHidden(partName)) return false - else if (typeof this.settings[set].only === 'string') return this.settings[set].only === partName - else if (Array.isArray(this.settings[set].only)) { - for (const part of this.settings[set].only) { - if (part === partName) return true - } - return false - } - - return true -} diff --git a/packages/core/src/pattern/index.mjs b/packages/core/src/pattern/index.mjs new file mode 100644 index 00000000000..71521b1b4c8 --- /dev/null +++ b/packages/core/src/pattern/index.mjs @@ -0,0 +1,461 @@ +import { Attributes } from '../attributes.mjs' +import { __addNonEnumProp } from '../utils.mjs' +import { Point } from '../point.mjs' +import { Path } from '../path.mjs' +import { Snippet } from '../snippet.mjs' +import { Store } from '../store.mjs' +import { version } from '../../data.mjs' +import { __loadPatternDefaults } from '../config.mjs' +import { PatternConfig } from './pattern-config.mjs' +import { PatternDrafter } from './pattern-drafter.mjs' +import { PatternSampler } from './pattern-sampler.mjs' +import { PatternPlugins } from './pattern-plugins.mjs' +import { PatternRenderer } from './pattern-renderer.mjs' +import cloneDeep from 'lodash.clonedeep' + +////////////////////////////////////////////// +// CONSTRUCTOR // +////////////////////////////////////////////// + +/** + * Constructor for a Pattern + * + * @constructor + * @param {object} config - The Design config + * @return {object} this - The Pattern instance + */ +export function Pattern(designConfig = {}) { + // Enumerable properties + this.designConfig = cloneDeep(designConfig) // The design configuration (unresolved) + this.config = {} // Will hold the resolved pattern after calling __init() + this.store = new Store() // Pattern-wide store + this.setStores = [] // Per-set stores + + // Non-enumerable properties + __addNonEnumProp(this, 'width', 0) + __addNonEnumProp(this, 'height', 0) + __addNonEnumProp(this, 'autoLayout', { stacks: {} }) + __addNonEnumProp(this, 'is', '') + __addNonEnumProp(this, 'Point', Point) + __addNonEnumProp(this, 'Path', Path) + __addNonEnumProp(this, 'Snippet', Snippet) + __addNonEnumProp(this, 'Attributes', Attributes) + __addNonEnumProp(this, '__initialized', false) + __addNonEnumProp(this, 'config.parts', {}) + __addNonEnumProp(this, 'config.resolvedDependencies', {}) + + __addNonEnumProp(this, 'plugins', new PatternPlugins(this)) + __addNonEnumProp(this, '__configResolver', new PatternConfig(this)) // handles config resolution during __init() as well as runtime part adding + + return this +} + +////////////////////////////////////////////// +// PUBLIC METHODS // +////////////////////////////////////////////// + +/////////// +// Setup // +/////////// + +/** + * Allows adding parts to the config at runtime + * + * @param {object} part - The part to add + * @param {boolean} resolveImmediately - Should the part be resolved now, or wait until the next call to {@link __init()}? + * It is useful to resolve immediately if one part is being added at runtime + * It might be useful to not resolve immediately if a number of parts will be added over multiple calls + * @return {object} this - The Pattern instance + */ +Pattern.prototype.addPart = function (part, resolveImmediately = true) { + if ( + this.__configResolver.isPartValid(part) && + !this.designConfig.parts.find((p) => p.name == part.name) + ) { + this.store.log.debug(`Adding Part \`${part.name}\` at runtime`) + this.designConfig.parts.push(part) + if (resolveImmediately) { + if (this.__configResolver.addPart(part) && typeof this.draftQueue !== 'undefined') + this.draftQueue.addPart(part.name) + } else this.__initialized = false + } + return this +} + +/** + * Return the initialized configuration + * + * @return {object} config - The initialized config + */ +Pattern.prototype.getConfig = function () { + return this.__init().config +} + +////////////////////////////// +// Plugin and Hook Handling // +////////////////////////////// + +/** + * Adds a lifecycle hook method to the pattern + * + * @param {string} hook - Name of the lifecycle hook + * @param {function} method - The method to run + * @param {object} data - Any data to pass to the hook method + * @return {object} this - The Pattern instance + */ +Pattern.prototype.on = function (hook, method, data) { + this.plugins.on(hook, method, data) + + return this +} + +/** + * Loads a plugin + * + * @param {object} plugin - The plugin to load + * @param {object} data - Any data to pass to the plugin + * @return {object} this - The Pattern instance + */ +Pattern.prototype.use = function (plugin, data) { + this.plugins.use(plugin, data, this.settings) + + return this +} + +////////////// +// Drafting // +////////////// +/** + * Drafts this pattern, aka the raison d'etre of FreeSewing + * + * @return {object} this - The Pattern instance + */ +Pattern.prototype.draft = function () { + this.__init() + new PatternDrafter(this).draft() + + return this +} + +Pattern.prototype.draftPartForSet = function (partName, set) { + this.__init() + return new PatternDrafter(this).draftPartForSet(partName, set) +} + +/////////////// +// Rendering // +/////////////// + +/** + * Renders the pattern to SVG + * + * @return {string} svg - The rendered SVG + */ +Pattern.prototype.render = function () { + return new PatternRenderer(this).render() +} + +/** Returns props required to render this pattern through + * an external renderer (eg. a React component) + * + * @return {object} this - The Pattern instance + */ +Pattern.prototype.getRenderProps = function () { + return new PatternRenderer(this).getRenderProps() +} + +////////////// +// Sampling // +////////////// + +/** + * Handles pattern sampling + * + * @return {object} this - The Pattern instance + */ +Pattern.prototype.sample = function () { + this.__init() + const sampleSetting = this.settings[0].sample + if (sampleSetting.type === 'option') { + return this.sampleOption(sampleSetting.option) + } else if (sampleSetting.type === 'measurement') { + return this.sampleMeasurement(sampleSetting.measurement) + } else if (sampleSetting.type === 'models') { + return this.sampleModels(sampleSetting.models, sampleSetting.focus || false) + } + return this.draft() +} + +/** + * Handles measurement sampling + * + * @return {object} this - The Pattern instance + */ +Pattern.prototype.sampleMeasurement = function (measurementName) { + return new PatternSampler(this).sampleMeasurement(measurementName) +} + +/** + * Handles models sampling + * + * @return {object} this - The Pattern instance + */ +Pattern.prototype.sampleModels = function (models, focus = false) { + return new PatternSampler(this).sampleModels(models, focus) +} + +/** + * Handles option sampling + * + * @return {object} this - The Pattern instance + */ +Pattern.prototype.sampleOption = function (optionName) { + return new PatternSampler(this).sampleOption(optionName) +} + +////////////////////////////////////////////// +// PRIVATE METHODS // +////////////////////////////////////////////// + +/////////// +// Setup // +/////////// + +/** + * Merges (sets of) settings with the default settings + * + * @private + * @param {Array} sets - An array of settings objects + * @return {object} this - The Pattern instance + */ +Pattern.prototype.__applySettings = function (sets) { + if (!Array.isArray(sets)) throw 'Sets should be an array of settings objects' + if (sets.length === 0) sets.push({}) // Required to load default settings + this.settings = [] + for (let i = 0; i < sets.length; i++) { + // Don't mutate the input itself + const set = { ...sets[i] } + if (!set.options) set.options = {} + if (!set.measurements) set.measurements = {} + this.settings.push({ + ...__loadPatternDefaults(), + ...set, + // Force creation of a new objects + // so we don't reference the original + options: { ...set.options }, + measurements: { ...set.measurements }, + }) + } + + return this +} + +/** + * Creates a store for a set (of settings) + * + * @private + * @return {Store} store - A new store populated with relevant data/methods + */ +Pattern.prototype.__createSetStore = function () { + const store = new Store() + store.set('data', this.store.data) + store.extend([...this.plugins.__storeMethods]) + + return store +} + +/** + * Initializes the pattern coniguration and settings + * + * @return {object} this - The Pattern instance + */ +Pattern.prototype.__init = function () { + if (this.__initialized) return this + + this.__runHooks('preInit') + // Say hello + this.store.log.info( + `New \`${this.designConfig.data?.name || 'No Name'}:` + + `${this.designConfig.data?.version || 'No version'}\` ` + + `pattern using \`@freesewing/core:${version}\`` + ) + + /* + * We allow late-stage updating of the design config (adding parts for example) + * so we need to do the things we used to do in the contructor at a later stage. + * This methods does that, and resolves the design config + user settings + */ + // Resolve parts + this.designConfig.parts.forEach((p) => this.__configResolver.addPart(p)) + + // Print final part distances. + this.__configResolver.logPartDistances() + + // get the config from the resolver + this.config = this.__configResolver.asConfig() + + // load resolved plugins + this.plugins.loadConfigPlugins(this.config, this.settings) + + // Make config data available in store + if (this.designConfig.data) this.store.set('data', this.designConfig.data) + + // Merges default options with user provided ones + this.__loadOptionDefaults() + + this.store.log.info(`Pattern initialized. Draft order is: ${this.config.draftOrder.join(', ')}`) + + this.__runHooks('postInit') + this.__initialized = true + + return this +} + +/** + * Merges defaults for options with user-provided options + * + * @private + * @return {Pattern} this - The Pattern instance + */ +Pattern.prototype.__loadOptionDefaults = function () { + if (!this.config.options) this.config.options = {} + if (Object.keys(this.config.options).length < 1) return this + for (const i in this.settings) { + for (const [name, option] of Object.entries(this.config.options)) { + // Don't overwrite user-provided settings.options + if (typeof this.settings[i].options[name] === 'undefined') { + if (typeof option === 'object') { + if (typeof option.pct !== 'undefined') this.settings[i].options[name] = option.pct / 100 + else if (typeof option.mm !== 'undefined') this.settings[i].options[name] = option.mm + else if (typeof option.deg !== 'undefined') this.settings[i].options[name] = option.deg + else if (typeof option.count !== 'undefined') + this.settings[i].options[name] = option.count + else if (typeof option.bool !== 'undefined') this.settings[i].options[name] = option.bool + else if (typeof option.dflt !== 'undefined') this.settings[i].options[name] = option.dflt + else { + let err = 'Unknown option type: ' + JSON.stringify(option) + this.store.log.error(err) + throw new Error(err) + } + } else this.settings[i].options[name] = option + } + } + } + + return this +} + +/////////// +// Hooks // +/////////// + +/** + * Runs subscriptions to a given lifecycle hook + * + * @private + * @param {string} hookName - Name of the lifecycle hook + * @param {obhect} data - Any data to pass to the hook method + * @return {Pattern} this - The Pattern instance + */ +Pattern.prototype.__runHooks = function (hookName, data = false) { + if (data === false) data = this + let hooks = this.plugins.hooks[hookName] + if (hooks.length > 0) { + this.store.log.debug(`Running \`${hookName}\` hooks`) + for (let hook of hooks) { + hook.method(data, hook.data) + } + } +} + +/////////////////////// +// Config Evaluation // +/////////////////////// + +/** + * Checks whether a part is hidden in the config + * + * @private + * @param {string} partName - Name of the part to check + * @return {bool} hidden - true if the part is hidden, or false if not + */ +Pattern.prototype.__isPartHidden = function (partName) { + const partHidden = this.parts?.[this.activeSet]?.[partName]?.hidden || false + if (Array.isArray(this.settings[this.activeSet || 0].only)) { + if (this.settings[this.activeSet || 0].only.includes(partName)) return partHidden + } + if (this.config.partHide?.[partName]) return true + + return partHidden +} + +/** + * Checks whether a stack is hidden in the config + * + * @private + * @param {string} stackName - Name of the stack to check + * @return {bool} hidden - true if the part is hidden, or false if not + */ +Pattern.prototype.__isStackHidden = function (stackName) { + if (!this.stacks[stackName]) return true + const parts = this.stacks[stackName].getPartNames() + for (const partName of parts) { + if (!this.__isPartHidden(partName)) return false + } + + return true +} + +/** + * Determines whether a part is needed, depending on the 'only' setting and the configured dependencies + * + * @private + * @param {string} partName - Name of the part + * @param {int} set - The index of the set of settings + * @return {bool} needs - true if the part is needed, or false if not + */ +Pattern.prototype.__needs = function (partName, set = 0) { + // If only is unset, all parts are needed + if ( + typeof this.settings[set].only === 'undefined' || + this.settings[set].only === false || + (Array.isArray(this.settings[set].only) && this.settings[set].only.length === 0) + ) + return true + + // Make only to always be an array + const only = + typeof this.settings[set].only === 'string' + ? [this.settings[set].only] + : this.settings[set].only + + // Walk the only parts, checking each one for a match in its dependencies + for (const part of only) { + if (part === partName) return true + if (this.config.resolvedDependencies[part]?.indexOf(partName) !== -1) return true + } + + return false +} + +/** + * Determines whether a part is wanted, depending on the 'only' setting and the configured dependencies + * + * @private + * @param {string} partName - Name of the part + * @param {int} set - The index of the set of settings + * @return {bool} wants - true if the part is wanted, or false if not + */ +Pattern.prototype.__wants = function (partName, set = 0) { + // Hidden parts are not wanted + if (this.__isPartHidden(partName)) return false + else if (typeof this.settings[set].only === 'string') return this.settings[set].only === partName + else if (Array.isArray(this.settings[set].only)) { + for (const part of this.settings[set].only) { + if (part === partName) return true + } + return false + } + + return true +} diff --git a/packages/core/src/pattern-config.mjs b/packages/core/src/pattern/pattern-config.mjs similarity index 97% rename from packages/core/src/pattern-config.mjs rename to packages/core/src/pattern/pattern-config.mjs index 7bac6c6fd2b..e0800eb0325 100644 --- a/packages/core/src/pattern-config.mjs +++ b/packages/core/src/pattern/pattern-config.mjs @@ -1,4 +1,5 @@ -import { __addNonEnumProp } from './utils.mjs' +import { __addNonEnumProp } from '../utils.mjs' +import { getPluginName } from './pattern-plugins.mjs' export const hidePresets = { HIDE_ALL: { @@ -13,17 +14,6 @@ export const hidePresets = { }, } -/** - * Get the name of the given plugin config - * - * @param {(Object|Object[])} plugin the plugin to get the name of - * @return {(string|false)} the name, or false if there isn't one - */ -export function getPluginName(plugin) { - const toCheck = Array.isArray(plugin) ? plugin[0] : plugin - return toCheck.name || toCheck.plugin?.name || false -} - ///////////////// // CONSTRUCTOR // ///////////////// @@ -76,7 +66,7 @@ export function PatternConfig(pattern) { const DISTANCE_DEBUG = false //////////////////// -// PUBLIC METHODs // +// PUBLIC METHODS // //////////////////// /** diff --git a/packages/core/src/pattern-draft-queue.mjs b/packages/core/src/pattern/pattern-draft-queue.mjs similarity index 100% rename from packages/core/src/pattern-draft-queue.mjs rename to packages/core/src/pattern/pattern-draft-queue.mjs diff --git a/packages/core/src/pattern/pattern-drafter.mjs b/packages/core/src/pattern/pattern-drafter.mjs new file mode 100644 index 00000000000..fe85faed41a --- /dev/null +++ b/packages/core/src/pattern/pattern-drafter.mjs @@ -0,0 +1,241 @@ +import { PatternDraftQueue } from './pattern-draft-queue.mjs' +import { Part } from '../part.mjs' +import { __macroName } from '../utils.mjs' + +/** + * A class to handle drafting a pattern + * @param {Pattern} pattern the pattern to draft + */ +export function PatternDrafter(pattern) { + this.pattern = pattern +} + +/** + * Drafts this pattern, aka the raison d'etre of FreeSewing + * + * @return {object} this - The Pattern instance + */ +PatternDrafter.prototype.draft = function () { + this.pattern.draftQueue = new PatternDraftQueue(this.pattern) + this.pattern.__runHooks('preDraft') + // Keep container for drafted parts fresh + this.pattern.parts = [] + + // Iterate over the provided sets of settings (typically just one) + for (const set in this.pattern.settings) { + this.pattern.setStores[set] = this.pattern.__createSetStore() + this.__useSet(set) + + this.activeStore.log.debug(`Initialized store for set ${set}`) + this.pattern.__runHooks('preSetDraft') + this.activeStore.log.debug(`📐 Drafting pattern for set ${set}`) + + // Create parts container + this.pattern.parts[set] = {} + + // Handle snap for pct options + this.__loadAbsoluteOptionsSet(set) + + // draft all the parts for this set + this.pattern.draftQueue.start() + while (this.pattern.draftQueue.hasNext()) { + const partName = this.pattern.draftQueue.next() + if (this.pattern.__needs(partName, set)) { + this.draftPartForSet(partName, set) + } else { + this.activeStore.log.debug(`Part \`${partName}\` is not needed. Skipping part`) + } + } + this.pattern.__runHooks('postSetDraft') + } + this.pattern.__runHooks('postDraft') +} + +/** + * Draft and save a part for the given set of settings + * @param {String} partName the name of the part + * @param {number} set the index of the settings set + * @return {Part} the drafted part, which is also stored in the Pattern + */ +PatternDrafter.prototype.draftPartForSet = function (partName, set) { + // gotta protect against attacks + if (set === '__proto__') { + throw new Error('malicious attempt at altering Object.prototype. Stopping action') + } + this.__useSet(set) + this.__createPartForSet(partName, set) + + // don't draft what can't be drafted + const configPart = this.pattern.config.parts?.[partName] + if (typeof configPart?.draft !== 'function') { + this.activeStore.log.error( + `Unable to draft pattern part __${partName}__. Part.draft() is not callable` + ) + return + } + + // set the active part for use by hooks and such + this.pattern.activePart = partName + this.activeStore.set('activePart', partName) + try { + this.pattern.__runHooks('prePartDraft') + // draft + const result = configPart.draft(this.pattern.parts[set][partName].shorthand()) + + if (typeof result === 'undefined') { + this.activeStore.log.error( + `Result of drafting part ${partName} was undefined. Did you forget to return the part?` + ) + } else { + // hide if necessary + if (!this.pattern.__wants(partName, set)) result.hide() + this.pattern.__runHooks('postPartDraft') + // save the result + this.pattern.parts[set][partName] = result + } + return result + } catch (err) { + this.activeStore.log.error([`Unable to draft part \`${partName}\` (set ${set})`, err]) + } +} + +/** + * Create a part for the given set of settings. + * Handles injection + * @param {String} partName the name of the part to create + * @param {Number} set the settings index + * @private + */ +PatternDrafter.prototype.__createPartForSet = function (partName, set = 0) { + // gotta protect against attacks + if (set === '__proto__') { + throw new Error('malicious attempt at altering Object.prototype. Stopping action') + } + // Create parts + this.activeStore.log.debug(`📦 Creating part \`${partName}\` (set ${set})`) + this.pattern.parts[set][partName] = this.__createPartWithContext(partName, set) + + // Handle inject/inheritance + const parent = this.pattern.config.inject[partName] + if (typeof parent === 'string') { + this.activeStore.log.debug(`Creating part \`${partName}\` from part \`${parent}\``) + try { + this.pattern.parts[set][partName].__inject(this.pattern.parts[set][parent]) + } catch (err) { + this.activeStore.log.error([ + `Could not inject part \`${parent}\` into part \`${partName}\``, + err, + ]) + } + } +} + +/** + * Instantiates a new Part instance and populates it with the pattern context + * + * @private + * @param {string} name - The name of the part + * @param {int} set - The index of the settings set in the list of sets + * @return {Part} part - The instantiated Part + */ +PatternDrafter.prototype.__createPartWithContext = function (name, set) { + // Context object to add to Part closure + const part = new Part() + part.name = name + part.set = set + part.stack = this.pattern.config.parts[name]?.stack || name + part.context = { + parts: this.pattern.parts[set], + config: this.pattern.config, + settings: this.pattern.settings[set], + store: this.pattern.setStores[set], + macros: this.pattern.plugins.macros, + } + + if (this.pattern.settings[set]?.partClasses) { + part.attr('class', this.pattern.settings[set].partClasses) + } + + for (const macro in this.pattern.plugins.macros) { + part[__macroName(macro)] = this.pattern.plugins.macros[macro] + } + + return part +} + +/** + * Generates an array of settings.absoluteOptions objects for sampling a list option + * + * @private + * @param {string} optionName - Name of the option to sample + * @return {Array} sets - The list of settings objects + */ +PatternDrafter.prototype.__loadAbsoluteOptionsSet = function (set) { + for (const optionName in this.pattern.settings[set].options) { + const option = this.pattern.config.options[optionName] + if ( + typeof option !== 'undefined' && + typeof option.snap !== 'undefined' && + option.toAbs instanceof Function + ) { + this.pattern.settings[set].absoluteOptions[optionName] = this.__snappedPercentageOption( + optionName, + set + ) + this.pattern.setStores[set].log.debug( + `🧲 Snapped __${optionName}__ to \`${this.pattern.settings[set].absoluteOptions[optionName]}\` for set __${set}__` + ) + } + } + + return this +} + +/** + * Returns the absolute value of a snapped percentage option + * + * @private + * @param {string} optionName - The name of the option + * @param {int} set - The index of the set in the list of settings + * @return {float} abs - The absolute value of the snapped option + */ +PatternDrafter.prototype.__snappedPercentageOption = function (optionName, set) { + const conf = this.pattern.config.options[optionName] + const abs = conf.toAbs(this.pattern.settings[set].options[optionName], this.pattern.settings[set]) + // Handle units-specific config - Side-step immutability for the snap conf + let snapConf = conf.snap + if (!Array.isArray(snapConf) && snapConf.metric && snapConf.imperial) + snapConf = snapConf[this.pattern.settings[set].units] + // Simple steps + if (typeof snapConf === 'number') return Math.ceil(abs / snapConf) * snapConf + // List of snaps + if (Array.isArray(snapConf) && snapConf.length > 1) { + for (const snap of snapConf + .sort((a, b) => a - b) + .map((snap, i) => { + const margin = + i < snapConf.length - 1 + ? (snapConf[Number(i) + 1] - snap) / 2 // Look forward + : (snap - snapConf[i - 1]) / 2 // Final snap, look backward + + return { + min: snap - margin, + max: snap + Number(margin), + snap, + } + })) + if (abs <= snap.max && abs >= snap.min) return snap.snap + } + + return abs +} + +/** + * Sets the active set + * @param {Number} set the set to use + * @private + */ +PatternDrafter.prototype.__useSet = function (set = 0) { + this.pattern.activeSet = set + this.activeStore = this.pattern.setStores[set] +} diff --git a/packages/core/src/pattern/pattern-plugins.mjs b/packages/core/src/pattern/pattern-plugins.mjs new file mode 100644 index 00000000000..a8006abf0ec --- /dev/null +++ b/packages/core/src/pattern/pattern-plugins.mjs @@ -0,0 +1,189 @@ +import { Hooks } from '../hooks.mjs' + +/** + * Get the name of the given plugin config + * + * @param {(Object|Object[])} plugin the plugin to get the name of + * @return {(string|false)} the name, or false if there isn't one + */ +export function getPluginName(plugin) { + const toCheck = Array.isArray(plugin) ? plugin[0] : plugin + return toCheck.name || toCheck.plugin?.name || false +} + +/** + * A class for managing the plugins and lifecycle hooks of a pattern + * @param {Pattern} pattern the pattern to manage + */ +export function PatternPlugins(pattern) { + this.store = pattern.store + + this.plugins = {} + this.hooks = new Hooks() + this.macros = {} + this.__storeMethods = new Set() +} + +/** + * Loads the plugins that are part of the config + * + * @private + * @return {Pattern} this - The Pattern instance + */ +PatternPlugins.prototype.loadConfigPlugins = function (config, settings) { + if (!config.plugins) return this + for (const plugin in config.plugins) + this.use(config.plugins[plugin], config.plugins[plugin]?.data, settings) + return this +} + +/** + * Adds a lifecycle hook method to the pattern + * + * @param {string} hook - Name of the lifecycle hook + * @param {function} method - The method to run + * @param {object} data - Any data to pass to the hook method + * @return {object} this - The Pattern instance + */ +PatternPlugins.prototype.on = function (hook, method, data) { + for (const added of this.hooks[hook]) { + // Don't add it twice + if (added.method === method) return this + } + this.hooks[hook].push({ method, data }) + + return this +} + +/** + * Loads a plugin + * + * @param {object} plugin - The plugin to load + * @param {object} data - Any data to pass to the plugin + * @return {object} this - The Pattern instance + */ +PatternPlugins.prototype.use = function (plugin, data, settings = [{}]) { + const name = getPluginName(plugin) + if (!this.plugins?.[name]) + return plugin.plugin && plugin.condition + ? this.__useIf(plugin, data, settings) // Conditional plugin + : this.__loadPlugin(plugin, data) // Regular plugin + + this.store.log.info(`Plugin \`${name}\` was requested, but it's already loaded. Skipping.`) + + return this +} + +/** + * Loads a plugin + * + * @private + * @param {object} plugin - The plugin object, or an object with `plugin` and `condition` keys + * @param {object} data - Any plugin data to load + * @return {Pattern} this - The Pattern instance + */ +PatternPlugins.prototype.__loadPlugin = function (plugin, data) { + const name = getPluginName(plugin) + this.plugins[name] = plugin + if (plugin.hooks) this.__loadPluginHooks(plugin, data) + if (plugin.macros) this.__loadPluginMacros(plugin) + if (plugin.store) this.__loadPluginStoreMethods(plugin) + this.store.log.info(`Loaded plugin \`${plugin.name}:${plugin.version}\``) + + return this +} + +/** + * Loads a plugin's hooks + * + * @private + * @param {object} plugin - The plugin object + * @param {object} data - Any plugin data to load + * @return {Pattern} this - The Pattern instance + */ +PatternPlugins.prototype.__loadPluginHooks = function (plugin, data) { + // console.log('hooks', plugin) + for (let hook of Object.keys(this.hooks)) { + if (typeof plugin.hooks[hook] === 'function') { + this.on(hook, plugin.hooks[hook], data) + } else if (Array.isArray(plugin.hooks[hook])) { + for (let method of plugin.hooks[hook]) { + this.on(hook, method, data) + } + } + } + + return this +} + +/** + * Loads a plugin's macros + * + * @private + * @param {object} plugin - The plugin object + * @return {Pattern} this - The Pattern instance + */ +PatternPlugins.prototype.__loadPluginMacros = function (plugin) { + // console.log('macros', plugin) + for (let macro in plugin.macros) { + if (typeof plugin.macros[macro] === 'function') { + this.__macro(macro, plugin.macros[macro]) + } + } +} + +/** + * Loads a plugin's store methods + * + * @private + * @param {object} plugin - The plugin object + * @return {Pattern} this - The Pattern instance + */ +PatternPlugins.prototype.__loadPluginStoreMethods = function (plugin) { + if (Array.isArray(plugin.store)) { + for (const method of plugin.store) this.__storeMethods.add(method) + } else this.store.log.warning(`Plugin store methods should be an Array`) + + // console.log('store', plugin, this.__storeMethods) + return this +} + +/** + * Sets a method for a macro + * + * @private + * @param {string} macro - Name of the macro to run + * @param {function} method - The macro method + * @return {object} this - The Pattern instance + */ +PatternPlugins.prototype.__macro = function (key, method) { + this.macros[key] = method + + return this +} + +/** + * Loads a conditional plugin + * + * @private + * @param {object} plugin - An object with `plugin` and `condition` keys + * @return {Pattern} this - The Pattern instance + */ +PatternPlugins.prototype.__useIf = function (plugin, settings = [{}]) { + let load = 0 + for (const set of settings) { + if (plugin.condition(set)) load++ + } + if (load > 0) { + this.store.log.info( + `Condition met: Loaded plugin \`${plugin.plugin.name}:${plugin.plugin.version}\`` + ) + this.__loadPlugin(plugin.plugin, plugin.data) + } else { + this.store.log.info( + `Condition not met: Skipped loading plugin \`${plugin.plugin.name}:${plugin.plugin.version}\`` + ) + } + + return this +} diff --git a/packages/core/src/pattern/pattern-renderer.mjs b/packages/core/src/pattern/pattern-renderer.mjs new file mode 100644 index 00000000000..4e418b0f0f7 --- /dev/null +++ b/packages/core/src/pattern/pattern-renderer.mjs @@ -0,0 +1,187 @@ +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 + * @param {Pattern} pattern the pattern to layout or render + */ +export function PatternRenderer(pattern) { + this.pattern = pattern + this.autoLayout = pattern.autoLayout +} + +/** + * Renders the pattern to SVG + * + * @return {string} svg - The rendered SVG + */ +PatternRenderer.prototype.render = function () { + this.__startRender() + this.pattern.svg = this.svg + return this.svg.render() +} + +/** Returns props required to render this pattern through + * an external renderer (eg. a React component) + * + * @return {object} this - The Pattern instance + */ +PatternRenderer.prototype.getRenderProps = function () { + this.pattern.store.log.info('Gathering render props') + // Run pre-render hook + this.__startRender() + this.svg.__runHooks('preRender') + + let props = { + svg: this.svg, + width: this.pattern.width, + height: this.pattern.height, + autoLayout: this.pattern.autoLayout, + settings: this.pattern.settings, + parts: [], + stacks: {}, + } + + for (const partSet of this.pattern.parts) { + const setPartProps = {} + for (let partName in partSet) { + const part = partSet[partName] + if (!part.hidden) { + setPartProps[partName] = { + ...partSet[partName].asProps(), + store: this.pattern.setStores[part.set], + } + } else if (this.pattern.setStores[part.set]) { + this.pattern.setStores[part.set].log.info( + `Part ${partName} is hidden in set ${part.set}. Not adding to render props` + ) + } + } + props.parts.push(setPartProps) + } + + for (let s in this.pattern.stacks) { + if (!this.pattern.__isStackHidden(s)) { + props.stacks[s] = this.pattern.stacks[s].asProps() + } else this.pattern.store.log.info(`Stack ${s} is hidden. Skipping in render props.`) + } + + props.logs = { + pattern: this.pattern.store.logs, + sets: this.pattern.setStores.map((store) => store.logs), + } + + this.svg.__runHooks('postRender') + return props +} + +PatternRenderer.prototype.__startRender = function () { + this.svg = new Svg(this.pattern) + this.svg.hooks = this.pattern.plugins.hooks + this.__pack() + + return this +} + +PatternRenderer.prototype.__stack = function () { + // First, create all stacks + this.stacks = {} + const settings = this.pattern.settings + for (const set in settings) { + for (const [name, part] of Object.entries(this.pattern.parts[set])) { + const stackName = + settings[set].stackPrefix + + (typeof part.stack === 'function' ? part.stack(settings[set], name) : part.stack) + if (typeof this.stacks[stackName] === 'undefined') + this.stacks[stackName] = this.__createStackWithContext(stackName, set) + this.stacks[stackName].addPart(part) + } + } + + this.pattern.stacks = this.stacks +} +/** + * Packs stacks in a 2D space and sets pattern size + * + * @private + * @return {Pattern} this - The Pattern instance + */ +PatternRenderer.prototype.__pack = function () { + this.pattern.__runHooks('preLayout') + const { settings, setStores, activeSet } = this.pattern + for (const set in settings) { + if (setStores[set].logs.error.length > 0) { + setStores[set].log.warning(`One or more errors occured. Not packing pattern parts`) + return this + } + } + + this.__stack() + + let bins = [] + for (const [key, stack] of Object.entries(this.stacks)) { + // Avoid multiple render calls to cause addition of transforms + stack.attributes.remove('transform') + if (!this.pattern.__isStackHidden(key)) { + stack.home() + if (settings[activeSet].layout === true) + bins.push({ id: key, width: stack.width, height: stack.height }) + } + } + 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 }) + this.autoLayout.width = size.width + this.autoLayout.height = size.height + + for (let bin of bins) { + let stack = this.stacks[bin.id] + this.autoLayout.stacks[bin.id] = { + move: { + x: bin.x + stack.layout.move.x, + y: bin.y + stack.layout.move.y, + }, + } + } + } + + const packedLayout = + typeof settings[activeSet].layout === 'object' ? settings[activeSet].layout : this.autoLayout + + this.width = packedLayout.width + this.height = packedLayout.height + for (let stackId of Object.keys(packedLayout.stacks)) { + // Some parts are added by late-stage plugins + if (this.stacks[stackId]) { + let transforms = packedLayout.stacks[stackId] + this.stacks[stackId].generateTransform(transforms) + } + } + + this.pattern.width = this.width + this.pattern.height = this.height + this.pattern.autoLayout = this.autoLayout + this.pattern.__runHooks('postLayout') + return this +} + +/** + * Instantiates a new Stack instance and populates it with the pattern context + * + * @private + * @param {string} name - The name of the stack + * @return {Stack} stack - The instantiated Stack + */ +PatternRenderer.prototype.__createStackWithContext = function (name) { + // Context object to add to Stack closure + const stack = new Stack() + stack.name = name + stack.context = { + config: this.pattern.config, + settings: this.pattern.settings, + setStores: this.pattern.setStores, + } + + return stack +} diff --git a/packages/core/src/pattern/pattern-sampler.mjs b/packages/core/src/pattern/pattern-sampler.mjs new file mode 100644 index 00000000000..07e5687765c --- /dev/null +++ b/packages/core/src/pattern/pattern-sampler.mjs @@ -0,0 +1,216 @@ +/** + * A class for handling pattern sampling + * @param {Pattern} pattern the pattern that will be sampled + */ +export function PatternSampler(pattern) { + this.pattern = pattern +} + +/** + * Handles measurement sampling + * + * @return {object} this - The Pattern instance + */ +PatternSampler.prototype.sampleMeasurement = function (measurementName) { + this.pattern.store.log.debug(`Sampling measurement \`${measurementName}\``) + this.pattern.__runHooks('preSample') + this.pattern.__applySettings(this.__measurementSets(measurementName)) + this.pattern.__init() + this.pattern.__runHooks('postSample') + + return this.pattern.draft() +} + +/** + * Handles models sampling + * + * @return {object} this - The Pattern instance + */ +PatternSampler.prototype.sampleModels = function (models, focus = false) { + this.pattern.store.log.debug(`Sampling models \`${Object.keys(models).join(', ')}\``) + this.pattern.__runHooks('preSample') + this.pattern.__applySettings(this.__modelSets(models, focus)) + this.pattern.__init() + this.pattern.__runHooks('postSample') + + return this.pattern.draft() +} + +/** + * Handles option sampling + * + * @return {object} this - The Pattern instance + */ +PatternSampler.prototype.sampleOption = function (optionName) { + this.pattern.store.log.debug(`Sampling option \`${optionName}\``) + this.pattern.__runHooks('preSample') + this.pattern.__applySettings(this.__optionSets(optionName)) + this.pattern.__init() + this.pattern.__runHooks('postSample') + + return this.pattern.draft() +} + +/** + * Generates an array of settings.options objects for sampling a list or boolean option + * + * @private + * @param {string} optionName - Name of the option to sample + * @return {Array} sets - The list of settings objects + */ +PatternSampler.prototype.__listBoolOptionSets = function (optionName) { + let option = this.pattern.config.options[optionName] + const base = this.__setBase() + const sets = [] + let run = 1 + if (typeof option.bool !== 'undefined') option = { list: [false, true] } + for (const choice of option.list) { + const settings = { + ...base, + options: { + ...base.options, + }, + idPrefix: `sample-${run}`, + partClasses: `sample-${run}`, + } + settings.options[optionName] = choice + sets.push(settings) + run++ + } + + return sets +} + +/** + * Generates an array of settings objects for sampling a measurement + * + * @private + * @param {string} measurementName - The name of the measurement to sample + * @return {Array} sets - The list of settings objects + */ +PatternSampler.prototype.__measurementSets = function (measurementName) { + let val = this.pattern.settings[0].measurements[measurementName] + if (val === undefined) + this.pattern.store.log.error( + `Cannot sample measurement \`${measurementName}\` because it's \`undefined\`` + ) + let step = val / 50 + val = val * 0.9 + const sets = [] + const base = this.__setBase() + for (let run = 1; run < 11; run++) { + const settings = { + ...base, + measurements: { + ...base.measurements, + }, + idPrefix: `sample-${run}`, + partClasses: `sample-${run}`, + } + settings.measurements[measurementName] = val + sets.push(settings) + val += step + } + + return sets +} + +/** + * Generates an array of settings objects for sampling a list of models + * + * @private + * @param {object} models - The models to sample + * @param {string} focus - The ID of the model that should be highlighted + * @return {Array} sets - The list of settings objects + */ +PatternSampler.prototype.__modelSets = function (models, focus = false) { + const sets = [] + const base = this.__setBase() + let run = 1 + // If there's a focus, do it first so it's at the bottom of the SVG + if (focus) { + sets.push({ + ...base, + measurements: models[focus], + idPrefix: `sample-${run}`, + partClasses: `sample-${run} sample-focus`, + }) + run++ + delete models[focus] + } + for (const measurements of Object.values(models)) { + sets.push({ + ...base, + measurements, + idPrefix: `sample-${run}`, + partClasses: `sample-${run}`, + }) + } + + return sets +} + +/** + * Generates an array of settings objects for sampling an option + * + * @private + * @param {string} optionName - The name of the option to sample + * @return {Array} sets - The list of settings objects + */ +PatternSampler.prototype.__optionSets = function (optionName) { + const sets = [] + if (!(optionName in this.pattern.config.options)) return sets + let option = this.pattern.config.options[optionName] + if (typeof option.list === 'object' || typeof option.bool !== 'undefined') + return this.__listBoolOptionSets(optionName) + let factor = 1 + let step, val + let numberRuns = 10 + let stepFactor = numberRuns - 1 + if (typeof option.min === 'undefined' || typeof option.max === 'undefined') { + const min = option * 0.9 + const max = option * 1.1 + option = { min, max } + } + if (typeof option.pct !== 'undefined') factor = 100 + val = option.min / factor + if (typeof option.count !== 'undefined' || typeof option.mm !== 'undefined') { + const numberOfCounts = option.max - option.min + 1 + if (numberOfCounts < 10) { + numberRuns = numberOfCounts + stepFactor = Math.max(numberRuns - 1, 1) + } + } + step = (option.max / factor - val) / stepFactor + const base = this.__setBase() + const roundVal = typeof option.count !== 'undefined' || typeof option.mm !== 'undefined' + for (let run = 1; run <= numberRuns; run++) { + const settings = { + ...base, + options: { + ...base.options, + }, + idPrefix: `sample-${run}`, + partClasses: `sample-${run}`, + } + settings.options[optionName] = roundVal ? Math.ceil(val) : val + sets.push(settings) + val += step + } + + return sets +} + +/** + * Returns the base/defaults to generate a set of settings + * + * @private + * @return {object} settings - The settings object + */ +PatternSampler.prototype.__setBase = function () { + return { + measurements: {}, + options: {}, + ...this.pattern.settings[0], + } +} diff --git a/packages/core/src/utils.mjs b/packages/core/src/utils.mjs index 30840c42b4e..aea6af3d686 100644 --- a/packages/core/src/utils.mjs +++ b/packages/core/src/utils.mjs @@ -320,7 +320,7 @@ export const generateStackTransform = ( // add the scaling to the transforms if (scaleX + scaleY < 2) { - transforms.push(`scale(${scaleX} ${scaleY})`) + transforms.push(`scale(${scaleX}, ${scaleY})`) } if (rotate) { @@ -331,11 +331,11 @@ export const generateStackTransform = ( } // add the rotation around the center to the transforms - transforms.push(`rotate(${rotate} ${center.x} ${center.y})`) + transforms.push(`rotate(${rotate}, ${center.x}, ${center.y})`) } // put the translation before any other transforms to avoid having to make complex calculations once the matrix has been rotated or scaled - if (xTotal !== 0 || yTotal !== 0) transforms.unshift(`translate(${xTotal} ${yTotal})`) + if (xTotal !== 0 || yTotal !== 0) transforms.unshift(`translate(${xTotal}, ${yTotal})`) return { transform: transforms.join(' '), diff --git a/packages/core/tests/hooks.test.mjs b/packages/core/tests/hooks.test.mjs index 20b24fe2624..1985f840ff4 100644 --- a/packages/core/tests/hooks.test.mjs +++ b/packages/core/tests/hooks.test.mjs @@ -6,7 +6,7 @@ const expect = chai.expect describe('Hooks', () => { it('Should contain all hooks', () => { const pattern = new Pattern() - const h = pattern.hooks + const h = pattern.plugins.hooks const test = { preInit: [], postInit: [], diff --git a/packages/core/tests/pattern-draft.test.mjs b/packages/core/tests/pattern-draft.test.mjs index b1b06850344..bceaa10221f 100644 --- a/packages/core/tests/pattern-draft.test.mjs +++ b/packages/core/tests/pattern-draft.test.mjs @@ -33,18 +33,6 @@ describe('Pattern', () => { expect(count).to.equal(2) }) }) - describe('Pattern.createPartForSet()', () => { - it('Should not allow malicious assignment to Object.prototype', () => { - const objProto = Object.prototype - const Pattern = new Design() - const pattern = new Pattern() - - expect(() => pattern.createPartForSet('part', '__proto__')).to.throw( - 'malicious attempt at altering Object.prototype. Stopping action' - ) - expect(objProto).to.equal(Object.prototype) - }) - }) it('Should check whether a part is needed', () => { const partA = { name: 'test.partA', @@ -175,7 +163,7 @@ describe('Pattern', () => { ) }) - describe('Pattern.__pack()', () => { + describe('PatternRenderer.__pack()', () => { it('should get a part stack name from a function that uses settings', () => { const expectedName = 'namedStack' const front = { @@ -201,7 +189,7 @@ describe('Pattern', () => { const pattern = new Test() pattern.draft() - pattern.__pack() + pattern.getRenderProps() const stackNames = Object.keys(pattern.stacks) expect(stackNames).to.include(expectedName) diff --git a/packages/core/tests/pattern-init.test.mjs b/packages/core/tests/pattern-init.test.mjs index 743d8bc5ab7..b24ec6a2ebe 100644 --- a/packages/core/tests/pattern-init.test.mjs +++ b/packages/core/tests/pattern-init.test.mjs @@ -18,7 +18,7 @@ describe('Pattern', () => { expect(Array.isArray(pattern.setStores)).to.equal(true) expect(typeof pattern.store).to.equal('object') expect(typeof pattern.config).to.equal('object') - expect(Object.keys(pattern).length).to.equal(5) + expect(Object.keys(pattern)).to.have.lengthOf(5) }) it('Pattern constructor should add non-enumerable properties', () => { @@ -26,18 +26,10 @@ describe('Pattern', () => { const pattern = new Pattern() expect(typeof pattern.plugins).to.equal('object') expect(typeof pattern.autoLayout).to.equal('object') - expect(typeof pattern.hooks).to.equal('object') expect(typeof pattern.Point).to.equal('function') expect(typeof pattern.Path).to.equal('function') expect(typeof pattern.Snippet).to.equal('function') expect(typeof pattern.Attributes).to.equal('function') - expect(typeof pattern.macros).to.equal('object') - // expect(typeof pattern.__designParts).to.equal('object') - // expect(typeof pattern.config.inject).to.equal('object') - // expect(typeof pattern.config.directDependencies).to.equal('object') - // expect(typeof pattern.__resolvedDependencies).to.equal('object') - // expect(typeof pattern.__hide).to.equal('object') - // expect(Array.isArray(pattern.__draftOrder)).to.equal(true) expect(pattern.width).to.equal(0) expect(pattern.height).to.equal(0) expect(pattern.is).to.equal('') @@ -58,7 +50,7 @@ describe('Pattern', () => { absoluteOptions: {}, } for (const [key, value] of Object.entries(dflts)) { - if (typeof value === 'object') expect(Object.keys(value).length).to.equal(0) + if (typeof value === 'object') expect(Object.keys(value)).to.have.lengthOf(0) else expect(pattern.settings[0][key]).to.equal(value) } }) @@ -120,19 +112,19 @@ describe('Pattern', () => { }) it('Pattern.__init() should resolve required measurements', () => { - expect(pattern.config.measurements.length).to.equal(2) + expect(pattern.config.measurements).to.have.lengthOf(2) expect(pattern.config.measurements[0]).to.equal('head') expect(pattern.config.measurements[1]).to.equal('knee') }) it('Pattern.__init() should resolve optional measurements', () => { - expect(pattern.config.optionalMeasurements.length).to.equal(2) + expect(pattern.config.optionalMeasurements).to.have.lengthOf(2) expect(pattern.config.optionalMeasurements[0]).to.equal('chest') expect(pattern.config.optionalMeasurements[1]).to.equal('waist') }) it('Pattern.__init() should resolve options', () => { - expect(Object.keys(pattern.config.options).length).to.equal(3) + expect(Object.keys(pattern.config.options)).to.have.lengthOf(3) for (const [key, value] of Object.entries(partA.options.optA)) { expect(pattern.config.options.optA[key]).to.equal(value) } @@ -149,7 +141,7 @@ describe('Pattern', () => { }) it('Pattern.__init() should resolve plugins', () => { - expect(Object.keys(pattern.config.plugins).length).to.equal(1) + expect(Object.keys(pattern.config.plugins)).to.have.lengthOf(1) }) it('Pattern.__init() should set config data in the store', () => { @@ -160,12 +152,12 @@ describe('Pattern', () => { it('Pattern.__init() should resolve dependencies', () => { expect(typeof pattern.config.resolvedDependencies).to.equal('object') expect(Array.isArray(pattern.config.resolvedDependencies['test.partA'])).to.equal(true) - expect(pattern.config.resolvedDependencies['test.partA'].length).to.equal(0) + expect(pattern.config.resolvedDependencies['test.partA']).to.have.lengthOf(0) expect(Array.isArray(pattern.config.resolvedDependencies['test.partB'])).to.equal(true) - expect(pattern.config.resolvedDependencies['test.partB'].length).to.equal(1) + expect(pattern.config.resolvedDependencies['test.partB']).to.have.lengthOf(1) expect(pattern.config.resolvedDependencies['test.partB'][0]).to.equal('test.partA') expect(Array.isArray(pattern.config.resolvedDependencies['test.partC'])).to.equal(true) - expect(pattern.config.resolvedDependencies['test.partC'].length).to.equal(2) + expect(pattern.config.resolvedDependencies['test.partC']).to.have.lengthOf(2) expect( pattern.config.resolvedDependencies['test.partC'].indexOf('test.partA') !== -1 ).to.equal(true) @@ -325,13 +317,13 @@ describe('Pattern', () => { const design = new Design({ parts: [partC] }) const pattern = new design().addPart(partR).draft() // Measurements - expect(pattern.config.measurements.length).to.equal(4) + expect(pattern.config.measurements).to.have.lengthOf(4) expect(pattern.config.measurements.indexOf('measieA') === -1).to.equal(false) expect(pattern.config.measurements.indexOf('measieB') === -1).to.equal(false) expect(pattern.config.measurements.indexOf('measieC') === -1).to.equal(false) expect(pattern.config.measurements.indexOf('measieR') === -1).to.equal(false) // Optional measurements - expect(pattern.config.optionalMeasurements.length).to.equal(4) + expect(pattern.config.optionalMeasurements).to.have.lengthOf(4) expect(pattern.config.optionalMeasurements.indexOf('optmeasieA') === -1).to.equal(false) expect(pattern.config.optionalMeasurements.indexOf('optmeasieB') === -1).to.equal(false) expect(pattern.config.optionalMeasurements.indexOf('optmeasieC') === -1).to.equal(false) @@ -473,13 +465,13 @@ describe('Pattern', () => { const design = new Design({ parts: [partD] }) const pattern = new design().draft() // Measurements - expect(pattern.config.measurements.length).to.equal(4) + expect(pattern.config.measurements).to.have.lengthOf(4) expect(pattern.config.measurements.indexOf('measieA') === -1).to.equal(false) expect(pattern.config.measurements.indexOf('measieB') === -1).to.equal(false) expect(pattern.config.measurements.indexOf('measieC') === -1).to.equal(false) expect(pattern.config.measurements.indexOf('measieD') === -1).to.equal(false) // Optional measurements - expect(pattern.config.optionalMeasurements.length).to.equal(4) + expect(pattern.config.optionalMeasurements).to.have.lengthOf(4) expect(pattern.config.optionalMeasurements.indexOf('optmeasieA') === -1).to.equal(false) expect(pattern.config.optionalMeasurements.indexOf('optmeasieB') === -1).to.equal(false) expect(pattern.config.optionalMeasurements.indexOf('optmeasieC') === -1).to.equal(false) @@ -578,7 +570,7 @@ describe('Pattern', () => { const design = new Design({ parts: [part] }) const pattern = new design() pattern.draft() - expect(pattern.hooks.preRender.length).to.equal(1) + expect(pattern.plugins.hooks.preRender).to.have.lengthOf(1) }) it('Pattern.__init() should load array of plugins', () => { @@ -608,7 +600,7 @@ describe('Pattern', () => { const design = new Design({ parts: [part] }) const pattern = new design() pattern.__init() - expect(pattern.hooks.preRender.length).to.equal(2) + expect(pattern.plugins.hooks.preRender).to.have.lengthOf(2) }) it('Pattern.__init() should load conditional plugin if condition is met', () => { @@ -629,11 +621,11 @@ describe('Pattern', () => { } const design = new Design({ parts: [part] }) const pattern = new design() - pattern.draft() - expect(pattern.hooks.preRender.length).to.equal(1) + pattern.__init() + expect(pattern.plugins.hooks.preRender).to.have.lengthOf(1) }) - it('Pattern.__init() should not load conditional plugin if condition is not mett', () => { + it('Pattern.__init() should not load conditional plugin if condition is not met', () => { const plugin = { name: 'example', version: 1, @@ -651,7 +643,7 @@ describe('Pattern', () => { } const design = new Design({ parts: [part] }) const pattern = new design() - expect(pattern.hooks.preRender.length).to.equal(0) + expect(pattern.plugins.hooks.preRender).to.have.lengthOf(0) }) it('Pattern.__init() should load multiple conditional plugins', () => { @@ -686,7 +678,7 @@ describe('Pattern', () => { const design = new Design({ parts: [part] }) const pattern = new design() pattern.draft() - expect(pattern.hooks.preRender.length).to.equal(1) + expect(pattern.plugins.hooks.preRender).to.have.lengthOf(1) }) it('Pattern.__init() should load a conditional plugin multiple times with different conditions', () => { @@ -718,7 +710,7 @@ describe('Pattern', () => { expect(pattern.config.plugins).to.be.an('object').that.has.all.keys('example1', 'example1_') expect(pattern.config.plugins.example1.plugin).to.deep.equal(plugin1) expect(pattern.config.plugins.example1_.plugin).to.deep.equal(plugin1) - expect(pattern.hooks.preRender.length).to.equal(1) + expect(pattern.plugins.hooks.preRender).to.have.lengthOf(1) }) it('Load conditional plugins that are also passing data', () => { @@ -757,7 +749,7 @@ describe('Pattern', () => { }) const pattern = new design() pattern.__init() - expect(pattern.hooks.preRender.length).to.equal(2) + expect(pattern.plugins.hooks.preRender).to.have.lengthOf(2) }) it('Pattern.__init() should register a hook via on', () => { @@ -814,6 +806,32 @@ describe('Pattern', () => { expect(count).to.equal(2) }) + it('Pattern.__init() should not register the same method twice on one hook', () => { + function hookMethod() { + count++ + } + const plugin = { + name: 'test', + version: '0.1-test', + hooks: { + preDraft: [ + hookMethod, + hookMethod, + function () { + count++ + }, + ], + }, + } + const Pattern = new Design() + const pattern = new Pattern() + let count = 0 + pattern._draft = () => {} + pattern.use(plugin) + pattern.draft() + expect(count).to.equal(2) + }) + it('Should check whether created parts get the pattern context', () => { let partContext const plugin = { diff --git a/packages/core/tests/pattern-other.test.mjs b/packages/core/tests/pattern-other.test.mjs index ba69d58a17d..46fd350cdda 100644 --- a/packages/core/tests/pattern-other.test.mjs +++ b/packages/core/tests/pattern-other.test.mjs @@ -61,9 +61,7 @@ describe('Pattern', () => { const design = new Design({ parts: [test, you] }) const pattern = new design({ only: ['you'] }) pattern.draft() - expect(pattern.setStores[0].logs.debug).to.include( - 'Part `test` is not needed. Skipping draft and setting hidden to `true`' - ) + expect(pattern.setStores[0].logs.debug).to.include('Part `test` is not needed. Skipping part') }) it('Should return the initialized config', () => { @@ -90,8 +88,8 @@ describe('Pattern', () => { pattern.use(plugin) pattern.use({ plugin }) pattern.use({ plugin }) - expect(Object.keys(pattern.plugins).length).to.equal(1) - expect(Object.keys(pattern.plugins)[0]).to.equal('test') + expect(Object.keys(pattern.plugins.plugins)).to.have.lengthOf(1) + expect(Object.keys(pattern.plugins.plugins)[0]).to.equal('test') }) it('Should log an error of added parts do not have a draft method', () => { @@ -161,7 +159,6 @@ describe('Pattern', () => { expect(pattern.setStores[0].logs.error[0][0]).to.equal('Unable to draft part `test` (set 0)') }) - // FIXME: Add assertions here it('Handle layout object', () => { const part = { name: 'test', @@ -176,7 +173,7 @@ describe('Pattern', () => { layout: { stacks: { test: { flipX: true } }, width: 300, height: 400 }, }) const props = pattern.draft().getRenderProps() - expect(props.stacks.test.attributes.get('transform')).to.equal('scale(-1 1)') + expect(props.stacks.test.attributes.get('transform')).to.equal('scale(-1, 1)') expect(props.width).to.equal(300) expect(props.height).to.equal(400) }) diff --git a/packages/core/tests/pattern-renderer.test.mjs b/packages/core/tests/pattern-renderer.test.mjs new file mode 100644 index 00000000000..1888363d577 --- /dev/null +++ b/packages/core/tests/pattern-renderer.test.mjs @@ -0,0 +1,39 @@ +import chai from 'chai' +import { Design } from '../src/index.mjs' + +const expect = chai.expect + +describe('Pattern Rendering', () => { + describe('Pattern.prototype.getRenderProps()', () => { + describe('Hidden parts and stacks', () => { + const part = { + name: 'test', + draft: ({ part }) => { + part.hide() + return part + }, + } + + const design = new Design({ parts: [part] }) + const pattern = new design({}) + const props = pattern.draft().getRenderProps() + + it('Should not include hidden parts', () => { + expect(props.parts[0]).not.to.have.property('test') + }) + it('Should log that it has skipped a hidden part', () => { + expect(props.logs.sets[0].info).to.include( + 'Part test is hidden in set 0. Not adding to render props' + ) + }) + it('Should not include hidden stacks', () => { + expect(props.stacks).not.to.have.property('test') + }) + it('Should log that it has skipped a hidden stack', () => { + expect(props.logs.pattern.info).to.include( + 'Stack test is hidden. Skipping in render props.' + ) + }) + }) + }) +}) diff --git a/packages/core/tests/pattern-sample.test.mjs b/packages/core/tests/pattern-sample.test.mjs index f8b6af4ea58..5da639eeb23 100644 --- a/packages/core/tests/pattern-sample.test.mjs +++ b/packages/core/tests/pattern-sample.test.mjs @@ -31,6 +31,7 @@ describe('Pattern', () => { pattern.sample() expect(pattern.setStores.length).to.equal(10) expect(pattern.settings.length).to.equal(10) + expect(pattern.parts[0].test.paths.test.ops[1].to.y).to.equal(80) expect(pattern.parts[9].test.paths.test.ops[1].to.y).to.equal(320) }) @@ -60,9 +61,70 @@ describe('Pattern', () => { pattern.sample() expect(pattern.setStores.length).to.equal(10) expect(pattern.settings.length).to.equal(10) + expect(round(pattern.parts[0].test.paths.test.ops[1].to.y)).to.equal(round(0.05 * 0.9 * 400)) expect(round(pattern.parts[9].test.paths.test.ops[1].to.y)).to.equal(22) }) + it('Should sample a count option', () => { + const part = { + name: 'test', + measurements: ['head'], + options: { + size: { count: 2, min: 0, max: 6 }, + }, + draft: ({ Point, paths, Path, measurements, options, part }) => { + paths.test = new Path() + .move(new Point(0, 0)) + .line(new Point(0, measurements.head * options.size)) + + return part + }, + } + const Pattern = new Design({ parts: [part] }) + const pattern = new Pattern({ + measurements: { head: 400 }, + sample: { + type: 'option', + option: 'size', + }, + }) + pattern.sample() + expect(pattern.setStores.length).to.equal(7) + expect(pattern.settings.length).to.equal(7) + expect(round(pattern.parts[0].test.paths.test.ops[1].to.y)).to.equal(0) + expect(round(pattern.parts[6].test.paths.test.ops[1].to.y)).to.equal(2400) + }) + + it('Should not sample a count option more than 10 times', () => { + const part = { + name: 'test', + measurements: ['head'], + options: { + size: { count: 2, min: 0, max: 20 }, + }, + draft: ({ Point, paths, Path, measurements, options, part }) => { + paths.test = new Path() + .move(new Point(0, 0)) + .line(new Point(0, measurements.head * options.size)) + + return part + }, + } + const Pattern = new Design({ parts: [part] }) + const pattern = new Pattern({ + measurements: { head: 400 }, + sample: { + type: 'option', + option: 'size', + }, + }) + pattern.sample() + expect(pattern.setStores.length).to.equal(10) + expect(pattern.settings.length).to.equal(10) + expect(round(pattern.parts[0].test.paths.test.ops[1].to.y)).to.equal(0) + expect(round(pattern.parts[9].test.paths.test.ops[1].to.y)).to.equal(8000) + }) + it('Should sample a list option', () => { const part = { name: 'test', @@ -89,9 +151,39 @@ describe('Pattern', () => { pattern.sample() expect(pattern.setStores.length).to.equal(10) expect(pattern.settings.length).to.equal(10) + expect(pattern.parts[0].test.paths.test.ops[1].to.y).to.equal(40) expect(pattern.parts[9].test.paths.test.ops[1].to.y).to.equal(400) }) + it('Should sample a boolean option', () => { + const part = { + name: 'test', + measurements: ['head'], + options: { + reverse: { bool: true }, + }, + draft: ({ Point, paths, Path, measurements, options, part }) => { + const yFac = options.reverse ? -1 : 1 + paths.test = new Path().move(new Point(0, 0)).line(new Point(0, measurements.head * yFac)) + + return part + }, + } + const Pattern = new Design({ parts: [part] }) + const pattern = new Pattern({ + measurements: { head: 400 }, + sample: { + type: 'option', + option: 'reverse', + }, + }) + pattern.sample() + expect(pattern.setStores.length).to.equal(2) + expect(pattern.settings.length).to.equal(2) + expect(pattern.parts[0].test.paths.test.ops[1].to.y).to.equal(400) + expect(pattern.parts[1].test.paths.test.ops[1].to.y).to.equal(-400) + }) + it('Should sample a measurement', () => { const part = { name: 'test', diff --git a/packages/core/tests/stacks.test.mjs b/packages/core/tests/stacks.test.mjs index 416dfc45743..f1b67b25ad1 100644 --- a/packages/core/tests/stacks.test.mjs +++ b/packages/core/tests/stacks.test.mjs @@ -170,7 +170,7 @@ describe('Stacks', () => { }, }) expect(pattern.stacks.test.attributes.list.transform.length).to.equal(1) - expect(pattern.stacks.test.attributes.list.transform[0]).to.equal('translate(10 20)') + expect(pattern.stacks.test.attributes.list.transform[0]).to.equal('translate(10, 20)') }) }) diff --git a/packages/core/tests/utils.test.mjs b/packages/core/tests/utils.test.mjs index 40ac8bf6467..bd0817f0ee6 100644 --- a/packages/core/tests/utils.test.mjs +++ b/packages/core/tests/utils.test.mjs @@ -492,6 +492,6 @@ describe('Utils', () => { const pattern = new design() const props = pattern.draft().getRenderProps() const transform = generateStackTransform(30, 60, 90, true, true, props.stacks.test) - expect(transform.transform).to.equal('translate(51 138) scale(-1 -1) rotate(90 10.5 39)') + expect(transform.transform).to.equal('translate(51, 138) scale(-1, -1) rotate(90, 10.5, 39)') }) })