220 lines
6.8 KiB
JavaScript
220 lines
6.8 KiB
JavaScript
/**
|
|
* 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 };
|