Skip to content

Commit 92a6279

Browse files
committed
feat: add --allow-rotation flag for automatic item rotation
When --allow-rotation is passed, GuillotineDP automatically adds the 90-degree rotated variant of each non-square item to the item list. The expanded item list is saved in the solution JSON so the visualizer renders rotated items with the same color as their original orientation. n_items_orig is stored to support the color mapping.
1 parent 6ab2f86 commit 92a6279

4 files changed

Lines changed: 60 additions & 30 deletions

File tree

src/guillotine/__main__.py

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -95,12 +95,10 @@ def estimate_memory(sheet_size, defect_sizes, defect_positions):
9595
try:
9696
from guillotine.core import _solver
9797
from guillotine.core.geometry import SheetGeometry
98-
# build defects_arr the same way geometry does
9998
geom = SheetGeometry(sheet_size, defect_sizes, defect_positions)
10099
t1dx, d1dx, t1dy, d1dy, t2d, d2d = _solver.estimate_slab(
101100
W0, H0, geom.defects_arr, n_defects)
102101

103-
# apply same selection logic as the C solver
104102
min_data = min(d1dx, d1dy, d2d)
105103
tolerance = 1.20
106104
candidates = [
@@ -124,7 +122,6 @@ def estimate_memory(sheet_size, defect_sizes, defect_positions):
124122
result["total"] = f_tables + g_tables + prefix + best_data * 4
125123

126124
except ImportError:
127-
# fall back to dense estimate if C extension not available
128125
dense = (W0+1)**2 * (H0+1)**2 * 4
129126
result["slab_data"] = dense
130127
result["slab_mb"] = dense / 1024**2
@@ -212,6 +209,7 @@ def parse_args(argv=None):
212209
" guillotine solve --sheet 27x27 --items 5x5 10x10 --defects 9,9,2x2\n"
213210
" guillotine solve problem.json --dry-run\n"
214211
" guillotine solve problem.json --plot\n"
212+
" guillotine solve problem.json --allow-rotation\n"
215213
" guillotine plot output/solution.json\n"
216214
"Run 'guillotine <command> --help' for more information."
217215
),
@@ -250,6 +248,12 @@ def parse_args(argv=None):
250248
action="store_true",
251249
help="Print problem summary and memory estimates without solving"
252250
)
251+
bench_parser.add_argument(
252+
"--allow-rotation",
253+
action="store_true",
254+
default=False,
255+
help="Allow 90-degree rotation of items (adds rotated variants automatically)"
256+
)
253257

254258
# ---- solve command ----
255259
solve_parser = subparsers.add_parser(
@@ -301,6 +305,12 @@ def parse_args(argv=None):
301305
action="store_true",
302306
help="Print problem summary and memory estimates without solving"
303307
)
308+
solve_parser.add_argument(
309+
"--allow-rotation",
310+
action="store_true",
311+
default=False,
312+
help="Allow 90-degree rotation of items (adds rotated variants automatically)"
313+
)
304314

