v2.1
Ship configurator updates with catalog DB included for server deploy. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* 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, '&')
|
||||
.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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user