Spaces:
Running
Running
| """ | |
| Nesting Otomatis Banner v2 β Super Optimized (Fixed) | |
| ===================================================== | |
| Perbaikan: original_dims dipass ke pack_multi_width_multi_roll | |
| """ | |
| import math | |
| import os | |
| import random | |
| import concurrent.futures | |
| import gradio as gr | |
| import pandas as pd | |
| from PIL import Image, ImageDraw, ImageFont | |
| from rectpack import newPacker | |
| from rectpack import ( | |
| SORT_AREA, SORT_PERI, SORT_DIFF, SORT_SSIDE, SORT_LSIDE, SORT_RATIO, SORT_NONE | |
| ) | |
| from rectpack.maxrects import MaxRectsBl, MaxRectsBssf, MaxRectsBaf, MaxRectsBlsf | |
| from rectpack.skyline import ( | |
| SkylineBl, SkylineBlWm, SkylineMwf, SkylineMwfl, SkylineMwfWm, SkylineMwflWm | |
| ) | |
| from rectpack.guillotine import ( | |
| GuillotineBssfSas, GuillotineBssfLas, GuillotineBssfSlas, | |
| GuillotineBssfLlas, GuillotineBssfMaxas, GuillotineBssfMinas, | |
| GuillotineBafSas, GuillotineBafLas, GuillotineBafSlas, | |
| GuillotineBafLlas, GuillotineBafMaxas, GuillotineBafMinas, | |
| GuillotineBlsfSas, GuillotineBlsfLas, GuillotineBlsfSlas, | |
| GuillotineBlsfLlas, GuillotineBlsfMaxas, GuillotineBlsfMinas, | |
| ) | |
| # -------------------------------------------------------------------------- | |
| # 1. FUNGSI BANTU | |
| # -------------------------------------------------------------------------- | |
| def generate_color(seed_text: str): | |
| random.seed(seed_text) | |
| return tuple(random.randint(90, 220) for _ in range(3)) | |
| def load_font(size: int = 14): | |
| candidates = [ | |
| "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", | |
| "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", | |
| ] | |
| for path in candidates: | |
| try: | |
| return ImageFont.truetype(path, size) | |
| except Exception: | |
| continue | |
| return ImageFont.load_default() | |
| def lighten_color(c, factor=0.55): | |
| return tuple(min(255, int(v + (255 - v) * factor)) for v in c) | |
| def get_sort_name(sort_algo): | |
| mapping = { | |
| SORT_AREA: "AREA", SORT_PERI: "PERI", SORT_LSIDE: "LSIDE", | |
| SORT_SSIDE: "SSIDE", SORT_RATIO: "RATIO", SORT_DIFF: "DIFF", | |
| SORT_NONE: "NONE", None: "Default", | |
| } | |
| return mapping.get(sort_algo, str(sort_algo)) | |
| def add_banner_to_list(label, width, height, item_margin, current_data): | |
| if not label or not str(label).strip(): | |
| label = f"Banner" | |
| try: | |
| w = float(width) | |
| h = float(height) | |
| m = float(item_margin) if item_margin is not None else 0.0 | |
| if m < 0: | |
| m = 0.0 | |
| except (TypeError, ValueError): | |
| return current_data # Abaikan jika nilai tidak valid | |
| new_row = [str(label).strip(), w, h, m] | |
| # Tangkap apakah current_data berupa DataFrame pandas atau List of Lists | |
| if isinstance(current_data, pd.DataFrame): | |
| new_row_df = pd.DataFrame([new_row], columns=current_data.columns) | |
| return pd.concat([current_data, new_row_df], ignore_index=True) | |
| elif isinstance(current_data, list): | |
| return current_data + [new_row] | |
| else: | |
| return [new_row] | |
| # -------------------------------------------------------------------------- | |
| # 2. PERSIAPAN DATA | |
| # -------------------------------------------------------------------------- | |
| def prepare_rects(df: pd.DataFrame, allow_rotation: bool = True): | |
| """ | |
| Setiap baris di tabel = satu banner (tanpa Qty). Margin/jarak antar item | |
| kini dibaca PER BARIS dari kolom "Margin", sehingga tiap desain banner | |
| bisa punya jarak potong yang berbeda-beda. Untuk mencetak banner yang | |
| sama lebih dari satu kali, tambahkan baris terpisah lewat form input | |
| (bisa dengan label & margin yang sama). | |
| """ | |
| rects, original_dims, item_margins = [], {}, {} | |
| for idx, row in df.iterrows(): | |
| label = str(row["Label"]).strip() or f"Item{idx}" | |
| try: | |
| w, h = float(row["Width"]), float(row["Height"]) | |
| m = float(row["Margin"]) if pd.notna(row.get("Margin")) else 0.0 | |
| except (TypeError, ValueError): | |
| continue | |
| if w <= 0 or h <= 0: | |
| continue | |
| if m < 0: | |
| m = 0.0 | |
| rid = f"{label}#{idx}" | |
| rects.append((w + m * 2, h + m * 2, rid)) | |
| original_dims[rid] = (w, h) | |
| item_margins[rid] = m | |
| if not rects: | |
| return None, None, None, 0, 0 | |
| max_margin = max(item_margins.values()) if item_margins else 0.0 | |
| # PERBAIKAN BUG: dulu tinggi bin (max_height) dihitung hanya dari sisi | |
| # "Height" tanpa rotasi (sum(h ...)). Kalau rotasi diizinkan, item dengan | |
| # rasio aspek ekstrem (mis. 605 x 60 cm) bisa ditempatkan berdiri, dan | |
| # footprint tingginya justru jadi 610 cm β jauh lebih besar dari 65 cm | |
| # yang tadinya diasumsikan. Bin yang terlalu pendek membuat rectpack | |
| # gagal menempatkan item ini walau seharusnya muat. Sekarang tiap item | |
| # disumbang oleh SISI TERPANJANG-nya (w atau h, mana yang lebih besar) | |
| # saat rotasi diizinkan, sebagai estimasi konservatif yang aman untuk | |
| # segala kemungkinan orientasi hasil packing. | |
| if allow_rotation: | |
| per_item_h = [max(w, h) for w, h, _ in rects] | |
| else: | |
| per_item_h = [h for _, h, _ in rects] | |
| max_height = sum(per_item_h) + max_margin * (len(rects) + 2) + 50 | |
| return rects, original_dims, item_margins, len(rects), max_height | |
| # -------------------------------------------------------------------------- | |
| # 3. CORE: PACKING ENGINE | |
| # -------------------------------------------------------------------------- | |
| def pack_multi_roll(rects, roll_width, max_height, allow_rotation, | |
| pack_algo=None, sort_algo=None, reverse=False): | |
| remaining = list(reversed(rects)) if reverse else rects[:] | |
| rolls, safety = [], 50 | |
| for _ in range(safety): | |
| if not remaining: | |
| break | |
| kwargs = {"rotation": allow_rotation} | |
| if pack_algo: | |
| kwargs["pack_algo"] = pack_algo | |
| if sort_algo: | |
| kwargs["sort_algo"] = sort_algo | |
| packer = newPacker(**kwargs) | |
| for w, h, rid in remaining: | |
| packer.add_rect(w, h, rid=rid) | |
| packer.add_bin(roll_width, max_height) | |
| packer.pack() | |
| if len(packer) == 0 or len(packer[0]) == 0: | |
| return rolls, len(remaining) | |
| placed = packer[0] | |
| used_height = max(r.y + r.height for r in placed) | |
| rolls.append((placed, used_height, roll_width)) | |
| placed_ids = {r.rid for r in placed} | |
| remaining = [r for r in remaining if r[2] not in placed_ids] | |
| return rolls, len(remaining) | |
| # -------------------------------------------------------------------------- | |
| # 4. GUILLOTINE CUT VALIDATION | |
| # -------------------------------------------------------------------------- | |
| def is_guillotine_cuttable(rolls): | |
| violations = 0 | |
| for placed, used_height, roll_width in rolls: | |
| items = [] | |
| for r in placed: | |
| items.append({ | |
| 'x': r.x, 'y': r.y, | |
| 'w': r.width, 'h': r.height, | |
| 'rid': r.rid | |
| }) | |
| for a in items: | |
| has_free_top = a['y'] + a['h'] >= used_height - 0.01 | |
| has_free_bottom = a['y'] <= 0.01 | |
| has_free_left = a['x'] <= 0.01 | |
| has_free_right = a['x'] + a['w'] >= roll_width - 0.01 | |
| if has_free_top or has_free_bottom or has_free_left or has_free_right: | |
| continue | |
| can_slide_up = all( | |
| not (b['x'] < a['x'] + a['w'] and b['x'] + b['w'] > a['x'] and | |
| b['y'] > a['y'] + a['h']) | |
| for b in items if b['rid'] != a['rid'] | |
| ) | |
| if can_slide_up: | |
| continue | |
| can_slide_down = all( | |
| not (b['x'] < a['x'] + a['w'] and b['x'] + b['w'] > a['x'] and | |
| b['y'] + b['h'] < a['y']) | |
| for b in items if b['rid'] != a['rid'] | |
| ) | |
| if can_slide_down: | |
| continue | |
| can_slide_left = all( | |
| not (b['y'] < a['y'] + a['h'] and b['y'] + b['h'] > a['y'] and | |
| b['x'] + b['w'] < a['x']) | |
| for b in items if b['rid'] != a['rid'] | |
| ) | |
| if can_slide_left: | |
| continue | |
| can_slide_right = all( | |
| not (b['y'] < a['y'] + a['h'] and b['y'] + b['h'] > a['y'] and | |
| b['x'] > a['x'] + a['w']) | |
| for b in items if b['rid'] != a['rid'] | |
| ) | |
| if can_slide_right: | |
| continue | |
| violations += 1 | |
| return violations == 0, violations | |
| # -------------------------------------------------------------------------- | |
| # 5. VISUALISASI | |
| # -------------------------------------------------------------------------- | |
| def render_multi_roll(rolls, original_dims, item_margins, px_per_unit=4.0): | |
| if not rolls: | |
| return None, 0, 0, 0 | |
| max_roll_w = max(w for _, _, w in rolls) | |
| img_w = max(1, int(round(max_roll_w * px_per_unit))) | |
| gap = 40 | |
| total_h = sum(int(round(h * px_per_unit)) for _, h, _ in rolls) | |
| total_h += gap * (len(rolls) - 1) + 60 | |
| img = Image.new("RGB", (img_w, total_h), "white") | |
| draw = ImageDraw.Draw(img) | |
| font = load_font(14) | |
| font_small = load_font(11) | |
| font_title = load_font(16) | |
| y_offset, total_true, total_roll_area, total_rot = 0, 0, 0, 0 | |
| for roll_idx, (placed, used_height, roll_width) in enumerate(rolls): | |
| roll_h_px = int(round(used_height * px_per_unit)) | |
| roll_label = f"ROLL {roll_idx + 1} | {roll_width:g} x {used_height:.1f} cm" | |
| draw.text((img_w / 2, y_offset + 5), roll_label, fill="#cc0000", font=font_title, anchor="ma") | |
| roll_y0 = y_offset + 25 | |
| roll_y1 = roll_y0 + roll_h_px | |
| draw.rectangle([0, roll_y0, int(round(roll_width * px_per_unit)) - 1, roll_y1], outline="red", width=3) | |
| for r in placed: | |
| base_label = r.rid.rsplit("#", 1)[0] | |
| color = generate_color(base_label) | |
| outer_color = lighten_color(color, 0.55) | |
| inner_color = color | |
| orig_w, orig_h = original_dims[r.rid] | |
| item_margin = item_margins.get(r.rid, 0.0) | |
| is_rotated = round(r.width - item_margin * 2, 3) != round(orig_w, 3) | |
| x0 = r.x * px_per_unit | |
| y0 = roll_y0 + (used_height - r.y - r.height) * px_per_unit | |
| x1 = (r.x + r.width) * px_per_unit | |
| y1 = roll_y0 + (used_height - r.y) * px_per_unit | |
| m = item_margin * px_per_unit | |
| ix0, iy0 = x0 + m, y0 + m | |
| ix1, iy1 = x1 - m, y1 - m | |
| draw.rectangle([x0, y0, x1, y1], fill=outer_color, outline="black", width=2) | |
| if ix1 > ix0 and iy1 > iy0: | |
| draw.rectangle([ix0, iy0, ix1, iy1], fill=inner_color, outline="black", width=1) | |
| label_text = base_label | |
| size_text = f"{orig_w:g} x {orig_h:g}" + (" (diputar)" if is_rotated else "") | |
| bbox_label = draw.textbbox((0, 0), label_text, font=font) | |
| bbox_size = draw.textbbox((0, 0), size_text, font=font_small) | |
| total_th = (bbox_label[3] - bbox_label[1]) + (bbox_size[3] - bbox_size[1]) + 4 | |
| cx, cy = (x0 + x1) / 2, (y0 + y1) / 2 | |
| ty = cy - total_th / 2 | |
| if (x1 - x0) > 20 and (y1 - y0) > 20: | |
| draw.text((cx, ty), label_text, fill="black", font=font, anchor="ma") | |
| draw.text((cx, ty + (bbox_label[3] - bbox_label[1]) + 4), size_text, | |
| fill="black", font=font_small, anchor="ma") | |
| total_true += orig_w * orig_h | |
| if is_rotated: | |
| total_rot += 1 | |
| for x in range(0, int(round(roll_width * px_per_unit)), 20): | |
| draw.line([(x, roll_y0), (x, roll_y1)], fill="#f5f5f5", width=1) | |
| for y in range(int(roll_y0), int(roll_y1), 20): | |
| draw.line([(0, y), (int(round(roll_width * px_per_unit)) - 1, y)], fill="#f5f5f5", width=1) | |
| total_roll_area += roll_width * used_height | |
| y_offset = roll_y1 + gap | |
| return img, total_true, total_roll_area, total_rot | |
| # -------------------------------------------------------------------------- | |
| # 5b. EKSPOR SVG (skala 1 unit SVG = 1 cm, untuk dibuka di CorelDraw/Illustrator) | |
| # -------------------------------------------------------------------------- | |
| def _xml_escape(text: str) -> str: | |
| return ( | |
| str(text) | |
| .replace("&", "&") | |
| .replace("<", "<") | |
| .replace(">", ">") | |
| .replace('"', """) | |
| ) | |
| def render_svg(rolls, original_dims, item_margins, roll_gap_cm=10.0, header_h_cm=2.0): | |
| """ | |
| Render layout nesting sebagai SVG vektor, 1 unit SVG = 1 cm. | |
| Tiap item digambar dengan DUA kotak: | |
| - Kotak LUAR (garis hitam) = area termasuk margin/jarak antar item = garis potong roll. | |
| - Kotak DALAM (garis biru putus-putus) = ukuran desain ASLI banner | |
| (tanpa margin). Ini adalah area yang harus diisi dengan desain asli | |
| saat file dibuka di CorelDraw, sehingga posisinya sudah pas dengan | |
| hasil nesting. | |
| Jika item diputar 90Β°, kotak dalam otomatis ikut ditukar (swap w/h) | |
| supaya tetap merepresentasikan orientasi tempel yang benar. | |
| Roll ditumpuk vertikal, sama seperti pratinjau PNG, supaya keduanya | |
| konsisten secara visual. | |
| """ | |
| if not rolls: | |
| return None | |
| max_roll_w = max(w for _, _, w in rolls) | |
| total_h = ( | |
| sum(h for _, h, _ in rolls) | |
| + header_h_cm * len(rolls) | |
| + roll_gap_cm * max(0, len(rolls) - 1) | |
| ) | |
| svg = [] | |
| svg.append( | |
| f'<svg xmlns="http://www.w3.org/2000/svg" ' | |
| f'width="{max_roll_w:.4f}cm" height="{total_h:.4f}cm" ' | |
| f'viewBox="0 0 {max_roll_w:.4f} {total_h:.4f}">' | |
| ) | |
| svg.append( | |
| '<desc>Layout nesting banner. 1 unit SVG = 1 cm. ' | |
| 'Kotak hitam = garis potong roll (termasuk margin). ' | |
| 'Kotak biru putus-putus = ukuran & posisi desain asli banner.</desc>' | |
| ) | |
| y_offset = 0.0 | |
| for roll_idx, (placed, used_height, roll_width) in enumerate(rolls): | |
| label_y = y_offset + header_h_cm - 0.4 | |
| svg.append( | |
| f'<text x="{roll_width / 2:.4f}" y="{label_y:.4f}" ' | |
| f'font-family="sans-serif" font-size="0.7" text-anchor="middle" ' | |
| f'fill="#cc0000">ROLL {roll_idx + 1} | {roll_width:g} x {used_height:.1f} cm</text>' | |
| ) | |
| roll_y0 = y_offset + header_h_cm | |
| roll_y1 = roll_y0 + used_height | |
| svg.append( | |
| f'<rect x="0" y="{roll_y0:.4f}" width="{roll_width:.4f}" height="{used_height:.4f}" ' | |
| f'fill="none" stroke="red" stroke-width="0.08"/>' | |
| ) | |
| for r in placed: | |
| base_label = r.rid.rsplit("#", 1)[0] | |
| orig_w, orig_h = original_dims[r.rid] | |
| item_margin = item_margins.get(r.rid, 0.0) | |
| is_rotated = round(r.width - item_margin * 2, 3) != round(orig_w, 3) | |
| # Kotak luar (footprint termasuk margin), y di-flip spy sama | |
| # orientasinya dgn pratinjau PNG (roll dibaca dari bawah ke atas). | |
| x0 = r.x | |
| y0 = roll_y0 + (used_height - r.y - r.height) | |
| w0 = r.width | |
| h0 = r.height | |
| svg.append( | |
| f'<rect x="{x0:.4f}" y="{y0:.4f}" width="{w0:.4f}" height="{h0:.4f}" ' | |
| f'fill="none" stroke="black" stroke-width="0.05"/>' | |
| ) | |
| # Kotak dalam = ukuran desain asli (swap kalau item diputar) | |
| inner_w, inner_h = (orig_h, orig_w) if is_rotated else (orig_w, orig_h) | |
| ix0 = x0 + item_margin | |
| iy0 = y0 + item_margin | |
| if inner_w > 0 and inner_h > 0: | |
| svg.append( | |
| f'<rect x="{ix0:.4f}" y="{iy0:.4f}" width="{inner_w:.4f}" height="{inner_h:.4f}" ' | |
| f'fill="none" stroke="blue" stroke-width="0.04" stroke-dasharray="0.3,0.2"/>' | |
| ) | |
| label_text = f"{base_label} ({orig_w:g}x{orig_h:g}{'R' if is_rotated else ''})" | |
| svg.append( | |
| f'<text x="{x0 + w0 / 2:.4f}" y="{y0 + h0 / 2:.4f}" ' | |
| f'font-family="sans-serif" font-size="0.4" text-anchor="middle" ' | |
| f'dominant-baseline="middle" fill="#333333">{_xml_escape(label_text)}</text>' | |
| ) | |
| y_offset = roll_y1 + roll_gap_cm | |
| svg.append('</svg>') | |
| return "\n".join(svg) | |
| def export_svg_file(rolls, original_dims, item_margins): | |
| """ | |
| Tulis hasil render_svg() ke file .svg di folder 'exports/' dan kembalikan | |
| path-nya, supaya bisa dipakai sebagai output gr.File (tombol download). | |
| Nama file memakai uuid4 (bukan timestamp detik) supaya aman dipakai | |
| banyak user sekaligus di HuggingFace Space publik tanpa risiko | |
| tabrakan/overwrite antar request yang terjadi di detik yang sama. | |
| """ | |
| if not rolls or not original_dims: | |
| return None | |
| svg_str = render_svg(rolls, original_dims, item_margins or {}) | |
| if not svg_str: | |
| return None | |
| import os | |
| import uuid | |
| out_dir = "exports" | |
| os.makedirs(out_dir, exist_ok=True) | |
| path = os.path.join(out_dir, f"nesting_layout_{uuid.uuid4().hex}.svg") | |
| with open(path, "w", encoding="utf-8") as f: | |
| f.write(svg_str) | |
| return path | |
| # -------------------------------------------------------------------------- | |
| # 6. STRATEGI PRE-SORT | |
| # -------------------------------------------------------------------------- | |
| def get_pre_sort_strategies(rects): | |
| strategies = [] | |
| def area(r): | |
| return r[0] * r[1] | |
| def max_side(r): | |
| return max(r[0], r[1]) | |
| def ratio(r): | |
| return max(r[0], r[1]) / max(min(r[0], r[1]), 0.001) | |
| base = [ | |
| ("default", rects[:]), | |
| ("area_desc", sorted(rects, key=area, reverse=True)), | |
| ("area_asc", sorted(rects, key=area)), | |
| ("width_desc", sorted(rects, key=lambda r: r[0], reverse=True)), | |
| ("width_asc", sorted(rects, key=lambda r: r[0])), | |
| ("height_desc", sorted(rects, key=lambda r: r[1], reverse=True)), | |
| ("height_asc", sorted(rects, key=lambda r: r[1])), | |
| ("longest_desc", sorted(rects, key=max_side, reverse=True)), | |
| ("ratio_desc", sorted(rects, key=ratio, reverse=True)), | |
| ("ratio_asc", sorted(rects, key=ratio)), | |
| ] | |
| strategies.extend(base) | |
| label_groups = {} | |
| for r in rects: | |
| label = r[2].rsplit("#", 1)[0] | |
| label_groups.setdefault(label, []).append(r) | |
| grouped = [] | |
| for label in sorted(label_groups.keys()): | |
| grouped.extend(label_groups[label]) | |
| strategies.append(("group_label", grouped)) | |
| by_area = sorted(rects, key=area, reverse=True) | |
| interleaved = [] | |
| i, j = 0, len(by_area) - 1 | |
| while i <= j: | |
| interleaved.append(by_area[i]) | |
| if i != j: | |
| interleaved.append(by_area[j]) | |
| i += 1 | |
| j -= 1 | |
| strategies.append(("interleave", interleaved)) | |
| return strategies | |
| # -------------------------------------------------------------------------- | |
| # 7. SIMULATED ANNEALING | |
| # -------------------------------------------------------------------------- | |
| def calculate_waste(rolls, original_dims): | |
| total_true = sum( | |
| original_dims[r.rid][0] * original_dims[r.rid][1] | |
| for placed, _, _ in rolls for r in placed | |
| ) | |
| total_roll = sum(w * h for _, h, w in rolls) | |
| return total_roll - total_true, (total_roll - total_true) / total_roll * 100 if total_roll else 100 | |
| def unified_key(waste, num_rolls, not_placed): | |
| """ | |
| Langkah 5: format key SATU-SATUNYA yang dipakai untuk membandingkan | |
| hasil single-width, multi-width, dan mode cepat, supaya perbandingan | |
| `key < best_key` semantiknya valid di seluruh jalur di process(). | |
| Item yang tidak muat diberi penalti besar (bukan diskualifikasi total), | |
| supaya solusi yang menempatkan semua item selalu diprioritaskan tapi | |
| solusi hampir-sempurna tetap bisa bersaing. | |
| """ | |
| return (waste + not_placed * 1e6, num_rolls, not_placed) | |
| def simulated_annealing(rects, roll_width, max_height, allow_rotation, | |
| pack_algo, sort_algo, original_dims, | |
| initial_temp=100, cooling=0.995, max_iter=3000, | |
| stall_limit=250, reheat_factor=0.6, seed=None): | |
| """ | |
| Perbaikan #4: menambah dua mekanisme supaya SA tidak terjebak di local | |
| optimum begitu saja: | |
| - REHEATING: kalau tidak ada perbaikan pada `best_waste` selama | |
| `stall_limit` iterasi berturut-turut, suhu dinaikkan kembali | |
| (temp = initial_temp * reheat_factor) supaya SA berani "melompat" | |
| keluar dari lembah lokal yang sedang dieksplorasi, alih-alih terus | |
| mendingin menuju nol dan berhenti bergerak. | |
| - SEED: parameter `seed` memungkinkan tiap chain SA (dijalankan | |
| paralel lewat _run_parallel) benar-benar independen satu sama lain | |
| (multi-start), bukan mengulang jalur pencarian yang persis sama. | |
| """ | |
| rng = random.Random(seed) if seed is not None else random | |
| current = rects[:] | |
| best = rects[:] | |
| best_result = None | |
| best_waste = float('inf') | |
| current_result, _ = pack_multi_roll( | |
| current, roll_width, max_height, allow_rotation, pack_algo, sort_algo, False | |
| ) | |
| if not current_result: | |
| return None, float('inf') | |
| current_waste, _ = calculate_waste(current_result, original_dims) | |
| temp = initial_temp | |
| stall = 0 | |
| for i in range(max_iter): | |
| neighbor = current[:] | |
| op = rng.choice(['swap', 'reverse', 'insert']) | |
| if op == 'swap' and len(neighbor) > 1: | |
| a, b = rng.sample(range(len(neighbor)), 2) | |
| neighbor[a], neighbor[b] = neighbor[b], neighbor[a] | |
| elif op == 'reverse' and len(neighbor) > 2: | |
| a, b = sorted(rng.sample(range(len(neighbor)), 2)) | |
| neighbor[a:b + 1] = list(reversed(neighbor[a:b + 1])) | |
| elif op == 'insert' and len(neighbor) > 1: | |
| a = rng.randint(0, len(neighbor) - 1) | |
| b = rng.randint(0, len(neighbor) - 1) | |
| item = neighbor.pop(a) | |
| neighbor.insert(b, item) | |
| rolls, _ = pack_multi_roll( | |
| neighbor, roll_width, max_height, allow_rotation, pack_algo, sort_algo, False | |
| ) | |
| if not rolls: | |
| stall += 1 | |
| else: | |
| waste, _ = calculate_waste(rolls, original_dims) | |
| if waste < best_waste - 1e-9: | |
| best_waste = waste | |
| best = neighbor[:] | |
| best_result = rolls | |
| stall = 0 | |
| else: | |
| stall += 1 | |
| if waste < current_waste or rng.random() < math.exp((current_waste - waste) / max(temp, 0.001)): | |
| current = neighbor[:] | |
| current_waste = waste | |
| if stall >= stall_limit: | |
| temp = initial_temp * reheat_factor | |
| stall = 0 | |
| else: | |
| temp *= cooling | |
| return best_result, best_waste | |
| # -------------------------------------------------------------------------- | |
| # 8. MULTI-WIDTH MULTI-ROLL (FIXED: tambah original_dims parameter) | |
| # -------------------------------------------------------------------------- | |
| def _try_pack_width(remaining, w, max_height, allow_rotation, pack_algo, sort_algo, original_dims): | |
| """ | |
| Helper: coba pack 'remaining' rects ke satu bin lebar w. Return None kalau | |
| gagal, atau (waste, placed, used_height, new_remaining) kalau berhasil. | |
| Dipakai bersama oleh pack_multi_width_multi_roll (greedy dan lookahead). | |
| """ | |
| kwargs = {"rotation": allow_rotation} | |
| if pack_algo: | |
| kwargs["pack_algo"] = pack_algo | |
| if sort_algo: | |
| kwargs["sort_algo"] = sort_algo | |
| packer = newPacker(**kwargs) | |
| for rect_w, rect_h, rid in remaining: | |
| packer.add_rect(rect_w, rect_h, rid=rid) | |
| packer.add_bin(w, max_height) | |
| packer.pack() | |
| if len(packer) == 0 or len(packer[0]) == 0: | |
| return None | |
| placed = packer[0] | |
| used_height = max(r.y + r.height for r in placed) | |
| placed_ids = {r.rid for r in placed} | |
| true_area = sum( | |
| (original_dims[rid][0] * original_dims[rid][1]) | |
| for _, _, rid in remaining if rid in placed_ids | |
| ) | |
| roll_area = w * used_height | |
| waste = roll_area - true_area | |
| new_remaining = [r for r in remaining if r[2] not in placed_ids] | |
| return waste, placed, used_height, new_remaining | |
| def pack_multi_width_multi_roll(rects, widths, max_height, allow_rotation, | |
| original_dims, # <-- FIX: tambah parameter ini | |
| pack_algo=None, sort_algo=None, reverse=False, | |
| lookahead=False, lookahead_k=3): | |
| """ | |
| Packing di mana TIAP ROLL boleh beda lebar. | |
| Langkah 4: bila lookahead=True, alih-alih langsung memilih lebar dengan | |
| waste terkecil untuk roll SAAT INI saja (murni greedy), fungsi ini | |
| mengambil top-`lookahead_k` kandidat lebar berdasar waste roll ini, lalu | |
| untuk tiap kandidat mengintip 1 roll ke depan (waste terbaik yang bisa | |
| dicapai pada sisa item) dan memilih kandidat dengan total waste gabungan | |
| (roll ini + estimasi roll berikutnya) terkecil. Ini membantu menghindari | |
| jebakan lokal di mana pilihan yang "tampak" optimal untuk roll saat ini | |
| justru membuat sisa item sulit dinestik secara efisien di roll berikutnya. | |
| """ | |
| remaining = list(reversed(rects)) if reverse else rects[:] | |
| all_rolls = [] | |
| safety = 50 | |
| for _ in range(safety): | |
| if not remaining: | |
| break | |
| candidates = [] | |
| for w in widths: | |
| res = _try_pack_width(remaining, w, max_height, allow_rotation, | |
| pack_algo, sort_algo, original_dims) | |
| if res is None: | |
| continue | |
| waste, placed, used_height, new_remaining = res | |
| candidates.append((waste, placed, used_height, w, new_remaining)) | |
| if not candidates: | |
| return all_rolls, len(remaining) | |
| candidates.sort(key=lambda c: c[0]) | |
| if not lookahead or len(candidates) <= 1: | |
| chosen = candidates[0] | |
| else: | |
| topk = candidates[:lookahead_k] | |
| chosen = None | |
| best_total = float('inf') | |
| for waste, placed, used_height, w, new_remaining in topk: | |
| if new_remaining: | |
| # Intip 1 langkah ke depan: waste terbaik di antara semua | |
| # lebar untuk sisa item setelah kandidat ini dipilih. | |
| next_best = float('inf') | |
| for w2 in widths: | |
| res2 = _try_pack_width(new_remaining, w2, max_height, allow_rotation, | |
| pack_algo, sort_algo, original_dims) | |
| if res2 is not None and res2[0] < next_best: | |
| next_best = res2[0] | |
| total = waste + (next_best if next_best != float('inf') else 0) | |
| else: | |
| total = waste | |
| if total < best_total: | |
| best_total = total | |
| chosen = (waste, placed, used_height, w, new_remaining) | |
| _, placed, used_height, w, new_remaining = chosen | |
| all_rolls.append((placed, used_height, w)) | |
| remaining = new_remaining | |
| return all_rolls, len(remaining) | |
| # -------------------------------------------------------------------------- | |
| # 8b. SIMULATED ANNEALING UNTUK MULTI-WIDTH MULTI-ROLL (Langkah 2) | |
| # -------------------------------------------------------------------------- | |
| def simulated_annealing_multi_width(rects, widths, max_height, allow_rotation, | |
| pack_algo, sort_algo, original_dims, | |
| initial_temp=100, cooling=0.995, max_iter=1500, | |
| stall_limit=200, reheat_factor=0.6, seed=None): | |
| """ | |
| Versi SA dari simulated_annealing(), tapi memakai pack_multi_width_multi_roll | |
| sebagai fungsi evaluasi sehingga tiap roll boleh punya lebar berbeda. | |
| Key evaluasi memakai (waste, not_placed) supaya solusi yang menempatkan | |
| semua item tetap diprioritaskan, tapi solusi dengan sedikit item tidak | |
| muat tidak langsung dibuang (lihat _mw_score). | |
| Perbaikan #4: reheating (keluar dari local optimum saat stagnan) dan | |
| dukungan `seed` untuk multi-start yang benar-benar independen. | |
| """ | |
| def _mw_score(rolls, not_placed): | |
| waste, _ = calculate_waste(rolls, original_dims) | |
| # Penalti besar per item tidak muat, tapi tidak diskualifikasi total | |
| return waste + not_placed * 1e6, waste | |
| rng = random.Random(seed) if seed is not None else random | |
| current = rects[:] | |
| best = rects[:] | |
| best_result = None | |
| best_not_placed = None | |
| best_waste = float('inf') | |
| best_score = float('inf') | |
| current_result, current_not_placed = pack_multi_width_multi_roll( | |
| current, widths, max_height, allow_rotation, original_dims, pack_algo, sort_algo, False | |
| ) | |
| if not current_result: | |
| return None, float('inf'), None | |
| current_score, current_waste = _mw_score(current_result, current_not_placed) | |
| best_result, best_not_placed, best_waste, best_score = ( | |
| current_result, current_not_placed, current_waste, current_score | |
| ) | |
| temp = initial_temp | |
| stall = 0 | |
| for _ in range(max_iter): | |
| neighbor = current[:] | |
| op = rng.choice(['swap', 'reverse', 'insert']) | |
| if op == 'swap' and len(neighbor) > 1: | |
| a, b = rng.sample(range(len(neighbor)), 2) | |
| neighbor[a], neighbor[b] = neighbor[b], neighbor[a] | |
| elif op == 'reverse' and len(neighbor) > 2: | |
| a, b = sorted(rng.sample(range(len(neighbor)), 2)) | |
| neighbor[a:b + 1] = list(reversed(neighbor[a:b + 1])) | |
| elif op == 'insert' and len(neighbor) > 1: | |
| a = rng.randint(0, len(neighbor) - 1) | |
| b = rng.randint(0, len(neighbor) - 1) | |
| item = neighbor.pop(a) | |
| neighbor.insert(b, item) | |
| rolls, not_placed = pack_multi_width_multi_roll( | |
| neighbor, widths, max_height, allow_rotation, original_dims, pack_algo, sort_algo, False | |
| ) | |
| if not rolls: | |
| stall += 1 | |
| else: | |
| score, waste = _mw_score(rolls, not_placed) | |
| if score < best_score - 1e-9: | |
| best_score = score | |
| best_waste = waste | |
| best_not_placed = not_placed | |
| best = neighbor[:] | |
| best_result = rolls | |
| stall = 0 | |
| else: | |
| stall += 1 | |
| if score < current_score or rng.random() < math.exp((current_score - score) / max(temp, 0.001)): | |
| current = neighbor[:] | |
| current_score = score | |
| if stall >= stall_limit: | |
| temp = initial_temp * reheat_factor | |
| stall = 0 | |
| else: | |
| temp *= cooling | |
| return best_result, best_waste, best_not_placed | |
| # -------------------------------------------------------------------------- | |
| # 8c. PARALLEL EXECUTION HELPERS (Perbaikan #3) | |
| # -------------------------------------------------------------------------- | |
| # Semua worker di bawah ini WAJIB berupa fungsi top-level (bukan closure/ | |
| # nested function) supaya bisa di-pickle dan dikirim ke proses worker oleh | |
| # ProcessPoolExecutor. Tiap worker menerima satu tuple argumen (payload) | |
| # dan mengembalikan hasil yang ringkas (bukan object rectpack mentah bila | |
| # tidak perlu), supaya biaya serialisasi antar-proses tetap murah. | |
| # Jumlah worker proses dibatasi supaya tidak membebani host (mis. HF Spaces | |
| # free tier biasanya cuma 2 vCPU). Bisa di-override lewat env var kalau perlu. | |
| _MAX_WORKERS = max(1, min(int(os.environ.get("NESTING_MAX_WORKERS", os.cpu_count() or 2)), 8)) | |
| # Berapa banyak "chain" independen yang dijalankan untuk tiap konfigurasi SA | |
| # (multi-start). Tiap chain pakai seed acak berbeda supaya benar-benar | |
| # menjelajah ruang solusi yang berbeda, bukan cuma mengulang jalur yang sama. | |
| _SA_STARTS = 3 | |
| def _run_parallel(worker_fn, tasks): | |
| """ | |
| Jalankan `worker_fn` untuk tiap item di `tasks` secara paralel memakai | |
| proses terpisah (menghindari GIL, supaya CPU-bound packing benar-benar | |
| berjalan bersamaan). Kalau environment tidak mengizinkan pembuatan | |
| proses baru (mis. container sandboxed), otomatis fallback ke eksekusi | |
| sekuensial biasa, supaya fitur ini tidak pernah membuat aplikasi crash. | |
| """ | |
| if not tasks: | |
| return [] | |
| if len(tasks) == 1 or _MAX_WORKERS <= 1: | |
| return [worker_fn(t) for t in tasks] | |
| try: | |
| max_workers = min(len(tasks), _MAX_WORKERS) | |
| with concurrent.futures.ProcessPoolExecutor(max_workers=max_workers) as executor: | |
| return list(executor.map(worker_fn, tasks)) | |
| except Exception: | |
| # Fallback aman: platform tidak mendukung multiprocessing (mis. | |
| # beberapa sandbox), atau objek gagal di-pickle. Jangan sampai | |
| # optimasi paralel mematikan fitur inti. | |
| return [worker_fn(t) for t in tasks] | |
| def _grid_worker(payload): | |
| """Worker untuk satu kombinasi (pack_algo, sort_algo, reverse) β single width.""" | |
| (rects, roll_width, max_height, allow_rotation, pack_algo, sort_algo, | |
| reverse, original_dims, label) = payload | |
| try: | |
| rolls, not_placed = pack_multi_roll( | |
| rects, roll_width, max_height, allow_rotation, pack_algo, sort_algo, reverse | |
| ) | |
| except Exception: | |
| return (label, None, None, None) | |
| if not rolls: | |
| return (label, None, None, None) | |
| waste, _ = calculate_waste(rolls, original_dims) | |
| return (label, rolls, not_placed, waste) | |
| def _sa_worker(payload): | |
| """Worker untuk satu chain Simulated Annealing (single width, multi-start).""" | |
| (rects, roll_width, max_height, allow_rotation, pack_algo, sort_algo, | |
| original_dims, initial_temp, cooling, max_iter, stall_limit, | |
| reheat_factor, seed, label) = payload | |
| rolls, waste = simulated_annealing( | |
| rects, roll_width, max_height, allow_rotation, pack_algo, sort_algo, | |
| original_dims, initial_temp=initial_temp, cooling=cooling, | |
| max_iter=max_iter, stall_limit=stall_limit, | |
| reheat_factor=reheat_factor, seed=seed, | |
| ) | |
| return (label, rolls, waste) | |
| def _swap_worker(payload): | |
| """Worker untuk satu percobaan local-swap (Fase 4).""" | |
| (order, roll_width, max_height, allow_rotation, original_dims, label) = payload | |
| rolls, not_placed = pack_multi_roll(order, roll_width, max_height, allow_rotation, None, None, False) | |
| if not rolls: | |
| return (label, None, None, None) | |
| waste, _ = calculate_waste(rolls, original_dims) | |
| return (label, rolls, not_placed, waste) | |
| def _mw_grid_worker(payload): | |
| """Worker untuk satu kombinasi (pack_algo, sort_algo, reverse) β multi-width.""" | |
| (rects, widths, max_height, allow_rotation, original_dims, pack_algo, | |
| sort_algo, reverse, label) = payload | |
| rolls, not_placed = pack_multi_width_multi_roll( | |
| rects, widths, max_height, allow_rotation, original_dims, pack_algo, sort_algo, reverse | |
| ) | |
| return (label, rolls, not_placed) | |
| def _mw_lookahead_worker(payload): | |
| """Worker untuk satu konfigurasi lookahead β multi-width.""" | |
| (rects, widths, max_height, allow_rotation, original_dims, pack_algo, | |
| sort_algo, label) = payload | |
| rolls, not_placed = pack_multi_width_multi_roll( | |
| rects, widths, max_height, allow_rotation, original_dims, pack_algo, sort_algo, False, | |
| lookahead=True, lookahead_k=3 | |
| ) | |
| return (label, rolls, not_placed) | |
| def _sa_mw_worker(payload): | |
| """Worker untuk satu chain Simulated Annealing multi-width (multi-start).""" | |
| (rects, widths, max_height, allow_rotation, pack_algo, sort_algo, | |
| original_dims, initial_temp, cooling, max_iter, stall_limit, | |
| reheat_factor, seed, label) = payload | |
| rolls, waste, not_placed = simulated_annealing_multi_width( | |
| rects, widths, max_height, allow_rotation, pack_algo, sort_algo, | |
| original_dims, initial_temp=initial_temp, cooling=cooling, | |
| max_iter=max_iter, stall_limit=stall_limit, | |
| reheat_factor=reheat_factor, seed=seed, | |
| ) | |
| return (label, rolls, waste, not_placed) | |
| # -------------------------------------------------------------------------- | |
| # 9. PENCARIAN UNTUK SATU LEBAR ROLL | |
| # -------------------------------------------------------------------------- | |
| def search_single_width(rects, original_dims, total_rects, max_height, | |
| roll_width, item_margins, allow_rotation, | |
| search_mode, target_waste_pct, px_per_unit): | |
| best_result = None | |
| best_key = None | |
| best_config = None | |
| best_rolls = None | |
| tried, failed = 0, 0 | |
| hit_target = False | |
| def consider(rolls, not_placed, config_name): | |
| """ | |
| Perbaikan #1: dulu fungsi ini (`evaluate`) memakai key | |
| (waste, num_rolls, not_placed) TANPA penalti β beda dengan | |
| `unified_key` yang dipakai process() untuk membandingkan lintas | |
| lebar roll. Akibatnya "terbaik per lebar" bisa tidak konsisten | |
| dengan perbandingan level atas. Sekarang keduanya memakai | |
| `unified_key` yang sama persis. | |
| """ | |
| nonlocal best_result, best_key, best_config, best_rolls, hit_target | |
| if not rolls: | |
| return | |
| waste, waste_pct = calculate_waste(rolls, original_dims) | |
| num_rolls = len(rolls) | |
| key = unified_key(waste, num_rolls, not_placed) | |
| if best_key is None or key < best_key: | |
| best_key = key | |
| best_result = (rolls, not_placed) | |
| best_config = config_name | |
| best_rolls = rolls | |
| if waste_pct <= target_waste_pct and not_placed == 0: | |
| hit_target = True | |
| def should_stop(): | |
| return hit_target | |
| def consider_batch(results): | |
| """Reduce hasil satu batch paralel: catat tried/failed lalu consider().""" | |
| nonlocal tried, failed | |
| for label, rolls, not_placed, waste in results: | |
| tried += 1 | |
| if not rolls: | |
| failed += 1 | |
| continue | |
| consider(rolls, not_placed, label) | |
| ALL_PACK = [ | |
| None, MaxRectsBssf, MaxRectsBaf, MaxRectsBl, MaxRectsBlsf, | |
| SkylineBl, SkylineBlWm, SkylineMwf, SkylineMwfl, | |
| SkylineMwfWm, SkylineMwflWm, | |
| GuillotineBssfSas, GuillotineBssfLas, GuillotineBssfMaxas, | |
| GuillotineBafSas, GuillotineBafLas, GuillotineBafMaxas, | |
| GuillotineBlsfSas, GuillotineBlsfLas, | |
| ] | |
| ALL_SORT = [None, SORT_AREA, SORT_PERI, SORT_LSIDE, SORT_RATIO, SORT_DIFF] | |
| # PHASE 1: GRID SEARCH (Perbaikan #3: satu batch besar dievaluasi paralel | |
| # lintas proses, bukan sekuensial satu-per-satu β jauh lebih cepat untuk | |
| # ratusan kombinasi algo x sort x reverse pada mode deep/pro/exhaustive) | |
| if search_mode in ("standard", "deep", "pro", "exhaustive"): | |
| if search_mode == "standard": | |
| pack_algos = [None, MaxRectsBssf, MaxRectsBaf, MaxRectsBlsf, | |
| SkylineMwfl, GuillotineBssfSas, GuillotineBafSas] | |
| sort_algos = [None, SORT_AREA, SORT_LSIDE] | |
| else: | |
| pack_algos, sort_algos = ALL_PACK, ALL_SORT | |
| tasks = [] | |
| for pack_algo in pack_algos: | |
| for sort_algo in sort_algos: | |
| for reverse in [False, True]: | |
| algo_name = pack_algo.__name__ if pack_algo else "Default" | |
| label = f"[Grid] {algo_name} + {get_sort_name(sort_algo)}{' rev' if reverse else ''}" | |
| tasks.append((rects, roll_width, max_height, allow_rotation, | |
| pack_algo, sort_algo, reverse, original_dims, label)) | |
| consider_batch(_run_parallel(_grid_worker, tasks)) | |
| # PHASE 2: PRE-SORT (dijalankan hanya kalau Fase 1 belum capai target) | |
| if search_mode in ("pro", "exhaustive") and not should_stop(): | |
| tasks = [] | |
| for sort_name, sorted_rects in get_pre_sort_strategies(rects): | |
| for pack_algo in [None, MaxRectsBssf, GuillotineBssfSas]: | |
| for reverse in [False, True]: | |
| algo_name = pack_algo.__name__ if pack_algo else "Default" | |
| label = f"[PreSort:{sort_name}] {algo_name}{' rev' if reverse else ''}" | |
| tasks.append((sorted_rects, roll_width, max_height, allow_rotation, | |
| pack_algo, SORT_NONE, reverse, original_dims, label)) | |
| consider_batch(_run_parallel(_grid_worker, tasks)) | |
| # PHASE 3: SIMULATED ANNEALING (Perbaikan #4: multi-start β tiap | |
| # kombinasi (pack_algo, sort_algo) dijalankan sebagai beberapa chain | |
| # independen dengan seed berbeda dan reheating aktif, semua dieksekusi | |
| # paralel, lalu diambil chain dengan waste terkecil.) | |
| if search_mode in ("pro", "exhaustive") and not should_stop(): | |
| sa_configs = [ | |
| (None, None), (MaxRectsBssf, SORT_AREA), (GuillotineBssfSas, SORT_AREA) | |
| ] | |
| max_iter = 5000 if search_mode == "exhaustive" else 2000 | |
| tasks = [] | |
| for pack_algo, sort_algo in sa_configs: | |
| algo_name = pack_algo.__name__ if pack_algo else "Default" | |
| for start in range(_SA_STARTS): | |
| seed = random.randint(0, 2**31 - 1) | |
| label = f"[SA] {algo_name} (start {start + 1}/{_SA_STARTS})" | |
| tasks.append((rects, roll_width, max_height, allow_rotation, | |
| pack_algo, sort_algo, original_dims, | |
| 100, 0.995, max_iter, 250, 0.6, seed, label)) | |
| results = _run_parallel(_sa_worker, tasks) | |
| for label, sa_rolls, sa_waste in results: | |
| tried += 1 | |
| if sa_rolls: | |
| consider(sa_rolls, 0, f"{label} (waste {sa_waste:.1f})") | |
| else: | |
| failed += 1 | |
| # PHASE 4: LOCAL SWAP (dievaluasi sebagai satu batch paralel alih-alih | |
| # perbaikan sekuensial satu-satu; sedikit kehilangan sifat "progresif" | |
| # tapi jauh lebih banyak kandidat swap yang bisa dicoba dalam waktu sama) | |
| if search_mode == "exhaustive" and best_rolls and not should_stop(): | |
| best_order = [] | |
| placed_ids = set() | |
| for placed, _, _ in best_rolls: | |
| for r in placed: | |
| placed_ids.add(r.rid) | |
| for r in rects: | |
| if r[2] in placed_ids: | |
| best_order.append(r) | |
| n = len(best_order) | |
| n_trials = min(500, n * n) if n > 1 else 0 | |
| tasks = [] | |
| seen_pairs = set() | |
| attempts = 0 | |
| while len(tasks) < n_trials and attempts < n_trials * 4: | |
| attempts += 1 | |
| i, j = random.randint(0, n - 1), random.randint(0, n - 1) | |
| if i == j or (i, j) in seen_pairs: | |
| continue | |
| seen_pairs.add((i, j)) | |
| swapped = best_order[:] | |
| swapped[i], swapped[j] = swapped[j], swapped[i] | |
| tasks.append((swapped, roll_width, max_height, allow_rotation, | |
| original_dims, f"[Swap:{i}-{j}]")) | |
| consider_batch(_run_parallel(_swap_worker, tasks)) | |
| if best_result is None: | |
| return None, f"β οΈ Lebar {roll_width:g} cm: tidak muat.", None, None, None, tried, False, None, None | |
| rolls, not_placed = best_result | |
| img, total_true, total_roll, total_rot = render_multi_roll( | |
| rolls, original_dims, item_margins, px_per_unit | |
| ) | |
| waste, waste_pct = calculate_waste(rolls, original_dims) | |
| num_rolls = len(rolls) | |
| is_guillotine, g_violations = is_guillotine_cuttable(rolls) | |
| target_status = "β TARGET TERCAPAI!" if waste_pct <= target_waste_pct else "β Target belum tercapai" | |
| stats = [ | |
| f"{'π―' if waste_pct <= target_waste_pct else 'β οΈ'} {target_status}", | |
| f"π Algoritma terpilih : {best_config}", | |
| f"π Mode : {search_mode.upper()}", | |
| f"π Iterasi dicoba : {tried} (gagal: {failed})", | |
| f"π Lebar roll : {roll_width:g} cm", | |
| f"π¦ Jumlah roll : {num_rolls}", | |
| f"π Total panjang : {sum(h for _, h, _ in rolls):.1f} cm", | |
| f"β Item ditempatkan : {sum(len(p) for p, _, _ in rolls)} / {total_rects}", | |
| f"π Diputar 90Β° : {total_rot}", | |
| f"π Luas item (asli) : {total_true:.1f} cmΒ²", | |
| f"π Luas roll terpakai : {total_roll:.1f} cmΒ²", | |
| f"β»οΈ TOTAL WASTE : {waste:.1f} cmΒ²", | |
| f"π Persentase waste : {waste_pct:.2f}% (target: β€{target_waste_pct}%)", | |
| f"πͺ Guillotine cuttable : {'β Ya' if is_guillotine else f'β οΈ Tidak ({g_violations} pelanggaran)'}", | |
| ] | |
| if not_placed > 0: | |
| stats.insert(7, f"β οΈ Item TIDAK muat : {not_placed}") | |
| return img, "\n".join(stats), waste, waste_pct, num_rolls, tried, hit_target, not_placed, rolls | |
| # -------------------------------------------------------------------------- | |
| # 10. FUNGSI UTAMA | |
| # -------------------------------------------------------------------------- | |
| def process(roll_widths_text, allow_rotation, search_mode, target_waste_pct, | |
| enable_multiwidth, table_data): | |
| if table_data is None or len(table_data) == 0: | |
| return None, "β οΈ Silakan isi tabel daftar banner terlebih dahulu.", None, None, None | |
| df = pd.DataFrame(table_data, columns=["Label", "Width", "Height", "Margin"]) | |
| df = df.dropna(subset=["Width", "Height"], how="any") | |
| df["Margin"] = pd.to_numeric(df["Margin"], errors="coerce").fillna(0.0) | |
| try: | |
| widths = [float(w.strip()) for w in roll_widths_text.split(",") if w.strip()] | |
| widths = [w for w in widths if w > 0] | |
| except ValueError: | |
| return None, "β οΈ Format daftar lebar roll tidak valid.", None, None, None | |
| if not widths: | |
| return None, "β οΈ Masukkan minimal satu lebar roll yang valid.", None, None, None | |
| try: | |
| target_waste_pct = float(target_waste_pct) | |
| if target_waste_pct < 0: | |
| target_waste_pct = 5.0 | |
| except (TypeError, ValueError): | |
| target_waste_pct = 5.0 | |
| rects, original_dims, item_margins, total_rects, max_height = prepare_rects(df, bool(allow_rotation)) | |
| if rects is None: | |
| return None, "β οΈ Tidak ada data banner yang valid.", None, None, None | |
| # Auto-generate lebar tambahan | |
| # MURNI MENGGUNAKAN INPUT USER (TANPA AUTO-GENERATE) | |
| # Hitung lebar roll minimal yang dibutuhkan. | |
| # PERBAIKAN BUG: sebelumnya kode selalu memakai kolom "Width" (w) saja, | |
| # padahal kalau rotasi diizinkan, sisi manapun dari banner bisa menempel | |
| # ke arah lebar roll β jadi syarat muatnya adalah SISI TERPENDEK + margin, | |
| # bukan w + margin. Akibatnya banner yang sebenarnya muat (setelah | |
| # diputar) malah ditolak duluan di sini sebelum sempat dicoba di-nesting. | |
| if allow_rotation: | |
| min_banner_w = max( | |
| min(w, h) + item_margins[rid] * 2 for rid, (w, h) in original_dims.items() | |
| ) | |
| else: | |
| min_banner_w = max( | |
| w + item_margins[rid] * 2 for rid, (w, h) in original_dims.items() | |
| ) | |
| # Filter: Hanya ambil lebar roll dari input user yang >= lebar minimal yang dibutuhkan | |
| all_widths = sorted([w for w in widths if w >= min_banner_w]) | |
| # Validasi jika tidak ada lebar roll yang cocok | |
| if not all_widths: | |
| rotasi_note = ( | |
| "(sudah memperhitungkan rotasi β sisi terpendek banner + margin)" | |
| if allow_rotation else | |
| "(rotasi tidak diaktifkan, jadi memakai kolom Width apa adanya)" | |
| ) | |
| return None, ( | |
| f"β οΈ Tidak ada lebar roll yang valid untuk menampung banner.\n" | |
| f"Lebar roll minimal yang diinput harus β₯ {min_banner_w:.1f} cm " | |
| f"(Lebar banner terbesar yang dibutuhkan + margin item tersebut) {rotasi_note}." | |
| ), None, None, None | |
| best_overall = None | |
| best_key = None | |
| best_width = None | |
| width_results = [] | |
| global_tried = 0 | |
| # MULTI-WIDTH MULTI-ROLL | |
| if enable_multiwidth and search_mode in ("pro", "exhaustive"): | |
| tried = 0 | |
| mw_best_rolls = None | |
| mw_best_not_placed = None | |
| mw_best_config = None | |
| # key global untuk multi-width sendiri: (waste + penalti not_placed, num_rolls) | |
| # dibandingkan terpisah dari single-width supaya kombinasi terbaik multi-width | |
| # yang benar-benar ditemukan (bukan cuma kombinasi pertama yang all-placed) | |
| mw_best_key = None | |
| def _mw_key(rolls, not_placed): | |
| waste, _ = calculate_waste(rolls, original_dims) | |
| return unified_key(waste, len(rolls), not_placed) | |
| def _consider_mw(rolls, not_placed, config_name): | |
| nonlocal mw_best_rolls, mw_best_not_placed, mw_best_config, mw_best_key | |
| if not rolls: | |
| return | |
| key = _mw_key(rolls, not_placed) | |
| if mw_best_key is None or key < mw_best_key: | |
| mw_best_key = key | |
| mw_best_rolls = rolls | |
| mw_best_not_placed = not_placed | |
| mw_best_config = config_name | |
| # Perbaikan #3: Fase A/B/D dibangun sebagai daftar task lalu | |
| # dievaluasi paralel lintas proses lewat _run_parallel, alih-alih | |
| # loop sekuensial murni. Fase C (SA) memakai multi-start (Perbaikan | |
| # #4) yang juga paralel. | |
| # --- Fase A: grid search dasar (kombinasi algo x sort x reverse) --- | |
| tasks = [] | |
| for pack_algo in [None, MaxRectsBssf, GuillotineBssfSas]: | |
| for sort_algo in [None, SORT_AREA]: | |
| for reverse in [False, True]: | |
| algo_name = pack_algo.__name__ if pack_algo else "Default" | |
| label = f"[Grid] {algo_name}+{get_sort_name(sort_algo)}{' rev' if reverse else ''}" | |
| tasks.append((rects, all_widths, max_height, allow_rotation, | |
| original_dims, pack_algo, sort_algo, reverse, label)) | |
| for label, rolls, not_placed in _run_parallel(_mw_grid_worker, tasks): | |
| tried += 1 | |
| _consider_mw(rolls, not_placed, label) | |
| # --- Fase B (Langkah 1): pre-sort strategies, sama seperti single-width --- | |
| if search_mode in ("pro", "exhaustive"): | |
| tasks = [] | |
| for sort_name, sorted_rects in get_pre_sort_strategies(rects): | |
| for pack_algo in [None, MaxRectsBssf, GuillotineBssfSas]: | |
| for reverse in [False, True]: | |
| algo_name = pack_algo.__name__ if pack_algo else "Default" | |
| label = f"[MW-PreSort:{sort_name}] {algo_name}{' rev' if reverse else ''}" | |
| tasks.append((sorted_rects, all_widths, max_height, allow_rotation, | |
| original_dims, pack_algo, SORT_NONE, reverse, label)) | |
| for label, rolls, not_placed in _run_parallel(_mw_grid_worker, tasks): | |
| tried += 1 | |
| _consider_mw(rolls, not_placed, label) | |
| # --- Fase C (Langkah 2): Simulated Annealing versi multi-width, | |
| # sekarang dengan multi-start (Perbaikan #4) --- | |
| if search_mode in ("pro", "exhaustive"): | |
| sa_mw_configs = [ | |
| (None, None), (MaxRectsBssf, SORT_AREA), (GuillotineBssfSas, SORT_AREA) | |
| ] | |
| max_iter = 2500 if search_mode == "exhaustive" else 1200 | |
| tasks = [] | |
| for pack_algo, sort_algo in sa_mw_configs: | |
| algo_name = pack_algo.__name__ if pack_algo else "Default" | |
| for start in range(_SA_STARTS): | |
| seed = random.randint(0, 2**31 - 1) | |
| label = f"[MW-SA] {algo_name} (start {start + 1}/{_SA_STARTS})" | |
| tasks.append((rects, all_widths, max_height, allow_rotation, | |
| pack_algo, sort_algo, original_dims, | |
| 100, 0.995, max_iter, 200, 0.6, seed, label)) | |
| for label, sa_rolls, sa_waste, sa_not_placed in _run_parallel(_sa_mw_worker, tasks): | |
| tried += 1 | |
| if sa_rolls: | |
| _consider_mw(sa_rolls, sa_not_placed, f"{label} (waste {sa_waste:.1f})") | |
| # --- Fase D (Langkah 4): pilihan lebar dgn lookahead 1-langkah --- | |
| # Dibatasi ke beberapa config saja karena tiap panggilan lebih mahal | |
| # (evaluasi top-k kandidat x semua lebar untuk mengintip roll berikutnya). | |
| if search_mode in ("pro", "exhaustive"): | |
| lookahead_configs = [(None, None), (MaxRectsBssf, SORT_AREA)] | |
| tasks = [] | |
| for pack_algo, sort_algo in lookahead_configs: | |
| for sort_name, sorted_rects in [("default", rects), ("area_desc", | |
| sorted(rects, key=lambda r: r[0] * r[1], reverse=True))]: | |
| algo_name = pack_algo.__name__ if pack_algo else "Default" | |
| label = f"[MW-Lookahead:{sort_name}] {algo_name}" | |
| tasks.append((sorted_rects, all_widths, max_height, allow_rotation, | |
| original_dims, pack_algo, sort_algo, label)) | |
| for label, rolls, not_placed in _run_parallel(_mw_lookahead_worker, tasks): | |
| tried += 1 | |
| _consider_mw(rolls, not_placed, label) | |
| # --- Bandingkan hasil terbaik multi-width dengan best_overall global --- | |
| if mw_best_rolls is not None: | |
| waste, waste_pct = calculate_waste(mw_best_rolls, original_dims) | |
| key = unified_key(waste, len(mw_best_rolls), mw_best_not_placed) | |
| if best_key is None or key < best_key: | |
| best_key = key | |
| img, total_true, total_roll, total_rot = render_multi_roll( | |
| mw_best_rolls, original_dims, item_margins | |
| ) | |
| is_g, g_v = is_guillotine_cuttable(mw_best_rolls) | |
| stats = [ | |
| "π― MULTI-WIDTH MULTI-ROLL (tiap roll beda lebar)", | |
| f"π Algoritma terpilih : {mw_best_config}", | |
| f"π Iterasi dicoba (multi-width): {tried}", | |
| f"π¦ Jumlah roll : {len(mw_best_rolls)}", | |
| f"π Lebar per roll : {', '.join(f'{w:.0f}' for _, _, w in mw_best_rolls)} cm", | |
| f"π Total panjang : {sum(h for _, h, _ in mw_best_rolls):.1f} cm", | |
| f"β Item ditempatkan : {sum(len(p) for p, _, _ in mw_best_rolls)} / {total_rects}", | |
| f"β»οΈ TOTAL WASTE : {waste:.1f} cmΒ²", | |
| f"π Persentase waste : {waste_pct:.2f}% (target: β€{target_waste_pct}%)", | |
| f"πͺ Guillotine cuttable : {'β Ya' if is_g else f'β οΈ Tidak ({g_v} pelanggaran)'}", | |
| ] | |
| if mw_best_not_placed: | |
| stats.insert(7, f"β οΈ Item TIDAK muat : {mw_best_not_placed}") | |
| best_overall = (img, "\n".join(stats), waste, waste_pct, mw_best_rolls) | |
| best_width = "multi" | |
| # Langkah 6 (persiapan): masukkan hasil multi-width ke tabel perbandingan juga | |
| width_results.append(("multi", waste, waste_pct, len(mw_best_rolls), f"{waste_pct:.2f}%")) | |
| global_tried += tried | |
| # SINGLE-WIDTH per lebar | |
| for width in all_widths: | |
| if search_mode == "quick": | |
| result = pack_multi_roll(rects, width, max_height, bool(allow_rotation)) | |
| if not result or not result[0]: | |
| width_results.append((width, None, None, "Gagal")) | |
| continue | |
| rolls, not_placed = result | |
| img, total_true, total_roll, total_rot = render_multi_roll( | |
| rolls, original_dims, item_margins | |
| ) | |
| waste, waste_pct = calculate_waste(rolls, original_dims) | |
| num_rolls = len(rolls) | |
| stats = [ | |
| f"π Mode : CEPAT", | |
| f"π Lebar roll : {width:g} cm", | |
| f"π¦ Jumlah roll : {num_rolls}", | |
| f"β»οΈ Total waste : {waste:.1f} cmΒ²", | |
| f"π Persentase waste : {waste_pct:.2f}%", | |
| ] | |
| key = unified_key(waste, num_rolls, not_placed) | |
| if best_key is None or key < best_key: | |
| best_key = key | |
| best_overall = (img, "\n".join(stats), waste, waste_pct, rolls) | |
| best_width = width | |
| width_results.append((width, waste, waste_pct, num_rolls, f"{waste_pct:.2f}%")) | |
| global_tried += 1 | |
| if waste_pct <= target_waste_pct: | |
| break | |
| else: | |
| img, stats, waste, waste_pct, num_rolls, tried, hit, sw_not_placed, sw_rolls = search_single_width( | |
| rects, original_dims, total_rects, max_height, | |
| width, item_margins, bool(allow_rotation), | |
| search_mode, target_waste_pct, 4.0 | |
| ) | |
| global_tried += tried | |
| if img is None: | |
| width_results.append((width, None, None, None, "Gagal")) | |
| continue | |
| key = unified_key( | |
| waste if waste is not None else float('inf'), | |
| num_rolls if num_rolls else 99, | |
| sw_not_placed if sw_not_placed is not None else 0, | |
| ) | |
| if best_key is None or key < best_key: | |
| best_key = key | |
| best_overall = (img, stats, waste, waste_pct, sw_rolls) | |
| best_width = width | |
| width_results.append((width, waste, waste_pct, num_rolls, f"{waste_pct:.2f}%")) | |
| if hit: | |
| break | |
| if best_overall is None: | |
| return None, "β οΈ Tidak ada satupun lebar roll yang bisa menampung semua banner.", None, None, None | |
| img, stats, best_waste, best_waste_pct, best_rolls_final = best_overall | |
| comparison = ["\n" + "β" * 55, "π PERBANDINGAN LEBAR ROLL (10 teratas):"] | |
| sorted_r = sorted([r for r in width_results if r[1] is not None], key=lambda x: (x[1], x[3])) | |
| for w, waste, wpct, num_rolls_disp, note in sorted_r[:10]: | |
| mark = " β PALING HEMAT" if w == best_width else "" | |
| target = " π―" if isinstance(wpct, (int, float)) and wpct <= target_waste_pct else "" | |
| w_label = "MULTI (beda lebar)" if w == "multi" else f"{w:g} cm" | |
| comparison.append( | |
| f" {'β ' if w == best_width else ' '} {w_label} β " | |
| f"{num_rolls_disp} roll, waste {waste:.1f} cmΒ² ({note}){mark}{target}" | |
| ) | |
| final = stats + "\n" + "\n".join(comparison) | |
| final += f"\n{'β' * 55}\nπ Total iterasi keseluruhan: {global_tried} kombinasi" | |
| return img, final, best_rolls_final, original_dims, item_margins | |
| # -------------------------------------------------------------------------- | |
| # 11. UI GRADIO | |
| # -------------------------------------------------------------------------- | |
| with gr.Blocks(title="Nesting Otomatis Banner v2 β Super Optimized") as demo: | |
| gr.Markdown("# π¨οΈ Nesting Otomatis v2 β Simulated Annealing + Multi-Width") | |
| gr.Markdown( | |
| "Upgrade: **Simulated Annealing**, **Multi-Width Multi-Roll**, " | |
| "dan **Guillotine Validation**. Berburu waste minimum dengan target." | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| roll_widths = gr.Textbox( | |
| label="Daftar Lebar Roll (cm, pisah koma)", | |
| value="150, 200, 250, 300", | |
| placeholder="Contoh: 150, 200, 250, 300", | |
| ) | |
| gr.Markdown( | |
| "βΉοΈ Jarak Antar Item / Margin kini diatur **per banner** lewat " | |
| "kolom **Margin** di form & tabel di bawah, bukan satu nilai global." | |
| ) | |
| allow_rotation = gr.Checkbox(label="Izinkan Rotasi Otomatis", value=True) | |
| target_waste = gr.Number( | |
| label="π― Target Waste Maksimum (%)", | |
| value=5.0, minimum=0.1, maximum=50.0, step=0.5, | |
| ) | |
| # 1. Multi-width aktif by default (True) | |
| enable_multiwidth = gr.Checkbox( | |
| label="π¨ Aktifkan Multi-Width Multi-Roll (tiap roll beda lebar)", | |
| value=True, | |
| ) | |
| # 2. Mode pencarian diatur ke "exhaustive" (Menyeluruh) by default | |
| search_mode = gr.Dropdown( | |
| label="Mode Pencarian", | |
| choices=[ | |
| ("β‘ Cepat", "quick"), | |
| ("π― Standar (~120)", "standard"), | |
| ("π Dalaman (~464)", "deep"), | |
| ("π§ Pro (~3.000+ + Simulated Annealing)", "pro"), | |
| ("π Menyeluruh (~8.000+ + SA + Swap)", "exhaustive"), | |
| ], | |
| value="exhaustive", | |
| ) | |
| gr.Markdown("### π₯ Form Input Banner Baru") | |
| with gr.Row(): | |
| input_label = gr.Textbox(label="Label Banner", value="") | |
| input_width = gr.Number(label="Lebar (cm)", value=100) | |
| with gr.Row(): | |
| input_height = gr.Number(label="Tinggi (cm)", value=60) | |
| input_margin = gr.Number( | |
| label="Margin (cm)", value=2.5, minimum=0 | |
| ) | |
| btn_add = gr.Button("β Tambah ke Daftar Banner", variant="secondary") | |
| gr.Markdown( | |
| "π‘ Untuk mencetak banner yang sama lebih dari satu kali, tekan " | |
| "tombol tambah beberapa kali (atau tambahkan baris manual di tabel)." | |
| ) | |
| gr.Markdown("### π Daftar Banner") | |
| # 3. Daftar banner default dikosongkan (value=[]) | |
| data_input = gr.Dataframe( | |
| headers=["Label", "Width", "Height", "Margin"], | |
| datatype=["str", "number", "number", "number"], | |
| row_count=(0, "dynamic"), | |
| value=[], | |
| ) | |
| # Event listener untuk tombol tambah banner berulang kali | |
| btn_add.click( | |
| fn=add_banner_to_list, | |
| inputs=[input_label, input_width, input_height, input_margin, data_input], | |
| outputs=[data_input] | |
| ) | |
| btn = gr.Button("π PROSES SUPER OPTIMIZED", variant="primary") | |
| with gr.Column(scale=2): | |
| output_img = gr.Image(label="Pratinjau Layout Cetakan", type="pil") | |
| output_stats = gr.Textbox(label="Statistik Hasil Nesting", lines=16) | |
| gr.Markdown("### π€ Ekspor Layout Terpilih") | |
| btn_export_svg = gr.Button("πΎ Download Layout (SVG untuk CorelDraw)", variant="secondary") | |
| output_svg_file = gr.File(label="File SVG Hasil Nesting (1 unit = 1 cm)") | |
| # State tersembunyi: menyimpan data mentah hasil nesting terbaik supaya | |
| # bisa dipakai ulang oleh tombol export SVG tanpa proses ulang perhitungan. | |
| state_best_rolls = gr.State(None) | |
| state_original_dims = gr.State(None) | |
| state_item_margins = gr.State(None) | |
| btn.click( | |
| fn=process, | |
| inputs=[roll_widths, allow_rotation, search_mode, target_waste, | |
| enable_multiwidth, data_input], | |
| outputs=[output_img, output_stats, state_best_rolls, state_original_dims, state_item_margins], | |
| ) | |
| btn_export_svg.click( | |
| fn=export_svg_file, | |
| inputs=[state_best_rolls, state_original_dims, state_item_margins], | |
| outputs=[output_svg_file], | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch() |