305315
# ---- plot command ----
306316
plot_parser = subparsers.add_parser(
@@ -335,7 +345,8 @@ def parse_args(argv=None):
335345
# Solver functions
336346
# -------------------------
337347
def run_solver(item_sizes, defect_sizes, defect_positions, sheet_size,
338-
output_file, profile_file=None, plot_file=None):
348+
output_file, profile_file=None, plot_file=None,
349+
allow_rotation=False):
339350
"""Run the solver, save results, and optionally plot.
340351
341352
Plotting is done AFTER saving — the solution JSON is loaded back from
@@ -347,17 +358,19 @@ def run_solver(item_sizes, defect_sizes, defect_positions, sheet_size,
347358
def solve():
348359
geom = SheetGeometry(sheet_size, defect_sizes, defect_positions)
349360
patterns = CutPatternGenerator(item_sizes, geom)
350-
dp = GuillotineDP(item_sizes, geom, patterns)
361+
dp = GuillotineDP(item_sizes, geom, patterns, allow_rotation=allow_rotation)
351362
start = time.time()
352363
value, sequence = dp.solve()
353364
solve_time = time.time() - start
354-
return value, sequence, solve_time
365+
expanded_item_sizes = [dp.item_w.tolist(), dp.item_h.tolist()]
366+
n_items_orig = len(item_sizes[0])
367+
return value, sequence, solve_time, expanded_item_sizes, n_items_orig
355368

356369
if profile_file:
357370
print(f"Profiling enabled, output: {profile_file}")
358371
profiler = cProfile.Profile()
359372
profiler.enable()
360-
value, sequence, solve_time = solve()
373+
value, sequence, solve_time, expanded_item_sizes, n_items_orig = solve()
361374
profiler.disable()
362375
profile_file = ensure_output_path(profile_file)
363376
with open(profile_file, 'w') as f:
@@ -366,36 +379,38 @@ def solve():
366379
stats.print_stats(50)
367380
print(f"Profile saved to: {profile_file}")
368381
else:
369-
value, sequence, solve_time = solve()
382+
value, sequence, solve_time, expanded_item_sizes, n_items_orig = solve()
370383

371384
output_file = ensure_output_path(output_file)
372385
save_solution_json(output_file, value, sequence,
373-
item_sizes, defect_sizes, defect_positions, sheet_size)
386+
expanded_item_sizes, defect_sizes, defect_positions,
387+
sheet_size, n_items_orig=n_items_orig)
374388

375389
print(f"Solved in {solve_time:.3f}s")
376390
print(f"Value: {value}/{sheet_size[0]*sheet_size[1]} "
377391
f"({value/(sheet_size[0]*sheet_size[1])*100:.1f}%)")
378392
print(f"Output saved to: {output_file}")
379393

380-
# Plot from the saved JSON — solver memory is no longer referenced
381-
# and can be collected before matplotlib loads.
382394
if plot_file:
383395
plot_file = ensure_output_path(plot_file)
384396
run_plot(output_file, plot_file)
385397

386398

387-
def run_benchmark(output_file, profile_file=None, plot_file=None):
399+
def run_benchmark(output_file, profile_file=None, plot_file=None,
400+
allow_rotation=False):
388401
"""Run the paper benchmark case."""
389402
print("Running paper benchmark (27x27 sheet)...")
390403
item_sizes = [[5, 10, 12, 15], [5, 10, 12, 15]]
391404
defect_sizes = [[2], [2]]
392405
defect_positions = [[9], [9]]
393406
sheet_size = (27, 27)
394407
run_solver(item_sizes, defect_sizes, defect_positions, sheet_size,
395-
output_file, profile_file, plot_file)
408+
output_file, profile_file, plot_file,
409+
allow_rotation=allow_rotation)
396410

397411

398-
def run_from_json(input_file, output_file, profile_file=None, plot_file=None):
412+
def run_from_json(input_file, output_file, profile_file=None, plot_file=None,
413+
allow_rotation=False):
399414
"""Run solver from JSON input file."""
400415
print(f"Loading problem from {input_file}...")
401416
problem = load_problem_json(input_file)
@@ -406,7 +421,8 @@ def run_from_json(input_file, output_file, profile_file=None, plot_file=None):
406421
problem["sheet_size"],
407422
output_file,
408423
profile_file,
409-
plot_file
424+
plot_file,
425+
allow_rotation=allow_rotation,
410426
)
411427

412428

@@ -429,7 +445,8 @@ def run_plot(solution_file, output_file):
429445
data["item_sizes"],
430446
data["defect_sizes"],
431447
data["defect_positions"],
432-
data["sheet_size"]
448+
data["sheet_size"],
449+
n_items_orig=data.get("n_items_orig", len(data["item_sizes"][0])),
433450
)
434451

435452
output_file = ensure_output_path(output_file)
@@ -449,11 +466,9 @@ def load_problem_for_dry_run(args):
449466
"sheet_size": (27, 27),
450467
}
451468

452-
# JSON input
453469
if args.input:
454470
return load_problem_json(args.input)
455471

456-
# Inline input
457472
if args.items and args.sheet:
458473
try:
459474
w, h = map(int, args.sheet.lower().split("x"))
@@ -515,13 +530,15 @@ def main(argv=None):
515530

516531
# ---- benchmark ----
517532
if args.command == "benchmark":
518-
run_benchmark(args.output, args.profile, args.plot)
533+
run_benchmark(args.output, args.profile, args.plot,
534+
allow_rotation=args.allow_rotation)
519535
return 0
520536

521537
# ---- solve ----
522538
elif args.command == "solve":
523539
if args.input:
524-
run_from_json(args.input, args.output, args.profile, args.plot)
540+
run_from_json(args.input, args.output, args.profile, args.plot,
541+
allow_rotation=args.allow_rotation)
525542
return 0
526543

527544
if args.items and args.sheet:
@@ -560,7 +577,8 @@ def main(argv=None):
560577
(w, h),
561578
args.output,
562579
args.profile,
563-
args.plot
580+
args.plot,
581+
allow_rotation=args.allow_rotation,
564582
)
565583
return 0
566584

src/guillotine/core/dp_solver.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,23 @@ class GuillotineDP:
2222
optimality without precomputed candidate sets.
2323
"""
2424

