Skip to content

Repository files navigation

GCW Sheet Optimizer

A Blazor Server app for optimizing plywood cutting on a table/panel saw. Import a cutlist from CSV, edit it in a grid, run a guillotine-cut nesting optimizer, print the sheet layouts, and keep an inventory of leftover partial sheets that the optimizer reuses on future jobs.

Running the app

Prerequisites

Install

# Clone repo
git clone https://github.com/drdotnet22/SheetOptimizer/ && cd SheetOptimizer

# Create your .env file from the template (edit the password if you like)
copy .env.example .env        # Windows
# cp .env.example .env        # macOS/Linux

# Build and start everything (app + PostgreSQL)
docker compose up --build

# Cean up build assets (Around 1.4GB)
docker system prune

Open http://localhost:8080 in your browser.

Other useful commands:

git pull && docker compose up -d --build && docker system prune -f # Pull updates, rebuild, and clean up build assets
docker compose logs -f app    # watch the app's logs
docker compose down           # stop everything (data is kept)
docker compose down -v        # stop AND wipe the database volume

Where does the database live?

Postgres stores its data in a named Docker volume called pgdata (full name is usually gcw-sheet-optimizer_pgdata or similar - run docker volume ls to see it). It survives rebuilds and restarts; only docker compose down -v deletes it.

How migrations are applied

On startup the app calls dbContext.Database.Migrate() (see Program.cs), which applies any pending EF Core migrations automatically. There is no manual migration step - docker compose up is all you need. The app retries for ~30 seconds if Postgres is still warming up.

Note: the initial migration in Migrations/ was written by hand rather than generated by dotnet ef, so Program.cs disables EF's "pending model changes" startup check (see the comment there). If you change the entities later, add a migration the normal way (dotnet ef migrations add MyChange) and you can remove that line.

Project structure

GcwSheetOptimizer.csproj   The project file (NuGet packages, target framework)
Program.cs                 App startup: services, database, auto-migration
appsettings.json           Config for running WITHOUT Docker (local Postgres)
Dockerfile                 Builds the app image (multi-stage: SDK -> runtime)
docker-compose.yml         Runs app + PostgreSQL together
.env.example               Template for the gitignored .env (db credentials)

Models/                    EF Core entities (one class = one table)
  Project.cs               A cutlist job (name, kerf width)
  Part.cs                  One cutlist row (qty, W x L, material, grain)
  PartialSheet.cs          A leftover piece in your inventory
  StockMaterial.cs         A standard sheet good you buy + its real sheet size
  NestingResult.cs         A saved optimizer run (layout stored as JSON)

Data/
  AppDbContext.cs          The EF Core database context + relationships
                           (also contains commented-out seed data example)

Migrations/                EF Core migrations (applied automatically at startup)

Services/
  CsvImportService.cs      CSV parsing with per-row error reporting
  Nesting/
    NestingModels.cs       The optimizer's output shape (serialized to JSON)
    NestingService.cs      The guillotine nesting algorithm

Components/
  App.razor                Root HTML document
  Routes.razor             URL routing
  Layout/MainLayout.razor  Nav bar + page frame
  Pages/
    Home.razor             /                    project list
    ProjectEditor.razor    /project/{id}         cutlist grid, CSV import, run optimizer
    ProjectResults.razor   /project/{id}/results layouts, print view, offcut saving
    StockMaterials.razor   /materials            standard stock list w/ sheet sizes
    PartialSheets.razor    /partial-sheets       leftover inventory

wwwroot/app.css            Custom styles incl. the @media print layout

CSV format

Header names are case-insensitive; extra columns are ignored. Label is optional. GrainMatters accepts true/false, 1/0, yes/no. Dimensions are inches (decimals fine).

Quantity,Width,Length,Material,GrainMatters,Label
2,24,34.5,3/4 Birch Plywood,true,Cabinet Side
3,22.5,23,3/4 Birch Plywood,false,Shelf

Bad rows are reported individually and skipped - they never block the good rows.

How the nesting algorithm works

The core is a guillotine free-rectangle heuristic:

  1. Parts are grouped by material; each material gets its own sheets.
  2. Every sheet keeps a list of empty rectangles ("free rects"). A fresh sheet starts as one big free rect.
  3. Each part is placed into the best free rect across all open sheets - trying both orientations when GrainMatters is false.
  4. After placing a part in a rect's corner, the remaining L-shape is split into two rectangles with one straight edge-to-edge cut. Because every split is a single straight cut, the whole layout can always be produced with full-length table-saw cuts (the guillotine constraint holds by construction).
  5. The kerf (blade thickness, set per project) is added to each part's footprint when splitting, so adjacent parts are spaced one blade-width apart.
  6. When a part fits on no open sheet, a new one is opened. The partial-sheet inventory is checked first: among partial sheets of the right material large enough for the part, the smallest one is chosen (best-fit - this deliberately saves your large leftovers for jobs that need them). Only if none fits is a fresh full sheet used.

