/** * Packing Engine — APEX 4.0 * Layer-based bin packing algorithm (per Dossier Technique Maître §3.1). * * Strategy: * Level 1 — Côtés empilés (side panels stacked flat) * Level 2 — Dessus + Fond côte à côte (top & bottom side by side) * Level 3 — Étagères + Renforts groupés par épaisseur (shelves & reinforcements) * * Within each level, pieces are packed using First-Fit-Decreasing by weight, * respecting a 28 kg max per box. */ import { PACKING, PANEL } from '../../shared/manufacturing.js'; /** * Estimate the weight of a single piece in kg. * Uses approximate melamine panel density by thickness. * @param {number} widthMm * @param {number} heightMm * @param {number} thicknessMm * @returns {number} weight in kg for ONE piece */ function estimatePieceWeight(widthMm, heightMm, thicknessMm) { const volumeM3 = (widthMm / 1000) * (heightMm / 1000) * (thicknessMm / 1000); let density; if (thicknessMm >= PANEL.structure) { density = PACKING.densityKgM3.melamine19; } else if (thicknessMm >= PANEL.reinforcement) { density = PACKING.densityKgM3.melamine16; } else { density = PACKING.densityKgM3.melamine8; } return volumeM3 * density; } /** * Expand cutting list pieces into individual packable items. * Each piece with quantity > 1 becomes multiple single items. * @param {Array} pieces - pieces from cutting list * @returns {Array} flat array of single items with weight */ function expandPieces(pieces) { const items = []; for (const piece of pieces) { // Skip non-physical items (edge banding, etc.) if (piece.unit === 'ml' || piece.widthMm === 0 || piece.heightMm === 0) continue; const weightPerPiece = estimatePieceWeight(piece.widthMm, piece.heightMm, piece.thicknessMm); for (let i = 0; i < piece.quantity; i++) { items.push({ pieceId: piece.id, elementType: piece.elementType, description: piece.description, widthMm: piece.widthMm, heightMm: piece.heightMm, thicknessMm: piece.thicknessMm, quantity: 1, weightKg: Math.round(weightPerPiece * 100) / 100, index: i, }); } } return items; } /** * Classify expanded items into packing layers per APEX 4.0 §3.1. * * Level 1: cotes (side panels) — stacked * Level 2: dessus + fond (top & bottom) — side by side * Level 3: everything else grouped by thickness (etageres, renforts, panneauxInterieurs, dos, etc.) * * @param {Array} items - expanded single-piece items * @returns {Object} { level1, level2, level3 } arrays */ function classifyByLayer(items) { const level1 = []; // Côtés const level2 = []; // Dessus + Fond const level3 = []; // Étagères, Renforts, Séparateurs, Dos, Portes, etc. for (const item of items) { switch (item.elementType) { case 'cotes': case 'cotesDoublage': level1.push(item); break; case 'dessus': case 'fond': level2.push(item); break; default: level3.push(item); break; } } // Sort level 3 by thickness (group same thickness together) then by weight desc level3.sort((a, b) => { if (a.thicknessMm !== b.thicknessMm) return b.thicknessMm - a.thicknessMm; return b.weightKg - a.weightKg; }); return { level1, level2, level3 }; } /** * First-Fit-Decreasing bin packing for a set of items. * Sort items by weight (heaviest first), then assign each to the * first box that still has capacity. * * @param {Array} items - items to pack * @param {number} startBoxId - starting box ID * @param {string} levelLabel - label for these boxes (e.g. "Niveau 1") * @returns {{ boxes: Array, nextBoxId: number }} */ function firstFitDecreasing(items, startBoxId = 1, levelLabel = '') { if (items.length === 0) return { boxes: [], nextBoxId: startBoxId }; const sorted = [...items].sort((a, b) => b.weightKg - a.weightKg); const maxWeight = PACKING.maxWeightKg; const boxes = []; for (const item of sorted) { // If the single item exceeds max weight, it gets its own box if (item.weightKg > maxWeight) { boxes.push({ id: startBoxId + boxes.length, level: levelLabel, pieces: [item], totalWeightKg: item.weightKg, }); continue; } // Try to fit in an existing box let placed = false; for (const box of boxes) { if (box.totalWeightKg + item.weightKg <= maxWeight) { box.pieces.push(item); box.totalWeightKg = Math.round((box.totalWeightKg + item.weightKg) * 100) / 100; placed = true; break; } } // Open a new box if (!placed) { boxes.push({ id: startBoxId + boxes.length, level: levelLabel, pieces: [item], totalWeightKg: item.weightKg, }); } } return { boxes, nextBoxId: startBoxId + boxes.length }; } /** * Generate packing list from a cutting list result. * Uses the APEX 4.0 layer-based strategy: * Level 1 → sides stacked * Level 2 → top + bottom side by side * Level 3 → shelves + reinforcements grouped by thickness * * Each level is independently bin-packed with FFD, then all boxes * are merged into a single sequential list. * * @param {Object} cuttingList - output of generateCuttingList() * @returns {Object} packing summary */ export function generatePackingList(cuttingList) { if (!cuttingList || !cuttingList.pieces || cuttingList.pieces.length === 0) { return { boxes: [], totalBoxes: 0, maxWeightKg: PACKING.maxWeightKg, heaviestBoxKg: 0, totalWeightKg: 0, strategy: 'layer-based (APEX 4.0)', }; } const items = expandPieces(cuttingList.pieces); const { level1, level2, level3 } = classifyByLayer(items); // Pack each level independently const pack1 = firstFitDecreasing(level1, 1, 'Niveau 1 — Côtés'); const pack2 = firstFitDecreasing(level2, pack1.nextBoxId, 'Niveau 2 — Dessus/Fond'); const pack3 = firstFitDecreasing(level3, pack2.nextBoxId, 'Niveau 3 — Étagères/Renforts'); const allBoxes = [...pack1.boxes, ...pack2.boxes, ...pack3.boxes]; const heaviestBoxKg = allBoxes.reduce((max, b) => Math.max(max, b.totalWeightKg), 0); const totalWeightKg = allBoxes.reduce((sum, b) => sum + b.totalWeightKg, 0); return { boxes: allBoxes, totalBoxes: allBoxes.length, maxWeightKg: PACKING.maxWeightKg, heaviestBoxKg: Math.round(heaviestBoxKg * 10) / 10, totalWeightKg: Math.round(totalWeightKg * 10) / 10, strategy: 'layer-based (APEX 4.0)', layerSummary: { level1: { label: 'Côtés empilés', boxes: pack1.boxes.length, items: level1.length }, level2: { label: 'Dessus + Fond', boxes: pack2.boxes.length, items: level2.length }, level3: { label: 'Étagères + Renforts', boxes: pack3.boxes.length, items: level3.length }, }, }; } export default { generatePackingList };