25-
def __init__(self, item_sizes, geometry, patterns):
25+
def __init__(self, item_sizes, geometry, patterns, allow_rotation=False):
2626
self.geom = geometry
2727
self.patterns = patterns
2828
self.W0 = geometry.W0
2929
self.H0 = geometry.H0
3030

31-
self.item_w = np.array(item_sizes[0], dtype=np.int32)
32-
self.item_h = np.array(item_sizes[1], dtype=np.int32)
31+
item_w_orig = np.array(item_sizes[0], dtype=np.int32)
32+
item_h_orig = np.array(item_sizes[1], dtype=np.int32)
33+
34+
if allow_rotation:
35+
non_square = item_w_orig != item_h_orig
36+
self.item_w = np.concatenate([item_w_orig, item_h_orig[non_square]])
37+
self.item_h = np.concatenate([item_h_orig, item_w_orig[non_square]])
38+
else:
39+
self.item_w = item_w_orig
40+
self.item_h = item_h_orig
41+
3342
self.item_area = self.item_w * self.item_h
3443
self.n_items = len(self.item_w)
3544

src/guillotine/io.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,8 @@ def load_problem_json(filepath):
104104
}
105105

106106

107-
def save_solution_json(filepath, value, sequence,
108-
item_sizes, defect_sizes, defect_positions, sheet_size):
107+
def save_solution_json(filepath, value, sequence, item_sizes, defect_sizes,
108+
defect_positions, sheet_size, n_items_orig=None):
109109
"""Save solution to JSON file with all metrics and the full problem definition.
110110
111111
The problem definition is embedded so that the solution file is
@@ -126,6 +126,7 @@ def save_solution_json(filepath, value, sequence,
126126

127127
output = {
128128
"problem": _build_problem_dict(item_sizes, defect_sizes, defect_positions, sheet_size),
129+
"n_items_orig": n_items_orig if n_items_orig is not None else len(item_sizes[0]),
129130
"solution": {
130131
"cut_area": value,
131132
"total_area": total_area,
@@ -204,6 +205,7 @@ def load_solution_json(filepath):
204205
"defect_sizes": defect_sizes,
205206
"defect_positions": defect_positions,
206207
"sheet_size": sheet_size,
208+
"n_items_orig": data.get("n_items_orig", len(item_sizes[0])),
207209
"cut_area": sol["cut_area"],
208210
"total_area": sol["total_area"],
209211
"defect_area": sol["defect_area"],

src/guillotine/visualize.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,17 +87,17 @@ def _select_ticks(all_positions, sheet_dim, max_ticks=20):
8787
class CuttingVisualizer:
8888
"""Visualizes cutting patterns."""
8989

90-
def __init__(self, item_sizes, defect_sizes, defect_positions, sheet_size):
90+
def __init__(self, item_sizes, defect_sizes, defect_positions, sheet_size, n_items_orig=None):
9191
self.item_sizes = item_sizes
9292
self.defect_sizes = defect_sizes
9393
self.defect_positions = defect_positions
9494
self.sheet_size = sheet_size
95+
self.n_items_orig = n_items_orig if n_items_orig is not None else len(item_sizes[0])
9596
self.xticks = []
9697
self.yticks = []
9798

9899
def _get_colors(self):
99-
"""Return one vibrant color per item type, cycling if needed."""
100-
n_items = len(self.item_sizes[0])
100+
n_items = self.n_items_orig
101101
if n_items <= len(_VIBRANT_PALETTE):
102102
return _VIBRANT_PALETTE[:n_items]
103103
extra = [
@@ -142,9 +142,10 @@ def _draw_cuts(self, ax, sequence, w, h, colors, lw, offset=(0, 0)):
142142
if isinstance(sequence, str):
143143
if sequence.startswith("g_"):
144144
item_idx = int(sequence.split("_")[1])
145+
color_idx = item_idx % self.n_items_orig
145146
item_w = self.item_sizes[0][item_idx]
146147
item_h = self.item_sizes[1][item_idx]
147-
self._fill_items(ax, w, h, item_w, item_h, colors[item_idx], lw, offset)
148+
self._fill_items(ax, w, h, item_w, item_h, colors[color_idx], lw, offset)
148149
return
149150

150151
direction, z, left, right = sequence

0 commit comments

Comments
 (0)