Files
apex/server/services/packingEngine.js
lejayandCursor 6c0d7e47b8 v2.0
Co-authored-by: Cursor <[email protected]>
2026-05-28 07:20:38 +02:00

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 };