Files
apex/server/services/cuttingPlanEngine.js
lejayandCursor 30921aade6 v2.1
Ship configurator updates with catalog DB included for server deploy.

Co-authored-by: Cursor <[email protected]>
2026-10-07 17:13:03 +02:00

262 lines
9.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<object>} 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
? `<text x="${rx + rw / 2}" y="${ry + rh / 2}" font-size="${fontSize}" text-anchor="middle" dominant-baseline="middle" fill="#1f2937">${escapeXml(label)}</text>`
: '';
return `<rect x="${rx.toFixed(1)}" y="${ry.toFixed(1)}" width="${rw.toFixed(1)}" height="${rh.toFixed(1)}" fill="${fill}" stroke="#374151" stroke-width="0.8"/>${text}`;
})
.join('');
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w + pad * 2} ${h + pad * 2 + 24}" width="${w + pad * 2}" height="${h + pad * 2 + 24}">
<rect x="${pad}" y="${pad}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" fill="#f9fafb" stroke="#111827" stroke-width="1.5"/>
${rects}
<text x="${pad}" y="${h + pad * 2 + 14}" font-size="12" fill="#111827">Panneau ${index + 1} — ${boardWidthMm}×${boardHeightMm} mm — chute ${boardEntry.wastePercent}%</text>
</svg>`;
}
function escapeXml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/**
* 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
};
}