We build small, single-purpose tools at KAVELA LTD. CutList is one of them — give it a stock sheet size and a list of pieces you need to cut, and it returns a layout that fits everything onto as few sheets as possible, with a printable PDF the carpenter can take to the saw.
It's the kind of problem that looks trivial until you start coding it. This post walks through the parts that took the most iteration: why we constrained the solver to guillotine cuts, how we handled kerf width without making the math a nightmare, and the gotchas that come with shipping jsPDF inside a Capacitor app for users who write in Turkish, Greek, Russian, Czech, and Polish.
What "panel cutting" actually means
A carpenter buys plywood, MDF, melamine, or acrylic in standard sheet sizes — 2440×1220 mm in most of Europe, 4×8 ft in North America. A project needs dozens of smaller rectangular pieces: cabinet sides, shelves, drawer fronts, backs.
The job is to figure out how to lay those pieces onto the smallest number of stock sheets, then print a cut diagram so the operator can run the panel saw without thinking about it.
This is 2D bin packing, a classic NP-hard problem. The literature is decades deep. The catch is that real-world panel saws don't make arbitrary cuts — they make guillotine cuts, which constrains the search space substantially.
Guillotine cuts: the constraint that makes everything tractable
A guillotine cut goes all the way across the sheet from edge to edge in one pass. You cannot cut an L-shape, you cannot cut around a piece, you cannot start in the middle. Every cut splits one rectangle into two rectangles.
This matches how panel saws actually work. It also dramatically simplifies the algorithm: any valid layout can be represented as a binary tree of cuts. The solver doesn't have to consider arbitrary nestings — it only has to decide, for each rectangle, whether to split horizontally or vertically and where.
The pure 2D bin-packing literature has prettier solutions (the Maximal Rectangles algorithm, skyline heuristics, or full ILP solvers) that produce tighter layouts when arbitrary cuts are allowed. None of those layouts can actually be produced on a panel saw. We deliberately rejected them.
The solver: shelf-based with first-fit-decreasing
The algorithm that ships is shelf-based. It's not the absolute tightest packer, but it is the one that produces layouts an operator can actually cut.
- Sort all pieces by longest edge, descending.
- For each piece, try to place it on an existing "shelf" (a horizontal strip across the current sheet) where it fits.
- If no shelf fits, open a new shelf at the height of the piece.
- If the new shelf doesn't fit on the current sheet, open a new sheet.
- Within each shelf, pieces are placed left-to-right.
Variations we tried and rejected:
- Best-fit instead of first-fit — marginal improvement on utilization, much slower, occasionally produced non-guillotine layouts depending on rotation handling.
- Branch-and-bound — got tighter layouts on small inputs but didn't terminate in reasonable time once piece count crossed ~40. Carpenters routinely throw 60–100 pieces at us.
- Ant colony optimization — fun to write, terrible to debug, gave answers that varied between runs (which carpenters hated — they want the same input to produce the same cutsheet).
The shipped algorithm gives 75–90% sheet utilization on typical inputs, runs in under 50 ms for 100 pieces on a phone, and is deterministic. Carpenters care more about determinism and cuttability than about a 3% utilization gain.
Kerf: the millimeter that ruins everything
A saw blade has thickness. When you cut a 2440 mm sheet in half, you don't get two 1220 mm pieces — you get two pieces that together measure 2440 minus the kerf, typically 3 mm for a panel saw blade.
Naive solvers ignore kerf. They produce layouts that don't physically work — the operator follows the diagram, makes the cuts, and discovers their last piece is 3 mm short.
The way we handle it: kerf is added to every piece's effective dimensions during packing. If the user requests a 600×400 piece with a 3 mm kerf, the packer treats it as 603×403 during placement. The PDF still labels the piece 600×400 (the actual finished dimension), but the cut layout has the kerf reserved between every pair of adjacent pieces.
This works because in a guillotine layout, every piece has cuts on at most two edges (the other two are the sheet edge or share a cut with a neighbor). Adding kerf to every dimension over-reserves slightly on edge pieces — about 1.5 mm of waste per outer edge — which is acceptable. The alternative (tracking which edges are "shared cuts" and only adding kerf there) is bookkeeping-heavy and error-prone.
The setting is exposed as a single number: kerf in millimeters. Default 3. We do not pretend to know your blade.
Rotation and grain
Some pieces can be rotated to fit better; some cannot. Veneered plywood and melamine have a grain direction — rotating a piece 90° to save a few cm of waste produces a finished cabinet with mismatched grain on the door fronts.
The packer accepts a canRotate flag per piece. When false, the piece is placed in its original orientation only. When true, the packer tries both orientations and picks whichever fits. The UI surfaces this as a checkbox per piece (default: rotate allowed) plus a "lock all rotations" toggle for cabinet projects.
We don't try to be clever about partial grain matching. The user knows their material; we trust them.
Capacitor + jsPDF + Turkish: the font VFS rabbit hole
The PDF export uses jsPDF. By default, jsPDF ships with the standard PostScript "core fonts" — Helvetica, Times, Courier — which cannot render Turkish-specific characters. ş, ğ, İ, ı, ç, Ö all render as garbage or blanks.
This is not a Capacitor bug or a bundling bug. The standard PostScript fonts genuinely don't have those glyphs. You have to embed a font that does.
The fix is jsPDF's Virtual File System (VFS). You load a TTF file, base64-encode it, register it as a VFS asset, and tell jsPDF to use it. The standard Roboto family covers Latin Extended (Turkish, Czech, Polish), Greek, and Cyrillic — exactly the locales we ship to.
import { jsPDF } from "jspdf";
import { robotoVFS } from "./roboto-vfs.js"; // base64-encoded TTFs
export function makePDF() {
const doc = new jsPDF();
doc.addFileToVFS("Roboto-Regular.ttf", robotoVFS.regular);
doc.addFileToVFS("Roboto-Bold.ttf", robotoVFS.bold);
doc.addFont("Roboto-Regular.ttf", "Roboto", "normal");
doc.addFont("Roboto-Bold.ttf", "Roboto", "bold");
doc.setFont("Roboto");
return doc;
}
The VFS file is large — 424 KB after base64 encoding for Regular + Bold — and that's the cost of supporting non-Latin scripts in a client-side PDF generator. It's a one-time hit per app install, gzipped to ~280 KB on the wire if you ship it as part of the bundle.
A small build-time gotcha: the openmaptiles GitHub mirror reliably hosts Apache-2.0 Roboto TTFs at predictable paths. The google/fonts repo paths shift around and 404 from time to time. Don't burn an evening trying to fetch from there — point your build script at the mirror.
Real Google Play Billing inside a Capacitor app
CutList offers a Pro tier (PDF export, WhatsApp share, unlimited saved projects) via subscription. The implementation uses cordova-plugin-purchase v13, which works inside Capacitor when configured correctly.
The non-obvious parts:
The subscription products must exist in Play Console before the app can fetch them. During development this is the most common confusion — the SDK's
purchase()call returnsPRODUCT_NOT_FOUNDeven though the code is clearly correct. The fix is to create the SKUs in Play Console and wait ~2 hours for propagation. There is no way to test the billing flow without Play Console SKUs; the emulator can't simulate it.Price comes from the Play Billing offer, not from your code. Hardcoding "₺50/month" in the UI breaks for users whose Play Store account is in a different region. Read the localized price string from the SKU offer object and render that.
The "Restore Purchases" button is required by Play policy. Users who reinstall, log in on a new device, or switch accounts need a way to re-acknowledge an existing subscription. The plugin exposes a
refresh()method; wire a button to it.Local entitlement cache + server verification. The plugin gives you a local
isProboolean. For a single-user offline tool like ours, that's enough — losing entitlement after a reinstall is fixed by the Restore button. Multi-device sync would need a server. We don't have one for this app, deliberately.
What's deliberately missing
- Cloud sync. Saved projects live in localStorage. Reinstall the app and they're gone. We considered Firebase. We decided that the kind of carpenter who uses CutList does not want a Google account dependency for their cabinet job.
- Multi-stock-size optimization. The solver assumes a single sheet size per project. If you have offcuts in two sizes, run the solver twice. The math for true multi-bin packing across heterogeneous bins is a real research problem; we won't fake it.
- 3D / kitchen layout. Out of scope. CutList is a sheet-cutting tool, not a cabinet designer. There are full CAD products for that — we don't compete with them.
Lessons
- For domain-constrained optimization problems, the constraint is the algorithm. Guillotine cuts looked like a limitation; they were the thing that made the solver shippable.
- Determinism over peak optimality when humans use the output. A 3% better layout that changes between runs is worse than a stable layout users can plan around.
- Embedded fonts in client-side PDF are non-negotiable for any app supporting locales beyond ASCII. Plan the 400+ KB into your bundle budget from day one.
- Subscription billing in mobile hybrid apps is mostly Play Console configuration, not code. Half a day reading the Billing v6 docs saves a week of debugging "why doesn't purchase() work."
CutList is on Google Play and the landing page is at kavela.pro/apps/Cutlist. Single-purpose, offline-first, no account, no telemetry beyond standard Play install metrics. The free tier covers small projects; Pro removes the saved-project cap and unlocks PDF export.
If you build optimization tools or panel-saw operators and want to compare notes on packing heuristics, drop a comment.
Top comments (0)