Stock materials and sheet sizes

Not all sheet goods are the same size - some plywood comes oversized (48.5" x 96.5"), some exactly 48" x 96". The Stock Materials page (/materials) is where you list the materials you buy and their real sheet sizes. When the optimizer runs, each cutlist material is matched against this list by exact name (ignoring case and surrounding spaces): a match means new full sheets for that material use the stock entry's size; no match falls back to the default 48" x 96". The cutlist editor warns you when a material in your list has no stock entry, so typos ("3/4 Birch Ply" vs "3/4 Birch Plywood") are easy to spot before optimizing.

Two construction modes

Following how the bin-packing literature classifies heuristics, the optimizer builds layouts two different ways and lets the scoring pick the winner per material:

  • Global (item-oriented) mode: each part is placed on the best spot across all open sheets. Strong at minimizing the total sheet count, but tends to spread leftover material across every sheet.
  • Sequential (bin-oriented) mode: packs one sheet at a time as full as possible - every rule combination is tried on the current sheet, the fullest packing is committed, and the process repeats with the remaining parts. This reliably produces tightly packed early sheets with all the waste pooled on the final sheet as one large reusable piece.

Neither mode wins on every cutlist (this matches published results on bin-oriented vs item-oriented heuristics), which is exactly why both run in the same batch.

The strategy batch

A single greedy heuristic is short-sighted, and no single rule wins on every cutlist. So each optimizer run is actually a batch: for every material, up to 48 deterministic candidate layouts are generated - every combination of 4 part sort orders (area / longest side / shortest side / perimeter), 3 placement rules (best area fit / best short-side fit / best long-side fit), 2 split rules (keep the bigger or the smaller leftover whole), and, when inventory exists, with/without partial sheets.

On top of the deterministic grid, the batch adds 400 randomized restarts per material: the part order is shaken up (mostly "noisy area order" - biggest-first with each area nudged ±20% - and sometimes a full shuffle) with random placement/split rules. This lets the batch escape layouts that every deterministic rule happens to be bad at, and regularly saves a sheet on larger cutlists. The random seed is fixed, so re-running on the same cutlist always gives the same answer. The count is configurable via NestingOptions.ExtraRandomRuns - a 400-piece cutlist runs the full ~450-run batch in about a second.

The winning layout is picked by comparing, in order: fewest unplaced parts, fewest full sheets, least total sheet area, emptiest least-filled sheet (pack the other sheets tight and leave the last sheet nearly whole, rather than spreading waste across every sheet), biggest single leftover piece, least small-scrap area, then fewest saw cuts, and finally exact tie-breakers.

The scrap and largest-leftover comparisons are done in coarse buckets of one square foot (NestingOptions.CutTieScrapTolerance): layouts in the same bucket count as "roughly equal", which is what lets the cut count matter between near-identical layouts without ever trading away real material efficiency. Buckets (rather than pairwise "within X of each other" checks) keep the comparison transitive - the winner is the same no matter what order the candidates are generated in. Estimated cut counts are shown per sheet and per material on the results page, alongside the winning strategy.

Grain convention: a part's Length runs along the grain (the 96" direction of a full sheet). Partial sheets are assumed to keep the grain direction of the sheet they came from.

Known limitations

The 2D guillotine cutting-stock problem is NP-hard, so every practical optimizer (including commercial ones) uses heuristics. Each individual run here is greedy (no backtracking); the batch winner is the best of ~450 good attempts across both construction modes, not a guaranteed optimum. In practice it's close, and results always respect the guillotine, grain, and kerf constraints exactly.

Background reading on the approaches used: sequential/bin-oriented construction for guillotine bin packing (constructive bin-oriented heuristic, sequential heuristic for 2D bin packing) and the general problem class (two-dimensional guillotine cutting stock).

Also worth knowing:

  • The optimizer does not decrement your partial-sheet inventory by itself. The results page shows which partial sheets a layout uses and offers a one-click "mark as consumed" button - that way re-running the optimizer never silently eats your inventory.
  • Leftover regions of a configurable minimum size (default 12" x 12") are offered on the results page to save into the inventory; nothing is saved without your confirmation.
  • Layout drawings show which face the dimensions refer to; the app doesn't model sheet thickness or edge banding.

Running without Docker (optional)

If you have the .NET 10 SDK and a local Postgres:

  1. Edit the connection string in appsettings.json.
  2. dotnet run - migrations still apply automatically.

About

Plywood sheet optimizer for cabinet cutlists

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages