/** * Export MPR (WOODWOP / Homag-family .mpr) pour le débit APEX — un fichier par panneau, ZIP. * Coordonnées XY = même repère que le G-code (.nc commentaires): origine bas-gauche, mm. * Macros RSO/* : chemins exemple — à présence confirmée sur le poste/cellule. */ import JSZip from 'jszip'; import { generateCuttingList } from './cuttingEngine.js'; import { collectHoleSpecsForPiece } from './holeSpecifications.js'; import { GROOVE, getBackGroovePlanOrientation, BACK_GROOVE_PLAN_VERTICAL_TYPES } from '../../shared/manufacturing.js'; const MPR_ARTICLE_CODES = Object.freeze({ plintheBas: 'PL_BAS', plintheGauche: 'PL_G', plintheDroite: 'PL_D', bandeauHaut: 'BH', dessus: 'BO_U', fond: 'BO_L', cotes: 'SE', cotesDoublage: 'CD', fileur: 'FI', fileurCotes: 'FI_C', fileurHaut: 'FI_H', habillageFinitionGauche: 'FR_L_G', habillageFinitionDroite: 'FR_L_D', habillageFinitionDessus: 'FR_H', dos: 'DO', renforts: 'RE', portes: 'TU', panneauxInterieurs: 'ZA_O', rayonFixe: 'RF_X', etageres: 'EB_M', renfortsTiroir: 'LABO', tiroirFond: 'TI_BO', tiroirDos: 'TI_RW', tiroirFacade: 'TI_FR' }); function fmtMm(n) { const v = Number(n); if (!Number.isFinite(v)) return '0.0'; const rounded = Math.round(v * 10) / 10; return Number.isInteger(rounded) ? `${rounded}.0` : `${rounded}`; } function toSafe(value, fb) { const n = Number(value); return Number.isFinite(n) ? n : fb; } function escQuoted(s) { return String(s ?? '').replace(/"/g, "'"); } /** Désignation « type » pour le bloc [001 (sans suffixe côté/index) — ex. SE_L → SE, ZA_O_2 → ZA. */ function baseDesignation(slug) { const s = String(slug || '').trim(); const cut = s.indexOf('_'); return cut > 0 ? s.slice(0, cut) : s; } /** Scinde ligne « gauche+droit » qty 2 en deux fichiers SE_L / SE_R. */ export function flattenPiecesForMprExport(pieces = []) { const out = []; for (const piece of pieces) { const qty = Math.max(1, Math.round(toSafe(piece.quantity, 1))); const splitSides = piece.elementType === 'cotes' && qty === 2 && piece.id === 'piece-cotes' && String(piece.description || '').includes('gauche et droit'); if (splitSides) { out.push({ piece: { ...piece, quantity: 1 }, slug: 'SE_L', qtyIndex: 0 }); out.push({ piece: { ...piece, quantity: 1 }, slug: 'SE_R', qtyIndex: 1 }); continue; } let baseSlug = MPR_ARTICLE_CODES[piece.elementType] || String(piece.elementType || '').toUpperCase() || 'PIECE'; if (piece.horizontalSpanId != null) { const spanId = String(piece.horizontalSpanId).replace(/[^\w-]+/g, '_'); const role = String(piece.horizontalSpanRole || 'panel').toUpperCase(); baseSlug = `HS_${spanId}_${role}_${baseSlug}`; if (piece.horizontalSpanRole === 'door' && Number.isFinite(Number(piece.doorIndex))) { baseSlug += `_${Number(piece.doorIndex) + 1}`; } } for (let qi = 0; qi < qty; qi++) { let slug = baseSlug; if ( qty > 1 && ['panneauxInterieurs', 'renforts', 'etageres', 'rayonFixe', 'bandeauHaut'].includes(piece.elementType || '') ) { slug = `${baseSlug}_${qi + 1}`; } out.push({ piece, slug, qtyIndex: qi }); } } return out; } function mprAnglesForPiece(piece) { const et = piece.elementType || ''; const widthMm = toSafe(piece.rawCutWidthMm, piece.widthMm); const heightMm = toSafe(piece.rawCutHeightMm, piece.heightMm); const vertical = heightMm > widthMm; if (vertical) { return { DRWIN: '270.0', beschGedreht: '0', turnMsg: 'DREHEN X-ACHSE' }; } if (['dessus', 'fond'].includes(et)) { return { DRWIN: '0.0', beschGedreht: '1', turnMsg: 'DREHEN Y-ACHSE' }; } return { DRWIN: '0.0', beschGedreht: '0', turnMsg: 'DREHEN Y-ACHSE' }; } function habillageEtPorte(et) { return ( ['habillageFinitionGauche', 'habillageFinitionDroite', 'habillageFinitionDessus', 'portes'].includes( et ) ); } /** Kantendicken : habillages façon exemple FR_* (tous faces 1 mm). */ function kantenFlags(piece) { const fin = habillageEtPorte(piece.elementType || ''); if (fin) { return { kavo: '1.0', kali: '1.0', kahi: '1.0', kare: '1.0' }; } return { kavo: '1.0', kali: '0.0', kahi: '0.0', kare: '0.0' }; } function grooveSection(piece, Lmm, Bmm) { const m = piece?.machining || {}; const orient = piece?.elementType ? getBackGroovePlanOrientation(piece.elementType) : null; if (!m.groove || !orient) return []; const gw = fmtMm(Number(m.groove.width ?? GROOVE.back.width)); const gd = fmtMm(Number(m.groove.depth ?? GROOVE.back.depth)); const abst = fmtMm(Number(m.groove.axisFromPanelEdge ?? m.groove.axisFromBackEdge ?? GROOVE.back.axisFromPanelEdge)); const vertical = BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(piece.elementType); const axis = Number(abst); const start = vertical ? [Math.max(0, Lmm - axis), 0] : [0, Math.max(0, Bmm - axis)]; const end = vertical ? [Math.max(0, Lmm - axis), Bmm] : [Lmm, Math.max(0, Bmm - axis)]; return [ '<121 \\Block\\', 'MNM="Kantenbearbeitungen von oben"', 'DP="1"', 'KO="04"', '<109 \\Nuten\\', 'MNM="Rainure dos"', `XA="${fmtMm(start[0])}"`, `YA="${fmtMm(start[1])}"`, `XE="${fmtMm(end[0])}"`, `YE="${fmtMm(end[1])}"`, `NB="${gw}"`, 'RK="NOWRK"', 'EM="MOD2"', 'TV="0"', 'MV="GL"', `TI="${gd}"`, 'MN="GL"', 'KO="04"' ]; } function doorHandleGrooveSection(piece, L, B) { const groove = piece?.machining?.fullHeightHandleGroove; if (piece?.elementType !== 'portes' || !groove) return []; const widthMm = Math.max(1, Number(groove.grooveWidthMm) || 8); const depthMm = Math.max(0.1, Number(groove.grooveDepthMm) || 3); const offsetMm = Math.max(0, Number(groove.topBottomOffsetMm) || 0); const hingeSide = piece?.doorPlanHints?.hingeSide === 'right' ? 'right' : 'left'; const xMm = hingeSide === 'right' ? widthMm / 2 : L - widthMm / 2; const y1Mm = Math.min(B, offsetMm); const y2Mm = Math.max(y1Mm, B - offsetMm); return [ '<109 \\Nuten\\', 'MNM="Rainure poignée toute hauteur"', `XA="${fmtMm(xMm)}"`, `YA="${fmtMm(y1Mm)}"`, `XE="${fmtMm(xMm)}"`, `YE="${fmtMm(y2Mm)}"`, `NB="${fmtMm(widthMm)}"`, 'RK="NOWRK"', 'EM="MOD2"', 'TV="0"', 'MV="GL"', `TI="${fmtMm(depthMm)}"`, 'MN="GL"', 'KO="04"' ]; } /** Un trou → lignes BohrVert (projection face Z+ ; perçages chant restent approximation LS/LSL). */ function bohrVertLines(spec, thicknessMm) { const dRaw = Number(spec.diameterMm) || 5; const depthRaw = Number(spec.depthMm) || 12; const edgeHint = Boolean(spec.toolMode === 'edge' || spec.face === 'end'); const throughHint = Boolean(spec.toolMode === 'through'); const looksThrough = depthRaw >= thicknessMm - 0.5; const fromUnder = spec.face === 'back'; let bm = 'LS'; let ti; if (throughHint || looksThrough) { bm = fromUnder ? 'LSLU' : 'LSL'; ti = `${fmtMm(thicknessMm)} + 1`; } else if (edgeHint && depthRaw >= 38) { bm = 'LSL'; ti = fmtMm(depthRaw); } else { bm = fromUnder ? 'LSU' : 'LS'; ti = fmtMm(depthRaw); } const name = fmtMm(Math.round(dRaw * 10) / 10); const du = fmtMm(Math.round(dRaw * 10) / 10); const xa = fmtMm(spec.xMm); const ya = fmtMm(spec.yMm); return [ '<102 \\BohrVert\\', `MNM="Bohrung ${name} mm"`, `XA="${xa}"`, `YA="${ya}"`, `BM="${bm}"`, `TI="${ti}"`, `DU="${du}"`, 'AN="1"', 'MI="0"', 'S_="2"', 'ASG="2"', 'MLM="0"', 'KO="04"' ]; } /** * Un trou de chant → lignes <103 BohrHoriz (perçage horizontal dans un chant). * `end` désigne le chant d'entrée dans le repère pièce (origine bas-gauche) : * left → depuis chant gauche (BM=XP), right → chant droit (BM=XM), * bottom → chant avant Y=0 (BM=YP), top → chant arrière Y=B (BM=YM). * ZA = position dans l'épaisseur (axe du trou) : `zMm` si fourni, sinon milieu (D/2). */ function bohrHorizLines(spec, L, B, D) { const du = fmtMm(Math.round((Number(spec.diameterMm) || 8) * 10) / 10); const ti = fmtMm(Number(spec.depthMm) || 25); const za = fmtMm(Number.isFinite(Number(spec.zMm)) ? Number(spec.zMm) : D / 2); let bm; let xa; let ya; switch (spec.end) { case 'right': bm = 'XM'; xa = L; ya = spec.yMm; break; case 'bottom': bm = 'YP'; xa = spec.xMm; ya = 0; break; case 'top': bm = 'YM'; xa = spec.xMm; ya = B; break; case 'left': default: bm = 'XP'; xa = 0; ya = spec.yMm; break; } return [ '<103 \\BohrHoriz\\', `MNM="Bohrung ${du} mm"`, `XA="${fmtMm(xa)}"`, `YA="${fmtMm(ya)}"`, `ZA="${za}"`, `DU="${du}"`, `TI="${ti}"`, `BM="${bm}"`, 'AN="1"', 'KO="04"' ]; } /** Supprime les doublons numériques (tolérance 0.02 mm — sans fusionner trous légitimes proches). */ function dedupeHoleSpecs(specs, eps = 0.02) { const out = []; for (const s of specs) { const hit = out.find( (o) => Math.abs(o.xMm - s.xMm) < eps && Math.abs(o.yMm - s.yMm) < eps && Math.abs((o.diameterMm || 0) - (s.diameterMm || 0)) < eps && (o.face || 'face') === (s.face || 'face') && (o.end || '') === (s.end || '') ); if (!hit) out.push(s); } return out; } /** * La fraise à rainurer travaille depuis le dessus de la machine. Pour le * panneau de dessus, on pose donc la face intérieure caisse vers le haut : * rainure dos + assemblages caisse dessus, perçages d'habillage dessous. */ function orientFaceSpecsForMpr(piece, specs) { if (piece?.elementType !== 'dessus' || !piece?.machining?.groove) return specs; return specs.map((spec) => { if (spec.face === 'face') return { ...spec, face: 'back' }; if (spec.face === 'back') return { ...spec, face: 'face' }; return spec; }); } /* --------------------------------------------------------------------------- * Coupes d'onglet 45° (habillages de finition) : * - tourillons perpendiculaires à la face d'onglet → <104 BohrUniv (perçage * spatial : azimut C `CA` + inclinaison agrégat `WI`), sur le système 04. * (<102 BohrVert exige un plan parallèle au plan supérieur.) * - trait de scie 45° → <124 Nut_R (« Nuten im A-Winkel » : sciage avec lame * inclinée WI, macro documenté du format MPR, équivalent Uni_Saeg_...W45 * du postprocesseur DXF HOMAG). Directement sur le plan supérieur (KO 04) : * pas de plan incliné custom, donc affichage propre et génération NC * possible avec l'agrégat scie pivotante. * Géométrie (coordonnées pièce) : la pièce occupe Z=0..D. La scie démarre donc * explicitement sur la face supérieure ZA=D, puis descend à 45° vers le chant * extérieur. Les trajectoires sont parcourues de sorte que leur normale gauche * pointe vers la chute : l'axe de scie est parallèle aux tourillons et le plan * de lame leur est perpendiculaire. Aucun plan S auxiliaire n'est nécessaire. * ------------------------------------------------------------------------- */ const MITER_SAW_KERF_MM = 4; function buildMiterSections({ miterSpecs, L, B, D }) { const empty = { levelLines: [], opLines: [] }; if (!miterSpecs.length) return empty; const kerfCenter = MITER_SAW_KERF_MM / 2; // Trajectoires globales sur KO=04. Pour chaque chant, le côté gauche du // parcours est l'extérieur de la pièce (la chute). const SIDES = { left: { label: 'gauche', ca: 0, entry: (sp) => [D / 2, sp.yMm], sawPath: [[D - kerfCenter, 0], [D - kerfCenter, B]] }, right: { label: 'droite', ca: 180, entry: (sp) => [L - D / 2, sp.yMm], sawPath: [[L - D + kerfCenter, B], [L - D + kerfCenter, 0]] }, top: { label: 'haut', ca: 270, entry: (sp) => [sp.xMm, B - D / 2], sawPath: [[0, B - D + kerfCenter], [L, B - D + kerfCenter]] } }; const opLines = []; for (const [side, def] of Object.entries(SIDES)) { const specs = miterSpecs.filter((sp) => sp.end === side); if (!specs.length) continue; /* Tourillons : perçage spatial sur le système 04 (repère pièce). */ for (const sp of specs) { const [xa, ya] = def.entry(sp); const du = fmtMm(Number(sp.diameterMm) || 8); opLines.push( '<104 \\BohrUniv\\', `MNM="Tourillon onglet ${def.label} ${du} mm"`, `XA="${fmtMm(xa)}"`, `YA="${fmtMm(ya)}"`, `ZA="${fmtMm(D / 2)}"`, `CA="${def.ca}"`, 'WI="45"', // woodWOP compte TI depuis le point XA/YA/ZA le long de l'axe incliné : // avec l'entrée au centre de la face d'onglet, la profondeur affichée // vaut le double de la profondeur utile → on émet la moitié. `TI="${fmtMm((Number(sp.depthMm) || 15) / 2)}"`, `DU="${du}"`, 'AN="1"', 'F_="STANDARD"', 'KO="04"' ); } /* Trait de scie 45° incliné (Nut_R / Nuten im A-Winkel). */ const [start, end] = def.sawPath; opLines.push( '<124 \\Nut_R\\', `MNM="Coupe onglet 45 ${def.label}"`, `XA="${fmtMm(start[0])}"`, `YA="${fmtMm(start[1])}"`, `XE="${fmtMm(end[0])}"`, `YE="${fmtMm(end[1])}"`, // KO=04 a son origine en Z=0 sous la pièce ; la face supérieure est Z=D. `ZA="${fmtMm(D)}"`, 'WI="45"', `NB="${fmtMm(MITER_SAW_KERF_MM)}"`, 'RK="NOWRK"', 'EM="MOD2"', 'TV="0"', 'MV="GL"', `TI="${fmtMm(D * Math.SQRT2 + 2)}"`, 'MN="GL"', 'F_="STANDARD"', 'KO="04"' ); } return { levelLines: [], opLines }; } export function buildSingleMprText({ piece, slug, qtyIndex, seqGlobal, groupIndex, orderReference, stackListKm, defaultMaterialArticle }) { const angles = mprAnglesForPiece(piece); const kant = kantenFlags(piece); const L = toSafe(piece.rawCutWidthMm, piece.widthMm); const B = toSafe(piece.rawCutHeightMm, piece.heightMm); const D = toSafe(piece.thicknessMm, 19); const posLabel = `${groupIndex}.${seqGlobal}`; const { specs, thicknessMm } = collectHoleSpecsForPiece(piece, L, B, qtyIndex); const unique = orientFaceSpecsForMpr(piece, dedupeHoleSpecs(specs)); // Répartition des perçages par destination d'usinage (repère pièce, origine bas-gauche) : // - onglet 45° (habillages) → plans inclinés dédiés (buildMiterSections), // - chant (face='end') → <103 BohrHoriz, regroupés par côté d'entrée, // - face opposée (face='back') → <102 BohrVert avec cycle WoodWOP « von unten », // - grande face (défaut) → <102 BohrVert « von oben ». const miterSpecs = unique.filter((sp) => sp.type === 'tourillon_miter' && sp.end); const rest = unique.filter((sp) => !miterSpecs.includes(sp)); const edgeSpecs = rest.filter((sp) => sp.face === 'end'); const backSpecs = rest.filter((sp) => sp.face === 'back'); const topSpecs = rest.filter((sp) => sp.face !== 'end' && sp.face !== 'back'); const matKm = escQuoted( piece.finishId != null && piece.finishId !== '' ? piece.finishId : defaultMaterialArticle || 'APEX' ); // Les opérations restent exprimées dans le repère pièce KO=04. DRWIN tourne // uniquement la présentation/pose machine (pièces hautes couchées en X). const miter = buildMiterSections({ miterSpecs, L, B, D }); const bez = escQuoted(baseDesignation(slug)); const hasBack = backSpecs.length > 0; const lines = []; lines.push('[H', 'VERSION="4.0 Alpha"', 'FW="1400"', 'FM="1"', 'ZS="40"', 'OP="1"'); // Une pièce plus haute que large est couchée à 270° pour correspondre à // l'orientation machine; les formules DW conservent son repère d'usinage. lines.push('[001', 'MAT="2"', 'KM="Materialart"', `DRWIN="${angles.DRWIN}"`, 'KM="Drehwinkel 0,90,180,270"'); lines.push('AUFT="0"', `KM="AUFTRAG: ${escQuoted(orderReference)}"`); lines.push('STKL="0"', `KM="STÜCKLISTE: ${escQuoted(stackListKm)}"`); lines.push(`POS="${escQuoted(posLabel)}"`, `KM="POSITION: ${escQuoted(posLabel)}"`); lines.push('BEZ="0"', `KM="Bezeichnung: ${bez}"`); lines.push(`L="${fmtMm(L)}"`, 'KM="Länge in X"', `B="${fmtMm(B)}"`, 'KM="Breite in Y"', `D="${fmtMm(D)}"`, 'KM="Dicke in Z"'); lines.push('ZF="2.0"', 'KM="Zugabe für DurchgangsFräsung"', 'ZB="3.0"', 'KM="Zugabe für DurchgangsBohrung"'); lines.push('RITZEN="1.0"', 'KM="Vorritztiefe in mm - 0=nicht Vorritzen"'); lines.push(`KAVODI="${kant.kavo}"`, 'KM="Kantendicke vorne"', `KALIDI="${kant.kali}"`, 'KM="Kantendicke links"'); lines.push(`KAHIDI="${kant.kahi}"`, 'KM="Kantendicke hinten"', `KAREDI="${kant.kare}"`, 'KM="Kantendicke rechts"'); lines.push('FORMAT="0"', 'KM="Formatieren 0 oder 1"'); lines.push('ZUGLI="0.0"', 'KM="Zugabe für Gehrung links"'); lines.push('ZUGRE="0.0"', 'KM="Zugabe für Gehrung rechts"'); lines.push('ZUGVO="0.0"', 'KM="Zugabe für Gehrung vorne"'); lines.push('ZUGHI="0.0"', 'KM="Zugabe für Gehrung hinten"'); lines.push('DW="IF DRWIN=0 OR DRWIN=90 OR DRWIN=180 THEN DRWIN ELSE 270"', 'KM="DISABLED Drehwinkel korrigiert bei Fehleingabe"'); lines.push('ld="IF DW=90 OR DW=270 THEN B ELSE L"', 'KM="DISABLED Länge in X (gedreht)"'); lines.push('bd="IF DW=90 OR DW=270 THEN L ELSE B"', 'KM="DISABLED Breite in Y (gedreht)"'); lines.push('offxL="IF DW=180 THEN ZUGRE ELSE ZUGLI"', 'KM="DISABLED Offset in X (0° oder 180°)"'); lines.push('offxB="IF DW=90 THEN ZUGHI ELSE ZUGVO"', 'KM="DISABLED Offset in X (90° oder 270°)"'); lines.push('offyL="IF DW=180 THEN ZUGHI ELSE ZUGVO"', 'KM="DISABLED Offset in Y (0° oder 180°)"'); lines.push('offyB="IF DW=90 THEN ZUGLI ELSE ZUGRE"', 'KM="DISABLED Offset in Y (90° oder 270°)"'); lines.push( '[K', '<00 \\Koordinatensystem\\', 'NR="04"', 'XP="IF DW=0 OR DW=270 THEN 0 ELSE ld"', 'YP="IF DW=0 OR DW=90 THEN 0 ELSE bd"', 'ZP="0"', 'D1="DW"', 'KI="0"', 'D2="0"', 'MI="0"', '<00 \\Koordinatensystem\\', 'NR="F1"', 'XP="IF DW=0 OR DW=270 THEN 0 ELSE ld"', 'YP="IF DW=0 OR DW=90 THEN 0 ELSE bd"', 'ZP="0"', 'D1="DW"', 'KI="90"', 'D2="0"', 'MI="0"', '<00 \\Koordinatensystem\\', 'NR="F2"', 'XP="IF DW=0 OR DW=270 THEN 0 ELSE ld"', 'YP="IF DW=90 OR DW=0 THEN 0 ELSE bd"', 'ZP="0"', 'D1="DW+270"', 'KI="90"', 'D2="0"', 'MI="1"', '<00 \\Koordinatensystem\\', 'NR="F3"', 'XP="IF DW=0 OR DW=90 THEN 0 ELSE ld"', 'YP="IF DW=180 OR DW=90 THEN 0 ELSE bd"', 'ZP="0"', 'D1="DW+180"', 'KI="90"', 'D2="0"', 'MI="1"', '<00 \\Koordinatensystem\\', 'NR="F4"', 'XP="IF DW=180 OR DW=270 THEN 0 ELSE ld"', 'YP="IF DW=0 OR DW=270 THEN 0 ELSE bd"', 'ZP="0"', 'D1="DW+90"', 'KI="90"', 'D2="0"', 'MI="0"', ...miter.levelLines, '<100 \\WerkStck\\', // WerkStck uses the piece-local dimensions. KO=04 applies DRWIN to the // stock and operations together; swapping here would rotate the stock twice. `LA="${fmtMm(L)}"`, `BR="${fmtMm(B)}"`, 'DI="D"', 'FNX="IF DW=90 OR DW=270 THEN offxB ELSE offxL"', 'FNY="IF DW=90 OR DW=270 THEN offyB ELSE offyL"', 'AX="IF DW=90 OR DW=270 THEN ZUGVO+ZUGHI ELSE ZUGLI+ZUGRE"', 'AY="IF DW=90 OR DW=270 THEN ZUGLI+ZUGRE ELSE ZUGVO+ZUGHI"', '<101 \\Kommentar\\', `KM="AUFTRAG : ${escQuoted(orderReference)}"`, `KM="STÜCKLISTE : ${escQuoted(stackListKm)}"`, `KM="POSITION : ${escQuoted(posLabel)}"`, `KM="Bezeichnung: ${escQuoted(slug)}"`, 'KM=""', `KM="${escQuoted(angles.turnMsg)}"`, 'KM=""', 'KM="Teileinfo"', `KM="Größe: ${fmtMm(L)} x ${fmtMm(B)}"`, `KM="Stärke: ${fmtMm(D)}"`, `KM=""`, `KM="Material: ${matKm}"`, 'KM="Belag IN: "', 'KM="Belag AU: "', 'KM=""' ); // Les usinages de sous-face utilisent directement BM=LSU/LSLU et ne // nécessitent donc aucun retournement de la pièce. lines.push( '<139 \\Komponente\\', 'MNM="Beschriftung"', 'IN="RSO/Makro/RSO_Beschriftung.mpr"', 'VA="L L"', 'VA="B B"', 'VA="KAVODI KAVODI"', 'VA="KALIDI KALIDI"', 'VA="KAHIDI KAHIDI"', 'VA="KAREDI KAREDI"', `VA="GEDREHT ${angles.beschGedreht}"`, 'VA="NCSTOPP 0"', 'KO="04"' ); // Macro de formatage (RSO_Format) — exécutée seulement si FORMAT=1 (??="FORMAT"). lines.push( '<139 \\Komponente\\', 'MNM="Formatierung"', 'IN="RSO/Makro/RSO_Format.mpr"', 'VA="L L"', 'VA="B B"', 'VA="D D"', 'VA="WKZ 316"', 'VA="KAVODI KAVODI"', 'VA="KALIDI KALIDI"', 'VA="KAHIDI KAHIDI"', 'VA="KAREDI KAREDI"', 'VA="DIST 0.0"', 'VA="ECKRUND 0.1"', 'VA="KAFR 0"', 'VA="KAFRMAX 2.0"', 'VA="MAT MAT"', '??="FORMAT"', 'KO="04"' ); // Bloc « Bearbeitungen von oben » — perçages verticaux sur la grande face. if (topSpecs.length) { lines.push('<121 \\Block\\', 'MNM="Bearbeitungen von oben "', `DP="${topSpecs.length}"`, 'KO="04"'); for (const sp of topSpecs) lines.push(...bohrVertLines(sp, thicknessMm)); } // Bloc « Bearbeitungen von unten » — BM=LSU/LSLU indique réellement à // WoodWOP que l'outil entre par la sous-face. if (hasBack) { lines.push('<121 \\Block\\', 'MNM="Bearbeitungen von unten "', `DP="${backSpecs.length}"`, 'KO="04"'); for (const sp of backSpecs) { lines.push(...bohrVertLines(sp, thicknessMm)); } } // Blocs de perçage sur chant — un bloc par côté d'entrée. const edgeBlocks = [ ['left', 'Bearbeitungen von links '], ['right', 'Bearbeitungen von rechts '], ['bottom', 'Bearbeitungen von vorne '], ['top', 'Bearbeitungen von hinten '] ]; for (const [end, label] of edgeBlocks) { const group = edgeSpecs.filter((sp) => (sp.end || 'left') === end); if (!group.length) continue; lines.push('<121 \\Block\\', `MNM="${label}"`, `DP="${group.length}"`, 'KO="04"'); for (const sp of group) lines.push(...bohrHorizLines(sp, L, B, D)); } lines.push(...miter.opLines); lines.push(...grooveSection(piece, L, B)); lines.push(...doorHandleGrooveSection(piece, L, B)); lines.push('!'); return lines.join('\r\n'); } function attachGlobalSeq(pieceFlat, allFlat) { return pieceFlat.map((entry) => { const globalIdx = allFlat.findIndex( (f) => f.piece.id === entry.piece.id && f.qtyIndex === entry.qtyIndex && f.slug === entry.slug ); return { ...entry, seqGlobal: globalIdx >= 0 ? globalIdx + 1 : 1 }; }); } function buildMprFileRecords(entries, mprOptions) { const { groupIndex = 1, orderReference = '', stackListKm = '01', defaultMaterialArticle = '' } = mprOptions; return entries.map((entry) => { const fileName = `${groupIndex}_${entry.seqGlobal}_${safeZipFileName(entry.slug)}.mpr`; const content = buildSingleMprText({ piece: entry.piece, slug: entry.slug, qtyIndex: entry.qtyIndex, seqGlobal: entry.seqGlobal, groupIndex, orderReference, stackListKm, defaultMaterialArticle }); return { fileName, content }; }); } async function packMprRecords(records, zipBaseName) { // Encodage Windows-1252 (Latin-1) : format attendu par woodWOP pour les // commentaires KM allemands (ä ö ü ß °), comme dans les fichiers fabricant. if (records.length === 1) { const only = records[0]; return { buffer: Buffer.from(only.content, 'latin1'), fileName: only.fileName, mimeType: 'application/octet-stream', fileCount: 1 }; } const zip = new JSZip(); for (const rec of records) { zip.file(rec.fileName, Buffer.from(rec.content, 'latin1')); } const safeBase = safeZipFileName(zipBaseName || 'piece-mpr'); return { buffer: await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }), fileName: `${safeBase}.zip`, mimeType: 'application/zip', fileCount: records.length }; } /** Nombre de fichiers MPR générés pour une ligne de débitage (ex. côtés L+R → 2). */ export function countMprInstancesForPiece(piece) { return flattenPiecesForMprExport([piece]).length; } export async function generateMprZipBuffer({ config, modules = [], colors = {}, orderReference = '', stackListKm = '01', defaultMaterialArticle = '', /** Index groupe commande dans le nom fichier (premier nombre). */ groupIndex = 1 }) { const cuttingList = generateCuttingList(config, colors, modules); const flat = flattenPiecesForMprExport(cuttingList.pieces); const withSeq = attachGlobalSeq(flat, flat); const records = buildMprFileRecords(withSeq, { groupIndex, orderReference, stackListKm, defaultMaterialArticle }); return { ...(await packMprRecords(records, `apex-mpr-g${groupIndex}`)), fileCount: records.length }; } /** * Export MPR pour une seule ligne de la liste de débitage (identifiée par pieceId). * @param {number|null|undefined} instanceIndex — exemplaire 0-based ; omis = tous les exemplaires de la ligne. */ export async function generateMprPieceDownload({ config, modules = [], colors = {}, pieceId, instanceIndex, orderReference = '', stackListKm = '01', defaultMaterialArticle = '', groupIndex = 1 }) { if (!pieceId || typeof pieceId !== 'string') { throw new Error('pieceId requis.'); } const cuttingList = generateCuttingList(config, colors, modules); const piece = (cuttingList.pieces || []).find((p) => p.id === pieceId); if (!piece) { throw new Error('Pièce introuvable dans la liste de débitage.'); } const allFlat = flattenPiecesForMprExport(cuttingList.pieces); let pieceFlat = attachGlobalSeq(flattenPiecesForMprExport([piece]), allFlat); if (instanceIndex != null && instanceIndex !== '') { const idx = Math.floor(Number(instanceIndex)); if (!Number.isFinite(idx) || idx < 0) { throw new Error('instanceIndex invalide.'); } pieceFlat = pieceFlat.filter((entry) => entry.qtyIndex === idx); if (pieceFlat.length === 0) { throw new Error('Exemplaire introuvable pour cette pièce.'); } } const records = buildMprFileRecords(pieceFlat, { groupIndex, orderReference, stackListKm, defaultMaterialArticle }); return packMprRecords(records, pieceId); } function safeZipFileName(seg) { return String(seg).replace(/[^\w\-]+/g, '_').replace(/_+/g, '_'); } export default { generateMprZipBuffer, generateMprPieceDownload, countMprInstancesForPiece, flattenPiecesForMprExport };