/** * Cutting Plan Engine (Calepinage / plan de coupe) — APEX * * Optimise le placement des pièces de la liste de débitage sur des panneaux de stock * (par finition + épaisseur) à l'aide d'un algorithme guillotine par étagères * (First-Fit Decreasing Height). Calcule le pourcentage de chute par panneau et global, * et produit un SVG de calepinage par panneau pour le fournisseur. */ /** Format de panneau de stock par défaut (mm) — mélaminé standard. */ export const DEFAULT_STOCK_BOARD = { widthMm: 2800, heightMm: 2070 }; /** Trait de scie (kerf) entre pièces, mm. */ const DEFAULT_KERF_MM = 3.2; /** Délignage de rive (trim) sur chaque bord du panneau, mm. */ const DEFAULT_TRIM_MM = 10; function round(n, d = 3) { const f = 10 ** d; return Math.round((Number(n) || 0) * f) / f; } /** * Place une liste de rectangles sur des panneaux successifs (guillotine FFDH). * @param {Array<{w:number,h:number,label:string,pieceId:string}>} rects * @param {object} board { usableW, usableH, kerf, trim, boardWidthMm, boardHeightMm } * @param {boolean} allowRotation * @returns {Array} boards */ function packRectsIntoBoards(rects, board, allowRotation) { const { usableW, usableH, kerf, trim, boardWidthMm, boardHeightMm } = board; // Tri par hauteur décroissante (puis largeur) pour FFDH. const sorted = [...rects].sort((a, b) => (b.h - a.h) || (b.w - a.w)); const boards = []; function newBoard() { return { shelves: [], usedY: 0, placements: [] }; } function tryPlaceOnBoard(b, rect) { // Orientations candidates. const orientations = [{ w: rect.w, h: rect.h, rotated: false }]; if (allowRotation && rect.w !== rect.h) { orientations.push({ w: rect.h, h: rect.w, rotated: true }); } // 1) Essayer dans une étagère existante. for (const shelf of b.shelves) { for (const o of orientations) { if (o.w <= usableW - shelf.usedX + 1e-6 && o.h <= shelf.height + 1e-6) { b.placements.push({ xMm: round(trim + shelf.usedX, 1), yMm: round(trim + shelf.y, 1), wMm: round(o.w, 1), hMm: round(o.h, 1), rotated: o.rotated, label: rect.label, pieceId: rect.pieceId }); shelf.usedX += o.w + kerf; return true; } } } // 2) Ouvrir une nouvelle étagère. for (const o of orientations) { if (o.h <= usableH - b.usedY + 1e-6 && o.w <= usableW + 1e-6) { const shelf = { y: b.usedY, height: o.h, usedX: 0 }; b.placements.push({ xMm: round(trim + 0, 1), yMm: round(trim + shelf.y, 1), wMm: round(o.w, 1), hMm: round(o.h, 1), rotated: o.rotated, label: rect.label, pieceId: rect.pieceId }); shelf.usedX = o.w + kerf; b.shelves.push(shelf); b.usedY += o.h + kerf; return true; } } return false; } let current = newBoard(); for (const rect of sorted) { // Pièce trop grande pour un panneau, même tournée : on la place seule (hors-format signalé). const fits = rect.w <= usableW + 1e-6 && rect.h <= usableH + 1e-6; const fitsRotated = allowRotation && rect.h <= usableW + 1e-6 && rect.w <= usableH + 1e-6; if (!fits && !fitsRotated) { boards.push(finalizeBoard(current, board)); current = newBoard(); current.placements.push({ xMm: round(trim, 1), yMm: round(trim, 1), wMm: round(rect.w, 1), hMm: round(rect.h, 1), rotated: false, label: rect.label, pieceId: rect.pieceId, oversize: true }); boards.push(finalizeBoard(current, board)); current = newBoard(); continue; } if (!tryPlaceOnBoard(current, rect)) { boards.push(finalizeBoard(current, board)); current = newBoard(); tryPlaceOnBoard(current, rect); } } if (current.placements.length) boards.push(finalizeBoard(current, board)); return boards; function finalizeBoard(b, brd) { const boardAreaMm2 = brd.boardWidthMm * brd.boardHeightMm; const usedAreaMm2 = b.placements.reduce((s, p) => s + p.wMm * p.hMm, 0); const wasteAreaMm2 = Math.max(0, boardAreaMm2 - usedAreaMm2); return { boardWidthMm: brd.boardWidthMm, boardHeightMm: brd.boardHeightMm, placements: b.placements, usedAreaM2: round(usedAreaMm2 / 1e6), boardAreaM2: round(boardAreaMm2 / 1e6), wasteAreaM2: round(wasteAreaMm2 / 1e6), wastePercent: round((wasteAreaMm2 / boardAreaMm2) * 100, 1) }; } } /** SVG de calepinage pour un panneau. */ function boardSvg(boardEntry, index) { const { boardWidthMm, boardHeightMm, placements } = boardEntry; const pad = 24; const maxW = 520; const scale = maxW / boardWidthMm; const w = boardWidthMm * scale; const h = boardHeightMm * scale; const palette = ['#dbeafe', '#fde68a', '#bbf7d0', '#fecaca', '#e9d5ff', '#c7d2fe', '#fed7aa']; const rects = placements .map((p, i) => { const fill = p.oversize ? '#fca5a5' : palette[i % palette.length]; const rx = pad + p.xMm * scale; const ry = pad + p.yMm * scale; const rw = Math.max(1, p.wMm * scale); const rh = Math.max(1, p.hMm * scale); const label = `${p.label}${p.rotated ? ' ↻' : ''}`; const fontSize = Math.max(7, Math.min(11, rh / 3, rw / 6)); const text = rw > 36 && rh > 14 ? `${escapeXml(label)}` : ''; return `${text}`; }) .join(''); return ` ${rects} Panneau ${index + 1} — ${boardWidthMm}×${boardHeightMm} mm — chute ${boardEntry.wastePercent}% `; } function escapeXml(value) { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>'); } /** * Génère le plan de coupe (calepinage) pour toute l'armoire. * @param {object} cuttingList résultat de generateCuttingList * @param {object} [options] * @param {{widthMm:number,heightMm:number}} [options.stockBoard] * @param {number} [options.kerfMm] * @param {number} [options.trimMm] * @param {boolean} [options.allowRotation] * @returns {object} */ export function generateCuttingPlan(cuttingList, options = {}) { const stock = options.stockBoard || DEFAULT_STOCK_BOARD; const kerf = Number.isFinite(options.kerfMm) ? options.kerfMm : DEFAULT_KERF_MM; const trim = Number.isFinite(options.trimMm) ? options.trimMm : DEFAULT_TRIM_MM; const allowRotation = options.allowRotation !== false; const usableW = stock.widthMm - 2 * trim; const usableH = stock.heightMm - 2 * trim; // Regrouper les pièces par finition + épaisseur (matériaux non mélangeables). const groupsMap = new Map(); for (const group of (cuttingList?.byFinish || [])) { for (const p of (group.pieces || [])) { const wMm = Number(p.rawCutWidthMm || p.widthMm) || 0; const hMm = Number(p.rawCutHeightMm || p.heightMm) || 0; const qty = Math.max(1, Math.round(Number(p.quantity) || 1)); if (!(wMm > 0) || !(hMm > 0)) continue; const key = `${group.finishLabel || 'finition'}__${p.thicknessMm || 0}mm`; if (!groupsMap.has(key)) { groupsMap.set(key, { key, finishLabel: group.finishLabel || 'Finition', thicknessMm: Number(p.thicknessMm) || 0, rects: [] }); } const bucket = groupsMap.get(key); for (let i = 0; i < qty; i++) { bucket.rects.push({ w: wMm, h: hMm, label: p.description || p.elementType || 'pièce', pieceId: p.id }); } } } const allBoards = []; const groups = []; for (const bucket of groupsMap.values()) { const boards = packRectsIntoBoards(bucket.rects, { usableW, usableH, kerf, trim, boardWidthMm: stock.widthMm, boardHeightMm: stock.heightMm }, allowRotation); const groupBoardAreaM2 = boards.reduce((s, b) => s + b.boardAreaM2, 0); const groupUsedAreaM2 = boards.reduce((s, b) => s + b.usedAreaM2, 0); const groupWasteAreaM2 = round(groupBoardAreaM2 - groupUsedAreaM2); groups.push({ key: bucket.key, finishLabel: bucket.finishLabel, thicknessMm: bucket.thicknessMm, boardCount: boards.length, usedAreaM2: round(groupUsedAreaM2), boardAreaM2: round(groupBoardAreaM2), wasteAreaM2: groupWasteAreaM2, wastePercent: groupBoardAreaM2 > 0 ? round((groupWasteAreaM2 / groupBoardAreaM2) * 100, 1) : 0 }); boards.forEach((b, i) => { allBoards.push({ ...b, groupKey: bucket.key, finishLabel: bucket.finishLabel, thicknessMm: bucket.thicknessMm, svg: boardSvg(b, allBoards.length + i) }); }); } const totalBoardAreaM2 = round(allBoards.reduce((s, b) => s + b.boardAreaM2, 0)); const totalUsedAreaM2 = round(allBoards.reduce((s, b) => s + b.usedAreaM2, 0)); const totalWasteAreaM2 = round(totalBoardAreaM2 - totalUsedAreaM2); const totalPlacedPieces = allBoards.reduce((s, b) => s + b.placements.length, 0); return { stock: { widthMm: stock.widthMm, heightMm: stock.heightMm, kerfMm: kerf, trimMm: trim, allowRotation }, boards: allBoards, groups, totalBoards: allBoards.length, totalPlacedPieces, totalUsedAreaM2, totalBoardAreaM2, totalWasteAreaM2, globalWastePercent: totalBoardAreaM2 > 0 ? round((totalWasteAreaM2 / totalBoardAreaM2) * 100, 1) : 0 }; }