Skip to content

Instantly share code, notes, and snippets.

@spoter368
Created October 30, 2025 05:15
Show Gist options
  • Select an option

  • Save spoter368/3c2446ccdf59c7a4b01fc8de7d052bb6 to your computer and use it in GitHub Desktop.

Select an option

Save spoter368/3c2446ccdf59c7a4b01fc8de7d052bb6 to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Cut Plan Optimizer for 8-ft 2×4s (or any stock length)
OVERVIEW
========
This program computes an *optimal* cutting plan that minimizes the number of
stock boards needed to produce a set of requested piece lengths. It models:
- A per-cut saw kerf between adjacent pieces on a board (internal kerf).
- A *conditional end-trim kerf*: if the used length does **not** perfectly
equal the stock length, you’ll make one final squaring/trim cut at the end.
That extra kerf is reserved in fit checks and included in the waste number.
It also prints **mark positions** (in inches from the original left end) for
each cut, assuming you mark to the **left** of the cut and cut **left→right**.
With this convention, the blade removes kerf to the **right** of your pencil
line, producing pieces at the intended lengths.
I/O (Inputs and Outputs)
========================
Inputs (choose one):
1) Edit the DEFAULT `requirements` mapping in this file:
{length_in_inches: quantity, ...}
2) Use CLI flags (repeatable) to pass pieces:
-p LENGTH QTY (e.g., -p 33 2 -p 25 2 ...)
You can specify the unit for LENGTHs via --units inches|feet (default:
inches). If you pass feet, the program converts to inches internally.
3) Adjust optional parameters via CLI:
--stock 96 (stock length in inches; default 96 for 8 ft)
--stock-ft 8 (stock length in feet; overrides --stock)
--kerf 0.125 (internal kerf per cut in inches)
--end-trim 0.125 (end-trim kerf; default: same as --kerf)
--no-marks (suppress mark position prints)
Outputs (to stdout):
- Minimal number of boards required.
- For each board:
* Piece breakdown
* Mark positions (left-of-blade, left→right)
* Optional “final end-trim mark” (if not an exact fill)
* Kerf totals, used length, and waste per board
- Total waste across all boards.
NOTES & ASSUMPTIONS
===================
- Kerf direction convention: marks are to the **left** of each cut; saw removes
kerf on the **right** of the line. If you flip this practice, adjust the
marking logic accordingly.
- Floating-point tolerances are used to treat “exact” fills robustly.
- The search uses an exact branch-and-bound with memoization and symmetry
breaking. For typical small/medium cut lists (like framing) it’s fast and
exact without external libraries.
USAGE EXAMPLES
==============
1) Use defaults embedded in the file:
python cut_optimizer.py
2) Same requirements as the prompt via CLI:
python cut_optimizer.py \
-p 33 2 -p 25 2 -p 13.8 2 -p 10.25 2 -p 20 4 -p 36.25 4 \
--stock 96 --kerf 0.125
3) Specify lengths in feet and an 8-ft stock:
python cut_optimizer.py -p 2.75 2 -p 2.0833 2 --units feet --stock-ft 8
"""
from __future__ import annotations
import argparse
from collections import Counter
from functools import lru_cache
from typing import Dict, Iterable, List, Tuple
# ---------------------------- Defaults (editable) -----------------------------
# Default stock and kerfs (can be overridden by CLI)
DEFAULT_STOCK_LENGTH_IN = 96.0 # inches (8 ft)
DEFAULT_KERF_IN = 0.125 # inches per internal cut
DEFAULT_END_TRIM_IN = None # None → use kerf; else set a number in inches
# Default requirements (can be overridden by CLI -p flags)
# Example from the prompt:
requirements: Dict[float, int] = {
33.0: 2,
25.0: 2,
13.8: 2,
10.25: 2,
20.0: 4,
36.25: 4,
}
# ------------------------------- Constants -----------------------------------
TOL = 1e-9 # numeric tolerance for “exact” comparisons
# ----------------------------- Helper Functions ------------------------------
def multiset_to_list(req: Dict[float, int]) -> List[float]:
"""
Expand a {length: qty} mapping into a flat list of piece lengths.
Parameters
----------
req : Dict[float, int]
Mapping from piece length (inches) to quantity.
Returns
-------
List[float]
Flat list of piece lengths (inches), with each length repeated by qty.
"""
pieces: List[float] = []
for length, qty in req.items():
pieces.extend([float(length)] * int(qty))
return pieces
def internal_used_no_end(board_pieces: Tuple[float, ...], kerf: float) -> float:
"""
Compute total used length from pieces and internal kerfs only (no end trim).
For N pieces on a board, there are max(0, N-1) internal kerfs.
Parameters
----------
board_pieces : Tuple[float, ...]
Piece lengths (inches) assigned to a board.
kerf : float
Internal kerf per cut (inches).
Returns
-------
float
Sum of piece lengths plus internal kerfs.
"""
if not board_pieces:
return 0.0
return sum(board_pieces) + (len(board_pieces) - 1) * kerf
def board_is_exact(board_pieces: Tuple[float, ...], stock: float, kerf: float) -> bool:
"""
Determine if a board is an exact fill, i.e., no end trim is needed.
Parameters
----------
board_pieces : Tuple[float, ...]
Piece lengths (inches) assigned to a board.
stock : float
Stock length (inches).
kerf : float
Internal kerf per cut (inches).
Returns
-------
bool
True if internal_used_no_end == stock within tolerance.
"""
return abs(stock - internal_used_no_end(board_pieces, kerf)) <= 1e-6
def board_used_length(
board_pieces: Tuple[float, ...], stock: float, kerf: float, end_trim: float
) -> float:
"""
Compute total consumed stock including conditional end-trim kerf.
If the internal sum is “exact,” no end-trim is added. Otherwise, add end_trim.
Parameters
----------
board_pieces : Tuple[float, ...]
Pieces on the board (inches).
stock : float
Stock length (inches).
kerf : float
Internal kerf (inches).
end_trim : float
End-trim kerf (inches) reserved if not exact.
Returns
-------
float
Total used length accounting for conditional end-trim.
"""
base = internal_used_no_end(board_pieces, kerf)
return base if board_is_exact(board_pieces, stock, kerf) else base + end_trim
def waste_for_board(
board_pieces: Tuple[float, ...], stock: float, kerf: float, end_trim: float
) -> float:
"""
Compute tail waste after applying conditional end-trim kerf when not exact.
Parameters
----------
board_pieces : Tuple[float, ...]
Pieces on the board (inches).
stock : float
Stock length (inches).
kerf : float
Internal kerf (inches).
end_trim : float
End-trim kerf (inches).
Returns
-------
float
Non-negative waste length (inches).
"""
return max(0.0, stock - board_used_length(board_pieces, stock, kerf, end_trim))
def can_add_to_board(
board_pieces: Tuple[float, ...],
new_len: float,
stock: float,
kerf: float,
end_trim: float,
) -> bool:
"""
Check if adding a piece fits the stock, honoring the conditional end-trim rule.
Adding a piece is allowed if:
(A) It yields an exact fill (no end-trim needed), OR
(B) It still fits after reserving one end-trim kerf (non-exact fill).
Parameters
----------
board_pieces : Tuple[float, ...]
Current pieces on the board (inches).
new_len : float
Candidate piece length (inches) to add.
stock : float
Stock length (inches).
kerf : float
Internal kerf per cut (inches).
end_trim : float
End-trim kerf (inches) reserved if non-exact.
Returns
-------
bool
True if the piece can be added under the rules above.
"""
base_before = internal_used_no_end(board_pieces, kerf)
added_internal = kerf if len(board_pieces) >= 1 else 0.0
base_after = base_before + added_internal + new_len # still “no end trim” sum
# Case A: exact (no end-trim needed)
if abs(stock - base_after) <= 1e-6:
return True
# Case B: non-exact; must also fit with one end-trim kerf
return (base_after + end_trim) <= stock + TOL
def normalize_bins(bins: Tuple[Tuple[float, ...], ...]) -> Tuple[Tuple[float, ...], ...]:
"""
Canonicalize bin representation to reduce symmetry in the search.
Sort pieces within each bin in descending order, then sort bins.
Parameters
----------
bins : Tuple[Tuple[float, ...], ...]
Current boards with their pieces.
Returns
-------
Tuple[Tuple[float, ...], ...]
Canonicalized bin tuple for memoization.
"""
norm = tuple(tuple(sorted(b, reverse=True)) for b in bins)
return tuple(sorted(norm, reverse=True))
def solve_cutting(
pieces: List[float],
stock: float,
kerf: float,
end_trim: float,
) -> Tuple[Tuple[Tuple[float, ...], ...], int]:
"""
Solve the cutting-stock layout with exact branch-and-bound search.
Parameters
----------
pieces : List[float]
Piece lengths (inches) to realize.
stock : float
Stock length (inches).
kerf : float
Internal kerf per cut (inches).
end_trim : float
End-trim kerf (inches) to reserve if non-exact.
Returns
-------
(best_bins, explored) : Tuple[Tuple[Tuple[float, ...], ...], int]
best_bins is a tuple of boards; each board is a tuple of piece lengths.
explored is a diagnostic count of explored search nodes.
"""
pieces = tuple(sorted(pieces, reverse=True)) # longest-first for tighter bounds
best_solution = {"bins": None}
explored = 0
total_len = sum(pieces)
optimistic_lb = int((total_len // stock) + (1 if (total_len % stock) > TOL else 0))
@lru_cache(maxsize=None)
def dfs(remaining: Tuple[float, ...], bins: Tuple[Tuple[float, ...], ...]) -> None:
nonlocal explored, best_solution
explored += 1
# Done: no pieces left
if not remaining:
if best_solution["bins"] is None or len(bins) < len(best_solution["bins"]):
best_solution["bins"] = bins
return
# Prune when already as bad as best known
if best_solution["bins"] is not None and len(bins) >= len(best_solution["bins"]):
return
# Lower bound on remaining boards ignoring kerf/end-trim fine details
rem_total = sum(remaining)
min_extra = int(rem_total // stock) + (1 if (rem_total % stock) > TOL else 0)
if best_solution["bins"] is not None and (len(bins) + min_extra) >= len(best_solution["bins"]):
return
next_len = remaining[0]
rest = remaining[1:]
# Try placing into existing boards, dedup by capacity signature
seen_signatures = set()
for i, b in enumerate(bins):
if not can_add_to_board(b, next_len, stock, kerf, end_trim):
continue
new_b = tuple(sorted(b + (next_len,), reverse=True))
used = board_used_length(new_b, stock, kerf, end_trim)
cap_after = stock - used
sig = (round(cap_after, 3), len(new_b))
if sig in seen_signatures:
continue
seen_signatures.add(sig)
new_bins = list(bins)
new_bins[i] = new_b
dfs(rest, normalize_bins(tuple(new_bins)))
# Optional early-exit: if we hit theoretical best for this prefix
if (best_solution["bins"] is not None
and len(best_solution["bins"]) == max(optimistic_lb, len(bins))):
return
# Or open a new board
new_bins = normalize_bins(bins + ((next_len,),))
dfs(rest, new_bins)
dfs(pieces, tuple())
assert best_solution["bins"] is not None # with valid inputs, a solution must exist
return best_solution["bins"], explored
def compute_mark_positions(board_pieces: Tuple[float, ...], kerf: float) -> List[float]:
"""
Compute absolute mark positions from the left end for each piece cut.
Convention: mark to the LEFT of the blade; cut LEFT→RIGHT. The blade removes
kerf to the RIGHT of the pencil line. The j-th mark is:
mark_j = sum(first j piece lengths) + (j-1) * kerf
Parameters
----------
board_pieces : Tuple[float, ...]
Piece sequence (inches) on the board.
kerf : float
Internal kerf per cut (inches).
Returns
-------
List[float]
Mark positions in inches from the original left end.
"""
marks: List[float] = []
acc = 0.0
for i, L in enumerate(board_pieces, start=1):
acc += L
mark = acc + (i - 1) * kerf
marks.append(mark)
return marks
def pretty_print_solution(
bins: Tuple[Tuple[float, ...], ...],
stock: float,
kerf: float,
end_trim: float,
show_marks: bool = True,
) -> None:
"""
Render the plan to stdout, including marks, kerfs, used length, and waste.
Parameters
----------
bins : Tuple[Tuple[float, ...], ...]
Boards and their assigned pieces.
stock : float
Stock length (inches).
kerf : float
Internal kerf (inches).
end_trim : float
End-trim kerf (inches).
show_marks : bool, default True
Whether to print mark positions and the final end-trim mark (if any).
"""
print(f"\nOptimal boards needed: {len(bins)}")
print("Assumptions: mark LEFT of blade; cut LEFT→RIGHT; blade removes kerf to the RIGHT of the mark.")
grand_waste = 0.0
for idx, b in enumerate(bins, start=1):
base = internal_used_no_end(b, kerf)
exact = board_is_exact(b, stock, kerf)
end_trim_applied = 0.0 if exact else end_trim
used = base + end_trim_applied
waste = max(0.0, stock - used)
grand_waste += waste
cut_str = " + ".join(f"{x:.3f}\"" for x in b)
cuts_internal = max(0, len(b) - 1)
print(f"\nBoard {idx}:")
print(f" Pieces: {cut_str}")
if show_marks:
marks = compute_mark_positions(b, kerf)
print(f" Mark positions (from left): " + ", ".join(f"{m:.3f}\"" for m in marks))
if not exact:
end_mark = base # location of the final end-trim mark (before removing kerf)
print(f" Final end-trim mark: {end_mark:.3f}\" (then remove {end_trim:.3f}\" to the right)")
print(f" Internal cuts: {cuts_internal} @ {kerf:.3f}\" = {cuts_internal*kerf:.3f}\" kerf")
print(f" End trim kerf: {end_trim_applied:.3f}\" ({'none, exact fill' if exact else 'applied'})")
print(f" Used: {used:.3f}\" / {stock:.3f}\"")
print(f" Waste: {waste:.3f}\"")
print(f"\nTotal waste: {grand_waste:.3f}\"")
# ---------------------------------- CLI --------------------------------------
def parse_args() -> argparse.Namespace:
"""
Parse command-line arguments for interactive use.
Returns
-------
argparse.Namespace
Parsed arguments with attributes:
- piece: list[[LENGTH, QTY]] or None
- units: 'inches' or 'feet'
- stock, stock_ft: optional floats
- kerf: float
- end_trim: Optional[float]
- no_marks: bool
"""
p = argparse.ArgumentParser(
description="Optimize 2×4 cut plan with kerf and conditional end trim; print mark positions.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
p.add_argument(
"-p", "--piece", nargs=2, action="append", metavar=("LENGTH", "QTY"),
help="Add a piece requirement (repeatable). LENGTH in --units; QTY integer."
)
p.add_argument(
"--units", choices=("inches", "feet"), default="inches",
help="Unit for LENGTH inputs (pieces only)."
)
p.add_argument(
"--stock", type=float, default=None,
help="Stock length in inches. Ignored if --stock-ft is given."
)
p.add_argument(
"--stock-ft", type=float, default=None,
help="Stock length in feet; overrides --stock if provided."
)
p.add_argument(
"--kerf", type=float, default=DEFAULT_KERF_IN,
help="Internal kerf per cut (inches)."
)
p.add_argument(
"--end-trim", type=float, default=None,
help="End-trim kerf (inches). If omitted, equals --kerf."
)
p.add_argument(
"--no-marks", action="store_true",
help="Do not print mark positions."
)
return p.parse_args()
def build_requirements_from_args(args: argparse.Namespace) -> Dict[float, int]:
"""
Build a {length_in_inches: qty} mapping from CLI args or defaults.
Parameters
----------
args : argparse.Namespace
Parsed CLI args.
Returns
-------
Dict[float, int]
Requirements mapping in inches.
"""
if not args.piece:
return dict(requirements) # use defaults
factor = 12.0 if args.units == "feet" else 1.0
req: Dict[float, int] = {}
for L, Q in args.piece:
length_in = float(L) * factor
qty = int(Q)
req[length_in] = req.get(length_in, 0) + qty
return req
def main() -> None:
"""
CLI entry point. Parses args, solves the layout, and prints the cut plan.
Steps
-----
1) Parse CLI args (or fall back to defaults in this file).
2) Construct the piece multiset.
3) Run the exact solver with kerf + conditional end-trim.
4) Pretty-print the plan, including mark positions (unless suppressed).
"""
args = parse_args()
# Resolve stock length
if args.stock_ft is not None:
stock = float(args.stock_ft) * 12.0
elif args.stock is not None:
stock = float(args.stock)
else:
stock = DEFAULT_STOCK_LENGTH_IN
kerf = float(args.kerf)
end_trim = kerf if args.end_trim is None else float(args.end_trim)
req_map = build_requirements_from_args(args)
pieces = multiset_to_list(req_map)
print("Input pieces (length in inches):")
ctr = Counter(pieces)
for L in sorted(ctr.keys(), reverse=True):
print(f" {L}\" x {ctr[L]}")
print(f"\nStock length per board: {stock}\"")
print(f"Internal kerf per cut: {kerf}\"")
print(f"End-trim kerf (conditional): {end_trim}\"")
bins, explored = solve_cutting(pieces, stock, kerf, end_trim)
pretty_print_solution(bins, stock, kerf, end_trim, show_marks=not args.no_marks)
print(f"\nSearch nodes explored: {explored}")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment