/** * Cutting Engine - Generate cutting list (liste de débitage) for wardrobe production * Calculates all panel dimensions, edge banding, machining operations, and * reinforcement panels based on APEX 4.0 manufacturing specifications. */ import { PANEL, EDGE_BANDING, GROOVE, CONNECTORS, SYSTEM32, SYSTEM32_SEPARATORS, HINGE, REINFORCEMENT_MACHINING, SHELF_MACHINING, getHingeCountForDoor, getHingeCupPositions, getReinforcementEndFixingAxesFromTopMm, DRAWERS, DRAWER_REINFORCEMENT, DRAWER_RENFORT_PANEL_IXCONNECT, drawerStackHeightMm, getDrawerReinforcementIxconnectHolePositionsMm, buildDrawerRenfortPanelIxconnectCornersMm, NICHE, HANDLE, BLOCK_ASSEMBLY, CARCASS_FASTENERS, CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM, REINFORCEMENT, getDoorHingeSide, getCarcassJointPositions, getCarcassFerrureDowelDrillings, getSeparatorDepthMm, getIxconnectJointPatternAlongDepthMm, getJunctionAxesAcrossWidthMm, getBackGrooveInnerEndInsetFromBackEdgeMm, tourillonFaceDrillDepthMm, BACK_GROOVE_PLAN_VERTICAL_TYPES, BACK_GROOVE_PLAN_HORIZONTAL_TYPES } from '../../shared/manufacturing.js'; import { IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM, IXCONNECT_SEPARATOR_BACK_ROW_MIN_WIDTH_MM, buildShelfMobileHoleSpecsMm } from '../../shared/cncPanelHoles.js'; // Aliases for readability const PANEL_THICKNESS = PANEL.structure; const BACK_PANEL_THICKNESS = PANEL.back; /** * Use the most reliable module array for counts/dimensions. * Some API calls pass `modules=[]` but store modules inside `config.modules`. */ function effectiveModules(modules = [], config = {}) { const m = Array.isArray(modules) ? modules.filter(Boolean) : []; if (m.length > 0) return m; const cm = Array.isArray(config?.modules) ? config.modules.filter(Boolean) : []; if (cm.length > 0) return cm; const blocks = Array.isArray(config?.blocks) ? config.blocks.filter(Boolean) : []; return blocks; } /** Zone renfort (hauteur + bas depuis bas panneau) pour le module i — tiroirs cachés uniquement. */ function moduleRenfortZoneForDrawerRenfort(config, mods, i) { const mod = mods?.[i]; const n = Math.max(0, Number(mod?.drawers) || 0); if (n <= 0) return null; const hw = config.hardware || {}; if (hw.drawerType !== 'hidden' && hw.drawerType !== 'hidden_with_handle') return null; const drawerHeightsMm = Array.isArray(hw.drawerHeightsMm) ? hw.drawerHeightsMm : [90, 128, 177, 240]; const stack = drawerStackHeightMm(n, drawerHeightsMm, 8); const hMm = stack - DRAWER_REINFORCEMENT.topFrontRadiusMm; if (hMm <= 0) return null; const zoneBottomMm = PANEL_THICKNESS; return { hMm, zoneBottomMm }; } function shelfCenterYsMm({ shelfCount, heightMm, plinthHeightMm, shelfGapMm }) { const T = PANEL_THICKNESS; const height = Number(heightMm) || 0; const plinthH = Number(plinthHeightMm) || 0; const bodyH = height - plinthH; const H_inner = bodyH - 2 * T; const yBase = -height / 2 + plinthH + T; if (shelfCount <= 0) return []; const equal = () => Array.from({ length: shelfCount }, (_, i) => yBase + (H_inner / (shelfCount + 1)) * (i + 1) ); if (shelfCount === 1 || shelfGapMm == null || !Number.isFinite(Number(shelfGapMm))) { return equal(); } const n = shelfCount; const gReq = Number(shelfGapMm); const maxG = n > 1 ? (H_inner - n * T) / (n - 1) : 0; if (n > 1 && !(maxG > 0)) return equal(); const gUse = n > 1 ? Math.max(0, Math.min(gReq, maxG)) : 0; const needed = n * T + (n > 1 ? (n - 1) * gUse : 0); const m = (H_inner - needed) / 2; return Array.from({ length: n }, (_, i) => yBase + m + T / 2 + i * (T + (n > 1 ? gUse : 0)) ); } function structuralMiddleShelfIndex(mod, config) { const shelfCount = Math.max(0, Number(mod?.shelves) || 0); if (shelfCount <= 0) return null; const heightMm = Number(config?.heightMm) || 0; const plinthHeightMm = Number(config?.plinthHeightMm) || 0; const needs = heightMm > 2000; if (!needs) return null; const ys = shelfCenterYsMm({ shelfCount, heightMm, plinthHeightMm, shelfGapMm: mod?.shelfGapMm }); const bottomInside = -heightMm / 2 + plinthHeightMm + PANEL_THICKNESS; const innerSpan = (heightMm - plinthHeightMm) - 2 * PANEL_THICKNESS; const yMiddle = bottomInside + innerSpan / 2; let best = Infinity; let idx = null; for (let i = 0; i < ys.length; i++) { const d = Math.abs(Number(ys[i]) - yMiddle); if (d < best) { best = d; idx = i; } } return idx; } /** * Perçages ferrure + tourillons sur les plans SVG : toujours recalculés depuis les centres * ferrure et le décalage courant (évite trous figés si données stockées sont anciennes). */ function getCarcassDowelStationsForPlan(cf, edgeLengthMm) { const L = Math.max(1, Number(cf.jointEdgeUsableLengthMm ?? edgeLengthMm) || 0); const off = cf.dowelSideOffset ?? CARCASS_FASTENERS.dowel.sideOffset; let positions = cf.positionsAlongJointMm; if (!positions?.length && cf.dowelStations?.length) { positions = cf.dowelStations.map((s) => s.centerMm).filter((v) => Number.isFinite(Number(v))); } return getCarcassFerrureDowelDrillings(positions || [], L, off); } /** Ixconnect sur plans : positions depuis règles atelier (profondeur = arête de jonction). */ function getIxconnectJointsForPlan(machining, cf, depthAlongJointMm) { if (!machining?.connectorHoles || !cf?.junctionAxesAcrossWidthMm?.length) return null; const jax = cf.junctionAxesAcrossWidthMm; const d = Math.max(1, Number(depthAlongJointMm) || 0); return jax.map((xAxis, idx) => ({ axisAcrossWidthMm: xAxis, ...getIxconnectJointPatternAlongDepthMm(d, idx === 0 || idx === jax.length - 1 ? 'edge' : 'middle', { horizontalPanel: true }) })); } /** * Element dimensions calculator * Returns the dimensions for each element type based on wardrobe config */ function getElementDimensions(elementType, config, modules = []) { const { widthMm, heightMm, depthMm, plinthHeightMm, installationType } = config; const bodyHeight = heightMm - plinthHeightMm; const innerWidth = widthMm - (PANEL_THICKNESS * 2); const innerDepth = depthMm - PANEL_THICKNESS; // Back panel space const isNiche = installationType === 'niche'; const isFree = installationType === 'free'; const exteriorFinishesEnabled = config?.exteriorFinishesEnabled !== false; const sideDefaultsEnabled = config?.sideFinishesEnabled !== false; const sideFinishLeftEnabled = config?.sideFinishLeftEnabled ?? sideDefaultsEnabled; const sideFinishRightEnabled = config?.sideFinishRightEnabled ?? sideDefaultsEnabled; const topFinishEnabled = config?.topFinishEnabled !== false; switch (elementType) { case 'plintheBas': return { width: widthMm, height: plinthHeightMm, thickness: PANEL_THICKNESS, quantity: 1, description: 'Plinthe de base' }; case 'bandeauHaut': return { width: widthMm, height: 100, thickness: PANEL_THICKNESS, quantity: 1, description: 'Bandeau supérieur' }; case 'dessus': return { width: innerWidth, height: innerDepth, thickness: PANEL_THICKNESS, quantity: 1, description: 'Panneau du dessus' }; case 'fond': return { width: innerWidth, height: innerDepth, thickness: PANEL_THICKNESS, quantity: 1, description: 'Panneau du fond (bas)' }; case 'cotes': return { width: depthMm, height: bodyHeight, thickness: PANEL_THICKNESS, quantity: 2, description: 'Côtés extérieurs (gauche et droit)' }; case 'cotesDoublage': // Only for niche installation if (!isNiche) return null; return { width: depthMm, height: bodyHeight, thickness: PANEL_THICKNESS, quantity: 2, description: 'Côtés de doublage (niche)' }; case 'fileur': // Only for niche installation if (!isNiche) return null; return { width: depthMm, height: bodyHeight, thickness: PANEL_THICKNESS, quantity: 2, description: 'Fileurs (bouche-trou niche)' }; case 'habillageFinitionGauche': if (!isFree || !exteriorFinishesEnabled || !sideFinishLeftEnabled) return null; { const topFinishEnabled = config?.topFinishEnabled !== false; return { width: depthMm, height: bodyHeight + (topFinishEnabled ? PANEL_THICKNESS : 0), thickness: PANEL_THICKNESS, quantity: 1, description: 'Habillage finition GAUCHE' }; } case 'habillageFinitionDroite': if (!isFree || !exteriorFinishesEnabled || !sideFinishRightEnabled) return null; { const topFinishEnabled = config?.topFinishEnabled !== false; return { width: depthMm, height: bodyHeight + (topFinishEnabled ? PANEL_THICKNESS : 0), thickness: PANEL_THICKNESS, quantity: 1, description: 'Habillage finition DROITE' }; } case 'habillageFinitionDessus': if (!isFree || !exteriorFinishesEnabled || !topFinishEnabled) return null; // Largeur : côte utile + une extension T par côté où l’habillage latéral joint le dessus (onglet 45°). // Sans habillage latéral : s’arrête au montant (pas d’onglet qui dépasse). { const sideDefaultsEnabled = config?.sideFinishesEnabled !== false; const extL = (config?.sideFinishLeftEnabled ?? sideDefaultsEnabled) ? PANEL_THICKNESS : 0; const extR = (config?.sideFinishRightEnabled ?? sideDefaultsEnabled) ? PANEL_THICKNESS : 0; return { width: widthMm + extL + extR, height: depthMm, thickness: PANEL_THICKNESS, quantity: 1, description: 'Habillage finition DESSUS' }; } case 'dos': return { width: innerWidth, height: bodyHeight - (PANEL_THICKNESS * 2), thickness: BACK_PANEL_THICKNESS, quantity: 1, description: 'Dos (panneau arrière 8 mm)' }; case 'renforts': { // Renforts Forteresse (règle atelier): // - largeur = largeur armoire - 2 × épaisseur côtés // - 1 en haut à 19 mm + 1 en bas à 19 mm // - 3e renfort si hauteur armoire > 1500 mm const reinforcementCount = heightMm > REINFORCEMENT.thirdRequiredAboveHeightMm ? 3 : 2; return { width: innerWidth, height: PANEL.reinforcementWidth, thickness: PANEL.reinforcement, quantity: reinforcementCount, description: `Renforts Forteresse 16mm (${reinforcementCount}) — haut/bas à ${REINFORCEMENT.topBottomOffsetMm} mm${reinforcementCount === 3 ? ' + 1 intermédiaire' : ''}` }; } case 'portes': { const doorCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2); const isSliding = config.doorType === 'sliding'; if (isSliding) { const panelCount = config.slidingPanelsCount || 2; const panelWidth = Math.ceil(widthMm / panelCount) + 50; // overlap return { width: panelWidth, height: bodyHeight - 20, // clearance thickness: PANEL_THICKNESS, quantity: panelCount, description: `Portes coulissantes (${panelCount} vantaux)` }; } else { // Hinged doors - one per module/block const fullHeightHandle = config.hardware?.handleType === 'full_height'; const widthDeduction = fullHeightHandle ? HANDLE.fullHeightWidthDeductionMm : 0; return { width: Math.max(100, Math.floor(widthMm / doorCount) - 4 - widthDeduction), // 2mm gap each side height: bodyHeight - 4, thickness: PANEL_THICKNESS, quantity: doorCount, description: `Portes battantes (${doorCount} portes)` }; } } case 'panneauxInterieurs': { // Vertical separators between modules const separatorCount = modules.length > 1 ? modules.length - 1 : (config.blocks?.length || 1) - 1; if (separatorCount <= 0) return null; return { width: getSeparatorDepthMm(depthMm), height: bodyHeight - (PANEL_THICKNESS * 2), thickness: PANEL_THICKNESS, quantity: separatorCount, description: `Séparateurs verticaux (${separatorCount}) — sans rainure dos, plaqués contre le dos` }; } case 'etageres': { // Count mobile shelves from modules (fallback to config.modules when API passes modules=[]) const mods = effectiveModules(modules, config); const totalShelvesBase = mods.reduce((sum, m) => sum + (Number(m?.shelves) || 0), 0); const structuralExisting = mods.reduce((sum, m) => sum + (structuralMiddleShelfIndex(m, config) != null ? 1 : 0), 0); const totalRayonsMobiles = Math.max(0, totalShelvesBase - structuralExisting); if (totalRayonsMobiles <= 0) return null; // Average shelf width based on modules const avgModuleWidth = mods.length > 0 ? mods.reduce((sum, m) => sum + (Number(m?.widthMm) || 0), 0) / mods.length : innerWidth; return { // APEX 4.0 — Règles rayons mobiles: L = L.int - 2mm ; P = P.int - 5mm width: avgModuleWidth - (PANEL_THICKNESS * 2) - 2, height: innerDepth - 5, thickness: PANEL_THICKNESS, quantity: totalRayonsMobiles, description: `Rayons mobiles (${totalRayonsMobiles})` }; } // Rayon fixe (structure): emitted per-module width in `generateCuttingList`. case 'rayonFixe': return null; case 'champs': { // Edge banding - calculated as linear meters // Include all visible edges const perimeter = (widthMm * 2) + (heightMm * 4) + (bodyHeight * 4); return { width: EDGE_BANDING.abs, height: perimeter, thickness: 0, quantity: 1, unit: 'ml', description: 'Chant ABS 2 mm visible' }; } case 'caisse': // The carcass/body - this is the assembled unit, not a cut piece return null; default: return null; } } /** * Get edge banding requirements for an element. * Per APEX 4.0 Cahier des Charges: * - Portes, côtés, plinthe, bandeau: ABS 2mm on 4 edges * - Dessus/Fond: ABS 2mm faces avant ET arrière (2 long edges) * - Étagères: ABS 2mm face avant uniquement (1 edge) * - Séparateurs, Renforts: ABS 2mm face avant (1 edge) * - Dos: ABS 2mm on 2 long edges (haut + bas) */ function getEdgeBanding(elementType) { const fullEdges = ['portes', 'cotes', 'plintheBas', 'bandeauHaut']; if (fullEdges.includes(elementType)) { return { type: 'visible', thickness: EDGE_BANDING.abs, sides: 4 }; } else if (elementType === 'dessus' || elementType === 'fond') { // APEX 4.0: "Placage ABS 2mm systématique sur les faces avant et arrière" return { type: 'visible', thickness: EDGE_BANDING.abs, sides: EDGE_BANDING.topBottomSides }; } else if (elementType === 'etageres' || elementType === 'rayonFixe') { // APEX 4.0: ABS face avant uniquement return { type: 'visible', thickness: EDGE_BANDING.abs, sides: EDGE_BANDING.shelfSides }; } else if (['panneauxInterieurs', 'renforts', 'renfortsTiroir'].includes(elementType)) { return { type: 'visible', thickness: EDGE_BANDING.abs, sides: 1 }; // front edge only } else if (elementType === 'dos') { // APEX 4.0: dos gets ABS on 2 long edges (haut + bas) return { type: 'visible', thickness: EDGE_BANDING.abs, sides: EDGE_BANDING.backPanelSides }; } return { type: 'hidden', thickness: EDGE_BANDING.hidden, sides: 2 }; } /** * Calculate machining operations for a piece based on APEX 4.0 specs. * * Key corrections from Dossier Technique Maître: * - System 32 on side panels: Ø5mm borgne (blind), depth 12mm, first/last hole 51mm * - System 32 on separators: Ø5mm TRAVERSANTS (through), first hole 32mm (51-19) * - SC 8/60 connector: edge Ø8×45mm, face Ø8×12mm * - Joint Ixconnect: montants (face haut/bas, bord avant) · séparateurs verticaux (face haut/bas, bords avant & arrière profondeur si largeur ≥ seuil) · horizontaux: ixconnectJointsAlongDepth * - Groove axis: 25mm from back edge * - Reinforcements: 1× SC 8/60 center + 2× tourillons at 20mm from edges (axes from piece width) */ function getMachining(elementType, dimensions, config, modules = []) { const machining = {}; const isFree = config?.installationType === 'free'; const exteriorFinishesEnabled = config?.exteriorFinishesEnabled !== false; const topFinishEnabled = config?.topFinishEnabled !== false; const underCabinetHeightMm = Math.max(0, Number(config?.plinthHeightMm) || 0); // System 32 drilling — side panels (blind holes) if (elementType === 'cotes') { const panelHeight = dimensions.height; const usableHeight = panelHeight - SYSTEM32.firstHoleOffset * 2; // first AND last at 51mm const holeCount = Math.max(0, Math.floor(usableHeight / SYSTEM32.pitch) + 1); const positions = []; for (let i = 0; i < holeCount; i++) { positions.push(SYSTEM32.firstHoleOffset + i * SYSTEM32.pitch); } machining.system32 = { holeDiameter: SYSTEM32.holeDiameter, holeDepth: SYSTEM32.holeDepth, throughHole: SYSTEM32.throughHole, count: holeCount, rows: 2, // front and back row positions, note: 'Borgne (blind) — reversible (first/last at 51mm)', }; const hasDrawers = (modules || []).some((m) => (m.drawers || 0) > 0); if (hasDrawers) { const rawDepth = (config.depthMm || dimensions.width || 600) - NICHE.drawerBackGap; const steppedDepth = Math.round(Math.max(DRAWERS.minDepth, Math.min(DRAWERS.maxDepth, rawDepth)) / DRAWERS.depthStep) * DRAWERS.depthStep; const dt = config.hardware?.drawerType; const reduction = dt === 'hidden' || dt === 'hidden_with_handle' ? DRAWERS.hiddenHandleDepthReduction : 0; machining.drawerSlideDrilling = { enabled: true, boreFromBottomOnly: true, depthMm: Math.max(350, steppedDepth - reduction), backGapMm: NICHE.drawerBackGap, note: 'Percement côté caisse pour coulisses tiroir' }; } } // Habillages de finition Premium — Ixconnect SC 8/25 (démontable, invisible) // - Caisson structurel (cotes) : faces extérieures, avant+arrière, y=50 depuis bas/haut + 2 points équidistants (par bord) // - Joues de doublage : faces intérieures, positions spécifiques haut/bas + 2 points équidistants au centre (avant+arrière) // - Dessus de doublage : 2 points centrés en largeur, à l'avant+arrière (mêmes règles de mesures que joues latérales) if (isFree && exteriorFinishesEnabled && ['cotes', 'habillageFinitionGauche', 'habillageFinitionDroite', 'habillageFinitionDessus'].includes(elementType)) { const W = Math.max(1, Number(dimensions.width) || 1); const H = Math.max(1, Number(dimensions.height) || 1); const clampX = (v) => Math.max(0, Math.min(W, Number(v) || 0)); const clampY = (v) => Math.max(0, Math.min(H, Number(v) || 0)); const uniq = (arr) => arr .map((v) => Math.round(Number(v) * 1000) / 1000) .filter((v, i, a) => a.findIndex((u) => Math.abs(u - v) < 1e-6) === i) .sort((a, b) => a - b); const connector = CONNECTORS.ixconnectFinish; const d = Number(connector.faceHole?.diameter || 8); const z = Number(connector.faceHole?.depth || 12.5); const dTour = 8; const zTourEnd = 15; const holes = []; const pushFaceHole = (xMm, yMm, type = 'ixconnect', face = 'face') => { holes.push({ xMm: clampX(xMm), yMm: clampY(yMm), diameterMm: d, depthMm: z, type, face }); }; const pushEndHole = (end, xMm, yMm, diameterMm, depthMm, type) => { holes.push({ xMm: clampX(xMm), yMm: clampY(yMm), diameterMm, depthMm, type, face: 'end', end }); }; const addEquidistantInner = (yBottom, yTop, innerCount = 2) => { const y0 = Number(yBottom); const y1 = Number(yTop); if (!Number.isFinite(y0) || !Number.isFinite(y1)) return []; const lo = Math.min(y0, y1); const hi = Math.max(y0, y1); const span = hi - lo; if (span < (innerCount + 1) * 6) return []; const step = span / (innerCount + 1); return Array.from({ length: innerCount }, (_, i) => lo + step * (i + 1)); }; if (elementType === 'cotes') { const xFront = 50; const xBack = W - 50; const yBottom = 50; const yTop = H - 50; const yInner = addEquidistantInner(yBottom, yTop, 2); const yAll = uniq([yBottom, ...yInner, yTop]); for (const yMm of yAll) { // Lignes haut et bas 50 mm : perçage depuis la face extérieure (habillage) — entrée opposée à la face caisson / System 32. const isEdgeLine = Math.abs(yMm - yBottom) < 0.5 || Math.abs(yMm - yTop) < 0.5; const faceSide = isEdgeLine ? 'back' : 'face'; pushFaceHole(xFront, yMm, 'ixconnect', faceSide); pushFaceHole(xBack, yMm, 'ixconnect', faceSide); } machining.finishHabillage = { model: connector.model, faceHole: { ...connector.faceHole }, note: 'Habillage finition — perçages SC 8/25 sur caisson (cotes) : 50mm bords + 2 équidistants avant/arrière · lignes haut/bas 50mm : entrée face extérieure (SC vers habillage) ; équidistants : face intérieure', holes }; } else if (elementType === 'habillageFinitionGauche' || elementType === 'habillageFinitionDroite') { // Référentiel: X=0 bord AVANT, X=W bord ARRIÈRE ; Y=0 bas panneau (comme G-code) const xFrontL = 72; const xBackL = W - 70; const mirror = elementType === 'habillageFinitionDroite'; const xFront = mirror ? W - xFrontL : xFrontL; const xBack = mirror ? W - xBackL : xBackL; const yTop = H - 69; const yBottom = underCabinetHeightMm + 50; // Répartition: ajouter autant de perçages que possible en gardant au moins 500 mm entre 2 trous. // (On conserve les trous de référence haut/bas et on répartit le reste équidistant.) const span = Math.abs(Number(yTop) - Number(yBottom)); const minSpacingMm = 500; const maxIntervals = Math.max(1, Math.floor(span / minSpacingMm)); const innerCount = Math.max(0, maxIntervals - 1); const yInner = addEquidistantInner(yBottom, yTop, innerCount); const yAll = uniq([yBottom, ...yInner, yTop]); for (const yMm of yAll) { pushFaceHole(xFront, yMm, 'ixconnect'); pushFaceHole(xBack, yMm, 'ixconnect'); } // 1 trou supplémentaire au centre (hors lignes avant/arrière) — équidistant sur la hauteur (milieu). pushFaceHole(W / 2, (yBottom + yTop) / 2, 'ixconnect'); // Tourillons sur coupe d'onglet 45° (chant haut) seulement si habillage dessus (joint avec bandeau). if (topFinishEnabled) { pushEndHole('top', 50, H, dTour, zTourEnd, 'tourillon_miter'); pushEndHole('top', Math.max(0, W - 50), H, dTour, zTourEnd, 'tourillon_miter'); } machining.finishHabillage = { model: connector.model, faceHole: { ...connector.faceHole }, underCabinetHeightMm, mirrored: mirror, note: 'Habillage finition — côté: Ixconnect' + (topFinishEnabled ? ' + tourillons sur onglet haut (avant/arrière 50)' : ' (sans onglet haut — pas de bandeau dessus)'), holes }; } else if (elementType === 'habillageFinitionDessus') { // Dessus de doublage (face intérieure) : // 2 points de fixation Ixconnect SC 8/25 centrés en largeur, un à l'avant et un à l'arrière, // en reprenant les mêmes règles de mesures que pour les joues latérales (72 / 70 mm). const sideDefaultsEnabled = config?.sideFinishesEnabled !== false; const miterLeft = config?.sideFinishLeftEnabled ?? sideDefaultsEnabled; const miterRight = config?.sideFinishRightEnabled ?? sideDefaultsEnabled; const yFront = 72; const yBack = H - 70; const xCenter = W / 2; pushFaceHole(xCenter, yFront, 'ixconnect'); pushFaceHole(xCenter, yBack, 'ixconnect'); // Tourillons sur coupes d'onglet 45° uniquement si habillage latéral correspondant (joint avec joue). if (miterLeft) { pushEndHole('left', 0, 50, dTour, zTourEnd, 'tourillon_miter'); pushEndHole('left', 0, Math.max(0, H - 50), dTour, zTourEnd, 'tourillon_miter'); } if (miterRight) { pushEndHole('right', W, 50, dTour, zTourEnd, 'tourillon_miter'); pushEndHole('right', W, Math.max(0, H - 50), dTour, zTourEnd, 'tourillon_miter'); } machining.finishHabillage = { model: connector.model, faceHole: { ...connector.faceHole }, note: 'Habillage finition — dessus doublage (face intérieure): 2 Ixconnect SC 8/25 centrés (avant 72 / arrière 70)' + (miterLeft || miterRight ? ' + tourillons onglet sur coupes côtés (selon habillages latéraux)' : ''), holes }; } } // System 32 drilling — vertical separators (THROUGH holes, offset 32mm) if (elementType === 'panneauxInterieurs') { const panelHeight = dimensions.height; const usableHeight = panelHeight - SYSTEM32_SEPARATORS.firstHoleOffset * 2; const holeCount = Math.max(0, Math.floor(usableHeight / SYSTEM32_SEPARATORS.pitch) + 1); const positions = []; for (let i = 0; i < holeCount; i++) { positions.push(SYSTEM32_SEPARATORS.firstHoleOffset + i * SYSTEM32_SEPARATORS.pitch); } machining.system32 = { holeDiameter: SYSTEM32_SEPARATORS.holeDiameter, throughHole: SYSTEM32_SEPARATORS.throughHole, count: holeCount, rows: 2, positions, note: 'TRAVERSANTS (through) — first hole at 32mm (51-19)', }; } // Hinge holes — applies to side panels (hinged doors only) if (elementType === 'cotes' && config.doorType !== 'sliding') { const doorCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2); const hingesTotal = doorCount * HINGE.perDoor; machining.hingeHoles = { count: hingesTotal, axisOffset: HINGE.axisOffset, perDoor: HINGE.perDoor, }; } // Ixconnect — panneaux horizontaux (ixconnectJointsAlongDepth) + montants / séparateurs (jointPattern face haut & bas) // Arête extrémité: T@20 → IX@50 → T@80 mm depuis bord avant · Jonction interne: IX au milieu profondeur + T ±30 mm if (['dessus', 'fond', 'cotes', 'panneauxInterieurs'].includes(elementType)) { const connector = CONNECTORS.ixconnect; const depthAlongJointMm = ['dessus', 'fond'].includes(elementType) ? dimensions.height : dimensions.width; const edgePattern = getIxconnectJointPatternAlongDepthMm(depthAlongJointMm, 'edge', { horizontalPanel: ['dessus', 'fond'].includes(elementType) }); const junctionAxesForHorizontals = elementType === 'dessus' || elementType === 'fond' ? getJunctionAxesAcrossWidthMm(dimensions.width, modules, config) : null; machining.connectorHoles = { model: connector.model, edgeHole: { ...connector.edgeHole }, // Ø8 × 45mm faceHole: { ...connector.faceHole }, // Ø8 × 12mm lockScrewInbus: { ...connector.lockScrewInbus }, // Ø6 × 9mm — face intérieure dessus/fond (trou de serrage) jointPattern: { tourillon1Axis: edgePattern.tourillon1Axis, ixconnectAxis: edgePattern.ixconnectAxis, tourillon2Axis: edgePattern.tourillon2Axis }, tourillonDiameter: CONNECTORS.tourillonDiameter, jointsPerPiece: junctionAxesForHorizontals ? junctionAxesForHorizontals.length : 1, connectorsPerJoint: 1, // 1 Ixconnect + 2 tourillons per joint tourillonsPerJoint: 2, }; if (junctionAxesForHorizontals) { machining.connectorHoles.ixconnectJointsAlongDepth = junctionAxesForHorizontals.map((xAxis, idx) => ({ axisAcrossWidthMm: xAxis, ...getIxconnectJointPatternAlongDepthMm( depthAlongJointMm, idx === 0 || idx === junctionAxesForHorizontals.length - 1 ? 'edge' : 'middle', { horizontalPanel: true } ) })); } } // Groove for back panel — montants, habillage niche (fileurs, doublage), bandeau, dessus, fond (pas séparateurs) if ( BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(elementType) || BACK_GROOVE_PLAN_HORIZONTAL_TYPES.includes(elementType) ) { machining.groove = { width: GROOVE.back.width, depth: GROOVE.back.depth, axisFromPanelEdge: GROOVE.back.axisFromPanelEdge, position: 'back', }; } // Reinforcement: sur CHAQUE petit chant d'extrémité (jonction côtés) // 1× SC 8/60 centre + 2× tourillons à 20 mm des bords haut/bas. if (elementType === 'renfortsTiroir') { const dr = DRAWER_REINFORCEMENT; const w = Math.max(1, Number(dimensions.width) || 1); const h = Math.max(1, Number(dimensions.height) || 1); const ixHoles = getDrawerReinforcementIxconnectHolePositionsMm(w, h); machining.drawerReinforcement = { thicknessMm: dr.thicknessMm, backInsetFromRearMm: dr.backInsetFromRearMm, hingeFrontClearanceAppliedMm: dr.hingeFrontClearanceMm, topFrontRadiusDeductionMm: dr.topFrontRadiusMm, note: `Renfort tiroir ${dr.thicknessMm} mm (tiroirs cachés derrière porte) — du fond +${dr.backInsetFromRearMm} mm au bord avant` + ` · réserve charnières porte −${dr.hingeFrontClearanceMm} mm · hauteur = pile tiroirs − R${dr.topFrontRadiusMm} mm (façade tiroir supérieure)`, ixconnectFixing: { model: dr.ixconnectModel, holeDiameterMm: dr.ixconnectHoleDiameterMm, depthInReinforcementMm: dr.ixconnectDepthInReinforcementMm, depthInVerticalPanelMm: dr.ixconnectDepthInVerticalPanelMm, cornerInsetMm: dr.ixconnectCornerInsetMm, holes: ixHoles, note: `4× ${dr.ixconnectModel} — Ø${dr.ixconnectHoleDiameterMm} à ${dr.ixconnectCornerInsetMm} mm des bords (chaque coin) · prof. ${dr.ixconnectDepthInReinforcementMm} mm dans le renfort · prof. ${dr.ixconnectDepthInVerticalPanelMm} mm en face montant/séparateur` } }; } { const modsR = effectiveModules(modules, config); const drawerHidden = (config.hardware?.drawerType || 'visible') === 'hidden' || (config.hardware?.drawerType || 'visible') === 'hidden_with_handle'; const depthMmR = Number(config.depthMm || 600); const sepDepthMmR = getSeparatorDepthMm(depthMmR); const ixP = DRAWER_RENFORT_PANEL_IXCONNECT; if (drawerHidden && elementType === 'cotes') { const zL = moduleRenfortZoneForDrawerRenfort(config, modsR, 0); const zR = modsR.length > 0 ? moduleRenfortZoneForDrawerRenfort(config, modsR, modsR.length - 1) : null; const cotes = {}; if (zL) { cotes.leftInnerFace = { holes: buildDrawerRenfortPanelIxconnectCornersMm(depthMmR, true, zL.zoneBottomMm, zL.hMm), panelKind: 'cote' }; } if (zR) { cotes.rightInnerFace = { holes: buildDrawerRenfortPanelIxconnectCornersMm(depthMmR, true, zR.zoneBottomMm, zR.hMm), panelKind: 'cote' }; } if (cotes.leftInnerFace || cotes.rightInnerFace) { machining.drawerRenfortPanelIxconnect = { model: ixP.model, faceHole: { diameter: ixP.faceHoleDiameterMm, depth: ixP.faceHoleDepthMm }, cotes, note: `Montants extérieurs — face intérieure caisse : Ixconnect ${ixP.model} Ø${ixP.faceHoleDiameterMm}×${ixP.faceHoleDepthMm} mm · ${ixP.frontAxesFromFrontMm} mm du devant · ${ixP.backAxesFromRearCoteMm} mm du fond (19+9+50) · ${ixP.verticalInsetMm} mm haut/bas zone renfort · Y depuis bas panneau` }; } } if (drawerHidden && elementType === 'panneauxInterieurs') { const sepCount = Math.max(0, modsR.length - 1); const separators = []; for (let s = 0; s < sepCount; s++) { const zs = moduleRenfortZoneForDrawerRenfort(config, modsR, s); const zs1 = moduleRenfortZoneForDrawerRenfort(config, modsR, s + 1); separators.push({ separatorIndex: s, faceTowardLowerModule: zs ? { holes: buildDrawerRenfortPanelIxconnectCornersMm(sepDepthMmR, false, zs.zoneBottomMm, zs.hMm), moduleIndex: s } : null, faceTowardHigherModule: zs1 ? { holes: buildDrawerRenfortPanelIxconnectCornersMm(sepDepthMmR, false, zs1.zoneBottomMm, zs1.hMm), moduleIndex: s + 1 } : null }); } if (separators.some((x) => x.faceTowardLowerModule || x.faceTowardHigherModule)) { machining.drawerRenfortPanelIxconnect = { model: ixP.model, faceHole: { diameter: ixP.faceHoleDiameterMm, depth: ixP.faceHoleDepthMm }, separators, note: `Séparateurs — faces intérieures vers colonnes : Ixconnect ${ixP.model} Ø${ixP.faceHoleDiameterMm}×${ixP.faceHoleDepthMm} mm · ${ixP.frontAxesFromFrontMm} mm du devant · ${ixP.backAxesFromRearSeparatorMm} mm du fond · ${ixP.verticalInsetMm} mm haut/bas zone renfort · Y depuis bas panneau` }; } } } if (elementType === 'renforts') { const axes = getReinforcementEndFixingAxesFromTopMm(dimensions.height); const conn = REINFORCEMENT_MACHINING.connector; machining.reinforcementFixing = { connectorModel: REINFORCEMENT_MACHINING.connectorModel, edgeHole: { ...conn.edgeHole }, faceHole: { ...conn.faceHole }, ixconnectCenter: REINFORCEMENT_MACHINING.ixconnectCenter, sideCount: 2, ixconnectPerSide: 1, tourillonsPerSide: REINFORCEMENT_MACHINING.tourillonCount, ixconnectAxisFromTopMm: axes.ixconnectAxisFromTopMm, tourillonCount: REINFORCEMENT_MACHINING.tourillonCount, tourillonEdgeOffset: REINFORCEMENT_MACHINING.tourillonEdgeOffset, tourillonAxesFromTopMm: axes.tourillonAxesFromTopMm, tourillonDiameter: REINFORCEMENT_MACHINING.tourillonDiameter, note: `Par côté: 1× ${REINFORCEMENT_MACHINING.connectorModel} centre + 2× tourillons Ø8 à ${REINFORCEMENT_MACHINING.tourillonEdgeOffset} mm des bords`, }; } // SC 8/25 connector holes — finish panels (doors) if (elementType === 'portes') { const sc825 = CONNECTORS.ixconnectFinish; machining.finishConnectors = { model: sc825.model, faceHole: { ...sc825.faceHole }, // Ø8 × 12.5mm axes: { ...sc825.axes }, // back: 30mm, front: 43mm note: 'SC 8/25 pour panneaux de finition', }; if (config.doorType !== 'sliding') { const hingeCount = getHingeCountForDoor({ heightMm: dimensions.height }); machining.hingeCups = { diameter: 35, cupDepth: 12, axisOffsetFromEdge: HINGE.axisOffset, count: hingeCount, positionsFromTop: getHingeCupPositions(dimensions.height, hingeCount), screwDiameterMm: HINGE.cupScrewDiameter, screwDepthMm: HINGE.cupScrewDepth, screwSpacingMm: HINGE.cupScrewSpacing, screwOffsetFromCupCenterMm: HINGE.cupScrewOffsetFromCupCenter, note: 'Charnières sur axe 103 mm (réversible), répartition intermédiaire sur grille 32 ; par cuvette : 2× Ø8×13 mm (45 mm c/c, 9,5 mm du centre cuvette)' }; if (config.hardware?.handleType === 'full_height') { machining.fullHeightHandleGroove = { oppositeToHinges: true, topBottomOffsetMm: HANDLE.fullHeightTopBottomOffset, grooveWidthMm: HANDLE.fullHeightGroove.width, grooveDepthMm: HANDLE.fullHeightGroove.depth, note: 'Rainure poignée toute hauteur côté opposé aux charnières' }; } } } // Shelf machining: tourillons Ø8 for pin alignment if (elementType === 'etageres') { machining.shelfPins = { tourillonDiameter: SHELF_MACHINING.tourillonDiameter, tourillonCount: SHELF_MACHINING.tourillonCount, shelvesPerStratum: SHELF_MACHINING.shelvesPerStratum, note: '4× tourillons Ø8 (2 avant + 2 arrière) — 2 rayons/strate', }; machining.mobileShelfSystem = { model: 'sp_15_8_5', faceHole: { diameterMm: 20, depthMm: 15, axisFromSideMm: 9.5 }, edgeHole: { diameterMm: 8, depthMm: 37, axisFromBottomFaceMm: 6 }, positions: { fromFrontMm: 32, fromBackMm: 37 }, note: 'Rayons mobiles (SP 15/8/5): Ø20×15 dessous + Ø8×37 chant à 6mm de la face inférieure' }; } if (elementType === 'rayonFixe') { // Fixed shelf: same face holes as mobile shelves, but Ø20 only, deeper (20mm), no edge holes. machining.mobileShelfSystem = { model: 'sp_15_8_5_face_only', faceHole: { diameterMm: 20, depthMm: 14.5, axisFromSideMm: 9.5 }, positions: { fromFrontMm: 32, fromBackMm: 37 }, note: 'Rayon fixe: perçages face dessous Ø20 · prof. 14.5 (positions SP 15/8/5)' }; machining.fixedShelfAssembly = { model: 'Rasant-Tab', pilotHoleDiametersMm: [3, 5], note: 'Rayon fixe de structure — ferrures Rasant-Tab à visser (pré-perçage Ø3 ou Ø5 selon vis)' }; } if (config.columnMode === 'block' && ['panneauxInterieurs', 'cotes'].includes(elementType)) { machining.blockAssembly = { middleCount: BLOCK_ASSEMBLY.middleCount, topBottomOffset: BLOCK_ASSEMBLY.topBottomOffset, fromBack: BLOCK_ASSEMBLY.sideOffsetFromBack, fromFace: BLOCK_ASSEMBLY.sideOffsetFromFace, note: 'Vis de jonction pour assemblage de caisses' }; } if (['fond', 'dessus'].includes(elementType)) { // Ferrures d’assemblage de corps: // connectent panneaux horizontaux (dessus/fond) avec côtés + séparateurs (pas le dos). const edgeLenMm = dimensions.height; // sens longueur de perçage (profondeur pièce) const grooveInsetMm = getBackGrooveInnerEndInsetFromBackEdgeMm(); const jointEdgeUsableLengthMm = Math.max(0, edgeLenMm - grooveInsetMm); const positionsAlongJointMm = getCarcassJointPositions(jointEdgeUsableLengthMm); const dowelStations = getCarcassFerrureDowelDrillings( positionsAlongJointMm, jointEdgeUsableLengthMm, CARCASS_FASTENERS.dowel.sideOffset ); const junctionAxesAcrossWidthMm = []; junctionAxesAcrossWidthMm.push(...getJunctionAxesAcrossWidthMm(dimensions.width, modules, config)); machining.carcassFasteners = { piecesPerJoint: CARCASS_FASTENERS.piecesPerJoint, jointCount: junctionAxesAcrossWidthMm.length, jointEdgeLabel: 'jonctions avec côtés + séparateurs (hors dos)', edgeLengthMm: edgeLenMm, jointEdgeUsableLengthMm, junctionAxesAcrossWidthMm, positionsAlongJointMm, dowelStations, dowelDiameter: CARCASS_FASTENERS.dowel.diameter, dowelDepth: CARCASS_FASTENERS.dowel.depth, dowelSideOffset: CARCASS_FASTENERS.dowel.sideOffset, ferrureDepthAlongLengthMm: CARCASS_FASTENERS.ferrureDepthAlongLengthMm, ferrureDepthInThicknessMm: CARCASS_FASTENERS.ferrureDepthInThicknessMm, note: `Par jonction: 2 ou 3 ferrures selon longueur d’arête (milieu si arête > ${CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM} mm) + 2 tourillons Ø8×${CARCASS_FASTENERS.dowel.faceDrillDepthMm} (face) à ±${CARCASS_FASTENERS.dowel.sideOffset} mm par ferrure`, }; } return Object.keys(machining).length > 0 ? machining : null; } function getEdgeBandingAxesByType(elementType) { switch (elementType) { case 'portes': case 'cotes': case 'plintheBas': case 'bandeauHaut': case 'cotesDoublage': case 'fileur': case 'habillageFinitionGauche': case 'habillageFinitionDroite': case 'habillageFinitionDessus': return { widthEdges: 2, heightEdges: 2 }; case 'dessus': case 'fond': return { widthEdges: 2, heightEdges: 0 }; case 'etageres': case 'rayonFixe': case 'panneauxInterieurs': case 'renforts': case 'renfortsTiroir': return { widthEdges: 1, heightEdges: 0 }; case 'dos': return { widthEdges: 2, heightEdges: 0 }; default: return { widthEdges: 0, heightEdges: 0 }; } } function computeRawCutDimensions(piece) { const axes = getEdgeBandingAxesByType(piece.elementType); const deductionPerEdge = EDGE_BANDING.abs; return { rawCutWidthMm: piece.widthMm + axes.widthEdges * deductionPerEdge, rawCutHeightMm: piece.heightMm + axes.heightEdges * deductionPerEdge }; } /** * Coupe largeur × épaisseur (dessus/fond) : un logement tourillon par jonction (côtés + séparateurs), * traitillé depuis la face supérieure (arête vue en coupe), axe en pointillés. */ function svgCarcassTourillonSlotsSectionView(origX, origY, scaleC, thicknessPx, widthMm, thicknessMm, cf) { if (!cf?.junctionAxesAcrossWidthMm?.length) return ''; const edgeInset = PANEL_THICKNESS * 0.5; const wMm = Math.max(1, Number(widthMm) || 0); const tMm = Math.max(1, Number(thicknessMm) || 0); const mapXmm = (xmm) => { const clamped = Math.max(0, Math.min(wMm, Number(xmm) || 0)); if (clamped <= 0) return edgeInset; if (clamped >= wMm) return Math.max(0, wMm - edgeInset); return clamped; }; const joints = cf.junctionAxesAcrossWidthMm.map(mapXmm); const dMm = cf.dowelDiameter || 8; const depthMm = tourillonFaceDrillDepthMm(tMm); const hPx = Math.min(depthMm * scaleC, thicknessPx - 1.5); const halfW = Math.max(1.2, (dMm * 0.5) * scaleC); let s = ''; for (const xmm of joints) { const x = origX + xmm * scaleC; const y0 = origY; const y1 = origY + hPx; s += ``; s += ``; } return s; } /** Dessus/fond/bandeau : en coupe L×ép., rainure ouverte depuis la face haute du rectangle (épaisseur), comme les montants. */ function backGrooveOpensFromBottomThickEdge(_piece) { return false; } /** * Vue A — rainure réception dos sur la face usinée (même repère que détail côté : dos à droite pour montants). */ function svgBackGrooveFaceViewA(piece, m, pAx, pAy, wA, hA, scaleA, fmt) { const axisMm = m.groove.axisFromPanelEdge ?? m.groove.axisFromBackEdge; if (axisMm == null) return ''; const gw = m.groove.width ?? GROOVE.back.width; const gd = m.groove.depth ?? GROOVE.back.depth; const sa = scaleA; const endInsetMm = getBackGrooveInnerEndInsetFromBackEdgeMm(); const insetV = endInsetMm * sa; let s = ''; if (BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(piece.elementType)) { const gx = pAx + wA - axisMm * sa - (gw * sa) / 2; s += `Rainure dos ${fmt(gw)}×${fmt(gd)} mm · axe ${fmt(axisMm)} mm du bord dos · retrait ${fmt(endInsetMm)} mm en bout`; s += ``; s += `dos`; s += ``; } else if (BACK_GROOVE_PLAN_HORIZONTAL_TYPES.includes(piece.elementType)) { const centerY = pAy + axisMm * sa; const gy = centerY - (gw * sa) / 2; /* Dessus/fond/bandeau : rainure sur toute la longueur utile du bord (joint dos), sans retrait latéral. */ const xin = pAx; const win = Math.max(0.5, wA); s += `Rainure dos ${fmt(gw)}×${fmt(gd)} mm · axe ${fmt(axisMm)} mm du bord dos · pleine longueur`; s += ``; s += ``; } return s; } /** Vue C — coupe largeur pièce × épaisseur : rainure côté dos (droite), ouverte depuis la face caisse. */ function svgBackGrooveSectionViewC(pCx, pCy, wC, tC, scaleC, piece, m, fmt) { const axisMm = m.groove.axisFromPanelEdge ?? m.groove.axisFromBackEdge; if (axisMm == null) return ''; const axis = Math.min(wC, Math.max(0, axisMm * scaleC)); const grooveW = Math.max(2, (m.groove.width || GROOVE.back.width) * scaleC); const depthMm = m.groove.depth ?? GROOVE.back.depth; const grooveD = Math.max(2, Math.min(tC - 2, depthMm * scaleC)); const gx = pCx + wC - axis - grooveW / 2; const fromBottom = backGrooveOpensFromBottomThickEdge(piece); const gy = fromBottom ? pCy + tC - grooveD : pCy; const midX = gx + grooveW / 2; let s = ''; s += ``; if (fromBottom) { s += ``; s += `Axe → ${fmt(axisMm)} mm du bord dos`; } else { s += ``; s += `Ouverture face caisse · ${fmt(axisMm)} mm du dos`; } s += `Bord dos`; s += `${fmt(m.groove.width || GROOVE.back.width)} × ${fmt(depthMm)} mm`; return s; } /** * Vue D — double schéma type plan technique : coupe sur épaisseur + vue sur face rainurée. */ function svgBackGrooveTechnicalDetail(piece, m, fmt) { const tMm = Math.max(1, Number(piece.thicknessMm) || PANEL_THICKNESS); const gw = m.groove.width ?? GROOVE.back.width; const gd = m.groove.depth ?? GROOVE.back.depth; const ax = m.groove.axisFromPanelEdge ?? m.groove.axisFromBackEdge ?? GROOVE.back.axisFromPanelEdge; const fromBottom = backGrooveOpensFromBottomThickEdge(piece); const sPx = 5.2; const Wsec = 210; const Ht = Math.max(30, Math.min(110, tMm * sPx)); const x0 = 36; const y0 = 58; let o = ''; o += `A — Coupe sur épaisseur (dos à droite)`; o += ``; const axisPx = ax * sPx; const gwp = gw * sPx; const gdp = Math.min(gd * sPx, Ht - 1.5); const gx = x0 + Wsec - axisPx - gwp / 2; const gy = fromBottom ? y0 + Ht - gdp : y0; o += ``; o += ``; o += ``; o += ``; o += `${fmt(gw)} ±0.3`; o += ``; o += ``; o += ``; o += `ép. ${fmt(tMm)}`; const depLineX = gx + gwp + 28; const y1d = fromBottom ? y0 + Ht : y0; const y2d = fromBottom ? gy : y0 + gdp; o += ``; o += `${fmt(gd)}`; const x1 = 400; const y1 = 58; const Lface = 300; const faceH = Math.max(36, Ht + 16); o += `B — Face intérieure (rainure en trait mixte)`; o += ``; o += ``; o += ``; if (BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(piece.elementType)) { const fw = Lface - 40; const gx2 = x1 + 20 + fw - axisPx - gwp / 2; o += ``; o += ``; o += `${fmt(ax)} mm (axe au bord dos)`; } else { const fw = Lface - 40; const cy = y1 + axisPx; const gy2 = cy - gwp / 2; o += ``; o += ``; o += `${fmt(ax)} mm`; } o += `Rainure réception dos · cotation indicative ±0.3 mm sur largeur de fraisage`; return o; } /** * Vue B — chant d'extrémité du renfort (H×ép.) avec perçages Ixconnect/tourillons. */ function svgReinforcementFixingEdgeViewB(rf, pBx, pBy, tB, longB, scaleB, fmt, horizontal = false) { if (!rf) return ''; const ixFromTop = Number(rf.ixconnectAxisFromTopMm) || 0; const axisCenter = horizontal ? (pBy + tB / 2) : (pBx + tB / 2); const posIx = horizontal ? (pBx + Math.min(longB, Math.max(0, ixFromTop * scaleB))) : (pBy + Math.min(longB, Math.max(0, ixFromTop * scaleB))); const rIx = Math.max(3, (rf.edgeHole?.diameter || 8) * 0.4 * scaleB); const rT = Math.max(2.1, (rf.tourillonDiameter || 8) * 0.3 * scaleB); let s = ''; if (horizontal) { s += ``; s += ``; } else { s += ``; s += ``; } for (const yTop of rf.tourillonAxesFromTopMm || []) { const pos = horizontal ? (pBx + Math.min(longB, Math.max(0, Number(yTop) * scaleB))) : (pBy + Math.min(longB, Math.max(0, Number(yTop) * scaleB))); if (horizontal) { s += ``; } else { s += ``; } } const axesText = (rf.tourillonAxesFromTopMm || []).map((v) => fmt(v)).join(' / '); const tourillonAxes = (rf.tourillonAxesFromTopMm || []) .map((v) => Number(v)) .filter((v) => Number.isFinite(v)) .sort((a, b) => a - b); const leftEdgeOffsetMm = tourillonAxes.length ? tourillonAxes[0] : null; const rightEdgeOffsetMm = tourillonAxes.length ? Number(rf.tourillonEdgeOffset ?? leftEdgeOffsetMm) : null; if (horizontal) { s += `${rf.connectorModel || 'SC 8/60'} (par chant) axe ${fmt(ixFromTop)} mm`; s += `Tourillons Ø${rf.tourillonDiameter || 8}: ${axesText} mm depuis haut`; // Dimension lines inside the piece (between top edge and hole axis; SVG y grows downward). const dimY20 = pBy + Math.max(3, tB * 0.22); const dimY50 = pBy + Math.max(3, tB * 0.42); if (leftEdgeOffsetMm != null && tourillonAxes.length >= 2) { const leftX = pBx; const firstX = pBx + leftEdgeOffsetMm * scaleB; const rightX = pBx + longB; const lastX = pBx + tourillonAxes[tourillonAxes.length - 1] * scaleB; s += ``; s += ``; s += ``; s += `${fmt(leftEdgeOffsetMm)} mm`; s += ``; s += ``; s += ``; s += `${fmt(rightEdgeOffsetMm)} mm`; } // Center hole (Ixconnect) dimension from edge: typically 50 mm on 100 mm reinforcement width. s += ``; s += ``; s += ``; s += `${fmt(ixFromTop)} mm`; } else { s += `${rf.connectorModel || 'SC 8/60'} (par chant) axe ${fmt(ixFromTop)} mm`; s += `Tourillons Ø${rf.tourillonDiameter || 8}: ${axesText} mm depuis haut`; } return s; } /** * Usinage face avant (cuvettes + rainure TH) pour une orientation de charnières donnée. */ function doorFaceUsinageSvg({ pAx, pAy, wA, hA, scaleA, m, hingeSide, heightMm, fmt }) { let viewAOps = ''; if (m.hingeCups?.positionsFromTop?.length) { const cupR = Math.max(2.5, (m.hingeCups.diameter || 35) * scaleA * 0.5); const cupInset = Math.min(22 * scaleA, wA * 0.13); const cupX = hingeSide === 'right' ? pAx + wA - cupInset : pAx + cupInset; const sd = m.hingeCups.screwDiameterMm ?? HINGE.cupScrewDiameter; const screwR = Math.max(1.2, sd * scaleA * 0.5); const offPx = (m.hingeCups.screwOffsetFromCupCenterMm ?? HINGE.cupScrewOffsetFromCupCenter) * scaleA; const halfSpPx = ((m.hingeCups.screwSpacingMm ?? HINGE.cupScrewSpacing) / 2) * scaleA; const screwX = hingeSide === 'right' ? cupX - offPx : cupX + offPx; for (const pos of m.hingeCups.positionsFromTop) { const y = pAy + Math.min(hA, Math.max(0, pos * scaleA)); viewAOps += `${Math.round(pos)}mm`; viewAOps += ``; viewAOps += ``; } } if (m.fullHeightHandleGroove) { const off = m.fullHeightHandleGroove.topBottomOffsetMm ?? HANDLE.fullHeightTopBottomOffset; const gw = m.fullHeightHandleGroove.grooveWidthMm ?? HANDLE.fullHeightGroove.width; const gd = m.fullHeightHandleGroove.grooveDepthMm ?? HANDLE.fullHeightGroove.depth; const axisFromHandleEdge = Math.max(2, (gw / 2) * scaleA); const gx = hingeSide === 'right' ? pAx + axisFromHandleEdge : pAx + wA - axisFromHandleEdge; const y1 = pAy + off * scaleA; const y2 = pAy + hA - off * scaleA; viewAOps += ``; viewAOps += `Rainure TH · ${fmt(gw)}×${fmt(gd)} mm · retrait ${fmt(off)} mm H/B`; const dx = pAx - 6; const mida = (y0, y1, label) => { const ym = (y0 + y1) / 2; return `${label} mm`; }; viewAOps += mida(pAy, y1, `${fmt(off)}`); viewAOps += mida(y1, y2, `${fmt(heightMm - 2 * off)}`); viewAOps += mida(y2, pAy + hA, `${fmt(off)}`); } return viewAOps; } /** * Détail fabrication porte : coupe rainure TH (cotes), schéma hauteur, perçages. * Utilisé en bandeau sous les vues A/B/C et en vue D plein écran. */ function doorMachiningDetailMarkup(piece, fmt, boxW, boxH, hingeSideOverride, vantailLabel) { const m = piece.machining || {}; const hingeSide = hingeSideOverride ?? piece.doorPlanHints?.hingeSide ?? 'left'; const fh = m.fullHeightHandleGroove; const gw = fh ? (fh.grooveWidthMm ?? HANDLE.fullHeightGroove.width) : HANDLE.fullHeightGroove.width; const gd = fh ? (fh.grooveDepthMm ?? HANDLE.fullHeightGroove.depth) : HANDLE.fullHeightGroove.depth; const off = fh ? (fh.topBottomOffsetMm ?? HANDLE.fullHeightTopBottomOffset) : HANDLE.fullHeightTopBottomOffset; const H = piece.heightMm; const tMm = piece.thicknessMm; const grooveLen = fh ? Math.max(0, H - 2 * off) : 0; const handleRight = hingeSide === 'left'; const fs = m.finishConnectors; const hc = m.hingeCups; const col1W = Math.min(240, boxW * 0.34); const col2W = Math.min(200, boxW * 0.28); const x1 = 8; const x2 = x1 + col1W + 16; const x3 = x2 + col2W + 16; const yTitle = 18; const y0 = 36; let s = ''; const titleText = vantailLabel ? `Détail usinage — ${vantailLabel} — rainure & perçages` : 'Détail usinage porte — rainure & perçages'; s += `${titleText}`; // Colonne 1 — Coupe sur épaisseur (chant poignée), même logique que rainure dos : larg. × prof. const sc = Math.min(110 / tMm, 95 / Math.max(gd, 1)); const barW = tMm * sc; const barH = 34; const px = x1; const py = y0; s += `Coupe sur chant poignée (profil rainure dans l'épaisseur)`; const barY = py + 10; s += ``; const gDpx = gd * sc; const gWpx = gw * sc; const gX = handleRight ? px + barW - gDpx : px; const ghVis = Math.min(gWpx, barH - 2); const gY = barY + barH - ghVis; s += ``; s += `Ouverture rainure ${fmt(gw)} mm · Profondeur ${fmt(gd)} mm`; s += `Épaisseur panneau ${fmt(tMm)} mm`; // Petites cotes sur la coupe s += ``; s += ``; s += `${fmt(gd)}`; s += `${fmt(gw)}`; // Colonne 2 — Schéma hauteur rainure TH const vx = x2 + 14; const vy = barY; s += `Hauteur rainure (poignée TH)`; if (fh) { const maxH = Math.min(120, boxH - 50); const vScale = maxH / Math.max(H, 1); const vLen = H * vScale; const oPx = off * vScale; s += ``; s += ``; s += ``; s += ``; s += ``; s += `Retrait ${fmt(off)} mm`; s += `Utile rainure ${fmt(grooveLen)} mm`; s += `Retrait ${fmt(off)} mm`; } else { s += `Poignée standard — pas de rainure TH`; } // Colonne 3 — Perçages let ly = y0 + 8; s += `Perçages`; if (hc) { const pos = (hc.positionsFromTop || []).map((v) => `${Math.round(v)}`).join(', '); const sd = hc.screwDiameterMm ?? HINGE.cupScrewDiameter; const sz = hc.screwDepthMm ?? HINGE.cupScrewDepth; const ssp = hc.screwSpacingMm ?? HINGE.cupScrewSpacing; const sof = hc.screwOffsetFromCupCenterMm ?? HINGE.cupScrewOffsetFromCupCenter; s += `Charnières — cuvette Ø${hc.diameter} mm, prof. ${hc.cupDepth} mm`; s += `Axes depuis haut : ${pos} mm — vis Ø${sd}×${sz} mm (${ssp} mm c/c, ${sof} mm du centre cuvette)`; ly += 36; } if (fs) { s += `${fs.model || 'SC 8/25'} — face Ø${fs.faceHole?.diameter || 8} × prof. ${fs.faceHole?.depth || 12.5} mm`; s += `Axes depuis face intérieure : ${fs.axes?.back ?? 30} mm (arrière) / ${fs.axes?.front ?? 43} mm (avant)`; } return s; } function generatePiecePlanSvg(piece) { const widthMm = Math.max(1, Number(piece.widthMm) || 1); const heightMm = Math.max(1, Number(piece.heightMm) || 1); const thicknessMm = Math.max(1, Number(piece.thicknessMm) || 1); const margin = 28; const panelGap = 24; const viewW = 330; const viewH = 220; const infoBlockH = 180; const xA = margin; const yA = 42; const xB = xA + viewW + panelGap; const yB = yA; const xC = xB + viewW + panelGap; const yC = yA; const svgW = xC + viewW + margin; const m = piece.machining || {}; const fmt = (v) => Number(v).toFixed(Number(v) % 1 === 0 ? 0 : 1); // View A: front view with drilling positions const scaleA = Math.min((viewW - 40) / widthMm, (viewH - 40) / heightMm); const wA = widthMm * scaleA; const hA = heightMm * scaleA; const pAx = xA + (viewW - wA) / 2; const pAy = yA + 24 + (viewH - 24 - hA) / 2; let viewAOps = ''; const isHabFinishSide = piece.elementType === 'habillageFinitionGauche' || piece.elementType === 'habillageFinitionDroite'; const isHabFinishTop = piece.elementType === 'habillageFinitionDessus'; let viewAMiterMarks = ''; let miterInfoLine = ''; if (m.system32?.positions?.length) { const frontX = pAx + Math.min(37 * scaleA, wA * 0.2); const backX = pAx + wA - Math.min(37 * scaleA, wA * 0.2); const r = Math.max(1.2, (m.system32.holeDiameter || 5) * scaleA * 0.5); for (const pos of m.system32.positions) { const y = pAy + Math.min(hA, Math.max(0, pos * scaleA)); viewAOps += ``; } viewAOps += `System 32: Ø${m.system32.holeDiameter}${m.system32.throughHole ? ' traversant' : ` prof. ${m.system32.holeDepth || 12}`}`; } // Rayons (SP 15/8/5): perçages face dessous Ø20×(15 ou 20 selon type) if ( ['etageres', 'rayonFixe'].includes(piece.elementType) && ['sp_15_8_5', 'sp_15_8_5_face_only'].includes(m.mobileShelfSystem?.model) ) { const specs = buildShelfMobileHoleSpecsMm(piece, widthMm, heightMm); const face = specs.filter((h) => h.face === 'face'); if (face.length) { const rFace = Math.max(2.0, (20 * 0.5) * scaleA); for (const h of face) { const x = pAx + Math.min(wA, Math.max(0, Number(h.xMm) * scaleA)); const y = pAy + Math.min(hA, Math.max(0, Number(h.yMm) * scaleA)); viewAOps += ``; } const z = Number(m.mobileShelfSystem?.faceHole?.depthMm ?? (piece.elementType === 'rayonFixe' ? 20 : 15)); viewAOps += `${piece.elementType === 'rayonFixe' ? 'Rayon fixe' : 'Rayons mobiles'} SP 15/8/5: Face Ø20 · prof. ${Math.round(z)} (dessous)`; } } if (piece.elementType === 'renfortsTiroir' && m.drawerReinforcement?.ixconnectFixing?.holes?.length) { const ix = m.drawerReinforcement.ixconnectFixing; const d = Number(ix.holeDiameterMm) || 8; const r = Math.max(1.5, (d * 0.5) * scaleA); for (const h of ix.holes) { const x = pAx + Math.min(wA, Math.max(0, Number(h.xMm) * scaleA)); const y = pAy + Math.min(hA, Math.max(0, Number(h.yMm) * scaleA)); viewAOps += ``; } viewAOps += `${ix.model || 'SC 8/25'} — 4× face Ø${d} · prof. ${ix.depthInReinforcementMm} mm (montant/séparateur Ø${d} · prof. ${ix.depthInVerticalPanelMm} mm) · ${ix.cornerInsetMm} mm des coins · repère Y depuis haut`; } const isDoor = piece.elementType === 'portes'; const doorCount = isDoor ? Math.max(1, piece.doorPlanHints?.doorCount || 1) : 1; const hingeSidesList = isDoor ? (piece.doorPlanHints?.hingeSides?.length === doorCount ? piece.doorPlanHints.hingeSides : Array.from({ length: doorCount }, (_, i) => getDoorHingeSide(i, doorCount, piece.doorPlanHints?.hardware || {}))) : ['left']; let doorMulti = null; if (isDoor && doorCount > 1) { const gapPx = 10; const totalWmm = widthMm * doorCount; const scaleMulti = Math.min((viewW - 36) / totalWmm, (viewH - 44) / heightMm); const eachW = widthMm * scaleMulti; const hPanel = heightMm * scaleMulti; const totalPx = eachW * doorCount + gapPx * (doorCount - 1); const baseAx = xA + (viewW - totalPx) / 2; const baseAy = yA + 24 + (viewH - 24 - hPanel) / 2; doorMulti = { gapPx, scaleMulti, eachW, hPanel, baseAx, baseAy }; } if (doorMulti) { for (let j = 0; j < doorCount; j++) { const x0 = doorMulti.baseAx + j * (doorMulti.eachW + doorMulti.gapPx); viewAOps += ``; viewAOps += ``; viewAOps += `Vantail ${j + 1} · ch. ${hingeSidesList[j] === 'right' ? 'droite' : 'gauche'}`; viewAOps += doorFaceUsinageSvg({ pAx: x0, pAy: doorMulti.baseAy, wA: doorMulti.eachW, hA: doorMulti.hPanel, scaleA: doorMulti.scaleMulti, m, hingeSide: hingeSidesList[j], heightMm, fmt }); } } else { const hingeOne = hingeSidesList[0] || 'left'; viewAOps += doorFaceUsinageSvg({ pAx, pAy, wA, hA, scaleA, m, hingeSide: hingeOne, heightMm, fmt }); } if (!doorMulti && (isHabFinishSide || isHabFinishTop)) { const k = Math.min(18, Math.max(10, Math.round(Math.min(wA, hA) * 0.08))); if (isHabFinishSide) { viewAMiterMarks += ``; viewAMiterMarks += ``; miterInfoLine = "Coupe d'onglet 45° : chant haut (jonction avec habillage dessus)"; } else if (isHabFinishTop) { // Mark left/right edges with corner chevrons + explicit text (avoid ambiguity). viewAMiterMarks += ``; viewAMiterMarks += ``; viewAMiterMarks += ``; viewAMiterMarks += ``; miterInfoLine = "Coupe d'onglet 45° : chants gauche & droit (jonction avec habillages côtés)"; } viewAOps += `${miterInfoLine}`; } const dimPax = doorMulti ? doorMulti.baseAx : pAx; const dimPay = doorMulti ? doorMulti.baseAy : pAy; const dimWa = doorMulti ? doorMulti.eachW * doorCount + doorMulti.gapPx * (doorCount - 1) : wA; const dimHa = doorMulti ? doorMulti.hPanel : hA; const widthDimTextY = dimPay + dimHa + 28; const patternTextY = widthDimTextY + 16; const widthLabelY = yA + dimHa + 26; const patternLabelY = widthLabelY + 16; const scConn = doorMulti ? doorMulti.scaleMulti : scaleA; if (['dessus', 'fond'].includes(piece.elementType) && (m.carcassFasteners?.dowelStations?.length || m.carcassFasteners?.positionsAlongJointMm?.length)) { const cf = m.carcassFasteners; const edgeL = Math.max(1, Number(cf.edgeLengthMm) || heightMm); const stations = getCarcassDowelStationsForPlan(cf, edgeL); const jointAxes = (cf.junctionAxesAcrossWidthMm || [0, widthMm]).map((xmm) => { const edgeInset = PANEL_THICKNESS * 0.5; const clamped = Math.max(0, Math.min(widthMm, xmm)); if (clamped <= 0) return edgeInset; if (clamped >= widthMm) return Math.max(0, widthMm - edgeInset); return clamped; }); const ixList = m.connectorHoles?.ixconnectJointsAlongDepth || getIxconnectJointsForPlan(m, cf, heightMm); const dTourMm = cf.dowelDiameter || 8; const rTourPx = Math.max(1.2, (dTourMm * 0.5) * scConn); const rFerrPx = Math.max(2.4, 7 * scConn); // perçage ferrure ~Ø14 mm vs tourillon Ø8 const mapJunctionXmm = (xmm) => { const edgeInset = PANEL_THICKNESS * 0.5; const clamped = Math.max(0, Math.min(widthMm, Number(xmm) || 0)); if (clamped <= 0) return edgeInset; if (clamped >= widthMm) return Math.max(0, widthMm - edgeInset); return clamped; }; for (const xAxisMm of jointAxes) { const x = dimPax + Math.min(dimWa, Math.max(0, xAxisMm * scConn)); viewAOps += ``; for (const st of stations) { const along = st.holesAlongMm || []; for (let hi = 0; hi < along.length; hi++) { const alongMm = along[hi]; const y = dimPay + Math.min(dimHa, Math.max(0, dimHa - alongMm * scConn)); const isTourillon = hi !== 1; // [tourillon, ferrure, tourillon] const rr = isTourillon ? rTourPx : rFerrPx; const cls = isTourillon ? 'connectorTourillon' : 'connectorFerrure'; viewAOps += ``; } } } if (ixList?.length) { const rIxPx = Math.max(2.4, 6.8 * scConn); for (const jix of ixList) { const xMm = mapJunctionXmm(jix.axisAcrossWidthMm); const x = dimPax + Math.min(dimWa, Math.max(0, xMm * scConn)); const triple = [ { pos: jix.tourillon1Axis, cls: 'connectorTourillon', r: rTourPx }, { pos: jix.ixconnectAxis, cls: 'connector', r: rIxPx }, { pos: jix.tourillon2Axis, cls: 'connectorTourillon', r: rTourPx } ]; for (const h of triple) { const y = dimPay + Math.min(dimHa, Math.max(0, dimHa - h.pos * scConn)); viewAOps += ``; } } } const yDimX = dimPax + dimWa + 18; const centers = cf.positionsAlongJointMm || []; if (centers.length >= 3) { const y0 = dimPay + dimHa; const y1 = dimPay + dimHa - centers[0] * scConn; const y2 = dimPay + dimHa - centers[1] * scConn; const y3 = dimPay + dimHa - centers[2] * scConn; const y4 = dimPay; const segs = [[y0, y1], [y1, y2], [y2, y3], [y3, y4]]; const vals = [centers[0], centers[1] - centers[0], centers[2] - centers[1], heightMm - centers[2]]; for (let i = 0; i < segs.length; i++) { const [a, b] = segs[i]; viewAOps += ``; viewAOps += ``; viewAOps += ``; viewAOps += `${fmt(vals[i])}`; } } else if (centers.length === 2) { const y0 = dimPay + dimHa; const y1 = dimPay + dimHa - centers[0] * scConn; const y2 = dimPay + dimHa - centers[1] * scConn; const y3 = dimPay; const segs = [[y0, y1], [y1, y2], [y2, y3]]; const vals = [centers[0], centers[1] - centers[0], heightMm - centers[1]]; for (let i = 0; i < segs.length; i++) { const [a, b] = segs[i]; viewAOps += ``; viewAOps += ``; viewAOps += ``; viewAOps += `${fmt(vals[i])}`; } } const depL = cf.ferrureDepthAlongLengthMm ?? 46; const depT = cf.ferrureDepthInThicknessMm ?? 12; const d = cf.dowelDiameter || 8; const dep = tourillonFaceDrillDepthMm(thicknessMm); const off = cf.dowelSideOffset ?? CARCASS_FASTENERS.dowel.sideOffset; const midStation = centers.length >= 3 ? stations[1] : null; if (midStation?.holesAlongMm?.length >= 3) { const ya = dimPay + dimHa - midStation.holesAlongMm[0] * scConn; const yb = dimPay + dimHa - midStation.holesAlongMm[2] * scConn; const spanMm = midStation.holesAlongMm[2] - midStation.holesAlongMm[0]; viewAOps += ``; viewAOps += `2×${off} = ${fmt(spanMm)} mm`; } const nFerr = centers.length; const ferrRule = nFerr >= 3 ? '3 ferrures (50 / milieu / 50)' : nFerr === 2 ? `2 ferrures (50/50, sans milieu si arête ≤ ${CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM} mm)` : `${nFerr} ferrure(s)`; if (ixList?.length) { viewAOps += `${jointAxes.length} jonctions · Ferrures corps: ${ferrRule} · prof ${depL}/${depT} mm · 2 tourillons/ferrure ±${off} mm (centre ferrure) · Ø${d}×${dep}`; viewAOps += `Ixconnect (axes bord avant, autre jeu): extrémités T@20·IX@50·T@80 · internes IX milieu + T ±30 mm`; } else { viewAOps += `${jointAxes.length} jonctions · par jonction: ${ferrRule} + 2 tourillons/ferrure (±${off})`; viewAOps += `Ferrure: prof. ${depL} mm (longueur) / ${depT} mm (épaisseur) · Tourillons: 2× Ø${d}×${dep}`; } } else if (m.connectorHoles?.jointPattern) { const p = m.connectorHoles.jointPattern; const mirrorSep = piece.elementType === 'panneauxInterieurs' && widthMm >= IXCONNECT_SEPARATOR_BACK_ROW_MIN_WIDTH_MM && Number(p.tourillon2Axis) > Number(p.tourillon1Axis); if (piece.elementType === 'panneauxInterieurs') { const ixLeg = mirrorSep ? `Séparateur — Ixconnect + tourillons sur faces w×ép. aux extrémités longueur (pas les grands chants X) · Y=0 / Y=H brut · avant & arrière profondeur · T@${p.tourillon1Axis} · IX@${p.ixconnectAxis} · T@${p.tourillon2Axis} mm — FACE=end END=top|bottom` : `Séparateur — Ixconnect + tourillons sur faces w×ép. aux extrémités longueur · Y=0 / Y=H brut · T@${p.tourillon1Axis} · IX@${p.ixconnectAxis} · T@${p.tourillon2Axis} mm — FACE=end END=top|bottom`; viewAOps += `${ixLeg}`; } else { const rowInsetPx = IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM * scConn; const yTop = dimPay + rowInsetPx; const yBottom = dimPay + dimHa - rowInsetPx; const holes = [ { pos: p.tourillon1Axis, cls: 'connectorTourillon', r: 2.2 }, { pos: p.ixconnectAxis, cls: 'connector', r: 3.4 }, { pos: p.tourillon2Axis, cls: 'connectorTourillon', r: 2.2 } ]; for (const y of [yTop, yBottom]) { for (const hole of holes) { const x = dimPax + Math.min(dimWa, Math.max(0, hole.pos * scConn)); viewAOps += ``; } } viewAOps += `Ixconnect face: T@${p.tourillon1Axis} · IX@${p.ixconnectAxis} · T@${p.tourillon2Axis} mm (haut & bas, depuis bord avant)`; } } if (!isDoor && (m.groove?.axisFromPanelEdge || m.groove?.axisFromBackEdge)) { viewAOps += svgBackGrooveFaceViewA(piece, m, dimPax, dimPay, dimWa, dimHa, scConn, fmt); } // Explicit finished/raw dimensions on drawing const dimY = dimPay + dimHa + 16; const dimX = dimPax - 16; viewAOps += ``; viewAOps += ``; const widthDimLabel = doorMulti ? `L finie ${fmt(widthMm)} mm / vantail · L brut ${fmt(piece.rawCutWidthMm)} mm · ${doorCount} vantaux (orientation ci-dessus)` : `L finie ${fmt(widthMm)} mm · L brut ${fmt(piece.rawCutWidthMm)} mm`; viewAOps += `${widthDimLabel}`; viewAOps += `H finie ${fmt(heightMm)} mm · H brut ${fmt(piece.rawCutHeightMm)} mm`; // View B: edge view for drilling depths const isReinforcementPiece = piece.elementType === 'renforts'; const scaleB = Math.min((viewW - 60) / heightMm, (viewH - 40) / thicknessMm); const hB = heightMm * scaleB; const tB = Math.max(8, thicknessMm * scaleB); const wBh = isReinforcementPiece ? hB : tB; // long side horizontal for reinforcements const hBh = isReinforcementPiece ? tB : hB; const pBx = xB + (viewW - wBh) / 2; const pBy = yB + 24 + (viewH - 24 - hBh) / 2; let viewBOps = ''; if (m.system32 && !isReinforcementPiece) { const s32d = m.system32.holeDepth || thicknessMm; const holeLen = Math.min(tB * 0.9, s32d * scaleB); viewBOps += ``; viewBOps += ``; const tx = Math.min(xB + viewW - 110, pBx + holeLen + 6); viewBOps += `Ø${m.system32.holeDiameter} · ${m.system32.throughHole ? 'TRAV.' : `prof ${fmt(s32d)}mm`}`; } if (m.hingeCups && !isReinforcementPiece) { const cupDepth = m.hingeCups.cupDepth || 12; const cupLen = Math.min(tB * 0.95, cupDepth * scaleB); viewBOps += ``; const tx = Math.min(xB + viewW - 125, pBx + cupLen + 6); viewBOps += `Cuvette Ø${m.hingeCups.diameter} · prof ${fmt(cupDepth)}mm`; } if (isReinforcementPiece && m.reinforcementFixing) { viewBOps += svgReinforcementFixingEdgeViewB(m.reinforcementFixing, pBx, pBy, tB, wBh, scaleB, fmt, true); } if (isReinforcementPiece) { viewBOps += ``; viewBOps += `Hauteur face chant ${fmt(heightMm)} mm`; viewBOps += ``; viewBOps += `Épaisseur ${fmt(thicknessMm)} mm`; } else { viewBOps += ``; viewBOps += `Épaisseur ${fmt(thicknessMm)} mm`; viewBOps += ``; viewBOps += `Hauteur ${fmt(heightMm)} mm`; } // View C: groove cross section + connector depths const scaleC = Math.min((viewW - 60) / widthMm, (viewH - 40) / thicknessMm); const wC = widthMm * scaleC; const tC = Math.max(10, thicknessMm * scaleC); const pCx = xC + (viewW - wC) / 2; const pCy = yC + 60; let viewCOps = ''; if (m.groove?.axisFromPanelEdge || m.groove?.axisFromBackEdge) { viewCOps += svgBackGrooveSectionViewC(pCx, pCy, wC, tC, scaleC, piece, m, fmt); } if (['dessus', 'fond'].includes(piece.elementType) && m.carcassFasteners) { const cf = m.carcassFasteners; const depL = cf.ferrureDepthAlongLengthMm ?? 46; const depT = cf.ferrureDepthInThicknessMm ?? 12; const dTourMm = cf.dowelDiameter || 8; const depthTourMm = tourillonFaceDrillDepthMm(thicknessMm); viewCOps += svgCarcassTourillonSlotsSectionView(pCx, pCy, scaleC, tC, widthMm, thicknessMm, cf); viewCOps += `Tourillons (vue C) : 1 logement/jonction, traitillé depuis la face haut · Ø${fmt(dTourMm)} mm, prof. ${fmt(depthTourMm)} mm · Ferrure ${fmt(depL)} / ${fmt(depT)} mm`; } if (m.connectorHoles?.edgeHole) { const depth = m.connectorHoles.edgeHole.depth || 45; const diam = m.connectorHoles.edgeHole.diameter || 8; const len = Math.min(wC * 0.35, depth * scaleC); viewCOps += ``; viewCOps += `Ixconnect about Ø${fmt(diam)} · prof ${fmt(depth)}mm`; if (m.connectorHoles?.faceHole) { viewCOps += `Face Ø${fmt(m.connectorHoles.faceHole.diameter || 8)} · prof ${fmt(m.connectorHoles.faceHole.depth || 12)}mm`; } } viewCOps += ``; viewCOps += `Largeur ${fmt(widthMm)} mm`; viewCOps += ``; viewCOps += `Épaisseur ${fmt(thicknessMm)} mm`; const opsLines = []; opsLines.push(`Pièce finie: ${piece.widthMm} x ${piece.heightMm} x ${piece.thicknessMm} mm · Qté ${piece.quantity}`); opsLines.push(`Brut CNC: ${piece.rawCutWidthMm} x ${piece.rawCutHeightMm} mm (déduction chants incluse)`); opsLines.push(`Chants: ${piece.edgeBanding?.sides || 0} côté(s), ép. ${piece.edgeBanding?.thickness || 0}mm`); if (miterInfoLine) opsLines.push(miterInfoLine); if (m.system32) { opsLines.push(`System 32: Ø${m.system32.holeDiameter} · pas ${m.system32.pitch || 32} · ${m.system32.throughHole ? 'traversant' : `prof ${m.system32.holeDepth || 12}mm`}`); } if (m.hingeCups) { const pos = (m.hingeCups.positionsFromTop || []).slice(0, 6).map(v => `${Math.round(v)}mm`).join(', '); const sd = m.hingeCups.screwDiameterMm ?? HINGE.cupScrewDiameter; const sz = m.hingeCups.screwDepthMm ?? HINGE.cupScrewDepth; const ssp = m.hingeCups.screwSpacingMm ?? HINGE.cupScrewSpacing; const sof = m.hingeCups.screwOffsetFromCupCenterMm ?? HINGE.cupScrewOffsetFromCupCenter; opsLines.push( `Charnières: cuvette Ø${m.hingeCups.diameter} prof ${m.hingeCups.cupDepth} · axes: ${pos}${(m.hingeCups.positionsFromTop || []).length > 6 ? ', ...' : ''} · vis Ø${sd}×${sz} (${ssp} c/c, ${sof} du centre)` ); } if (isDoor && doorCount > 1) { const sideLabel = (s) => (s === 'right' ? 'droite' : 'gauche'); opsLines.push( `Orientation charnières (vue extérieure) : ${hingeSidesList.map((s, i) => `V${i + 1} ${sideLabel(s)}`).join(' · ')}` ); } if (m.fullHeightHandleGroove) { const off = m.fullHeightHandleGroove.topBottomOffsetMm ?? HANDLE.fullHeightTopBottomOffset; const gw = m.fullHeightHandleGroove.grooveWidthMm ?? HANDLE.fullHeightGroove.width; const gd = m.fullHeightHandleGroove.grooveDepthMm ?? HANDLE.fullHeightGroove.depth; opsLines.push(`Poignée toute hauteur: rainure ${gw}×${gd} mm, côté opposé charnières, retrait mécanique ${off} mm haut et bas`); } if (m.carcassFasteners?.positionsAlongJointMm?.length && ['dessus', 'fond'].includes(piece.elementType)) { const cf = m.carcassFasteners; const ax = (cf.positionsAlongJointMm || []).map((v) => `${Math.round(v)}`).join(', '); const off = cf.dowelSideOffset ?? CARCASS_FASTENERS.dowel.sideOffset; const depL = cf.ferrureDepthAlongLengthMm ?? 46; const depT = cf.ferrureDepthInThicknessMm ?? 12; const nAx = (cf.positionsAlongJointMm || []).length; const pat = nAx >= 3 ? '50/milieu/50' : nAx === 2 ? `50/50 (sans milieu si arête ≤ ${CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM} mm)` : 'répartition selon cotes'; const ixJ = m.connectorHoles?.ixconnectJointsAlongDepth; const ixSuffix = ixJ?.length && ['dessus', 'fond'].includes(piece.elementType) ? ` · Ixconnect (2e jeu, axes bord avant): extrémités T@20·IX@50·T@80 · internes IX milieu + T ±30 mm (${ixJ.length} ligne(s))` : ''; opsLines.push( `Ferrures corps (${CARCASS_FASTENERS.model}): ${cf.jointCount || 1} jonction(s) côtés/séparateurs↔dessus/fond · par jonction centres ${ax} mm (${pat}) · perçage ferrure ${depL} / ${depT} mm · +2 tourillons Ø${cf.dowelDiameter || 8}×${tourillonFaceDrillDepthMm(piece.thicknessMm)} (face) à ±${off} mm du centre ferrure · ${CARCASS_FASTENERS.productUrl}${ixSuffix}` ); } if (m.connectorHoles) { opsLines.push(`Connecteurs: about Ø${m.connectorHoles.edgeHole?.diameter || 8} prof ${m.connectorHoles.edgeHole?.depth || 45} · face Ø${m.connectorHoles.faceHole?.diameter || 8} prof ${m.connectorHoles.faceHole?.depth || 12}`); const ixJ = m.connectorHoles.ixconnectJointsAlongDepth; const mergedIx = m.carcassFasteners?.positionsAlongJointMm?.length && ['dessus', 'fond'].includes(piece.elementType); if (ixJ?.length && ['dessus', 'fond'].includes(piece.elementType) && !mergedIx) { opsLines.push( `Ixconnect axes (bord avant profondeur): extrémités T@20·IX@50·T@80 · jonctions internes IX au milieu + T ±30 mm — ${ixJ.length} ligne(s) de perçage` ); } } if (m.groove) { opsLines.push(`Rainure: ${m.groove.width || 9} x ${m.groove.depth || 10.5}mm · position ${m.groove.axisFromPanelEdge || 16}mm du bord`); } if (m.mobileShelfSystem?.model === 'sp_15_8_5') { opsLines.push('Rayons mobiles SP 15/8/5: face dessous Ø20×15 (axe 9.5) · chant Ø8×37 (à 6mm du dessous) · avant 32 / arrière 37'); } else if (m.mobileShelfSystem?.model === 'sp_15_8_5_face_only') { opsLines.push('Rayon fixe: face dessous Ø20×14.5 (axe 9.5) · avant 32 / arrière 37'); } if (m.finishConnectors) { const fc = m.finishConnectors; opsLines.push(`${fc.model || 'SC 8/25'}: face Ø${fc.faceHole?.diameter || 8} prof. ${fc.faceHole?.depth || 12.5} mm · axes ${fc.axes?.back ?? 30}/${fc.axes?.front ?? 43} mm (arrière/avant)`); } if (m.reinforcementFixing) { const rf = m.reinforcementFixing; const tour = (rf.tourillonAxesFromTopMm || []).join(' / '); const sides = rf.sideCount || 2; opsLines.push( `Renfort: ${sides} chants extrémité · par chant 1× ${rf.connectorModel || 'SC 8/60'} axe ${rf.ixconnectAxisFromTopMm ?? '—'} mm + ${rf.tourillonsPerSide || 2} tourillons Ø${rf.tourillonDiameter || 8} aux axes ${tour} mm · about Ø${rf.edgeHole?.diameter || 8}×${rf.edgeHole?.depth || 45} · face Ø${rf.faceHole?.diameter || 8}×${rf.faceHole?.depth || 12}` ); } if (m.drawerReinforcement?.ixconnectFixing) { const ix = m.drawerReinforcement.ixconnectFixing; opsLines.push( `Renfort tiroir: ${ix.model || 'SC 8/25'} — 4× Ø${ix.holeDiameterMm || 8} prof. ${ix.depthInReinforcementMm} mm face renfort · prof. ${ix.depthInVerticalPanelMm} mm face montant/séparateur · ${ix.cornerInsetMm} mm des coins (repère Y depuis haut)` ); } const isDoorPiece = piece.elementType === 'portes'; const doorBandCount = isDoorPiece ? Math.max(1, piece.doorPlanHints?.doorCount || 1) : 1; const doorBandSides = isDoorPiece ? (piece.doorPlanHints?.hingeSides?.length === doorBandCount ? piece.doorPlanHints.hingeSides : hingeSidesList) : []; const blockH = isDoorPiece && doorBandCount > 1 ? Math.min(188, Math.max(148, Math.floor(520 / doorBandCount))) : 200; const doorRowH = isDoorPiece ? (doorBandCount > 1 ? doorBandCount * (blockH + 10) + 12 : 200) : 0; const doorRowY = yA + viewH + 10; const infoY = doorRowY + doorRowH + (doorRowH ? 14 : 10); const svgH = infoY + (infoBlockH - 12) + margin; // Contenu du bandeau en coordonnées locales (0,0) — sinon les se superposent au titre / vues A–C const doorBandW = svgW - margin * 2; const doorDetailInner = isDoorPiece ? (doorBandCount > 1 ? ` ${Array.from({ length: doorBandCount }, (_, i) => { const yOff = i * (blockH + 10); const side = doorBandSides[i] || 'left'; const label = `vantail ${i + 1} (charnières ${side === 'right' ? 'droite' : 'gauche'})`; return ` ${doorMachiningDetailMarkup(piece, fmt, doorBandW - 16, blockH - 4, side, label)} `; }).join('')} ` : ` ${doorMachiningDetailMarkup(piece, fmt, doorBandW, doorRowH)} `) : ''; return ` ${piece.description} Plan pièce multi-vues · fabrication CNC Vue A - Face usinage ${doorMulti ? '' : ` `} ${viewAMiterMarks} ${viewAOps} Vue B - Chant / profondeurs ${viewBOps} ${piece.elementType === 'portes' ? 'Vue C — Coupe largeur × épaisseur (rainure TH → voir bandeau / Vue D)' : 'Vue C — Coupe L×ép. · rainure côté dos (face caisse)'} ${viewCOps} ${doorDetailInner} ${opsLines.map((line, idx) => `${line}`).join('')} `; } function generatePiecePlanViews(piece) { const widthMm = Math.max(1, Number(piece.widthMm) || 1); const heightMm = Math.max(1, Number(piece.heightMm) || 1); const thicknessMm = Math.max(1, Number(piece.thicknessMm) || 1); const m = piece.machining || {}; const fmt = (v) => Number(v).toFixed(Number(v) % 1 === 0 ? 0 : 1); function wrapSvg(title, subtitle, content, viewBox = '0 0 760 460', svgHeight = 460) { return ` ${title} ${subtitle} ${content} `; } // Vue A const isDoorPv = piece.elementType === 'portes'; const doorCountPv = isDoorPv ? Math.max(1, piece.doorPlanHints?.doorCount || 1) : 1; const hingeSidesPv = isDoorPv ? (piece.doorPlanHints?.hingeSides?.length === doorCountPv ? piece.doorPlanHints.hingeSides : Array.from({ length: doorCountPv }, (_, i) => getDoorHingeSide(i, doorCountPv, piece.doorPlanHints?.hardware || {}))) : ['left']; let doorMultiPv = null; if (isDoorPv && doorCountPv > 1) { const gapPx = 10; const totalWmm = widthMm * doorCountPv; const scaleMulti = Math.min(600 / totalWmm, 320 / heightMm); const eachW = widthMm * scaleMulti; const hPanel = heightMm * scaleMulti; const totalPx = eachW * doorCountPv + gapPx * (doorCountPv - 1); const baseAx = 70 + (620 - totalPx) / 2; const baseAy = 70 + (340 - hPanel) / 2; doorMultiPv = { gapPx, scaleMulti, eachW, hPanel, baseAx, baseAy }; } const scaleA = Math.min(620 / widthMm, 340 / heightMm); const wA = widthMm * scaleA; const hA = heightMm * scaleA; const xA = 70 + (620 - wA) / 2; const yA = 70 + (340 - hA) / 2; let aOps = ''; if (doorMultiPv) { for (let j = 0; j < doorCountPv; j++) { const x0 = doorMultiPv.baseAx + j * (doorMultiPv.eachW + doorMultiPv.gapPx); aOps += ``; aOps += ``; aOps += `V${j + 1} · ch. ${hingeSidesPv[j] === 'right' ? 'dr.' : 'g.'}`; aOps += doorFaceUsinageSvg({ pAx: x0, pAy: doorMultiPv.baseAy, wA: doorMultiPv.eachW, hA: doorMultiPv.hPanel, scaleA: doorMultiPv.scaleMulti, m, hingeSide: hingeSidesPv[j], heightMm, fmt }); } } else { aOps = ``; if (m.system32?.positions?.length) { const frontX = xA + Math.min(37 * scaleA, wA * 0.2); const backX = xA + wA - Math.min(37 * scaleA, wA * 0.2); const r = Math.max(1.5, (m.system32.holeDiameter || 5) * scaleA * 0.5); for (const pos of m.system32.positions) { const y = yA + Math.min(hA, Math.max(0, pos * scaleA)); aOps += ``; } } aOps += doorFaceUsinageSvg({ pAx: xA, pAy: yA, wA, hA, scaleA, m, hingeSide: hingeSidesPv[0] || 'left', heightMm, fmt }); } // Rayons (SP 15/8/5): perçages face dessous Ø20×(15 ou 20) (vue A) if ( ['etageres', 'rayonFixe'].includes(piece.elementType) && ['sp_15_8_5', 'sp_15_8_5_face_only'].includes(m.mobileShelfSystem?.model) && !doorMultiPv ) { const specs = buildShelfMobileHoleSpecsMm(piece, widthMm, heightMm); const face = specs.filter((h) => h.face === 'face'); if (face.length) { const rFace = Math.max(2.2, (20 * 0.5) * scaleA); for (const h of face) { const x = xA + Math.min(wA, Math.max(0, Number(h.xMm) * scaleA)); const y = yA + Math.min(hA, Math.max(0, Number(h.yMm) * scaleA)); aOps += ``; } } } const dimAx = doorMultiPv ? doorMultiPv.baseAx : xA; const dimAy = doorMultiPv ? doorMultiPv.baseAy : yA; const dimAw = doorMultiPv ? doorMultiPv.eachW * doorCountPv + doorMultiPv.gapPx * (doorCountPv - 1) : wA; const dimAh = doorMultiPv ? doorMultiPv.hPanel : hA; const widthLabelY = dimAy + dimAh + 26; const scaleConnPv = doorMultiPv ? doorMultiPv.scaleMulti : scaleA; if (['dessus', 'fond'].includes(piece.elementType) && (m.carcassFasteners?.dowelStations?.length || m.carcassFasteners?.positionsAlongJointMm?.length)) { const cf = m.carcassFasteners; const edgeL = Math.max(1, Number(cf.edgeLengthMm) || heightMm); const stations = getCarcassDowelStationsForPlan(cf, edgeL); const jointAxes = (cf.junctionAxesAcrossWidthMm || [0, widthMm]).map((xmm) => { const edgeInset = PANEL_THICKNESS * 0.5; const clamped = Math.max(0, Math.min(widthMm, xmm)); if (clamped <= 0) return edgeInset; if (clamped >= widthMm) return Math.max(0, widthMm - edgeInset); return clamped; }); const ixListPv = m.connectorHoles?.ixconnectJointsAlongDepth || getIxconnectJointsForPlan(m, cf, heightMm); const dTourMmPv = cf.dowelDiameter || 8; const rTourPxPv = Math.max(1.3, (dTourMmPv * 0.5) * scaleConnPv); const rFerrPxPv = Math.max(2.5, 7 * scaleConnPv); const mapJunctionXmmPv = (xmm) => { const edgeInset = PANEL_THICKNESS * 0.5; const clamped = Math.max(0, Math.min(widthMm, Number(xmm) || 0)); if (clamped <= 0) return edgeInset; if (clamped >= widthMm) return Math.max(0, widthMm - edgeInset); return clamped; }; for (const xAxisMm of jointAxes) { const x = dimAx + Math.min(dimAw, Math.max(0, xAxisMm * scaleConnPv)); aOps += ``; for (const st of stations) { const along = st.holesAlongMm || []; for (let hi = 0; hi < along.length; hi++) { const alongMm = along[hi]; const y = dimAy + Math.min(dimAh, Math.max(0, dimAh - alongMm * scaleConnPv)); const isTourillon = hi !== 1; const rr = isTourillon ? rTourPxPv : rFerrPxPv; const cls = isTourillon ? 'connectorTourillon' : 'connectorFerrure'; aOps += ``; } } } if (ixListPv?.length) { const rIxPxPv = Math.max(2.5, 7 * scaleConnPv); for (const jix of ixListPv) { const xMm = mapJunctionXmmPv(jix.axisAcrossWidthMm); const x = dimAx + Math.min(dimAw, Math.max(0, xMm * scaleConnPv)); const triple = [ { pos: jix.tourillon1Axis, cls: 'connectorTourillon', r: rTourPxPv }, { pos: jix.ixconnectAxis, cls: 'connector', r: rIxPxPv }, { pos: jix.tourillon2Axis, cls: 'connectorTourillon', r: rTourPxPv } ]; for (const h of triple) { const y = dimAy + Math.min(dimAh, Math.max(0, dimAh - h.pos * scaleConnPv)); aOps += ``; } } } const centers = cf.positionsAlongJointMm || []; const yChain = dimAy + dimAh + 26; if (centers.length >= 3) { const ys = [dimAy + dimAh, ...centers.map((c) => dimAy + dimAh - c * scaleConnPv), dimAy]; const segs = [centers[0], centers[1] - centers[0], centers[2] - centers[1], heightMm - centers[2]]; const xDim = dimAx + dimAw + 20; for (let i = 0; i < segs.length; i++) { const a = ys[i]; const b = ys[i + 1]; aOps += ``; aOps += `${fmt(segs[i])}`; } } else if (centers.length === 2) { const ys = [dimAy + dimAh, dimAy + dimAh - centers[0] * scaleConnPv, dimAy + dimAh - centers[1] * scaleConnPv, dimAy]; const segs = [centers[0], centers[1] - centers[0], heightMm - centers[1]]; const xDim = dimAx + dimAw + 20; for (let i = 0; i < segs.length; i++) { const a = ys[i]; const b = ys[i + 1]; aOps += ``; aOps += `${fmt(segs[i])}`; } } const off = cf.dowelSideOffset ?? CARCASS_FASTENERS.dowel.sideOffset; const depL = cf.ferrureDepthAlongLengthMm ?? 46; const depT = cf.ferrureDepthInThicknessMm ?? 12; const midStation = centers.length >= 3 ? stations[1] : null; /** Légende sous le panneau : une ligne après l’autre (évite chevauchement L / tourillons / ferrures). */ let legendY = yChain + 18; if (midStation?.holesAlongMm?.length >= 3) { const spanMm = midStation.holesAlongMm[2] - midStation.holesAlongMm[0]; aOps += `Tourillons ferrure milieu: 2×${off} = ${fmt(spanMm)} mm`; legendY += 16; } const nFerrPv = centers.length; const ferrRulePv = nFerrPv >= 3 ? '3 ferrures (50/milieu/50)' : nFerrPv === 2 ? `2 ferrures (50/50, sans milieu ≤${CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM} mm)` : `${nFerrPv} ferrure(s)`; const dPv = cf.dowelDiameter || 8; const depPv = tourillonFaceDrillDepthMm(thicknessMm); if (ixListPv?.length) { aOps += `${jointAxes.length} jonctions · Ferrures corps: ${ferrRulePv} · prof ${depL}/${depT} mm · 2 tourillons/ferrure ±${off} mm (centre ferrure) · Ø${dPv}×${depPv}`; legendY += 16; aOps += `Ixconnect (axes bord avant, autre jeu): extrémités T@20·IX@50·T@80 · internes IX milieu + T ±30 mm`; } else { aOps += `${jointAxes.length} jonctions · ${ferrRulePv} · 2 tourillons/ferrure (±${off}) · prof ferrure ${depL}/${depT}`; } } else if (m.connectorHoles?.jointPattern) { const p = m.connectorHoles.jointPattern; const mirrorSepPv = piece.elementType === 'panneauxInterieurs' && widthMm >= IXCONNECT_SEPARATOR_BACK_ROW_MIN_WIDTH_MM && Number(p.tourillon2Axis) > Number(p.tourillon1Axis); if (piece.elementType === 'panneauxInterieurs') { const ixLegPv = mirrorSepPv ? `Séparateur — Ixconnect + tourillons extrémités longueur (w×ép.) · Y=0 / Y=H · avant & arrière prof. — END=top|bottom` : `Séparateur — Ixconnect + tourillons extrémités longueur (w×ép.) · Y=0 / Y=H — END=top|bottom`; aOps += `${ixLegPv}`; } else { const rowInsetPx = IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM * scaleConnPv; const yTop = dimAy + rowInsetPx; const yBottom = dimAy + dimAh - rowInsetPx; const triple = [ { pos: p.tourillon1Axis, cls: 'connectorTourillon', r: 2.4 }, { pos: p.ixconnectAxis, cls: 'connector', r: 3.6 }, { pos: p.tourillon2Axis, cls: 'connectorTourillon', r: 2.4 } ]; for (const y of [yTop, yBottom]) { for (const hole of triple) { const x = dimAx + Math.min(dimAw, Math.max(0, hole.pos * scaleConnPv)); aOps += ``; } } aOps += `Ixconnect: T@${p.tourillon1Axis} · IX@${p.ixconnectAxis} · T@${p.tourillon2Axis} mm (haut & bas)`; } } if (!isDoorPv && (m.groove?.axisFromPanelEdge || m.groove?.axisFromBackEdge)) { aOps += svgBackGrooveFaceViewA(piece, m, dimAx, dimAy, dimAw, dimAh, scaleConnPv, fmt); } const widthLine = doorMultiPv ? `L ${fmt(widthMm)} mm/vant · ${doorCountPv} vantaux · Brut ${fmt(piece.rawCutWidthMm)} mm` : `L ${fmt(widthMm)} mm · Brut ${fmt(piece.rawCutWidthMm)} mm`; aOps += ``; aOps += `${widthLine}`; aOps += ``; aOps += `H ${fmt(heightMm)} mm · Brut ${fmt(piece.rawCutHeightMm)} mm`; const viewA = wrapSvg( `${piece.description} - Vue A`, `Face usinage · ${piece.widthMm} x ${piece.heightMm} mm`, aOps ); // Vue B const scaleB = Math.min(620 / heightMm, 300 / thicknessMm); const hB = heightMm * scaleB; const tB = Math.max(10, thicknessMm * scaleB); const xB = 140; const isReinforcementPv = piece.elementType === 'renforts'; const wBhPv = isReinforcementPv ? hB : tB; const hBhPv = isReinforcementPv ? tB : hB; const yB = 90 + (300 - hBhPv) / 2; let bOps = ``; if (m.system32 && !isReinforcementPv) { const s32d = m.system32.holeDepth || thicknessMm; const len = Math.min(tB * 0.95, s32d * scaleB); bOps += `System32 Ø${m.system32.holeDiameter} · ${m.system32.throughHole ? 'TRAV.' : `prof ${fmt(s32d)}mm`}`; } if (m.hingeCups && !isReinforcementPv) { const d = m.hingeCups.cupDepth || 12; const len = Math.min(tB * 0.95, d * scaleB); bOps += `Cuvette Ø${m.hingeCups.diameter} · prof ${fmt(d)}mm`; } if (isReinforcementPv && m.reinforcementFixing) { bOps += svgReinforcementFixingEdgeViewB(m.reinforcementFixing, xB, yB, tB, wBhPv, scaleB, fmt, true); } if (isReinforcementPv) { bOps += ``; bOps += `Hauteur face chant ${fmt(heightMm)} mm`; bOps += ``; bOps += `Épaisseur ${fmt(thicknessMm)} mm`; } else { bOps += ``; bOps += `Épaisseur ${fmt(thicknessMm)} mm`; bOps += ``; bOps += `Hauteur ${fmt(heightMm)} mm`; } const viewB = wrapSvg( `${piece.description} - Vue B`, `Vue de chant · épaisseur ${piece.thicknessMm} mm`, bOps ); // Vue C const scaleC = Math.min(620 / widthMm, 230 / thicknessMm); const wC = widthMm * scaleC; const tC = Math.max(12, thicknessMm * scaleC); const xC = 70 + (620 - wC) / 2; const yC = 150; let cOps = ``; if (m.groove?.axisFromPanelEdge || m.groove?.axisFromBackEdge) { cOps += svgBackGrooveSectionViewC(xC, yC, wC, tC, scaleC, piece, m, fmt); } if (['dessus', 'fond'].includes(piece.elementType) && m.carcassFasteners) { const cf = m.carcassFasteners; const depL = cf.ferrureDepthAlongLengthMm ?? 46; const depT = cf.ferrureDepthInThicknessMm ?? 12; const dTourMm = cf.dowelDiameter || 8; const depthTourMm = tourillonFaceDrillDepthMm(thicknessMm); cOps += svgCarcassTourillonSlotsSectionView(xC, yC, scaleC, tC, widthMm, thicknessMm, cf); cOps += `Tourillons : 1/jonction (traitillé, face haut) · Ø${fmt(dTourMm)} mm, prof. ${fmt(depthTourMm)} mm · Ferrure ${fmt(depL)} / ${fmt(depT)} mm`; } if (m.connectorHoles?.edgeHole) { cOps += `Ixconnect about Ø${fmt(m.connectorHoles.edgeHole.diameter || 8)} · prof ${fmt(m.connectorHoles.edgeHole.depth || 45)} mm`; if (m.connectorHoles?.faceHole) { cOps += `Ixconnect face Ø${fmt(m.connectorHoles.faceHole.diameter || 8)} · prof ${fmt(m.connectorHoles.faceHole.depth || 12)} mm`; } } cOps += ``; cOps += `Largeur ${fmt(widthMm)} mm`; cOps += ``; cOps += `Épaisseur ${fmt(thicknessMm)} mm`; const viewC = wrapSvg( `${piece.description} - Vue C`, 'Coupe L×ép. · rainure côté dos (ouverture face caisse)', cOps ); const views = [ { id: 'view-a', label: 'Vue A', title: 'Face usinage', svg: viewA }, { id: 'view-b', label: 'Vue B', title: 'Chant / profondeurs', svg: viewB }, { id: 'view-c', label: 'Vue C', title: 'Coupe rainure / connecteurs', svg: viewC } ]; if ( piece.elementType !== 'portes' && m.groove && (BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(piece.elementType) || BACK_GROOVE_PLAN_HORIZONTAL_TYPES.includes(piece.elementType)) ) { views.push({ id: 'view-d', label: 'Vue D', title: 'Détail rainure dos', svg: wrapSvg( `${piece.description} — Vue D`, 'Coupe et cotation (réf. type plan technique)', svgBackGrooveTechnicalDetail(piece, m, fmt), '0 0 760 440', 440 ) }); } if (piece.elementType === 'portes') { const dc = Math.max(1, piece.doorPlanHints?.doorCount || 1); const sidesD = piece.doorPlanHints?.hingeSides?.length === dc ? piece.doorPlanHints.hingeSides : Array.from({ length: dc }, (_, i) => getDoorHingeSide(i, dc, piece.doorPlanHints?.hardware || {})); let viewDContent; let vbH = 400; let outH = 400; if (dc > 1) { const blockH = Math.min(260, Math.max(200, Math.floor(880 / dc))); vbH = 20 + dc * (blockH + 14); outH = Math.min(1200, vbH); viewDContent = sidesD.map((side, i) => { const label = `vantail ${i + 1} (charnières ${side === 'right' ? 'droite' : 'gauche'})`; return `${doorMachiningDetailMarkup(piece, fmt, 720, blockH - 10, side, label)}`; }).join(''); } else { viewDContent = `${doorMachiningDetailMarkup(piece, fmt, 720, 300)}`; } const viewD = wrapSvg( `${piece.description} - Vue D`, 'Détail rainure (cotes) · schéma hauteur · perçages', viewDContent, dc > 1 ? `0 0 760 ${vbH}` : '0 0 760 400', dc > 1 ? outH : 400 ); views.push({ id: 'view-d', label: 'Vue D', title: 'Rainure & perçages', svg: viewD }); } return views; } function coteFaceHasRenfortHoles(faceBlock) { return Array.isArray(faceBlock?.holes) && faceBlock.holes.length > 0; } function separatorBlockHasRenfort(block) { return Array.isArray(block?.holes) && block.holes.length > 0; } function separatorHasRenfort(sep) { if (!sep) return false; return separatorBlockHasRenfort(sep.faceTowardLowerModule) || separatorBlockHasRenfort(sep.faceTowardHigherModule); } function scopeMachiningCotesDrawerRenfort(machining, includeLeft, includeRight) { const ix = machining?.drawerRenfortPanelIxconnect; if (!ix?.cotes) return machining; const { leftInnerFace, rightInnerFace } = ix.cotes; const cotes = {}; if (includeLeft && coteFaceHasRenfortHoles(leftInnerFace)) cotes.leftInnerFace = leftInnerFace; if (includeRight && coteFaceHasRenfortHoles(rightInnerFace)) cotes.rightInnerFace = rightInnerFace; const next = { ...machining }; if (Object.keys(cotes).length === 0) { delete next.drawerRenfortPanelIxconnect; } else { next.drawerRenfortPanelIxconnect = { ...ix, cotes }; } return next; } function scopeMachiningSeparatorsDrawerRenfort(machining, separatorIndex) { const ix = machining?.drawerRenfortPanelIxconnect; if (!ix?.separators?.length) return machining; const sep = ix.separators[separatorIndex]; if (!separatorHasRenfort(sep)) { const next = { ...machining }; delete next.drawerRenfortPanelIxconnect; return next; } return { ...machining, drawerRenfortPanelIxconnect: { ...ix, separators: [sep] } }; } function stripDrawerRenfortPanelIxconnect(machining) { if (!machining?.drawerRenfortPanelIxconnect) return machining; const { drawerRenfortPanelIxconnect, ...rest } = machining; return Object.keys(rest).length ? rest : null; } /** * Generate complete cutting list for a wardrobe configuration * @param {Object} config - Wardrobe configuration * @param {Object} colors - Color/finish assignments per element * @param {Array} modules - Module configurations (optional) * @returns {Object} Cutting list with pieces grouped by finish */ export function generateCuttingList(config, colors = {}, modules = []) { const pieces = []; const sideDefaultsEnabled = config?.sideFinishesEnabled !== false; const sideFinishLeftEnabled = config?.sideFinishLeftEnabled ?? sideDefaultsEnabled; const sideFinishRightEnabled = config?.sideFinishRightEnabled ?? sideDefaultsEnabled; const topFinishEnabled = config?.topFinishEnabled !== false; // All element types to process (includes renforts per APEX 4.0) const elementTypes = [ 'plintheBas', 'bandeauHaut', 'dessus', 'fond', 'cotes', 'dos', 'renforts', 'portes', 'panneauxInterieurs', 'rayonFixe', 'renfortsTiroir', 'etageres' ]; if (config.installationType === 'niche') { elementTypes.splice(5, 0, 'cotesDoublage', 'fileur'); } else if (config.installationType === 'free' && config?.exteriorFinishesEnabled !== false) { if (sideFinishLeftEnabled) elementTypes.splice(5, 0, 'habillageFinitionGauche'); if (sideFinishRightEnabled) elementTypes.splice(5, 0, 'habillageFinitionDroite'); if (topFinishEnabled) elementTypes.splice(5, 0, 'habillageFinitionDessus'); } for (const elementType of elementTypes) { // Special-case hinged doors: door height can vary per module when drawers exist. // If dimensions differ, emit multiple "portes" pieces so cutting list + plans remain accurate. if (elementType === 'portes' && config?.doorType !== 'sliding') { const doorCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2); const hw = config.hardware || {}; const fullHeightHandle = hw.handleType === 'full_height'; const widthDeduction = fullHeightHandle ? HANDLE.fullHeightWidthDeductionMm : 0; const bodyHeightMm = (config.heightMm || 2400) - (config.plinthHeightMm || 100); const baseDoorHeightMm = Math.max(80, Math.round(bodyHeightMm - 4)); const drawerHeightsMm = Array.isArray(hw.drawerHeightsMm) ? hw.drawerHeightsMm : [90, 128, 177, 240]; const drawerGapMm = 8; const overrideSides = Array.isArray(hw.doorHingeSides) && hw.doorHingeSides.length === doorCount ? hw.doorHingeSides : null; const doorWidthForIndex = (i) => { const modW = modules?.[i]?.widthMm; const rawW = Number.isFinite(Number(modW)) && Number(modW) > 0 ? Number(modW) : (config.widthMm || 2400) / doorCount; return Math.max(100, Math.floor(rawW) - 4 - widthDeduction); }; const drawerStackHeightForIndex = (i) => { const n = Math.max(0, Number(modules?.[i]?.drawers) || 0); if (n <= 0) return 0; const sum = drawerHeightsMm .slice(0, Math.min(n, drawerHeightsMm.length)) .reduce((s, v) => s + (Number(v) || 0), 0); return Math.max(0, Math.round(sum + Math.max(0, n - 1) * drawerGapMm)); }; const groups = new Map(); for (let i = 0; i < doorCount; i++) { const w = doorWidthForIndex(i); const drawerDeduct = drawerStackHeightForIndex(i); const h = Math.max(120, Math.round(baseDoorHeightMm - drawerDeduct)); const hingeSide = overrideSides ? (overrideSides[i] === 'right' ? 'right' : 'left') : getDoorHingeSide(i, doorCount, hw); const key = `${w}x${h}`; const entry = groups.get(key) || { width: w, height: h, hingeSides: [] }; entry.hingeSides.push(hingeSide === 'right' ? 'right' : 'left'); groups.set(key, entry); } const finish = colors[elementType] || { finishId: null, label: 'Non défini' }; const edgeBanding = getEdgeBanding(elementType); let gi = 0; for (const entry of groups.values()) { const dimensions = { width: entry.width, height: entry.height, thickness: PANEL_THICKNESS, quantity: entry.hingeSides.length, description: groups.size === 1 ? `Portes battantes (${doorCount} portes)` : `Portes battantes (groupe ${++gi}/${groups.size})` }; const machining = getMachining(elementType, dimensions, config, modules); const piece = { id: `piece-portes-${groups.size === 1 ? 'all' : gi}`, elementType, description: dimensions.description, finishId: finish.finishId, finishLabel: finish.label, widthMm: Math.round(dimensions.width), heightMm: Math.round(dimensions.height), thicknessMm: Math.round(dimensions.thickness ?? PANEL_THICKNESS), quantity: dimensions.quantity, unit: dimensions.unit || 'pcs', edgeBanding, machining, surfaceM2: calculateSurface(dimensions.width, dimensions.height, dimensions.quantity) }; piece.doorPlanHints = { doorCount: dimensions.quantity, hingeSides: entry.hingeSides, hingeSide: entry.hingeSides[0] || 'left', hardware: hw, fullHeightHandle: hw.handleType === 'full_height' }; const raw = computeRawCutDimensions(piece); piece.rawCutWidthMm = raw.rawCutWidthMm; piece.rawCutHeightMm = raw.rawCutHeightMm; piece.piecePlanSvg = generatePiecePlanSvg(piece); piece.piecePlanViews = generatePiecePlanViews(piece); pieces.push(piece); } continue; } if (elementType === 'rayonFixe') { const mods = effectiveModules(modules, config); const countModules = Math.max(1, mods.length || 0); const depthMm = Number(config.depthMm || 600); const innerDepth = depthMm - PANEL_THICKNESS; const shelfDepthMm = Math.max(40, Math.round(innerDepth - 5)); // APEX 4.0: P = P.int - 5mm const needsMiddle = (Number(config.heightMm) || 0) > 2000; const hasDrawersForIndex = (i) => (Number(mods?.[i]?.drawers) || 0) > 0; const shelfWidthForIndex = (i) => { const modW = mods?.[i]?.widthMm; const rawW = Number.isFinite(Number(modW)) && Number(modW) > 0 ? Number(modW) : (config.widthMm || 2400) / Math.max(1, countModules); // APEX 4.0: L = L.int - 2mm return Math.max(80, Math.round(rawW - (PANEL_THICKNESS * 2) - 2)); }; // Merge all rayonFixe of same dimensions into one cutting-list line. // Per module, qty = (middle structural if needed) + (above drawers if any). const groups = new Map(); // key => { width, qty } for (let i = 0; i < countModules; i++) { const addMiddle = Boolean(needsMiddle); const addAboveDrawers = hasDrawersForIndex(i); const qty = (addMiddle ? 1 : 0) + (addAboveDrawers ? 1 : 0); if (qty <= 0) continue; const w = shelfWidthForIndex(i); const key = `${w}x${shelfDepthMm}`; const entry = groups.get(key) || { width: w, qty: 0 }; entry.qty += qty; groups.set(key, entry); } if (groups.size === 0) continue; const finish = colors[elementType] || colors.etageres || { finishId: null, label: 'Non défini' }; const edgeBanding = getEdgeBanding(elementType); let gi = 0; for (const entry of groups.values()) { gi += 1; const dimensions = { width: entry.width, height: shelfDepthMm, thickness: PANEL_THICKNESS, quantity: entry.qty, description: groups.size === 1 ? `Rayon fixe (structure) (${entry.qty})` : `Rayon fixe (structure) (groupe ${gi}/${groups.size})` }; const machining = getMachining(elementType, dimensions, config, modules); const piece = { id: `piece-${elementType}-${groups.size === 1 ? 'all' : gi}`, elementType, description: dimensions.description, finishId: finish.finishId, finishLabel: finish.label, widthMm: Math.round(dimensions.width), heightMm: Math.round(dimensions.height), thicknessMm: Math.round(dimensions.thickness ?? PANEL_THICKNESS), quantity: dimensions.quantity, unit: dimensions.unit || 'pcs', edgeBanding, machining, surfaceM2: calculateSurface(dimensions.width, dimensions.height, dimensions.quantity) }; const raw = computeRawCutDimensions(piece); piece.rawCutWidthMm = raw.rawCutWidthMm; piece.rawCutHeightMm = raw.rawCutHeightMm; piece.piecePlanSvg = generatePiecePlanSvg(piece); piece.piecePlanViews = generatePiecePlanViews(piece); pieces.push(piece); } continue; } if (elementType === 'renfortsTiroir') { const mods = effectiveModules(modules, config); const hw = config.hardware || {}; const drawerHidden = (hw.drawerType || 'visible') === 'hidden' || (hw.drawerType || 'visible') === 'hidden_with_handle'; if (!drawerHidden) continue; const countModules = Math.max(1, mods.length); const depthMm = Number(config.depthMm || 600); const drawerHeightsMm = Array.isArray(hw.drawerHeightsMm) ? hw.drawerHeightsMm : [90, 128, 177, 240]; const drawerGapMm = 8; const hingeClearanceMm = DRAWER_REINFORCEMENT.hingeFrontClearanceMm; const sepDepthMm = getSeparatorDepthMm(depthMm); const { backInsetFromRearMm, topFrontRadiusMm, thicknessMm } = DRAWER_REINFORCEMENT; const groups = new Map(); for (let i = 0; i < countModules; i++) { const n = Math.max(0, Number(mods?.[i]?.drawers) || 0); if (n <= 0) continue; const stackRaw = drawerStackHeightMm(n, drawerHeightsMm, drawerGapMm); const hMm = stackRaw - topFrontRadiusMm; if (hMm <= 0) continue; const leftPanelDepth = i === 0 ? depthMm : sepDepthMm; const rightPanelDepth = i === countModules - 1 ? depthMm : sepDepthMm; const leftLen = leftPanelDepth - backInsetFromRearMm - hingeClearanceMm; const rightLen = rightPanelDepth - backInsetFromRearMm - hingeClearanceMm; const a = Math.max(0, leftLen); const b = Math.max(0, rightLen); if (a <= 0 && b <= 0) continue; /** Les deux renforts d’une même colonne ont la même profondeur (largeur en plan) : minimum des deux côtés. */ const commonLen = a > 0 && b > 0 ? Math.min(a, b) : a + b; const qtyAdd = a > 0 && b > 0 ? 2 : 1; const key = `${commonLen}x${hMm}`; const entry = groups.get(key) || { width: commonLen, height: hMm, qty: 0 }; entry.qty += qtyAdd; groups.set(key, entry); } if (groups.size === 0) continue; const finish = colors[elementType] || colors.tiroirs || { finishId: null, label: 'Non défini' }; const edgeBanding = getEdgeBanding(elementType); let gi = 0; for (const entry of groups.values()) { gi += 1; const dimensions = { width: entry.width, height: entry.height, thickness: thicknessMm, quantity: entry.qty, description: groups.size === 1 ? `Renforts tiroir 25 mm — tiroirs cachés (${entry.qty})` : `Renforts tiroir 25 mm — tiroirs cachés (groupe ${gi}/${groups.size})` }; const machining = getMachining(elementType, dimensions, config, modules); const piece = { id: `piece-${elementType}-${groups.size === 1 ? 'all' : gi}`, elementType, description: dimensions.description, finishId: finish.finishId, finishLabel: finish.label, widthMm: Math.round(dimensions.width), heightMm: Math.round(dimensions.height), thicknessMm: Math.round(dimensions.thickness ?? thicknessMm), quantity: dimensions.quantity, unit: dimensions.unit || 'pcs', edgeBanding, machining, surfaceM2: calculateSurface(dimensions.width, dimensions.height, dimensions.quantity) }; const raw = computeRawCutDimensions(piece); piece.rawCutWidthMm = raw.rawCutWidthMm; piece.rawCutHeightMm = raw.rawCutHeightMm; piece.piecePlanSvg = generatePiecePlanSvg(piece); piece.piecePlanViews = generatePiecePlanViews(piece); pieces.push(piece); } continue; } if (elementType === 'cotes') { const dimensions = getElementDimensions(elementType, config, modules); if (!dimensions) continue; const machining = getMachining(elementType, dimensions, config, modules); const finish = colors[elementType] || { finishId: null, label: 'Non défini' }; const edgeBanding = getEdgeBanding(elementType); const drm = machining?.drawerRenfortPanelIxconnect?.cotes; const hasL = coteFaceHasRenfortHoles(drm?.leftInnerFace); const hasR = coteFaceHasRenfortHoles(drm?.rightInnerFace); const pushCotePiece = (opts) => { const piece = { id: opts.id, elementType, description: opts.description, finishId: finish.finishId, finishLabel: finish.label, widthMm: Math.round(dimensions.width), heightMm: Math.round(dimensions.height), thicknessMm: Math.round(dimensions.thickness ?? PANEL_THICKNESS), quantity: opts.quantity, unit: dimensions.unit || 'pcs', edgeBanding, machining: opts.machining, surfaceM2: calculateSurface(dimensions.width, dimensions.height, opts.quantity) }; const raw = computeRawCutDimensions(piece); piece.rawCutWidthMm = raw.rawCutWidthMm; piece.rawCutHeightMm = raw.rawCutHeightMm; piece.piecePlanSvg = generatePiecePlanSvg(piece); piece.piecePlanViews = generatePiecePlanViews(piece); pieces.push(piece); }; if (!drm || hasL === hasR) { const desc = hasL && hasR ? 'Côtés extérieurs (gauche et droit) — perçages Ixconnect renfort tiroirs (faces intérieures caisse)' : dimensions.description; pushCotePiece({ id: 'piece-cotes', description: desc, quantity: dimensions.quantity, machining }); } else { pushCotePiece({ id: 'piece-cotes-gauche', description: hasL ? 'Côté extérieur gauche — perçages Ixconnect renfort tiroirs (face intérieure caisse)' : 'Côté extérieur gauche', quantity: 1, machining: scopeMachiningCotesDrawerRenfort(machining, true, false) }); pushCotePiece({ id: 'piece-cotes-droit', description: hasR ? 'Côté extérieur droit — perçages Ixconnect renfort tiroirs (face intérieure caisse)' : 'Côté extérieur droit', quantity: 1, machining: scopeMachiningCotesDrawerRenfort(machining, false, true) }); } continue; } if (elementType === 'panneauxInterieurs') { const dimensions = getElementDimensions(elementType, config, modules); if (!dimensions) continue; const machining = getMachining(elementType, dimensions, config, modules); const finish = colors[elementType] || { finishId: null, label: 'Non défini' }; const edgeBanding = getEdgeBanding(elementType); const sepCount = dimensions.quantity; const ixSep = machining?.drawerRenfortPanelIxconnect?.separators; const pushSepPiece = (opts) => { const piece = { id: opts.id, elementType, description: opts.description, finishId: finish.finishId, finishLabel: finish.label, widthMm: Math.round(dimensions.width), heightMm: Math.round(dimensions.height), thicknessMm: Math.round(dimensions.thickness ?? PANEL_THICKNESS), quantity: opts.quantity, unit: dimensions.unit || 'pcs', edgeBanding, machining: opts.machining, surfaceM2: calculateSurface(dimensions.width, dimensions.height, opts.quantity) }; const raw = computeRawCutDimensions(piece); piece.rawCutWidthMm = raw.rawCutWidthMm; piece.rawCutHeightMm = raw.rawCutHeightMm; piece.piecePlanSvg = generatePiecePlanSvg(piece); piece.piecePlanViews = generatePiecePlanViews(piece); pieces.push(piece); }; if (!ixSep || ixSep.length === 0) { pushSepPiece({ id: 'piece-panneauxInterieurs', description: dimensions.description, quantity: sepCount, machining }); continue; } const flags = []; for (let s = 0; s < sepCount; s++) { flags.push(separatorHasRenfort(ixSep[s])); } const allSame = flags.length > 0 && flags.every((f) => f === flags[0]); if (allSame) { const desc = flags[0] ? `Séparateurs verticaux (${sepCount}) — sans rainure dos, plaqués contre le dos — perçages Ixconnect renfort tiroirs (faces vers colonnes)` : dimensions.description; pushSepPiece({ id: 'piece-panneauxInterieurs', description: desc, quantity: sepCount, machining: flags[0] ? machining : stripDrawerRenfortPanelIxconnect(machining) }); } else { for (let s = 0; s < sepCount; s++) { pushSepPiece({ id: `piece-panneauxInterieurs-sep${s + 1}`, description: `Séparateur vertical ${s + 1}/${sepCount} — sans rainure dos, plaqués contre le dos${ flags[s] ? ' — perçages Ixconnect renfort tiroirs (faces vers colonnes)' : '' }`, quantity: 1, machining: scopeMachiningSeparatorsDrawerRenfort(machining, s) }); } } continue; } const dimensions = getElementDimensions(elementType, config, modules); if (!dimensions) continue; const finish = colors[elementType] || { finishId: null, label: 'Non défini' }; const edgeBanding = getEdgeBanding(elementType); const machining = getMachining(elementType, dimensions, config, modules); const piece = { id: `piece-${elementType}`, elementType, description: dimensions.description, finishId: finish.finishId, finishLabel: finish.label, widthMm: Math.round(dimensions.width), heightMm: Math.round(dimensions.height), thicknessMm: Math.round(dimensions.thickness ?? PANEL_THICKNESS), quantity: dimensions.quantity, unit: dimensions.unit || 'pcs', edgeBanding, machining, surfaceM2: calculateSurface(dimensions.width, dimensions.height, dimensions.quantity) }; if (elementType === 'habillageFinitionDessus') { const sideDefaultsEnabled = config?.sideFinishesEnabled !== false; piece.habillageTopMiterLeft = config?.sideFinishLeftEnabled ?? sideDefaultsEnabled; piece.habillageTopMiterRight = config?.sideFinishRightEnabled ?? sideDefaultsEnabled; } if (elementType === 'habillageFinitionGauche' || elementType === 'habillageFinitionDroite') { piece.habillageSideTopMiter = config?.topFinishEnabled !== false; } if (elementType === 'portes') { const doorCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2); const hw = config.hardware || {}; const isSide = (v) => v === 'left' || v === 'right'; const perDoor = Array.isArray(hw.doorHingeSides) && hw.doorHingeSides.length === doorCount && hw.doorHingeSides.every((v) => isSide(v)); const hingeSides = perDoor ? hw.doorHingeSides.map((v) => (v === 'right' ? 'right' : 'left')) : Array.from({ length: doorCount }, (_, i) => getDoorHingeSide(i, doorCount, hw)); piece.doorPlanHints = { doorCount, hingeSides, hingeSide: hingeSides[0], hardware: hw, fullHeightHandle: config.hardware?.handleType === 'full_height' }; } const raw = computeRawCutDimensions(piece); piece.rawCutWidthMm = raw.rawCutWidthMm; piece.rawCutHeightMm = raw.rawCutHeightMm; piece.piecePlanSvg = generatePiecePlanSvg(piece); piece.piecePlanViews = generatePiecePlanViews(piece); pieces.push(piece); } // Calculate totals by finish const byFinish = {}; for (const piece of pieces) { const key = piece.finishLabel || 'Non défini'; if (!byFinish[key]) { byFinish[key] = { finishId: piece.finishId, finishLabel: key, pieces: [], totalSurfaceM2: 0, pieceCount: 0 }; } byFinish[key].pieces.push(piece); byFinish[key].totalSurfaceM2 += piece.surfaceM2; byFinish[key].pieceCount += piece.quantity; } // Calculate edge banding totals const edgeBandingMl = pieces.reduce((total, piece) => { if (piece.edgeBanding.type !== 'none') { const perimeter = (piece.widthMm + piece.heightMm) * 2 * piece.quantity / 1000; // convert to meters return total + (perimeter * piece.edgeBanding.sides / 4); } return total; }, 0); return { pieces, byFinish: Object.values(byFinish), summary: { totalPieces: pieces.reduce((sum, p) => sum + p.quantity, 0), totalSurfaceM2: pieces.reduce((sum, p) => sum + p.surfaceM2, 0), edgeBandingMl: Math.round(edgeBandingMl * 10) / 10, finishCount: Object.keys(byFinish).length }, config: { widthMm: config.widthMm, heightMm: config.heightMm, depthMm: config.depthMm, installationType: config.installationType, doorType: config.doorType } }; } /** * Calculate surface area in m² */ function calculateSurface(widthMm, heightMm, quantity = 1) { return Math.round((widthMm * heightMm * quantity) / 1000000 * 1000) / 1000; } /** * Format cutting list for display or export */ export function formatCuttingListForExport(cuttingList) { const lines = []; lines.push('LISTE DE DÉBITAGE — APEX 4.0'); lines.push('============================='); lines.push(''); lines.push(`Configuration: ${cuttingList.config.widthMm}mm x ${cuttingList.config.heightMm}mm x ${cuttingList.config.depthMm}mm`); lines.push(`Type: ${cuttingList.config.installationType === 'niche' ? 'Encastrée' : 'Libre'}`); lines.push(`Portes: ${cuttingList.config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'}`); lines.push(''); lines.push('RÉSUMÉ'); lines.push('------'); lines.push(`Nombre total de pièces: ${cuttingList.summary.totalPieces}`); lines.push(`Surface totale: ${cuttingList.summary.totalSurfaceM2.toFixed(2)} m²`); lines.push(`Chant ABS 2mm: ${cuttingList.summary.edgeBandingMl} ml`); lines.push(`Finitions différentes: ${cuttingList.summary.finishCount}`); lines.push(''); for (const group of cuttingList.byFinish) { lines.push(`FINITION: ${group.finishLabel}`); lines.push('-'.repeat(40)); lines.push('Pièce | Dim. finies | Dim. brutes CNC | Ép. | Qté | Surface | Usinage'); for (const piece of group.pieces) { const machiningStr = piece.machining ? Object.keys(piece.machining).join(', ') : '-'; lines.push( `${piece.description.padEnd(35)} | ${piece.widthMm}x${piece.heightMm} | ${piece.rawCutWidthMm}x${piece.rawCutHeightMm} | ${piece.thicknessMm}mm | x${piece.quantity} | ${piece.surfaceM2.toFixed(3)}m² | ${machiningStr}` ); } lines.push(`Sous-total: ${group.pieceCount} pièces, ${group.totalSurfaceM2.toFixed(2)} m²`); lines.push(''); } return lines.join('\n'); } export default { generateCuttingList, formatCuttingListForExport };