DIY Material cut optimiser

From DIYWiki
Jump to navigation Jump to search

What is cut optimisation?

When you need lots of parts cut to length for a larger project, you could just work out the parts you need, and then cut them as you need them from lengths of martial to hand. This works just fine, but on more involved projects you might find you are using more material that is really necessary, because you might not be making optimal use of the spare bits.

Cut optimisation is basically taking a step back to look at the bigger picture, and trying to group the required cut lengths together in combinations that leave less waste.

The manual approach

For small projects, you can often "see" a fairly optimal set of cut combinations. With larger projects, a spreadsheet will often let you try various "what if" style combinations until you get better solutions.

Automation

There are many tools out that that will do some of this. Many are free for use on the web if you only have limited requirements (like not many parts or not many material types etc). More comprehensive tools are usually commercial offerings (and these days "as a service" - i.e. monthly subscriptions).

DIY cut optimisation

So this article describes a "hybrid" cut optimiser that is free to use, and can be expanded to cope with large projects if desired. Hybrid, because some of it is just a spreadsheet knocked up in Google Sheets, but there is some back end automation that does the clever stuff that runs behind the scenes.

You can use it to produce cut lists, and also calculate required order quantities of materials.

The code included here is released under GPL v3, so feel free to reuse or adapt as you fancy. Please include a attribution back to this page in any derivative work.

Disclaimer

This project includes software - so it probably won't work, and it will very likely have plenty of bugs! It was cobbled together as a fun experiment to solve a particular problem. It worked for me, but has not yet been that well tested, and it may also be somewhat "fragile" if you use differently from how it was intended!

Implementation

For this particular implementation you will need a google account (not necessarily gmail, but if you have a gmail address, then you also have google account!). Any free account will do, it does not need to be a paid for one. Once you have one of those, head over to drive.google.com This will take you to your document / file repository. Here you can create all typical office style documents like word processing files, spreadsheets, presentation etc. It runs nicely in any web browser (also true on mobile, although there is an "app" for use on mobile platforms if you want).

Not only does google docs make it easy to work on docs collaboratively, it also has a rich infrastructure that allows you to add code to your documents to augment and automate tasks. This can be a simple as adding a new user defined function to a spreadsheet, or can be a more complicated "mash up" with code that links out to external services, and downloads, updates, emails, automates etc, using a well designed and well documented set of libraries to handle much of the detail and hard work.

Getting started - Creating a spreadsheet

Click on the "New" button in google drive, and create a new blank spreadsheet.

CreateNewGoogleSheet.png

That will create an empty spreadsheet containing a single page called "Sheet1".

Creating a Materials sheet:

Click on the down arrow of the tab, select "Rename" and call this tab "Materials".

RenamePageInSheet.png


Add some column headings for

Material Description Standard Length Cut kerf Join Overlap

Note you can change these names, but they are referenced directly in the optimisation code, so if you change the names in the sheet, you will also need to change them there. Feel free to make the heading line look pretty with some colour and larger fonts etc.

