construction.timber-length
Cutting list: which stock lengths to buy and which pieces to cut from each, allowing for saw kerf.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 16 tests, run in TypeScript, Python and Rust.
What it does
A cutting list: given the pieces a job needs, the length timber is bought in and the saw's kerf, how many lengths to buy and which pieces to cut from each.
## The rule
For example
timberCuttingList(1,200, 1,200, 1,200, 3,600, 3)→ bars ×2, bar count 2, waste 3,600 three 1200s need two 3600 lengths once the kerf is countedtimberCuttingList(1,200, 1,200, 1,200, 3,600, 0)→ bars ×1, bar count 1, waste 0 with no kerf three 1200s fill one 3600 exactlytimberCuttingList(600, 1,800, 1,200, 1,200, 600, 2,400, 0)→ bars ×3, bar count 3, waste 1,800 first-fit decreasing fills earlier bars before opening a new one
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
export function timberCuttingList(cuts: readonly number[], stockLength: number, kerf: number): CuttingList
| cuts | int[] | the pieces needed, in millimetres, in any order |
| stockLength | int | the length timber is bought in, in millimetres, e.g. 4800 |
| kerf | int | the width the saw blade removes with each cut, in millimetres; 0 to ignore it |
| returns | CuttingList | bars in the order they are opened, first-fit decreasing |
The types it declares, generated into your project
/** One stock length and the pieces cut from it. */
export interface CutBar {
/** piece lengths in the order they are placed, longest first */
readonly cuts: readonly number[];
/** usable length left after the last cut, in millimetres; 0 when less than a kerf remains */
readonly offcut: number;
}
/** The bars to buy and what to cut from each. */
export interface CuttingList {
readonly bars: readonly CutBar[];
/** how many stock lengths to buy */
readonly barCount: number;
/** millimetres bought but not in any piece: offcuts plus everything the saw removed */
readonly waste: number;
}
Your code names it in one line, in the file that uses it
import { timberCuttingList } from "#fune/construction.timber-length@^1";
import { type CutBar, type CuttingList } from "./construction_timber_length_types.ts";
/**
* Plan which pieces to cut from which stock length, first-fit decreasing.
*
* A bar holds pieces p1..pn when their lengths plus a kerf between each pair
* fit: sum + kerf * (n - 1) <= stockLength. The last piece may end exactly at
* the end of the bar, which needs no final cut. Ignoring kerf is the usual
* mistake: three 1200 mm pieces do not come out of one 3600 mm length.
*/
export function timberCuttingList(cuts: readonly number[], stockLength: number, kerf: number): CuttingList {
if (!Number.isInteger(stockLength) || stockLength <= 0) {
throw new RangeError(`stockLength must be a whole number of millimetres greater than 0, received ${stockLength}`);
}
if (!Number.isInteger(kerf) || kerf < 0) {
throw new RangeError(`kerf must be a whole number of millimetres, 0 or more, received ${kerf}`);
}
cuts.forEach((cut, i) => {
if (!Number.isInteger(cut) || cut <= 0) {
throw new RangeError(`cut ${i + 1} must be a whole number of millimetres greater than 0, received ${cut}`);
}
if (cut > stockLength) {
throw new RangeError(`cut ${i + 1} (${cut} mm) is longer than the stock length (${stockLength} mm)`);
}
});
// Equal lengths are interchangeable, so a plain descending sort is deterministic.
const sorted = [...cuts].sort((a, b) => b - a);
const pieces: number[][] = [];
const used: number[] = [];
for (const cut of sorted) {
let placed = false;
for (let b = 0; b < pieces.length; b++) {
if (used[b] + kerf + cut <= stockLength) {
pieces[b].push(cut);
used[b] += kerf + cut;
placed = true;
break;
}
}
if (!placed) {
pieces.push([cut]);
used.push(cut);
}
}
let total = 0;
for (const cut of cuts) total += cut;
const bars: CutBar[] = pieces.map((p, b) => ({
cuts: p,
offcut: Math.max(0, stockLength - used[b] - kerf),
}));
return { bars, barCount: bars.length, waste: bars.length * stockLength - total };
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add construction.timber-length
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./construction.timber-length-1.0.0-typescript.fune, or fetch it from a terminal with fune pull construction.timber-length@1.0.0:typescript.
The whole function, every language, is one file too: construction.timber-length-1.0.0.fune, 19,176 bytes, sha256 124e7adbbd6c2a9011a4643743e9dc813bf87e176a788a2da7e1a58768ac3aea. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before construction.timber-length
after — your function gets the result and the arguments, and returns the final result.
// fune: after construction.timber-length
replace — it requires no other capability, so there is no dependency to replace.
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show construction.timber-length --steps.
// fune: step construction.timber-length after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| three 1200s need two 3600 lengths once the kerf is counted | 1,200, 1,200, 1,200, 3,600, 3 | → | bars ×2, bar count 2, waste 3,600 |
| with no kerf three 1200s fill one 3600 exactly | 1,200, 1,200, 1,200, 3,600, 0 | → | bars ×1, bar count 1, waste 0 |
| first-fit decreasing fills earlier bars before opening a new one | 600, 1,800, 1,200, 1,200, 600, 2,400, 0 | → | bars ×3, bar count 3, waste 1,800 |
| the last piece may end at the end of the bar without a final cut | 1,197, 1,200, 2,400, 3 | → | bars ×1, bar count 1, waste 3 |
| less than a kerf left over is dust, not an offcut | 1,198, 1,198, 2,400, 3 | → | bars ×1, bar count 1, waste 4 |
| a piece as long as the stock takes the whole bar | 2,400, 2,400, 3 | → | bars ×1, bar count 1, waste 0 |
| a mixed stud and nogging list from 4800 lengths | 2,400, 2,400, 1,500, 1,500, 1,500, 900, 900, 300, 4,800, 4 | → | bars ×3, bar count 3, waste 3,000 |
| input order does not change the plan | 300, 900, 1,500, 2,400, 900, 1,500, 2,400, 1,500, 4,800, 4 | → | bars ×3, bar count 3, waste 3,000 |
| a single short piece leaves the rest of the bar as offcut | 450, 3,000, 5 | → | bars ×1, bar count 1, waste 2,550 |
| nothing to cut buys nothing | , 4,800, 3 | → | bars , bar count 0, waste 0 |
Show the other 6 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a wide kerf pushes a piece onto a new bar | 1,000, 1,000, 2,020, 25 | → | bars ×2, bar count 2, waste 2,040 |
| a cut longer than the stock is an error | 2,000, 4,000, 3,600, 3 | → | error: cut 2 (4000 mm) is longer than the stock length (3600 mm) |
| a zero-length cut is an error | 0, 3,600, 3 | → | error: cut 1 must be a whole number of millimetres greater than 0 |
| a fractional cut is an error | 1.5, 3,600, 3 | → | error: cut 1 must be a whole number of millimetres greater than 0 |
| a negative kerf is an error | 100, 3,600, -1 | → | error: kerf must be a whole number of millimetres, 0 or more |
| a zero stock length is an error | 100, 0, 3 | → | error: stockLength must be a whole number of millimetres greater than 0 |
More from the author
- **First-fit decreasing.** Pieces are sorted longest first, and each goes on the first bar (in the order bars were opened) that still has room; if none has, a new bar is opened. It is not always the fewest bars possible (that problem is NP-hard), but it is never more than 11/9 of the optimum plus one, it is what most cutting-list tools and carpenters do by hand, and it is deterministic: equal lengths are interchangeable, so the input order does not change the answer. - **Kerf.** Every cut removes `kerf` millimetres. A bar holds pieces p1..pn when `sum + kerf × (n − 1) <= stockLength`: there is a kerf between each pair, and the last piece may run to the very end of the bar with no final cut. So three 1200 mm pieces do not come out of a 3600 mm length with a 3 mm blade; ignoring kerf is the usual mistake. - **Offcut** is what is left after cutting the last piece off: `stockLength − sum − kerf × n`. When that is less than zero (less than a kerf's width remained) it is 0: the saw turned it to dust. - **Waste** is everything bought but not in a piece, `barCount × stockLength − sum of cuts`: offcuts plus all the kerf.
All lengths are whole millimetres. One call is one section size and one stock length; call it once per section (47×100 studs, 47×150 joists). It does not trim a factory end or allow for defects: add that to each piece, or shorten `stockLength`, if your timber needs squaring.
Errors: a piece that is not a positive whole number, a piece longer than the stock, a stock length that is not positive, or a negative kerf. An empty list buys nothing.
Source: D. S. Johnson, "Near-optimal bin packing algorithms" (MIT, 1973), for first-fit decreasing and its 11/9 bound.
Files
| Path | Bytes |
|---|---|
| README.md | 1,937 |
| impl/python.py | 2,022 |
| impl/rust.rs | 3,594 |
| impl/typescript.ts | 2,069 |
| vectors.json | 5,894 |