@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 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 };
|
||||
Reference in New Issue
Block a user