You can now enter some stock materials. Start on row 2, and add as many as you like. Each material name needs to be unique, so if you have stock material in multiple lengths, modify the name to indicate it (e.g. "4x2 CLS 2.4m" and "4x2 CLS 3.6m"

The "Cut kerf" column indicates how much material is lost during cutting - this is typically due to the width of the blade used. This needs to be accounted for since you can't cut a 1m length of timber into two 500mm lengths! You can also increase the kerf if you are less sure of your dimensions, and want to add some "slack" into the optimisation. A 10mm kerf will pack parts less tightly.

The overlap column comes into play when you want to make "cuts" that are longer than the stock length of the material you want to cut from. So if you ask for a 5m plank when the stock is only 2.1m, the optimiser will need to use 3 separate lengths of material to achieve your final length. An overlap of zero assumes that the parts are going to simply butt jointed. This would be sensible for cladding for example. However, for something like a ridge beam, where you plan to scarf joint sections together, you may need an overlap of say 200mm such that each bit is cut that much longer to allow for the length lost making the joint.

You should get something like:

(I have included some sample data here)

ExampleMaterialsListSheet.png

Adding required items

Create a new page in the sheet using the "+" icon at the bottom left. Call this one "Required items".

Required items and the final cut lengths of material required. Add columns for

Item Name Cut From Required Length Qty

The item name gives a name to a part like "Door jamb" or "Cold water feed pipe". The Cut from field indicates what material you want it cut from - this should be one that you have added to the Materials page. The required length and Qty should hopefully be self explanatory.

Example of how to set a validation rule. This colour codes the options and also sets the source of the options to be taken from another sheet.

With some data in it, it could look like this:

The "cut from" item cells have been give a validation rule (from the "Data" menu) that formats the options as "drop down" controls, and imports the allowable values from the Materials sheet.

Adding Required areas

Although this cut optimiser is designed for cutting linear lengths of stuff, this is a nod to the existence of another dimension! Sometimes you may want to fill in an area with multiple strips of something like shiplap planks for a shed wall, or T&G for a garden gate.

Add the columns headings:

Item Name Cut From Area Type Width Height Offset Minimum

The area type is a text field, and two values are currently recognised "Rectangle", and "Gable Triangle". Width and height indicate the area wanted, the Offset tells it the effective height of the material being used - so how far another length needs to be shifted from the previous one. (Note "effective", means actual distance shifted, which might be less than the material width - e.g. you might allow an overlap for cladding, and the overlap does not cover any more area).

The result should look like:

ExampleRequiredItemsList.png

The minimum column comes into play when a cut length wanted is actually longer than the stock available. If you specify a minimum length of material, say for example you are cladding an area of wall, where you will need to use multiple lengths of material you are probably fixing to studwork or battens, which means your shortest bit has to be long enough to span at least a pair of studs (or perhaps three, so you get at least three fixing points).

Add a page fo the cut list

Next add a page called "Cut List". Add these headings:

Material Item Remaining Length (mm) Part Name Length (mm)

After you have actually run an optimisation, you may get something like:

PartOfACutList.png

Add a "Wastage" page

Material Total length used Total wasted %

Note the "%" field contains a formula "=C2/B2" - this can be copied down for multiple lines.One the formula is in place it will by default display as a decimal fraction. You can then change the format to have it displayed as a proper percentage. You should have something like:

ExampleWastagePage.png

Add the "Order Quantities" page

Easy one this:

Material Items

That should look something like:

ExampleOrderQuantitiesSheet.png

Adding some code

Now we have some pages that will let you list the materials you want to to cut ("Materials"), and also specify what you want cut ("Required Items" and "Required Areas") , plus some to collect the results ("Cut List", "Wastage", and "Order Quantities"), we need something to read in the requirements and spit out the answers.

For that we need some "Apps Script".

So click on the Extensions menu in your sheet, and select the "Apps Script"

That will open the apps script code editor, with a default script called "Code.gs" already created, and an example Javascript function called "myFunction". You can click on the "Untitled project" heading and change that to something more memorable!

AppsScriptCodeEditor.png

You can also delete the definition of myFunction - we won't need it.

Now paste in the "sheet I/O and UI code" from the listing below.

Next create another script file using the "+" icon at the end of the "Files" header at the top left of the editor. Call this file "BestFitCalc.gs". Paste in the "best fit" code from below.

Hit CTRL + S to make sure both files are saved... (the editor may pop up an error if it detects some improperly constructed code).

Security

When you attach apps script code to your document, that code will run within your google account, and it can interact with that account. This includes capabilities like being able create, edit, or even delete documents in you google drive. If there is an gmail address associated with the account, and can access all your email and even send email as you. Hence you must take great care when using code that you have not written yourself to make sure it is safe to run.

Google will throw up some warnings the first time you try to run a script attached to your spreadsheet, you will need to click through several steps to verify you understand what you are doing, and also to learn exactly which permissions you are giving the script. (I will only asks for ones that it needs to carry out the tasks included in the current script - if you add more functionality to the script at a later date, it may ask again for new permissions)

First test

Click on the BestFitCalc.gs file. At the top of the editor window are buttons for "Run", "Debug", and a drop down that will list any callable functions in the file - it will probably show show "test", the name of a text / example script at the bottom of the file.

Click "Run".

Here is where you will need to step through the permissions pages...

First will be the "Authorization required" popup, click "Review permissions"
ScriptAuthorisation.png

Next it will ask which google account you want to use to run the script. Click on yours.

You then will get a "Google hasn't verified this app" warning. You will need to avoid the "dark pattern" design, and click the small "Advanced" link.

Next you need to click the "Go to...(unsafe)" link

Finally you will need to Allow the script to have the permissions it is requesting. (which at present is only the capability to see, edit, create and delete google sheets spreadsheets).

(of those permissions, it only uses the ability to read and write to pages in the sheet it is attached to).

If all goes well, you should see a Cut list scroll past in the Execution log window:

ExecutingFirstTest.png

If that works ok, you are ready to use the sheet to do some analysis.

Close the spreadsheet tab in your browser (it will close the script window automatically with it)

In Use

Next time you open the sheet, you will see a new menu added to the end of the tool bar:

ScriptNewMenu.png

- this is added by the special "onOpen" function in Code.gs, which will automatically run when you open the sheet.

To perform a cut analysis, just select the "Run best fit analysis" from that new menu.

For the geeks - how it works

The script is split into two files - Code.gs, and BestFitCalc.gs.

The first one is code that will only work in the context of google sheets - it makes use of the google apps script API to read content from the sheet, which it then stuffs into the best fit calculator, and finally pushes the results from the calculator back into the spreadsheet.

The main block of functionality is in the BestFitCalc.gs file. This defines a set of Javascript classes that do all the analysis work. This code should be portable, and can run anywhere you can run Javascript. That would include in a web browser (either on the web or from a local file), or in a locally hosted Javascript engine like Node.js

To use in a different environment you would need to replace the Code.gs functionality with something appropriate to your new environment.

Code

The Code.GS File:

/**
 * Best Fit sheet I/O and UI code
 * 
 * Best Fit Cut Optimiser is licensed under GPLv3
 * 
 * A Javascript class definition to aid breaking down lengths of linear 
 * material using stock lengths with minimal waste.
 * 
 * Copyright (C) 2024  John Rumm, Internode Ltd
 * 
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 * 
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>
 * 
 * 
 * Change Record
 * =============
 * 
 * 24th Aug 2024    J Rumm    First release
 * 
 */

/**
 *  Add a custom menu to sheet UI to allow our calculator to be invoked.
 */
function onOpen(){
  const ui = SpreadsheetApp.getUi()
  ui.createMenu( "Best Cut")
    .addItem( "Run best fit analysis", "doBestFit" ) 
    .addToUi()    
}

/** 
 * Read data from the sheets, feed them to the best fit calculator, and display the results on the cut list and 
 * wastage sheets. 
 * 
 */

function doBestFit(){

  /**
   * When reading from a sheet, we load the entire sheet in one hit, and then remove the top row and store it in
   * a separate variable. That then allows that top row to be used as an index for finding column postions by name. 
   * While a little "wordy" and slower, this saves neding to use absolute references to any positons in the sheet, 
   * which makes maintenance *so* much easier as any changes to the sheet layout or column orders do not affect the 
   * code. 
   * 
   */

  // Read material data
  let ss = SpreadsheetApp.getActiveSpreadsheet().getSheetByName( "Materials" )  
  let materialData = ss.getRange( 1, 1, ss.getLastRow(), ss.getLastColumn() ).getValues()
  let matIdx = materialData.shift()

  // Get list of required items
  ss = SpreadsheetApp.getActiveSpreadsheet().getSheetByName( "Required Items" )
  let itemData = ss.getRange( 1, 1, ss.getLastRow(), ss.getLastColumn() ).getValues()
  let iIdx = itemData.shift()

  // Get list of required areas and gables
  ss = SpreadsheetApp.getActiveSpreadsheet().getSheetByName( "Required Areas" )
  let areaData = ss.getRange( 1, 1, ss.getLastRow(), ss.getLastColumn() ).getValues()
  let aIdx = areaData.shift()


  // Import data into calculator
  let fitter = new bestFitCalculator

  for( let line of materialData ){
    fitter.addMaterial( line[ matIdx.indexOf( "Material Description" ) ],	
                        line[ matIdx.indexOf( "Standard Length"	) ],	
                        line[ matIdx.indexOf( "Cut kerf" ) ],		
                        line[ matIdx.indexOf( "Join Overlap" ) ] )
  }

  for( let line of itemData ){
    fitter.addCut( line[ iIdx.indexOf( "Item Name" ) ],
                   line[ iIdx.indexOf( "Cut From" ) ],
                   line[ iIdx.indexOf( "Required Length" ) ],
                   line[ iIdx.indexOf( "Qty" ) ] )
  }

  for( let line of areaData ){
    switch( line[ aIdx.indexOf( "Area Type" ) ] ){
      case "Rectangle" :
        fitter.addCladdingCut( line[ aIdx.indexOf( "Item Name" ) ],
                               line[ aIdx.indexOf( "Cut From" ) ],
                               line[ aIdx.indexOf( "Width" ) ],
                               line[ aIdx.indexOf( "Height") ],  
                               line[ aIdx.indexOf( "Offset") ],
                               line[ aIdx.indexOf( "Minimum") ] )
        break

      case "Gable Triangle" : 
        fitter.addGableCut( line[ aIdx.indexOf( "Item Name" ) ],
                            line[ aIdx.indexOf( "Cut From" ) ],
                            line[ aIdx.indexOf( "Width" ) ],
                            line[ aIdx.indexOf( "Height") ],  
                            line[ aIdx.indexOf( "Offset") ],
                            line[ aIdx.indexOf( "Minimum") ] )
        break

    }
  }

  fitter.findBestFit()

  // Output results
  
  let results = fitter.getCutList( "", true )
  let wastage = fitter.getTotalWastage( true )
  let usage = fitter.getMaterialQuantity()

  ss = SpreadsheetApp.getActiveSpreadsheet().getSheetByName( "Cut List" )
  ss.getRange( 2, 1, ss.getLastRow(), ss.getLastColumn() ).clearContent()
  ss.getRange( 2, 1, results.length, results[0].length ).setValues( results )
  
  ss = SpreadsheetApp.getActiveSpreadsheet().getSheetByName( "Wastage" )
  ss.getRange( 2, 1, wastage.length, wastage[0].length ).setValues( wastage )

  ss = SpreadsheetApp.getActiveSpreadsheet().getSheetByName( "Order Quantities")
  ss.getRange( 2, 1, usage.length, usage[0].length ).setValues( usage )

}

The BestFitCalc.gs file:

/**
 * Implementation of "Best Fit First" material optimisation for one dimentional materials
 * 
 * Best Fit Cut Optimiser is licensed under GPLv3
 * 
 * A Javascript class definition to aid breaking down lengths of linear 
 * material using stock lengths with minimal waste.
 * 
 * Copyright (C) 2024  John Rumm, Internode Ltd
 * 
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 * 
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>
 *
 * To use:
 * 
 *    Create an instance of bestFitCalculator (see test code at the end):
 * 
 *    1)  Add defnitions of stock material by calling addMaterial. The material name must be unique, so if you have 
 *        the same stock in different lengths, add a material for each length. e.g. "CLS 4x2 2.1m" and "CLS 4x2 3.6m"
 *        The material defnition also includes a cut Kerf (how much material you will lose due to the blade width),
 *        and a optional overlap to allow for joins when cutting parts longer than your stock length. 
 * 
 *    2)  Add the items you want to cut using calls to addCut. Give each a name that will help you identify the part. 
 *        Cuts can also be longer than the stock length, it will assume that you are going to join parts. The 
 *        addCladdingCut and addGableCut allow you to specify area fill cuts for walls and gable cladding. 
 * 
 *    3)  Call findBestFit. This will work out a good fit for the required parts reducing the material wastage.
 * 
 *    4)  Call getCutList to return the brakdown of the cuts. You can have it return cuts for all materials, or
 *        specified ones. You can have a text based response that can be used directly for output, or a result in
 *        a two dimentional array suitable for updating a spreadsheet etc. 
 * 
 *    5)  getTotalWastage will show you the amount of material offcut wastage, and the getMaterialQuantity will 
 *        get a quick summary of the lengths that you will need to order. 
 * 
 * Change Record
 * =============
 * 
 * 24th Aug 2024    J Rumm    Alpha test release
 * 5th  Sep 2024    J Rumm    Fix error that allowed kerf adjustment to execeed material length in some cases
 *    
 */

// ====================================================================================================
// Support classes
// ====================================================================================================

/**
 * A container of a material definition. This is used to register one or more categories of 
 * material that can be later used to create items ready to be cut. Typically it could be something like a
 * plank, or a pipe, or a trim. 
 * 
 * @param {string} materialName   : A unique name for the material like "4x2 CLS" or "15mm Copper Pipe"
 * @param {number} standardLength : The length of a uncut piece, measured in mm
 * @param {number} cutKerf        : The thickness of material lost when cutting due to the blade thickness
 * @param {number} joinOverlap    : When "cutting" a length longer than the length of one standard piece
 *                                  this is an extra allowance to make for forming a join. 
 */
class TMaterial {
  constructor( materialName, standardLength, cutKerf, joinOverlap ){
    this.name = materialName
    this.length = standardLength
    this.kerf = cutKerf
    this.overlap = joinOverlap 
  }
}

/**
 * This is a length of stock allocated to be cut.
 * 
 * @param {string} itemName       : a name for this unit - typically something like "plank 1" etc
 * @param {object} fromMaterial   : A TMaterial class object detailing the stock it is selected from
 * 
 */
class TItem {
  constructor( itemName, fromMaterial ){
    this.name = itemName
    this.material = fromMaterial               
    this.remainingLength = fromMaterial.length
    this.cutList = []              // An array of one or more TCut objects
  }
}

/**
 * This is a cut operation that will produce a final dimensioned part. One or more of these are added to a TItem object
 * 
 * @param {string} partName       : Name of the finished part like "Window cill", "Downpipe"
 * @param {number} length         : Length in mm of the part
 * 
 */
class TCut {
  constructor( partName, length ){
    this.name = partName
    this.length = length
  }
}

// ====================================================================================================
// "Best Fit First" optimiser
// ====================================================================================================

class bestFitCalculator {
  constructor (){
    this.materialTypes = {}       // List of different material types held in a object to that they are accessible by name
    this.itemLists = {}           // Object that links each item type used to an array of TItem objects. 
    this.cutsRequired = {}        // An object that links a material type name to an array of TCut objects. This allows the
                                  // total cut list for that material type to be accumulated before it is it actually allocated
                                  // to the individual cut list included in the TItem objects.
  }


  /**
   * Defines a new material type
   * 
   * See TMaterial class definition for parameter details
   */
  addMaterial( name, standardLength, cutKerf, joinOverlap ){
    if( this.materialTypes.hasOwnProperty( name ) ){
      throw "Material already added : " + name
    }
    if( name == "" ){
      throw "You must supply a material name"
    }

    this.materialTypes[ name ] = new TMaterial( name, standardLength, cutKerf, joinOverlap )
    this.itemLists[ name ]     = []                                                             // array of TItem objects

    return this  // allow daisy chain
  }

  /**
   * Adds a new length of material into the final cut list
   * 
   * @param {string} materialTypeName    : The name of the type of material, should match one previously added with the addMaterial method. 
   */
  addLengthOfMaterial( materialTypeName ){
    let currentItems = this.itemLists[ materialTypeName ].length // number each new length incrementally
    let newLength = new TItem( "Length " + Number( currentItems + 1), this.materialTypes[ materialTypeName ] )
    this.itemLists[ materialTypeName ].push( newLength )
  }


  /**
   * Add a new cut or cuts to the list of required final pieces. Each material type has its own list. Cuts with repeats are 
   * expanded out into multiple single cuts to enable better optimisation when fitting the cuts into items of material.
   * Cuts that exceed the standard length of the specified material, will be split into multiple cuts.
   * 
   * @param {string} pieceName          : a name for the finished part
   * @param {string} materialTypeName   : A name matching a mterial type added with the addMaterial method previously
   * @param {number} requiredLength     : Length in mm
   * @param {number} howMany            : Optional parameter, use to specify multples of the same item
   * @param {number} minLength          : Specify the shortest desired length for cuts that require multiple lengths of material
   * @returns {object}                  : self reference  
   */
  addCut( pieceName, materialTypeName, requiredLength, howMany = 1, minLength = 0 ){
    // Check there is a entry for this material type, and add one if not
    if( ! this.cutsRequired.hasOwnProperty( materialTypeName ) ){
      this.cutsRequired[ materialTypeName ] = []
    }

    // Check if this cut is longer than the material length, and instead needs to be split into multiple lengths
    let materialLength = this.materialTypes[ materialTypeName ].length 
    let overlap = this.materialTypes[ materialTypeName ].overlap

    if( requiredLength > materialLength ){
      
      // Split into multiple cuts - allow extra length for joint overlaps
      
      let fullLengths = Math.trunc( requiredLength / ( materialLength - overlap ) )
      let lastLength = requiredLength - ( fullLengths * materialLength ) + overlap
      let totalLengths = fullLengths
      let firstLength = materialLength
      if( lastLength != 0 ){
        totalLengths++

        // Check if there is a minimum length for a part
        if( lastLength < minLength ){
          // If there is, then increase the short bit to that length and trim the first one to compensate. 
          firstLength -= ( minLength - lastLength )   // Shorten the first length by the amount extra we added to the short bit
          lastLength = minLength                      // Extend the short bit to the minimum length
        }
      }

      if( howMany == 1) {

        this.cutsRequired[ materialTypeName ].push( new TCut( `${pieceName} (part 1 of ${totalLengths})`, firstLength ) )

        for( let n = 1 ; n < fullLengths ; n++ ){
          this.cutsRequired[ materialTypeName ].push( new TCut( `${pieceName} (part ${n+1} of ${totalLengths})`, materialLength ) )
        }
        if( totalLengths > fullLengths ){
          this.cutsRequired[ materialTypeName ].push( new TCut( `${pieceName} (part ${totalLengths} of ${totalLengths})`, lastLength ) )
        }
      }
      else {
        for( let i = 0 ; i < howMany ; i++ ){

          this.cutsRequired[ materialTypeName ].push( new TCut( `${pieceName + " " + Number(i + 1) } (part 1 of ${totalLengths})`, firstLength ) )

          for( let n = 1 ; n < fullLengths ; n++ ){
            this.cutsRequired[ materialTypeName ].push( new TCut( `${pieceName + " " + Number(i + 1) } (part ${n+1} of ${totalLengths})`, materialLength ) )
          }
          if( totalLengths > fullLengths ){
            this.cutsRequired[ materialTypeName ].push( new TCut( `${pieceName + " " + Number(i + 1) } (part ${totalLengths} of ${totalLengths})`, lastLength ) )
          }
        }
      }
    }
    else {
      // Normal cuts...
      if( howMany == 1 ){
        this.cutsRequired[ materialTypeName ].push( new TCut( pieceName, requiredLength ) )
      }
      else {
        for( let i = 0 ; i < howMany ; i++ ){
          this.cutsRequired[ materialTypeName ].push( new TCut( `${pieceName} (${i+1} of ${howMany})`, requiredLength ) )
        }
      }
    }
    return this  // allow daisy chain
  }


  /**
   * A simple wrapper for the addCut capability to allow it to calculate cuts for cladding rectangular areas. Handy for 
   * products like feather edge or shiplap. 
   * 
   * @param {string}    pieceName         A name for the part
   * @param {string}    materialTypeName  The name of the material to cut from 
   * @param {number}    requiredWidth     The width of the area to clad
   * @param {number}    requiredHeight    The height of the area to clad
   * @param {number}    offset            The vertical offset between lengths. This may be less or more than the actual width of the material
   * @param {number}    minWidth          When cutting a length that will require multiple lengths of material, you may want to specify
   *                                      the minimum length of the end board - e.g. make sure it exeeds the spacing of the fixing points.
   * @returns {object}                    self reference  
   */
  addCladdingCut( pieceName, materialTypeName, requiredWidth, requiredHeight, offset, minWidth ){
    let numberOfLengths = Math.ceil( requiredHeight / offset )
    this.addCut( pieceName, materialTypeName, requiredWidth, numberOfLengths, minWidth )
    
    return this 
  }

/**
   * Another wrapper for the addCut capability to allow it to calculate cuts for cladding a gabled wall section. Notionally 
   * this is simlar to cladding a rectangular area of half the total width x the height x 2 (i.e. treating it as two right angle 
   * triangle sections side by side), but it is complicated because you get more waste on each board because you have to allow 
   * for a angled cut at each end. 
   * 
   * Other details similar to the cladding cut method. 
   * 
   * @param {string}    pieceName         A name for the part
   * @param {string}    materialTypeName  The name of the material to cut from 
   * @param {number}    requiredWidth     The width of the base of the gable triangle
   * @param {number}    requiredHeight    The height of the triangle
   * @param {number}    offset            The vertical offset between lengths. This may be less or more than the actual width of the material
   * @param {number}    minWidth          When cutting a length that will require multiple lengths of material, you may want to specify
   *                                      the minimum length of the end board - e.g. make sure it exeeds the spacing of the fixing points.
   * @returns {object}                    self reference  
   */
  addGableCut( pieceName, materialTypeName, requiredWidth, requiredHeight, offset, minWidth ){
    let halfWidth = requiredWidth / 2
    let roofAngle = Math.atan( requiredHeight / halfWidth )
    let numberOfLengths = Math.ceil( requiredHeight / offset )
    let lengthStepping = Math.ceil( offset / Math.tan( roofAngle ) * 2 )     // Step removed from both ends - hence x 2
    
    let currentWidth = requiredWidth
    for( let i = 0 ; i < numberOfLengths ; i++ ){
      this.addCut( pieceName + " " + Number( i + 1 ), materialTypeName, currentWidth, 1, minWidth )
      currentWidth -= lengthStepping
    }

    return this
  }

  /**
   * Once all the materials required have been defined, and all the required pieces of each material recorded, call this
   * method. 
   * 
   * For each material type that is used, it will: 
   *  Sort all the required cuts by length with the longest first. 
   *  Scan the list of allocated lengths of material, and look for the one that can have this cut made with least waste 
   *  If there is no space to allocate the cut, it will add another item and try again. 
   * 
   */
  findBestFit(){
    let inspect = this  // make instance data visible in debug
    let materialsUsed = Object.keys( this.itemLists )    // get list of materials that are being used
    for( let material of materialsUsed ){

      if( ! this.cutsRequired.hasOwnProperty( material ) ){
        continue    // Material defined but not used - so skip it
      }
      
      // Sort the list to place longest items first
      this.cutsRequired[ material ] = this.cutsRequired[ material ].sort( (a, b ) => b.length - a.length )

      // Create first item
      this.addLengthOfMaterial( material ) 
      
      // Now find best place to each required cut
      let chosenItem

      for( let thisCut of this.cutsRequired[ material ] ){

        let requiredLength = thisCut.length         
        if( thisCut.length != this.materialTypes[ material ].length ){
          requiredLength += this.materialTypes[ material ].kerf       // unless the "cut" is full length, allow for blade thickness
          if( requiredLength > this.materialTypes[ material ].length ){
            // limit the effect of adding a kerf so that it can't exceeed the length of the material
            requiredLength = this.materialTypes[ material ].length
          }
        }
       
        let allocated
        do { 
          allocated = false
          let leastWaste = this.materialTypes[ material ].length + 1   // as bad as it can get!
    
          // scan through all the items
          for( let item of this.itemLists[ material ] ){

            // find the item where it fits, and with the least waste
            if( requiredLength <= item.remainingLength &&
                item.remainingLength < leastWaste ){

              chosenItem = item
              leastWaste = chosenItem.remainingLength
              allocated = true   
            }
          }

          if( allocated ){
            chosenItem.cutList.push( thisCut )            // Add  the cut to the list of the best item found
            chosenItem.remainingLength -= requiredLength  // And remove the cut from the remaining length
          }
          else {
            this.addLengthOfMaterial( material )  // Nowhere to add the cut, so add another item and try again
          }

        } while( ! allocated )
      }
    }
  }

  /**
   * A method to get the cut list for a specified material
   * 
   * @param {string} material   : The name of the material to report on. Omit to return all materials
   * @param {boolean} dataMode  : Optional. When true will return data in a 2D array format rather than plain text.
   * 
   * 
   */
  getCutListForMaterial( material, dataMode = false ){
    let list = []
    let items = this.itemLists[ material ]
    if( typeof items === "undefined" ) {
      return list
    }
    else{
      if( dataMode ){
        // Return an array based grid response suitable for direct entry into a spreadsheet
        for( let item of items ){
          list.push( [ [ item.material.name ], [ item.name],  [ item.remainingLength ], [], [], [] ] )
          for( let cut of item.cutList ){
            list.push( [ [], [], [], [ cut.name ], [ cut.length ], [] ])
          }
        }
        list.push( [ [ "Total Cuts" ], [ this.getTotalCutsInMaterial( material ) ], [], [], [], [] ])
      }
      else {
        // Return a more readable text version
        for( let item of items ){
          list.push( `Item: ${item.name}, Cut from: ${item.material.name}, Leaving ${item.remainingLength} spare` )
          for( let cut of item.cutList ){
            list.push( `  Part: ${cut.name}, Length: ${cut.length}`)
          }
        }
        list.push( "Total Cuts: " + this.getTotalCutsInMaterial( material ) )
      }
    }
    return list
  }

  /**
   * Return the total number of cus required for a paricular material type. This can help to estimate cut time, 
   * blade ware and incedental expenses like fastenings, aadheasive, or joint water proofing tape etc. 
   * 
   * @param {string} The name of the material
   * @returns {number} The total number of cuts
   * 
   */
  getTotalCutsInMaterial( material ){
    let cuts = 0
    let items = this.itemLists[ material ]
    if( typeof items === "undefined" ) {
      return 0
    }
    else {
      let items = this.itemLists[ material ]
      for( let item of items ){
        if( item.remainingLength != 0 || item.cutList.length != 1 ){
          cuts += item.cutList.length
        }
      }
    }
    return cuts
  }

  /**
   * This general method will return a complete optimised cut list. If a material name is specified, then it will 
   * return the cut list just for that material type. If the name is left out, then it will return lists
   * for all materials
   * 
   * @param {string} materialTypeName : Optional material name, omit to return cut list for all materials
   * @param {boolean} dataMode        : Optional flag. If set, response will come in a 2D array without extra wording
   * @returns {array} of strings
   * 
   */
  getCutList( materialTypeName = "", dataMode = false ){
    let list = []
    if( materialTypeName != "" ){
      list = this.getCutListForMaterial( materialTypeName, dataMode )
    }
    else {
      let materials = Object.keys( this.materialTypes )
      for( let material of materials ){
        list = list.concat( this.getCutListForMaterial( material, dataMode ) )
      }
    }
    return list
  }

  /**
   * Returns a summary of the total quantity required for each material
   * 
   * @returns {array}   A 2d Array of material and quantity pairs. 
   */
  getMaterialQuantity(){
    let inspect = this
    let result = []
    
    let itemNames =  Object.keys( this.itemLists )
    for( let item of itemNames ){
      result.push( [ item, this.itemLists[ item ].length ] ) 
    }

    return result
  }


  /**
   * Returns the linear quantity of all material both used and wasted in off cuts that are not large enough to 
   * accomodate any of the reqired cuts. 
   * 
   * @param {boolean} dataMode  Optional parameter (defaults to false). Indicates that the response is wanted
   *                            in array format rather than text. 
   * @returns                   Wastage data in preferred format
   */
  getTotalWastage( dataMode = false ){
    let result = []

    let materials = Object.keys( this.materialTypes )

    for( let material of materials ){

      if( ! this.cutsRequired.hasOwnProperty( material ) ){
        continue    // Material defined but not used - so skip it
      }

      let totalLength = 0 
      let totalWastage = 0 

      let items = this.itemLists[ material ]

      for( let item of items ){
        totalLength += item.material.length
        totalWastage += item.remainingLength
      }

      if( dataMode ){
        result.push( [ material, totalLength, totalWastage ] )        
      }
      else {
        result.push( `For material: ${ material }, total length required: ${ totalLength }, wastage ${ totalWastage } / ${ Math.round( totalWastage / totalLength * 10000 ) / 100 }%` )
      }
    }
    return result
  }

} // class bestFitCalculator



// =============================================================================================
// Test harnesses / Example Usage
// =============================================================================================

function test(){
  let cutter = new bestFitCalculator

  cutter.addMaterial( "Plank", 3600, 10.0, 0 )
        .addMaterial( "15mm Copper Pipe", 3000, 0, 0 )
        .addMaterial( "4x2 CLS", 2400, 3, 100 )
        .addCut( "Full Panel", "Plank", 3600, 1 )
        .addCut( "Panel 2", "Plank", 3400, 1 )
        .addCut( "Short Panels", "Plank", 600, 3 )
        .addCut( "Infill", "Plank", 100, 10 )
        .addCut( "Ridge Beam", "4x2 CLS", 5500 )
        .addCladdingCut( "Feather edge facing", "Plank", 5500, 2100, 160, 800 )
        .addGableCut( "Dormer Gable", "Plank", 4000, 1200, 160, 800 )
        .addCut( "Water pipe", "15mm Copper Pipe", 25000 )
        
        .findBestFit()

  
  console.log( cutter.getCutList() )
  console.log( cutter.getTotalWastage() )
  
}