Ship configurator updates with catalog DB included for server deploy.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
lejay
2026-10-07 17:13:03 +02:00
co-authored by Cursor
parent 6c0d7e47b8
commit 30921aade6
75 changed files with 15618 additions and 1300 deletions
+261
View File
@@ -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, '&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
};
}