/** * 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, CUTTING_SURCOTE_MM, GROOVE, CONNECTORS, SYSTEM32, SYSTEM32_SEPARATORS, HINGE, REINFORCEMENT_MACHINING, SHELF_MACHINING, getHingeCountForDoor, getHingeCupPositions, getReinforcementEndFixingAxesFromTopMm, DRAWERS, DRAWER_REINFORCEMENT, DRAWER_RENFORT_PANEL_IXCONNECT, drawerStackHeightMm, drawerNicheHeightsMm, 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, PLINTH, MAX_PANEL_DIMENSION_MM, getAxiloPlinthPanelHeightMm, getAxiloPlinthReturnLengthMm, getAxiloPlinthFrontSideDeductionMm, getAxiloPlateCentersMm, getAxiloLateralCenterInsetMm, hasSidePlinthReturn, resolveHorizontalSpan, validateHorizontalSpans, getSeparatorVerticalSegments, usesBlockConstruction, horizontalConstructionRuns } from '../../shared/manufacturing.js'; import { IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM, IXCONNECT_SEPARATOR_BACK_ROW_MIN_WIDTH_MM, buildShelfMobileHoleSpecsMm } from '../../shared/cncPanelHoles.js'; import { LEGRABOX_PANEL_THICKNESS_MM, LEGRABOX_RAIL_DRILLING, LEGRABOX_FACADE_INSERTA, getLegraboxSystemForSideHeight, getLegraboxRailHolePositionsMm, getLegraboxNominalLengthMm, legraboxBottomPanelMm, legraboxBackPanelMm, buildLegraboxFacadeInsertaHolePositionsMm } from '../../shared/blumLegrabox.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; } /** Les côtés structurels passent entre dessus/fond uniquement derrière un habillage latéral libre. */ function getExteriorSideAssembly(config = {}) { const sideDefaultsEnabled = config?.sideFinishesEnabled !== false; const finishesEnabled = config?.installationType === 'free' && config?.exteriorFinishesEnabled !== false; return { left: finishesEnabled && (config?.sideFinishLeftEnabled ?? sideDefaultsEnabled), right: finishesEnabled && (config?.sideFinishRightEnabled ?? sideDefaultsEnabled) }; } /** * Géométrie d'un panneau horizontal et modes de jonction de ses montants extérieurs. * Les axes historiques restent inchangés sans habillage; l'extension est ajoutée du côté habillé. */ function getHorizontalPanelJunctionLayout(widthMm, modules = [], config = {}) { const natural = getExteriorSideAssembly(config); const leftFace = config?._horizontalAssemblyLeft ?? natural.left; const rightFace = config?._horizontalAssemblyRight ?? natural.right; const axes = [...getJunctionAxesAcrossWidthMm(widthMm, modules, config)]; if (axes.length > 0) { axes[0] = leftFace ? PANEL_THICKNESS / 2 : 0; axes[axes.length - 1] = rightFace ? Math.max(0, Number(widthMm) - PANEL_THICKNESS / 2) : Number(widthMm); } const faceModes = axes.map((_, index) => ( index === 0 ? (leftFace ? 'face' : 'end') : index === axes.length - 1 ? (rightFace ? 'face' : 'end') : 'face' )); return { axes, faceModes, leftFace, rightFace }; } /** * Resolve only production-valid spans. The legacy cutting path remains byte-for-byte * equivalent when no spans are configured. */ function horizontalSpanContext(config, modules = []) { if (!Array.isArray(config?.horizontalSpans) || config.horizontalSpans.length === 0) return null; const mods = effectiveModules(modules, config); const spanConfig = { ...config, modules: mods }; const invalidIds = new Set( validateHorizontalSpans(spanConfig) .map((issue) => issue?.spanId) .filter((id) => id != null) .map(String) ); const resolved = config.horizontalSpans .filter((span) => span?.id != null && !invalidIds.has(String(span.id))) .map((span) => resolveHorizontalSpan(span, spanConfig)) .filter(Boolean); return resolved.length ? { config: spanConfig, modules: mods, resolved } : null; } function spanCoversModuleAt(spanCtx, moduleIndex, bottomMm, topMm) { if (!spanCtx) return false; const eps = 0.5; return spanCtx.resolved.some((span) => span.moduleIndexes.includes(moduleIndex) && Number(bottomMm) >= span.bottomMm - eps && Number(topMm) <= span.topMm + eps ); } function spanCoversHeight(spanCtx, moduleIndex, heightMm) { if (!spanCtx) return false; const eps = 0.5; return spanCtx.resolved.some((span) => span.moduleIndexes.includes(moduleIndex) && heightMm > span.bottomMm + eps && heightMm < span.topMm - eps ); } function spanReplacesHorizontalFace(spanCtx, moduleIndex, boundaryMm, elementType) { if (!spanCtx) return false; const eps = 0.5; return spanCtx.resolved.some((span) => { if (!span.moduleIndexes.includes(moduleIndex)) return false; const replacedBoundary = elementType === 'fond' ? span.bottomMm : span.topMm; return Math.abs(boundaryMm - replacedBoundary) <= eps; }); } function drawerTypeForModule(config, mod) { const value = mod?.drawerType ?? config?.hardware?.drawerType ?? 'visible'; return value === 'hidden' || value === 'hidden_with_handle' ? 'hidden' : 'visible'; } function moduleHasHiddenDrawers(config, mod) { return Math.max(0, Number(mod?.drawers) || 0) > 0 && drawerTypeForModule(config, mod) === 'hidden'; } /** 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 (!moduleHasHiddenDrawers(config, mod)) 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 }; } /** Profondeur utile tiroir (mm) — même logique que useWardrobeConfig / configValidation. */ function effectiveDrawerDepthMmForConfig(config, mod = null) { const depthMm = Number(config?.depthMm || 600); const rawGap = depthMm - NICHE.drawerBackGap; const clamped = Math.max(DRAWERS.minDepth, Math.min(DRAWERS.maxDepth, rawGap)); const stepped = Math.round(clamped / DRAWERS.depthStep) * DRAWERS.depthStep; const reduction = drawerTypeForModule(config, mod) === 'hidden' ? DRAWERS.hiddenHandleDepthReduction : 0; return Math.max(350, stepped - reduction); } /** * Spécifications Blum Legrabox pure par colonne à tiroirs. * Chaque tiroir occupe sa hauteur de NICHE (SMS) ; empilés depuis le fond de caisse. * @returns {Array<{ moduleIndex:number, moduleWidthMm:number, internalWidthMm:number, * loadKg:40|70, nominalLengthMm:number|null, drawers:Array<{ index:number, systemKey:string, * sideHeightMm:number, nicheHeightMm:number, nicheBottomFromPanelBottomMm:number }> }>} */ function legraboxColumnsForConfig(config, modules = []) { const mods = effectiveModules(modules, config); const hw = config?.hardware || {}; const drawerHeightsMm = Array.isArray(hw.drawerHeightsMm) ? hw.drawerHeightsMm : [90, 128, 177, 240]; const loadKg = Number(hw.drawerLoadKg) === 70 ? 70 : 40; const countModules = Math.max(1, mods.length || 0); const columns = []; for (let i = 0; i < countModules; i++) { const n = Math.max(0, Number(mods?.[i]?.drawers) || 0); if (n <= 0) continue; const effDepth = effectiveDrawerDepthMmForConfig(config, mods[i]); const modW = Number(mods?.[i]?.widthMm); const rawW = Number.isFinite(modW) && modW > 0 ? modW : (Number(config?.widthMm) || 2400) / countModules; /** largeur_libre_corps_X = largeur extérieure − 2× épaisseur montants (consigne Blum). */ const internalWidthMm = Math.max(0, Math.round(rawW - 2 * PANEL_THICKNESS)); const niches = drawerNicheHeightsMm(n, drawerHeightsMm); const drawers = []; let cursor = PANEL_THICKNESS; // fond de caisse = bas de la zone tiroirs sur le panneau côté for (let di = 0; di < n; di++) { const rawH = Number(drawerHeightsMm[Math.min(di, drawerHeightsMm.length - 1)]) || 128; const sideHeightMm = rawH === 60 ? 66 : rawH; const sys = getLegraboxSystemForSideHeight(sideHeightMm); drawers.push({ index: di, systemKey: sys?.key || 'K', sideHeightMm, nicheHeightMm: niches[di], nicheBottomFromPanelBottomMm: cursor }); cursor += niches[di]; } /** NL max dispo pour la colonne : plus petite valeur commune aux systèmes présents (prof. int. min = NL + 3). */ let nominalLengthMm = null; for (const d of drawers) { const nl = getLegraboxNominalLengthMm(d.systemKey, effDepth - 3, loadKg); if (nl != null && (nominalLengthMm == null || nl < nominalLengthMm)) nominalLengthMm = nl; } columns.push({ moduleIndex: i, moduleWidthMm: Math.round(rawW), internalWidthMm, loadKg, nominalLengthMm, drawers }); } return columns; } 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; // Colonne empilée: la jonction bas/haut (double panneau) joue le rôle de rayon structurel. if (moduleStacked(mod, config)) 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, junctionFace: cf.junctionFaceModes?.[idx], ...getIxconnectJointPatternAlongDepthMm(d, idx === 0 || idx === jax.length - 1 ? 'edge' : 'middle', { horizontalPanel: true }) })); } /** * Dos optionnel PAR ZONE (colonne). * Construction: dos → caisson block ; sinon côté (montant extérieur) ou rayonnage → block ; sinon séparations. * Legacy: `mod.hasBack` absent → `config.hasBack` global; les deux absents (anciennes commandes) → dos présent. */ function moduleHasBack(mod, config) { if (mod && typeof mod.hasBack === 'boolean') return mod.hasBack; if (typeof config?.hasBack === 'boolean') return config.hasBack; return true; } /** Hauteur de caisse (hors plinthe). */ function carcassBodyMm(config) { return (Number(config?.heightMm) || 0) - (Number(config?.plinthHeightMm) || 0); } /** Empilage vertical: bornes de hauteur de chaque block (caisse). max = panneau max. */ const STACK_BLOCK_MIN_MM = 300; const STACK_BLOCK_MAX_MM = MAX_PANEL_DIMENSION_MM; const STACK_BLOCK_STEP_MM = 50; /** * Empilage vertical PAR COLONNE — modèle N blocks. * Canonique: `mod.blockHeightsMm` (bas → haut). Legacy 2 blocks: `mod.stacked` + `mod.stackBottomHeightMm` * (défauts config.stackEnabled / config.stackBottomHeightMm). Hauteurs normalisées, somme = caisse. */ function moduleBlockHeightsMm(mod, config) { const body = carcassBodyMm(config); const min = STACK_BLOCK_MIN_MM; if (!(body >= min * 2)) return [Math.max(0, body)]; const maxBlocks = Math.max(1, Math.floor(body / min)); const minBlocks = Math.max(1, Math.ceil(body / STACK_BLOCK_MAX_MM)); let raw = Array.isArray(mod?.blockHeightsMm) ? mod.blockHeightsMm.map(Number).filter((v) => Number.isFinite(v) && v > 0) : null; if (!raw || raw.length === 0) { const stacked = mod && typeof mod.stacked === 'boolean' ? mod.stacked : config?.stackEnabled === true; if (stacked && maxBlocks >= 2) { const b = Number(mod?.stackBottomHeightMm ?? config?.stackBottomHeightMm); const bottom = Math.min(Math.max(min, body - min), Math.max(min, Number.isFinite(b) ? b : 1000)); raw = [bottom, body - bottom]; } else { raw = [body]; } } let count = Math.min(Math.max(raw.length, minBlocks), maxBlocks); if (count <= 1) return [body]; if (raw.length !== count) { // Nombre de blocks imposé/écrêté → répartition égale. const even = body / count; raw = Array.from({ length: count }, () => even); } const out = []; let remaining = body; for (let i = 0; i < count - 1; i++) { const blocksAfter = count - i - 1; const maxH = Math.min(STACK_BLOCK_MAX_MM, remaining - min * blocksAfter); const stepped = Math.round((Number(raw[i]) || min) / STACK_BLOCK_STEP_MM) * STACK_BLOCK_STEP_MM; const h = Math.min(maxH, Math.max(min, stepped)); out.push(h); remaining -= h; } out.push(remaining); return out; } /** Porte par block (bas → haut). Défaut: porte sur chaque block (block sans porte = niche ouverte). */ function moduleBlockDoors(mod, config, blockCount = null) { const n = blockCount ?? moduleBlockHeightsMm(mod, config).length; const arr = Array.isArray(mod?.blockDoors) ? mod.blockDoors : null; return Array.from({ length: n }, (_, i) => (arr && typeof arr[i] === 'boolean' ? arr[i] : true)); } /** Dos par block (bas → haut). Legacy: hasBack (block 1 / défaut), hasBackTop (dernier block). */ function moduleBlockBacks(mod, config, blockCount = null) { const n = blockCount ?? moduleBlockHeightsMm(mod, config).length; const arr = Array.isArray(mod?.blockBacks) ? mod.blockBacks : null; const base = moduleHasBack(mod, config); return Array.from({ length: n }, (_, i) => { if (arr && typeof arr[i] === 'boolean') return arr[i]; if (i === n - 1 && n >= 2 && mod && typeof mod.hasBackTop === 'boolean') return mod.hasBackTop; return base; }); } /** Colonne empilée = au moins 2 blocks en hauteur. */ function moduleStacked(mod, config) { return moduleBlockHeightsMm(mod, config).length >= 2; } /** * Segments verticaux (blocks) d'une colonne, bas → haut: * [{ index, count, heightMm, hasBack, level }] — level: null (1 block) | 'bottom' | 'top' | 'mid'. */ function moduleStackSegments(mod, config) { const heights = moduleBlockHeightsMm(mod, config); const backs = moduleBlockBacks(mod, config, heights.length); if (heights.length <= 1) { return [{ index: 0, count: 1, heightMm: heights[0], hasBack: backs[0], level: null }]; } return heights.map((h, k) => ({ index: k, count: heights.length, heightMm: h, hasBack: backs[k], level: k === 0 ? 'bottom' : k === heights.length - 1 ? 'top' : 'mid' })); } /** Au moins une colonne empilée (fallback legacy: config.stackEnabled sans modules). */ function anyStackedColumn(config, modules) { const mods = effectiveModules(modules, config); if (mods.length === 0) return moduleStacked(null, config); return mods.some((m) => moduleStacked(m, config)); } /** Dos d'une zone (colonne × niveau). level: 'bottom' | 'top' (premier / dernier block). */ function moduleHasBackAt(mod, config, level) { const backs = moduleBlockBacks(mod, config); return level === 'top' ? backs[backs.length - 1] : backs[0]; } /** Au moins une zone avec dos au niveau donné ('bottom' | 'top'). */ function anyBackAtLevel(config, modules, level) { const mods = effectiveModules(modules, config); if (mods.length === 0) return typeof config?.hasBack === 'boolean' ? config.hasBack : true; return mods.some((m) => moduleHasBackAt(m, config, level)); } /** Dos au niveau donné sur la colonne d'extrémité gauche/droite. */ function backAtSideLevel(config, modules, side, level) { const mods = effectiveModules(modules, config); if (mods.length === 0) return typeof config?.hasBack === 'boolean' ? config.hasBack : true; const mod = side === 'left' ? mods[0] : mods[mods.length - 1]; return moduleHasBackAt(mod, config, level); } /** Au moins une zone avec dos, tous blocks confondus (pilote rainures + pièce dos). */ function anyBackPanel(config, modules) { const mods = effectiveModules(modules, config); if (mods.length === 0) return typeof config?.hasBack === 'boolean' ? config.hasBack : true; return mods.some((m) => moduleBlockBacks(m, config).some(Boolean)); } /** Toutes les zones (tous blocks) avec dos. */ function allBackPanels(config, modules) { const mods = effectiveModules(modules, config); if (mods.length === 0) return typeof config?.hasBack === 'boolean' ? config.hasBack : true; return mods.every((m) => moduleBlockBacks(m, config).every(Boolean)); } /** Dos requis côté extérieur gauche / droit, à n'importe quel niveau. */ function backAtSide(config, modules, side) { const mods = effectiveModules(modules, config); if (mods.length === 0) return typeof config?.hasBack === 'boolean' ? config.hasBack : true; const mod = side === 'left' ? mods[0] : mods[mods.length - 1]; return moduleBlockBacks(mod, config).some(Boolean); } function columnIsBlock(mod, config) { return usesBlockConstruction(mod, moduleBlockBacks(mod, config).some(Boolean)); } function horizontalInsetMm(mods, config, moduleIndex, side) { const neighbor = side === 'left' ? moduleIndex - 1 : moduleIndex + 1; if (neighbor < 0 || neighbor >= mods.length) return PANEL_THICKNESS; if (!columnIsBlock(mods[moduleIndex], config)) return 0; return PANEL_THICKNESS + ( columnIsBlock(mods[neighbor], config) ? (BLOCK_ASSEMBLY.jointGapMm || 3) / 2 : 0 ); } /** * Layout of dessus/fond construction runs in wardrobe X: origin of each * panel (after montant insets) so AXILO feet can be spaced on the armoire * then assigned to the matching bottom board. */ function horizontalRunLayouts(mods, config, totalWidthMm, level) { const runs = horizontalConstructionRuns(mods, config, level); let moduleCursor = 0; const layouts = []; for (const run of runs) { let rawW = 0; for (let i = run.start; i <= run.end; i++) { rawW += moduleWidthMm(mods[i], totalWidthMm, mods.length); } const leftInset = horizontalInsetMm(mods, config, run.start, 'left'); const rightInset = horizontalInsetMm(mods, config, run.end, 'right'); const widthMm = Math.max(50, Math.round(rawW - leftInset - rightInset)); layouts.push({ ...run, widthMm, originMm: moduleCursor + leftInset, leftInset, rightInset }); for (let i = run.start; i <= run.end; i++) { moduleCursor += moduleWidthMm(mods[i], totalWidthMm, mods.length); } } return layouts; } function axiloSpanConfigForRun(layouts, run) { if (!layouts.length) return {}; const spanOrigin = layouts[0].originMm; const last = layouts[layouts.length - 1]; const spanWidthMm = last.originMm + last.widthMm - spanOrigin; const index = layouts.findIndex((layout) => layout.start === run.start && layout.end === run.end); if (index < 0) return { _axiloSpanWidthMm: spanWidthMm, _axiloPanelOffsetMm: 0 }; const layout = layouts[index]; const offset = layout.originMm - spanOrigin; const prevEnd = index > 0 ? layouts[index - 1].originMm - spanOrigin + layouts[index - 1].widthMm : null; const nextStart = index < layouts.length - 1 ? layouts[index + 1].originMm - spanOrigin : null; return { _axiloSpanWidthMm: spanWidthMm, _axiloPanelOffsetMm: offset, _axiloAssignMinMm: prevEnd == null ? 0 : (prevEnd + offset) / 2, _axiloAssignMaxMm: nextStart == null ? spanWidthMm : (offset + layout.widthMm + nextStart) / 2, _axiloAssignMaxInclusive: nextStart == null }; } function moduleWidthMm(mod, totalWidthMm, moduleCount) { return Number.isFinite(Number(mod?.widthMm)) && Number(mod.widthMm) > 0 ? Number(mod.widthMm) : totalWidthMm / Math.max(1, moduleCount); } /** * Existe-t-il une jonction caisson↔caisson (vis d'assemblage block) ? * Deux colonnes adjacentes en block (dos ou rayonnage). Legacy sans flags: columnMode. */ function hasBlockJoint(config, modules) { const mods = effectiveModules(modules, config); const anyExplicit = typeof config?.hasBack === 'boolean' || mods.some((m) => typeof m?.hasBack === 'boolean' || typeof m?.hasBackTop === 'boolean' || Array.isArray(m?.blockBacks)); if (!anyExplicit) { if (config?.columnMode === 'block') return mods.length >= 2; return mods.some((m, i) => i + 1 < mods.length && columnIsBlock(m, config) && columnIsBlock(mods[i + 1], config)); } for (let i = 0; i + 1 < mods.length; i++) { if (columnIsBlock(mods[i], config) && columnIsBlock(mods[i + 1], config)) return true; } return false; } /** * 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 nicheMeasurements = config?.nicheMeasurements || {}; const smallestPositive = (values, fallback) => { const valid = values.map(Number).filter((value) => Number.isFinite(value) && value > 0); return valid.length > 0 ? Math.min(...valid) : fallback; }; const sanitaryGapMm = Math.max(0, Number(config?.sanitaryGapMm) || 30); const nicheWidthMm = smallestPositive( [ nicheMeasurements.widthTopMm, nicheMeasurements.widthMidMm, nicheMeasurements.widthBottomMm ], widthMm + sanitaryGapMm * 2 ); const nicheHeightMm = smallestPositive( [nicheMeasurements.heightLeftMm, nicheMeasurements.heightRightMm], heightMm + sanitaryGapMm ); 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': { // Habillage au nu, ou retour reculé de 20 mm : la face avant s'arrête entre les deux. // In a niche, the front plinth always spans the complete wardrobe width. const sideDeduction = isNiche ? 0 : getAxiloPlinthFrontSideDeductionMm(config, 'left') + getAxiloPlinthFrontSideDeductionMm(config, 'right'); return { width: widthMm - sideDeduction, height: getAxiloPlinthPanelHeightMm(plinthHeightMm), thickness: PLINTH.panelThicknessMm, quantity: 1, description: `Plinthe AXILO 78 — hauteur cible ${plinthHeightMm} mm (jeu supérieur ${PLINTH.panelTopClearanceMm} mm)` }; } case 'plintheGauche': { if (!hasSidePlinthReturn(config, 'left')) return null; return { width: getAxiloPlinthReturnLengthMm(depthMm), height: getAxiloPlinthPanelHeightMm(plinthHeightMm), thickness: PLINTH.panelThicknessMm, quantity: 1, description: 'Plinthe AXILO 78 — retour gauche' }; } case 'plintheDroite': { if (!hasSidePlinthReturn(config, 'right')) return null; return { width: getAxiloPlinthReturnLengthMm(depthMm), height: getAxiloPlinthPanelHeightMm(plinthHeightMm), thickness: PLINTH.panelThicknessMm, quantity: 1, description: 'Plinthe AXILO 78 — retour droit' }; } case 'bandeauHaut': return { width: widthMm, height: 100, thickness: PANEL_THICKNESS, quantity: 1, description: 'Bandeau supérieur' }; case 'dessus': return { width: innerWidth, // Pleine profondeur caisse : le dos 8 mm s'engage dans la rainure, il ne raccourcit pas le panneau. height: depthMm, thickness: PANEL_THICKNESS, quantity: 1, description: 'Panneau du dessus' }; case 'fond': return { width: innerWidth, height: depthMm, 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 'fileurCotes': if (!isNiche) return null; return { width: 50, height: nicheHeightMm, thickness: PANEL_THICKNESS, quantity: 2, description: 'Fileurs niche gauche et droit — brut 50 mm, à ajuster sur chantier' }; case 'fileurHaut': if (!isNiche) return null; return { width: nicheWidthMm, height: 50, thickness: PANEL_THICKNESS, quantity: 1, description: 'Fileur niche haut — brut 50 mm, à ajuster sur chantier' }; case 'habillageFinitionGauche': if (!isFree || !exteriorFinishesEnabled || !sideFinishLeftEnabled) return null; { const topFinishEnabled = config?.topFinishEnabled !== false; return { width: depthMm, // Jusqu'au sol (remplace le retour de plinthe) + onglet haut si bandeau. height: heightMm + (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: heightMm + (topFinishEnabled ? PANEL_THICKNESS : 0), thickness: PANEL_THICKNESS, quantity: 1, description: 'Habillage finition DROITE' }; } case 'habillageFinitionDessus': if (!isFree || !exteriorFinishesEnabled || !topFinishEnabled) return null; return { width: widthMm, height: depthMm, thickness: PANEL_THICKNESS, quantity: 1, description: 'Habillage finition DESSUS' }; // 'dos' : émis par zone (colonne avec dos) dans generateCuttingList. case 'dos': return null; 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', 'plintheGauche', 'plintheDroite', '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 === 'tiroirFacade') { // Façade tiroir Legrabox : ABS 2 mm sur les 4 chants (comme les portes) return { type: 'visible', thickness: EDGE_BANDING.abs, sides: 4 }; } else if (elementType === 'tiroirFond' || elementType === 'tiroirDos') { // Fond / paroi arrière 16 mm : invisibles dans la caisse métallique — pas de chant return { type: 'none', thickness: 0, sides: 0 }; } 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 sideDefaultsEnabled = config?.sideFinishesEnabled !== false; const sideFinishLeftEnabled = config?.sideFinishLeftEnabled ?? sideDefaultsEnabled; const sideFinishRightEnabled = config?.sideFinishRightEnabled ?? sideDefaultsEnabled; const topFinishEnabled = config?.topFinishEnabled !== false; const underCabinetHeightMm = Math.max(0, Number(config?.plinthHeightMm) || 0); const bottomHasBackGroove = elementType === 'fond' && ( config?._grooveForce === true || (config?._grooveLevel ? anyBackAtLevel(config, modules, config._grooveLevel) : anyBackAtLevel(config, modules, 'bottom')) ); // Häfele AXILO 78 à planter — platines sous le fond bas uniquement. if (elementType === 'fond' && config?._axiloFeetEnabled !== false) { const leftHasSideCladding = !isFree || (exteriorFinishesEnabled && sideFinishLeftEnabled); const rightHasSideCladding = !isFree || (exteriorFinishesEnabled && sideFinishRightEnabled); const leftEdgeCenterInsetMm = getAxiloLateralCenterInsetMm(leftHasSideCladding); const rightEdgeCenterInsetMm = getAxiloLateralCenterInsetMm(rightHasSideCladding); const rearCenterFromRearMm = PLINTH.mountingPlate.rearCenterFromRearMm + (bottomHasBackGroove ? getBackGrooveInnerEndInsetFromBackEdgeMm() : 0); const centers = getAxiloPlateCentersMm( dimensions.width, dimensions.height, { leftEdgeCenterInsetMm, rightEdgeCenterInsetMm, spanWidthMm: config?._axiloSpanWidthMm, panelOffsetMm: config?._axiloPanelOffsetMm, assignMinMm: config?._axiloAssignMinMm, assignMaxMm: config?._axiloAssignMaxMm, assignMaxInclusive: config?._axiloAssignMaxInclusive, rearCenterFromRearMm } ); if (centers.length > 0) { machining.axiloFootPlates = { model: PLINTH.model, articleNumber: PLINTH.mountingPlate.articleNumber, targetHeightMm: Number(config?.plinthHeightMm) || 100, face: PLINTH.mountingPlate.face, holeDiameterMm: PLINTH.mountingPlate.holeDiameterMm, holeDepthMm: PLINTH.mountingPlate.holeDepthMm, holeOffsetsFromCenterMm: PLINTH.mountingPlate.holeOffsetsFromCenterMm.map((p) => ({ ...p })), lateralCenterInsetsMm: { left: leftEdgeCenterInsetMm, right: rightEdgeCenterInsetMm }, rearCenterFromRearMm, maxSpacingMm: PLINTH.maxFootSpacingMm, centers, frontFootCount: centers.filter((c) => c.row === 'front').length, rearFootCount: centers.filter((c) => c.row === 'rear').length, note: `Sous-face du fond — 4 pieds d'armoire + 2 tous les ${PLINTH.maxFootSpacingMm} mm, platine Press-fit entraxe 64 mm` }; } } // 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)', }; } // Coulisses Blum Legrabox — perçages cumulés depuis le chant avant (Système 32, Ø5×13) // sur montants extérieurs et séparateurs, par tiroir (ligne de vissage du profil de corps). if (['cotes', 'panneauxInterieurs'].includes(elementType)) { const columns = legraboxColumnsForConfig(config, modules); if (columns.length) { const modsAll = effectiveModules(modules, config); const countModules = Math.max(1, modsAll.length || 0); const panelDepthMm = Number(config.depthMm || dimensions.width || 600); const byModuleIndex = new Map(columns.map((c) => [c.moduleIndex, c])); const R = LEGRABOX_RAIL_DRILLING; const faceForColumn = (col) => { if (!col) return null; const nl = col.nominalLengthMm; const maxX = Math.min(nl ?? Infinity, panelDepthMm - NICHE.drawerBackGap); const xs = getLegraboxRailHolePositionsMm(col.loadKg, maxX); if (!xs.length) return null; return { railAxesYMm: col.drawers.map((d) => Math.round((d.nicheBottomFromPanelBottomMm + R.screwLineAboveNicheBottomMm) * 10) / 10 ), moduleIndex: col.moduleIndex, positionsXFromFrontMm: xs }; }; const base = { system: 'legrabox_pure', holeDiameterMm: R.holeDiameterMm, holeDepthMm: R.holeDepthMm, note: `Coulisses Legrabox — positions cumulées depuis le chant avant (Ø${R.holeDiameterMm}×${R.holeDepthMm} mm), ` + `ligne de vissage à ${R.screwLineAboveNicheBottomMm} mm au-dessus du bas de niche` }; if (elementType === 'cotes') { const colL = byModuleIndex.get(0) || null; const colR = byModuleIndex.get(countModules - 1) || null; const left = faceForColumn(colL); const right = countModules > 1 ? faceForColumn(colR) : left; if (left || right) { const cotes = {}; if (left) cotes.leftInnerFace = left; if (right) cotes.rightInnerFace = right; machining.drawerSlideDrilling = { ...base, positionsXFromFrontMm: (left || right).positionsXFromFrontMm, loadKg: (colL || colR).loadKg, nominalLengthMm: (colL || colR).nominalLengthMm, cotes }; } } else { const sepCount = Math.max(0, countModules - 1); const separators = []; for (let s = 0; s < sepCount; s++) { const lower = faceForColumn(byModuleIndex.get(s) || null); const higher = faceForColumn(byModuleIndex.get(s + 1) || null); separators.push({ separatorIndex: s, faceTowardLowerModule: lower, faceTowardHigherModule: higher }); } if (separators.some((x) => x.faceTowardLowerModule || x.faceTowardHigherModule)) { const anyFace = separators.map((x) => x.faceTowardLowerModule || x.faceTowardHigherModule).find(Boolean); machining.drawerSlideDrilling = { ...base, positionsXFromFrontMm: anyFace.positionsXFromFrontMm, loadKg: columns[0].loadKg, nominalLengthMm: columns[0].nominalLengthMm, separators }; } } } } // 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) // - Caisson structurel (dessus) : face supérieure, 1 paire avant/arrière centrée par colonne (accouplement bandeau) // - Joues de doublage : perçages centrés sur chaque block de la colonne d'extrémité (pas la hauteur d'armoire) // - Dessus de doublage : perçages centrés sur chaque colonne / block (pas la largeur d'armoire) if (isFree && exteriorFinishesEnabled && ['cotes', 'dessus', '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)); }; /** Stations strictly inside a span, always including the midpoint (odd count). */ const centeredStationsInSpan = (startMm, endMm, minSpacingMm = 500) => { const lo = Number(startMm); const hi = Number(endMm); if (!Number.isFinite(lo) || !Number.isFinite(hi)) return []; const span = Math.abs(hi - lo); if (span < 6) return uniq([(lo + hi) / 2]); let count = Math.max(1, Math.floor(span / Math.max(1, minSpacingMm))); if (count % 2 === 0) count += 1; return uniq(addEquidistantInner(lo, hi, count)); }; 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) { // Tous les SC 8/25 entrent depuis la face extérieure, côté habillage. // FACE=back est opposée à la face intérieure caisse / System 32. pushFaceHole(xFront, yMm, 'ixconnect', 'back'); pushFaceHole(xBack, yMm, 'ixconnect', 'back'); } 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 · tous depuis la face extérieure (SC vers habillage)', holes }; } else if (elementType === 'dessus' && topFinishEnabled) { // Accouplement bandeau : même géométrie que l'habillage dessus (72 / 70), centrée par colonne // sur la largeur du panneau structurel — perçage face supérieure (FACE=face). const yFront = 72; const yBack = H - 70; const junctionAxes = getJunctionAxesAcrossWidthMm(W, modules, config); const xCenters = []; for (let i = 0; i < junctionAxes.length - 1; i++) { xCenters.push((Number(junctionAxes[i]) + Number(junctionAxes[i + 1])) / 2); } for (const xCenter of uniq(xCenters)) { pushFaceHole(xCenter, yFront, 'ixconnect_finish', 'face'); pushFaceHole(xCenter, yBack, 'ixconnect_finish', 'face'); } machining.finishHabillage = { model: connector.model, faceHole: { ...connector.faceHole }, note: 'Habillage finition — caisson dessus (face supérieure): Ixconnect SC 8/25 centrés par colonne (avant 72 / arrière 70), accouplement bandeau', holes }; } else if (elementType === 'habillageFinitionGauche' || elementType === 'habillageFinitionDroite') { // Référentiel: X=0 bord AVANT, X=W bord ARRIÈRE ; Y=0 bas de CE panneau (segment ou pièce entière). const xFrontL = 72; const xBackL = W - 70; const mirror = elementType === 'habillageFinitionDroite'; const xFront = mirror ? W - xFrontL : xFrontL; const xBack = mirror ? W - xBackL : xBackL; const mods = effectiveModules(modules, config); const sideMod = elementType === 'habillageFinitionGauche' ? (mods[0] ?? null) : (mods.length ? mods[mods.length - 1] : null); const segs = moduleStackSegments(sideMod, config); const fullH = (Number(config?.heightMm) || H) + (topFinishEnabled ? PANEL_THICKNESS : 0); const segBottom = Math.max(0, Number(config?._habillageSegmentBottomMm) || 0); const pieceTop = segBottom + H; const minSpacingMm = 500; let blockBottom = underCabinetHeightMm; for (const seg of segs) { const blockTop = blockBottom + Number(seg.heightMm); const yRows = centeredStationsInSpan(blockBottom, blockTop, minSpacingMm); for (const yFull of yRows) { const yMm = yFull - segBottom; if (yMm < -0.5 || yMm > H + 0.5) continue; pushFaceHole(xFront, yMm, 'ixconnect'); pushFaceHole(xBack, yMm, 'ixconnect'); } const yMid = (blockBottom + blockTop) / 2 - segBottom; if (yMid >= -0.5 && yMid <= H + 0.5) { pushFaceHole(W / 2, yMid, 'ixconnect'); } blockBottom = blockTop; } // Tourillons sur coupe d'onglet 45° (chant haut) seulement si ce panneau porte le joint avec le bandeau. if (topFinishEnabled && pieceTop >= fullH - 0.5) { 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 centrés par block' + (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) : // 1 paire avant/arrière (72 / 70 mm) centrée sur chaque colonne — pas au milieu de l'armoire. const miterLeft = config?.sideFinishLeftEnabled ?? sideDefaultsEnabled; const miterRight = config?.sideFinishRightEnabled ?? sideDefaultsEnabled; const yFront = 72; const yBack = H - 70; const junctionAxes = getJunctionAxesAcrossWidthMm(W, modules, config); const xCenters = []; for (let i = 0; i < junctionAxes.length - 1; i++) { xCenters.push((Number(junctionAxes[i]) + Number(junctionAxes[i + 1])) / 2); } for (const xCenter of uniq(xCenters)) { 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): Ixconnect SC 8/25 centrés par colonne (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 horizontalJunctionLayout = elementType === 'dessus' || elementType === 'fond' ? getHorizontalPanelJunctionLayout(dimensions.width, modules, config) : null; const junctionAxesForHorizontals = horizontalJunctionLayout?.axes || null; const coteAssemblySide = config?._coteAssemblySide; const coteUsesEdgeJoint = elementType === 'cotes' && (coteAssemblySide === 'left' ? getExteriorSideAssembly(config).left : coteAssemblySide === 'right' ? getExteriorSideAssembly(config).right : getExteriorSideAssembly(config).left && getExteriorSideAssembly(config).right); machining.connectorHoles = { model: connector.model, edgeHole: { ...connector.edgeHole }, // Ø8 × 45mm faceHole: { ...connector.faceHole }, // Ø8 × 12mm lockScrewInbus: { ...connector.lockScrewInbus }, // Ø6 × 9mm — accès sur la pièce percée du long Ø8 en chant 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, ...(elementType === 'cotes' ? { verticalJointMode: coteUsesEdgeJoint ? 'edge' : 'face' } : {}), }; if (junctionAxesForHorizontals) { machining.connectorHoles.ixconnectJointsAlongDepth = junctionAxesForHorizontals.map((xAxis, idx) => ({ axisAcrossWidthMm: xAxis, junctionFace: horizontalJunctionLayout.faceModes[idx], ...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) // Uniquement au droit des zones avec dos. En cas d'empilage: dessus (toit) → zones du niveau haut, // fond → zones du niveau bas; `config._grooveLevel` force le niveau (pièces de jonction). { const grooveLevel = config?._grooveLevel; const grooveNeeded = config?._grooveForce === true ? true : elementType === 'cotes' ? backAtSide(config, modules, 'left') || backAtSide(config, modules, 'right') : grooveLevel ? anyBackAtLevel(config, modules, grooveLevel) : elementType === 'dessus' ? anyBackAtLevel(config, modules, 'top') : elementType === 'fond' ? anyBackAtLevel(config, modules, 'bottom') : anyBackPanel(config, modules); if ( grooveNeeded && (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', }; if ( BACK_GROOVE_PLAN_HORIZONTAL_TYPES.includes(elementType) && !allBackPanels(config, modules) ) { machining.groove.note = 'Rainure dos uniquement au droit des zones avec dos (configuration mixte)'; } } } // 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 depthMmR = Number(config.depthMm || 600); const sepDepthMmR = getSeparatorDepthMm(depthMmR); const ixP = DRAWER_RENFORT_PANEL_IXCONNECT; if (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 (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 (hasBlockJoint(config, modules) && ['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); if ( bottomHasBackGroove && config?._axiloFeetEnabled !== false && positionsAlongJointMm.length > 0 ) { const rearIndex = positionsAlongJointMm.length - 1; positionsAlongJointMm[rearIndex] = Math.max( 0, positionsAlongJointMm[rearIndex] - CARCASS_FASTENERS.axiloGrooveRearRowShiftMm ); } const dowelStations = getCarcassFerrureDowelDrillings( positionsAlongJointMm, jointEdgeUsableLengthMm, CARCASS_FASTENERS.dowel.sideOffset ); const horizontalJunctionLayout = getHorizontalPanelJunctionLayout(dimensions.width, modules, config); const junctionAxesAcrossWidthMm = [...horizontalJunctionLayout.axes]; machining.carcassFasteners = { piecesPerJoint: CARCASS_FASTENERS.piecesPerJoint, jointCount: junctionAxesAcrossWidthMm.length, jointEdgeLabel: 'jonctions avec côtés + séparateurs (hors dos)', edgeLengthMm: edgeLenMm, jointEdgeUsableLengthMm, junctionAxesAcrossWidthMm, junctionFaceModes: [...horizontalJunctionLayout.faceModes], 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 'plintheGauche': case 'plintheDroite': case 'bandeauHaut': case 'cotesDoublage': case 'fileur': case 'fileurCotes': case 'fileurHaut': case 'habillageFinitionGauche': case 'habillageFinitionDroite': case 'habillageFinitionDessus': case 'tiroirFacade': 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 }; } } /** * Sens de collage des bandes de chant : indique le long de quel axe chaque chant * est appliqué (Longueur = axe hauteur de la pièce, Largeur = axe largeur de la pièce). * @returns {{ widthEdges:number, heightEdges:number, label:string }} */ function describeEdgeBandingDirection(elementType) { const axes = getEdgeBandingAxesByType(elementType); const parts = []; // widthEdges = chants dont la bande court le long de la LARGEUR de la pièce. if (axes.widthEdges > 0) parts.push(`${axes.widthEdges} sur largeur`); // heightEdges = chants dont la bande court le long de la LONGUEUR (hauteur) de la pièce. if (axes.heightEdges > 0) parts.push(`${axes.heightEdges} sur longueur`); return { widthEdges: axes.widthEdges, heightEdges: axes.heightEdges, label: parts.length ? parts.join(' + ') : 'aucun' }; } function computeRawCutDimensions(piece) { const axes = getEdgeBandingAxesByType(piece.elementType); const deductionPerEdge = EDGE_BANDING.abs; // Sens de collage des chants (Longueur/Largeur) attaché à la pièce. if (piece.edgeBanding && piece.edgeBanding.type !== 'none') { piece.edgeBanding.direction = describeEdgeBandingDirection(piece.elementType); } piece.surcoteMm = CUTTING_SURCOTE_MM; // Surcote sur-mesure de 10 mm en longueur et en largeur, en plus de la // compensation chant (2 mm par chant ABS appliqué sur l'axe concerné). return { rawCutWidthMm: piece.widthMm + axes.widthEdges * deductionPerEdge + CUTTING_SURCOTE_MM, rawCutHeightMm: piece.heightMm + axes.heightEdges * deductionPerEdge + CUTTING_SURCOTE_MM, surcoteMm: CUTTING_SURCOTE_MM }; } /** * 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; 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 · pleine longueur`; 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`; } // Coulisses Legrabox — vissage sur montants/séparateurs (X cumulé depuis chant avant) if (m.drawerSlideDrilling?.positionsXFromFrontMm?.length && ['cotes', 'panneauxInterieurs'].includes(piece.elementType)) { const sd = m.drawerSlideDrilling; const r = Math.max(1.2, ((Number(sd.holeDiameterMm) || 5) * 0.5) * scaleA); const faces = []; if (sd.cotes) { if (sd.cotes.leftInnerFace) faces.push(sd.cotes.leftInnerFace); else if (sd.cotes.rightInnerFace) faces.push(sd.cotes.rightInnerFace); } else if (Array.isArray(sd.separators)) { const sep = sd.separators.find((x) => x?.faceTowardLowerModule || x?.faceTowardHigherModule); if (sep?.faceTowardLowerModule) faces.push(sep.faceTowardLowerModule); if (sep?.faceTowardHigherModule) faces.push(sep.faceTowardHigherModule); } for (const f of faces) { for (const y0 of f.railAxesYMm || []) { const y = pAy + hA - Math.min(hA, Math.max(0, Number(y0) * scaleA)); for (const x0 of f.positionsXFromFrontMm || sd.positionsXFromFrontMm) { const x = pAx + Math.min(wA, Math.max(0, Number(x0) * scaleA)); viewAOps += ``; } } } viewAOps += `Coulisses Legrabox ${sd.loadKg || 40} kg — Ø${sd.holeDiameterMm}×${sd.holeDepthMm} · X: ${(sd.positionsXFromFrontMm || []).join(', ')} mm du chant avant`; } // Façade tiroir Legrabox — perçages INSERTA (Ø10, prof. 13) if (piece.elementType === 'tiroirFacade' && m.legraboxInserta?.holes?.length) { const ins = m.legraboxInserta; const r = Math.max(1.8, ((Number(ins.holeDiameterMm) || 10) * 0.5) * scaleA); for (const h of ins.holes) { const x = pAx + Math.min(wA, Math.max(0, Number(h.xMm) * scaleA)); const y = pAy + hA - Math.min(hA, Math.max(0, Number(h.yMm) * scaleA)); viewAOps += ``; } viewAOps += `INSERTA (système ${ins.systemKey}) — Ø${ins.holeDiameterMm} · prof. ${ins.holeDepthMm} mm · repère Y depuis bas façade`; } 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 verticalEdgeJoint = piece.elementType === 'panneauxInterieurs' || (piece.elementType === 'cotes' && m.connectorHoles.verticalJointMode === 'edge'); const mirrorSep = verticalEdgeJoint && widthMm >= IXCONNECT_SEPARATOR_BACK_ROW_MIN_WIDTH_MM && Number(p.tourillon2Axis) > Number(p.tourillon1Axis); if (verticalEdgeJoint) { const panelLabel = piece.elementType === 'cotes' ? 'Côté habillé' : 'Séparateur'; const ixLeg = mirrorSep ? `${panelLabel} — Ixconnect + tourillons sur chants haut/bas · Y=0 / Y=H brut · avant & arrière profondeur · T@${p.tourillon1Axis} · IX@${p.ixconnectAxis} · T@${p.tourillon2Axis} mm — FACE=end END=top|bottom` : `${panelLabel} — Ixconnect + tourillons sur chants haut/bas · 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 verticalEdgeJointPv = piece.elementType === 'panneauxInterieurs' || (piece.elementType === 'cotes' && m.connectorHoles.verticalJointMode === 'edge'); const mirrorSepPv = verticalEdgeJointPv && widthMm >= IXCONNECT_SEPARATOR_BACK_ROW_MIN_WIDTH_MM && Number(p.tourillon2Axis) > Number(p.tourillon1Axis); if (verticalEdgeJointPv) { const panelLabelPv = piece.elementType === 'cotes' ? 'Côté habillé' : 'Séparateur'; const ixLegPv = mirrorSepPv ? `${panelLabelPv} — Ixconnect + tourillons chants haut/bas · Y=0 / Y=H · avant & arrière prof. — END=top|bottom` : `${panelLabelPv} — Ixconnect + tourillons chants haut/bas · 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); } /** Retire la rainure dos du machining (côté sans zone à dos adjacente). */ function stripBackGroove(machining) { if (!machining?.groove) return machining; const { groove, ...rest } = machining; return Object.keys(rest).length ? rest : null; } 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 scopeSeparatorSegmentMachining(machining, separatorIndex, bottomMm, topMm) { let next = scopeMachiningSeparatorsDrawerRenfort(machining, separatorIndex); if (!next) return next; next = { ...next }; const localOriginMm = Number(bottomMm) + PANEL_THICKNESS; const localTopMm = Number(topMm) - PANEL_THICKNESS; const translateAxes = (axes) => (Array.isArray(axes) ? axes : []) .map(Number) .filter((axis) => axis >= localOriginMm - 0.5 && axis <= localTopMm + 0.5) .map((axis) => Math.round((axis - localOriginMm) * 10) / 10); const translateHoles = (holes) => (Array.isArray(holes) ? holes : []) .filter((hole) => Number(hole?.yMm) >= localOriginMm - 0.5 && Number(hole?.yMm) <= localTopMm + 0.5) .map((hole) => ({ ...hole, yMm: Math.round((Number(hole.yMm) - localOriginMm) * 10) / 10 })); const slides = next.drawerSlideDrilling; if (slides?.separators?.length) { const source = slides.separators[separatorIndex] || slides.separators[0]; const translateFace = (face) => { if (!face) return null; const railAxesYMm = translateAxes(face.railAxesYMm); return railAxesYMm.length ? { ...face, railAxesYMm } : null; }; const scoped = source ? { ...source, faceTowardLowerModule: translateFace(source.faceTowardLowerModule), faceTowardHigherModule: translateFace(source.faceTowardHigherModule) } : null; if (scoped && (scoped.faceTowardLowerModule || scoped.faceTowardHigherModule)) { next.drawerSlideDrilling = { ...slides, separators: [scoped] }; } else { delete next.drawerSlideDrilling; } } const renfort = next.drawerRenfortPanelIxconnect; if (renfort?.separators?.length) { const source = renfort.separators[0]; const translateFace = (face) => { if (!face) return null; const holes = translateHoles(face.holes); return holes.length ? { ...face, holes } : null; }; const scoped = { ...source, faceTowardLowerModule: translateFace(source.faceTowardLowerModule), faceTowardHigherModule: translateFace(source.faceTowardHigherModule) }; if (scoped.faceTowardLowerModule || scoped.faceTowardHigherModule) { next.drawerRenfortPanelIxconnect = { ...renfort, separators: [scoped] }; } else { delete next.drawerRenfortPanelIxconnect; } } return Object.keys(next).length ? next : null; } 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 spanCtx = horizontalSpanContext(config, modules); 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', 'plintheGauche', 'plintheDroite', 'bandeauHaut', 'dessus', 'fond', 'cotes', 'dos', 'renforts', 'portes', 'panneauxInterieurs', 'rayonFixe', 'renfortsTiroir', 'tiroirFond', 'tiroirDos', 'tiroirFacade', 'etageres' ]; if (config.installationType === 'niche') { elementTypes.splice(5, 0, 'cotesDoublage', 'fileurCotes', 'fileurHaut'); } 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 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); // Norme SMS / Legrabox : pile = somme des hauteurs de niche (jeux inclus dans la niche). return drawerStackHeightMm(n, drawerHeightsMm); }; const groups = new Map(); let totalDoors = 0; for (let i = 0; i < doorCount; i++) { const w = doorWidthForIndex(i); const drawerDeduct = drawerStackHeightForIndex(i); const hingeSide = overrideSides ? (overrideSides[i] === 'right' ? 'right' : 'left') : getDoorHingeSide(i, doorCount, hw); // Colonne empilée: 1 porte par block (chaque block = caisson avec sa propre façade). // Block sans porte (mod.blockDoors) = niche ouverte → aucune pièce. // Les tiroirs partent du bas → déduction sur la porte du block bas uniquement. const segs = moduleStackSegments(modules?.[i] ?? null, config); const doors = moduleBlockDoors(modules?.[i] ?? null, config, segs.length); let segmentBottomMm = 0; for (const seg of segs) { const segmentTopMm = segmentBottomMm + seg.heightMm; const coveredBySpan = spanCoversModuleAt(spanCtx, i, segmentBottomMm, segmentTopMm); segmentBottomMm = segmentTopMm; if (coveredBySpan) continue; if (!doors[seg.index]) continue; const deduct = seg.index === 0 ? drawerDeduct : 0; const h = segs.length > 1 ? Math.max(120, Math.round(seg.heightMm - 4 - deduct)) : Math.max(120, Math.round(baseDoorHeightMm - deduct)); 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); totalDoors += 1; } } 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 (${totalDoors} 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 === 'etageres' && spanCtx) { const mods = effectiveModules(modules, config); const depthMm = Number(config.depthMm || 600); const shelfDepthMm = Math.max(40, Math.round(depthMm - PANEL_THICKNESS - 5)); const groups = new Map(); for (let moduleIndex = 0; moduleIndex < mods.length; moduleIndex++) { const mod = mods[moduleIndex]; const shelfCount = Math.max(0, Number(mod?.shelves) || 0); if (shelfCount === 0) continue; const structuralIndex = structuralMiddleShelfIndex(mod, config); const centers = shelfCenterYsMm({ shelfCount, heightMm: config.heightMm, plinthHeightMm: config.plinthHeightMm, shelfGapMm: mod?.shelfGapMm }); const physicalCenters = centers.map( (center) => center + Number(config.heightMm || 0) / 2 - Number(config.plinthHeightMm || 0) ); const kept = physicalCenters.filter( (center, shelfIndex) => shelfIndex !== structuralIndex && !spanCoversHeight(spanCtx, moduleIndex, center) ); if (!kept.length) continue; const rawWidthMm = Number(mod?.widthMm) || Number(config.widthMm || 0) / Math.max(1, mods.length); const widthMm = Math.max(80, Math.round(rawWidthMm - PANEL_THICKNESS * 2 - 2)); const key = `${widthMm}x${shelfDepthMm}`; const entry = groups.get(key) || { widthMm, quantity: 0 }; entry.quantity += kept.length; groups.set(key, entry); } if (!groups.size) continue; const finish = colors[elementType] || { finishId: null, label: 'Non défini' }; let groupIndex = 0; for (const entry of groups.values()) { groupIndex += 1; const dimensions = { width: entry.widthMm, height: shelfDepthMm, thickness: PANEL_THICKNESS, quantity: entry.quantity }; const piece = { id: `piece-etageres-span-aware-${groupIndex}`, elementType, description: `Rayons mobiles hors portée horizontale (${entry.quantity})`, finishId: finish.finishId, finishLabel: finish.label, widthMm: dimensions.width, heightMm: dimensions.height, thicknessMm: dimensions.thickness, quantity: dimensions.quantity, unit: 'pcs', edgeBanding: getEdgeBanding(elementType), machining: getMachining(elementType, dimensions, config, modules), 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 === '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++) { // Colonne empilée: les jonctions (doubles panneaux) remplacent le rayon structurel. const middleHeightMm = carcassBodyMm(config) / 2; const addMiddle = Boolean(needsMiddle) && !moduleStacked(mods?.[i] ?? null, config) && !spanCoversHeight(spanCtx, i, middleHeightMm); 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 || {}; if (!mods.some((mod) => moduleHasHiddenDrawers(config, mod))) 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 || !moduleHasHiddenDrawers(config, mods[i])) 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; } // Blum Legrabox pure — pièces fabriquées du tiroir : fond 16 mm, paroi arrière 16 mm, façade. if (['tiroirFond', 'tiroirDos', 'tiroirFacade'].includes(elementType)) { const columns = legraboxColumnsForConfig(config, modules); if (!columns.length) continue; /** key => { width, height, thickness, qty, machining, systemKeys } */ const groups = new Map(); const addToGroup = (w, h, t, machiningEntry, sysKey) => { const wi = Math.round(w); const hi = Math.round(h); if (wi <= 0 || hi <= 0) return; const key = `${wi}x${hi}x${t}${sysKey ? `:${sysKey}` : ''}`; const entry = groups.get(key) || { width: wi, height: hi, thickness: t, qty: 0, machining: machiningEntry, systemKeys: new Set() }; entry.qty += 1; if (sysKey) entry.systemKeys.add(sysKey); groups.set(key, entry); }; for (const col of columns) { const X = col.internalWidthMm; const NL = col.nominalLengthMm; for (const d of col.drawers) { if (elementType === 'tiroirFond') { const bottom = NL != null ? legraboxBottomPanelMm(NL, X) : null; if (!bottom) continue; addToGroup(bottom.lengthMm, bottom.widthMm, bottom.thicknessMm, { legraboxBottom: { nominalLengthMm: NL, internalWidthMm: X, rebate: bottom.rebate, note: `Fond tiroir Legrabox 16 mm — L = NL − 10 (${NL} − 10), l = X − 35 (${X} − 35) · feuillure 38 × 8 mm G+D` } }); } else if (elementType === 'tiroirDos') { const back = legraboxBackPanelMm(X, d.systemKey); if (!back) continue; addToGroup(back.widthMm, back.heightMm, back.thicknessMm, { legraboxBack: { systemKey: d.systemKey, internalWidthMm: X, note: `Paroi arrière tiroir Legrabox 16 mm — l = X − 38 (${X} − 38), h = ${back.heightMm} mm (système ${d.systemKey})` } }, d.systemKey); } else { /** Façade : largeur module − 4 (jeux), hauteur = niche SMS − 8 (jeu pris dans la niche). */ const fw = Math.max(100, Math.round(col.moduleWidthMm - 4)); const fh = Math.max(60, Math.round(d.nicheHeightMm - 8)); const insertaHoles = buildLegraboxFacadeInsertaHolePositionsMm(d.systemKey, fw, fh, X); addToGroup(fw, fh, PANEL_THICKNESS, { legraboxInserta: { systemKey: d.systemKey, holeDiameterMm: LEGRABOX_FACADE_INSERTA.holeDiameterMm, holeDepthMm: LEGRABOX_FACADE_INSERTA.holeDepthMm, holes: insertaHoles, note: `Façade tiroir Legrabox (système ${d.systemKey}) — INSERTA : ${LEGRABOX_FACADE_INSERTA.toolNote}` } }, d.systemKey); } } } if (groups.size === 0) continue; const labels = { tiroirFond: 'Fond de tiroir 16 mm (Legrabox)', tiroirDos: 'Paroi arrière tiroir 16 mm (Legrabox)', tiroirFacade: 'Façade de tiroir (Legrabox)' }; 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 sysSuffix = entry.systemKeys.size ? ` — ${Array.from(entry.systemKeys).join('/')}` : ''; const piece = { id: `piece-${elementType}-${groups.size === 1 ? 'all' : gi}`, elementType, description: groups.size === 1 ? `${labels[elementType]}${sysSuffix} (${entry.qty})` : `${labels[elementType]}${sysSuffix} (groupe ${gi}/${groups.size})`, finishId: finish.finishId, finishLabel: finish.label, widthMm: entry.width, heightMm: entry.height, thicknessMm: entry.thickness, quantity: entry.qty, unit: 'pcs', edgeBanding, machining: entry.machining, surfaceM2: calculateSurface(entry.width, entry.height, entry.qty) }; 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 === 'dos') { // Dos PAR ZONE (colonne × niveau si empilage): 1 panneau 8 mm par zone avec dos, regroupés par dimensions. const mods = effectiveModules(modules, config); const bodyHeightMm = (Number(config.heightMm) || 2400) - (Number(config.plinthHeightMm) || 100); const dosHeightMm = Math.max(50, Math.round(bodyHeightMm - PANEL_THICKNESS * 2)); const totalWidthMm = Number(config.widthMm) || 2400; const hasExplicitBackFlags = typeof config?.hasBack === 'boolean' || mods.some((m) => typeof m?.hasBack === 'boolean') || Boolean(spanCtx && mods.some((m) => Array.isArray(m?.blockBacks) || typeof m?.hasBackTop === 'boolean')); const groups = new Map(); // `${w}x${h}` => { width, height, qty } const addDos = (w, h) => { const key = `${w}x${h}`; const entry = groups.get(key) || { width: w, height: h, qty: 0 }; entry.qty += 1; groups.set(key, entry); }; if (mods.length === 0 || !hasExplicitBackFlags) { // Anciennes commandes (sans flags) : dos unique pleine largeur (comportement historique). if (anyBackPanel(config, modules)) { const w = Math.max(50, Math.round(totalWidthMm - PANEL_THICKNESS * 2)); addDos(w, dosHeightMm); } } else { for (let moduleIndex = 0; moduleIndex < mods.length; moduleIndex++) { const m = mods[moduleIndex]; const rawW = Number.isFinite(Number(m?.widthMm)) && Number(m.widthMm) > 0 ? Number(m.widthMm) : totalWidthMm / Math.max(1, mods.length); const w = Math.max(50, Math.round(rawW - PANEL_THICKNESS * 2)); // Colonne empilée: 1 dos potentiel par block, hauteurs propres à la colonne. let blockBottomMm = 0; for (const seg of moduleStackSegments(m, config)) { const blockTopMm = blockBottomMm + seg.heightMm; const covered = spanCoversModuleAt(spanCtx, moduleIndex, blockBottomMm, blockTopMm); blockBottomMm = blockTopMm; if (!seg.hasBack || covered) continue; addDos(w, Math.max(50, Math.round(seg.heightMm - PANEL_THICKNESS * 2))); } } } if (groups.size === 0) continue; const finish = colors[elementType] || { 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: BACK_PANEL_THICKNESS, quantity: entry.qty, description: groups.size === 1 ? `Dos (panneau arrière 8 mm) — ${entry.qty} zone${entry.qty > 1 ? 's' : ''} avec dos` : `Dos (panneau arrière 8 mm) — groupe ${gi}/${groups.size}` }; const machining = getMachining(elementType, dimensions, config, modules); const piece = { id: `piece-dos-${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), quantity: dimensions.quantity, 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 baseDims = getElementDimensions(elementType, config, modules); if (!baseDims) continue; const finish = colors[elementType] || { finishId: null, label: 'Non défini' }; const edgeBanding = getEdgeBanding(elementType); // Empilage vertical PAR COLONNE: chaque côté suit la colonne d'extrémité correspondante. const modsForSides = effectiveModules(modules, config); const leftMod = modsForSides[0] ?? null; const rightMod = modsForSides.length ? modsForSides[modsForSides.length - 1] : null; const sideAssembly = getExteriorSideAssembly(config); const segsBySide = { left: moduleStackSegments(leftMod, config), right: moduleStackSegments(rightMod, config) }; const sameStructure = segsBySide.left.length === segsBySide.right.length && segsBySide.left.every((sg, k) => sg.heightMm === segsBySide.right[k].heightMm); const segMeta = (seg) => seg.count <= 1 ? { suffix: '', label: '', allowRenfort: true } : { suffix: `b${seg.index + 1}`, label: `block ${seg.index + 1}${seg.index === 0 ? ' (bas)' : seg.index === seg.count - 1 ? ' (haut)' : ''}`, // Renfort tiroirs: les tiroirs partent du bas → block bas uniquement. allowRenfort: seg.index === 0 }; const pushCotePiece = (dims, opts) => { const piece = { id: opts.id, elementType, description: opts.description, finishId: finish.finishId, finishLabel: finish.label, widthMm: Math.round(dims.width), heightMm: Math.round(dims.height), thicknessMm: Math.round(dims.thickness ?? PANEL_THICKNESS), quantity: opts.quantity, unit: dims.unit || 'pcs', edgeBanding, machining: opts.machining, surfaceM2: calculateSurface(dims.width, dims.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); }; /** Usinage d'un côté à la hauteur donnée; renfort tiroirs seulement en zone basse. */ const buildMachining = (heightMm, allowRenfort, side = 'both') => { const inset = side === 'left' ? sideAssembly.left : side === 'right' ? sideAssembly.right : sideAssembly.left && sideAssembly.right; const dims = { ...baseDims, height: Math.max(50, heightMm - (inset ? PANEL_THICKNESS * 2 : 0)) }; let machining = getMachining( elementType, dims, { ...config, _coteAssemblySide: side }, modules ); if (!allowRenfort && machining?.drawerRenfortPanelIxconnect) { machining = { ...machining }; delete machining.drawerRenfortPanelIxconnect; } return { dims, machining }; }; if (sameStructure) { // Les 2 côtés ont la même structure (mêmes hauteurs de blocks) → pièces par block (paire ou split). for (let k = 0; k < segsBySide.left.length; k++) { const segL = segsBySide.left[k]; const segR = segsBySide.right[k]; const meta = segMeta(segL); const split = segL.count > 1; if (sideAssembly.left !== sideAssembly.right) { for (const side of ['left', 'right']) { const sideLabel = side === 'left' ? 'gauche' : 'droit'; const seg = side === 'left' ? segL : segR; const { dims, machining: baseMach } = buildMachining(seg.heightMm, meta.allowRenfort, side); let machining = scopeMachiningCotesDrawerRenfort( baseMach, side === 'left', side === 'right' ); const drm = machining?.drawerRenfortPanelIxconnect?.cotes; const hasRenfort = coteFaceHasRenfortHoles( side === 'left' ? drm?.leftInnerFace : drm?.rightInnerFace ); if (machining?.groove && !seg.hasBack) machining = stripBackGroove(machining); let desc = `Côté extérieur ${sideLabel}${seg.count > 1 ? ` — ${meta.label}` : ''}`; if (hasRenfort) { desc += ' — perçages Ixconnect renfort tiroirs (face intérieure caisse)'; } desc += seg.hasBack ? ' — avec rainure dos' : ' — sans rainure dos (zone sans dos)'; if (sideAssembly[side]) desc += ' — montage entre dessus et fond'; pushCotePiece(dims, { id: `piece-cotes-${sideLabel}${seg.count > 1 ? `-${meta.suffix}` : ''}`, description: desc, quantity: 1, machining }); } continue; } const { dims, machining: baseMach } = buildMachining(segL.heightMm, meta.allowRenfort, 'both'); let machining = baseMach; dims.description = split ? `Côtés extérieurs ${meta.label} (gauche et droit)` : baseDims.description; const drm = machining?.drawerRenfortPanelIxconnect?.cotes; const hasL = coteFaceHasRenfortHoles(drm?.leftInnerFace); const hasR = coteFaceHasRenfortHoles(drm?.rightInnerFace); // Rainure dos par côté: seulement si le block d'extrémité correspondant a un dos. const grooveL = segL.hasBack; const grooveR = segR.hasBack; if (machining?.groove && !grooveL && !grooveR) { machining = stripBackGroove(machining); } const grooveMismatch = Boolean(machining?.groove) && grooveL !== grooveR; const renfortMismatch = Boolean(drm) && hasL !== hasR; const idBase = split ? `piece-cotes-${meta.suffix}` : 'piece-cotes'; if (!renfortMismatch && !grooveMismatch) { const desc = hasL && hasR ? `${dims.description} — perçages Ixconnect renfort tiroirs (faces intérieures caisse)` : dims.description; pushCotePiece(dims, { id: idBase, description: desc, quantity: dims.quantity, machining }); } else { const sideDesc = (label, hasRenfort, hasGroove) => { let d = split ? `Côté extérieur ${label} — ${meta.label}` : `Côté extérieur ${label}`; if (hasRenfort) d += ' — perçages Ixconnect renfort tiroirs (face intérieure caisse)'; if (machining?.groove) d += hasGroove ? ' — avec rainure dos' : ' — sans rainure dos (zone sans dos)'; return d; }; const machiningFor = (includeLeft, includeRight, hasGroove) => { const scoped = scopeMachiningCotesDrawerRenfort(machining, includeLeft, includeRight); return hasGroove ? scoped : stripBackGroove(scoped); }; pushCotePiece(dims, { id: `${idBase}-gauche`, description: sideDesc('gauche', hasL, grooveL), quantity: 1, machining: machiningFor(true, false, grooveL) }); pushCotePiece(dims, { id: `${idBase}-droit`, description: sideDesc('droit', hasR, grooveR), quantity: 1, machining: machiningFor(false, true, grooveR) }); } } } else { // Structures différentes (empilage par colonne) → chaque côté émis séparément selon sa colonne. for (const side of ['left', 'right']) { const sideLabel = side === 'left' ? 'gauche' : 'droit'; for (const seg of segsBySide[side]) { const meta = segMeta(seg); const split = seg.count > 1; const { dims, machining: baseMach } = buildMachining(seg.heightMm, meta.allowRenfort, side); let machining = scopeMachiningCotesDrawerRenfort(baseMach, side === 'left', side === 'right'); const drm = machining?.drawerRenfortPanelIxconnect?.cotes; const hasRenfort = coteFaceHasRenfortHoles(side === 'left' ? drm?.leftInnerFace : drm?.rightInnerFace); const groove = seg.hasBack; if (machining?.groove && !groove) machining = stripBackGroove(machining); let desc = `Côté extérieur ${sideLabel}${split ? ` — ${meta.label}` : ''}`; if (hasRenfort) desc += ' — perçages Ixconnect renfort tiroirs (face intérieure caisse)'; desc += groove ? ' — avec rainure dos' : ' — sans rainure dos (zone sans dos)'; if (sideAssembly[side]) desc += ' — montage entre dessus et fond'; pushCotePiece(dims, { id: `piece-cotes-${sideLabel}${split ? `-${meta.suffix}` : ''}`, description: desc, quantity: 1, machining }); } } } continue; } if (elementType === 'panneauxInterieurs') { const baseDims = getElementDimensions(elementType, config, modules); if (!baseDims) continue; const finish = colors[elementType] || { finishId: null, label: 'Non défini' }; const edgeBanding = getEdgeBanding(elementType); const mods = effectiveModules(modules, config); const sepCount = baseDims.quantity; const pushSepPiece = (dimensions, 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, ...(opts.metadata || {}), 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 (spanCtx) { for (let s = 0; s < sepCount; s++) { const segments = getSeparatorVerticalSegments(spanCtx.config, s); for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) { const segment = segments[segmentIndex]; const physicalHeightMm = segment.topMm - segment.bottomMm; const heightMm = Math.max(50, physicalHeightMm - PANEL_THICKNESS * 2); const dimensions = { ...baseDims, height: heightMm, quantity: 1 }; let machining = getMachining(elementType, dimensions, config, modules); machining = scopeSeparatorSegmentMachining( machining, s, segment.bottomMm, segment.topMm ); const split = segments.length > 1 || physicalHeightMm < carcassBodyMm(config) - 0.5; pushSepPiece(dimensions, { id: `piece-panneauxInterieurs-sep${s + 1}${split ? `-seg${segmentIndex + 1}` : ''}`, description: `Séparateur vertical ${s + 1}/${sepCount}` + ` — segment ${Math.round(segment.bottomMm)}–${Math.round(segment.topMm)} mm` + ' — sans rainure dos, plaqué contre le dos', quantity: 1, machining, metadata: { separatorIndex: s, bottomMm: segment.bottomMm, topMm: segment.topMm } }); } } continue; } /** * Segments du séparateur s (entre colonnes s et s+1): 1 pièce par block si les deux * colonnes adjacentes ont les mêmes blocks (mêmes hauteurs). Sinon: pleine hauteur si * ≤ panneau max, ou suivant la colonne la plus découpée (pièces ≤ panneau max) au-delà. */ const segsForSeparator = (s) => { const a = mods[s]; const b = mods[s + 1]; if (a && b) { const sa = moduleStackSegments(a, config); const sb = moduleStackSegments(b, config); const aligned = sa.length === sb.length && sa.every((sg, k) => sg.heightMm === sb[k].heightMm); if (aligned) return sa; const body = carcassBodyMm(config); if (body <= STACK_BLOCK_MAX_MM) { return [{ index: 0, count: 1, heightMm: body, hasBack: false, level: null }]; } return sa.length >= sb.length ? sa : sb; } return moduleStackSegments(a ?? b ?? null, config); }; const anySplit = Array.from({ length: sepCount }, (_, s) => segsForSeparator(s)) .some((segs) => segs.length > 1); if (!anySplit) { // Aucune jonction d'empilage au droit des séparateurs → comportement historique (pleine hauteur). const dimensions = baseDims; const machining = getMachining(elementType, dimensions, config, modules); const ixSep = machining?.drawerRenfortPanelIxconnect?.separators; if (!ixSep || ixSep.length === 0) { pushSepPiece(dimensions, { 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(dimensions, { id: 'piece-panneauxInterieurs', description: desc, quantity: sepCount, machining: flags[0] ? machining : stripDrawerRenfortPanelIxconnect(machining) }); } else { for (let s = 0; s < sepCount; s++) { pushSepPiece(dimensions, { 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; } // Empilage par colonne: chaque séparateur émis selon ses propres segments (blocks). for (let s = 0; s < sepCount; s++) { for (const seg of segsForSeparator(s)) { const split = seg.count > 1; const heightMm = split ? Math.max(50, seg.heightMm - PANEL_THICKNESS * 2) : baseDims.height; const levelLabel = split ? ` — block ${seg.index + 1}${seg.index === 0 ? ' (bas)' : seg.index === seg.count - 1 ? ' (haut)' : ''}` : ''; const dimensions = { ...baseDims, height: heightMm, quantity: 1 }; let machining = getMachining(elementType, dimensions, config, modules); // Renfort tiroirs: les tiroirs partent du bas → perçages sur le block bas / pleine hauteur uniquement. if (split && seg.index > 0 && machining?.drawerRenfortPanelIxconnect) { machining = { ...machining }; delete machining.drawerRenfortPanelIxconnect; } else if (machining?.drawerRenfortPanelIxconnect) { machining = scopeMachiningSeparatorsDrawerRenfort(machining, s); } const hasRenfort = separatorHasRenfort(machining?.drawerRenfortPanelIxconnect?.separators?.[0]); pushSepPiece(dimensions, { id: `piece-panneauxInterieurs-sep${s + 1}${split ? `-b${seg.index + 1}` : ''}`, description: `Séparateur vertical ${s + 1}/${sepCount}${levelLabel} — sans rainure dos, plaqués contre le dos${ hasRenfort ? ' — perçages Ixconnect renfort tiroirs (faces vers colonnes)' : '' }`, quantity: 1, machining }); } } continue; } if ( (elementType === 'dessus' || elementType === 'fond') && (anyStackedColumn(config, modules) || effectiveModules(modules, config).length > 1) ) { // Faces extérieures selon construction: block (dos / rayonnage) vs séparations fusionnées. // Côtés extérieurs restent des montants block (jonction à l'angle) même sans dos. const dimensions = getElementDimensions(elementType, config, modules); if (!dimensions) continue; const finish = colors[elementType] || { finishId: null, label: 'Non défini' }; const edgeBanding = getEdgeBanding(elementType); const mods = effectiveModules(modules, config); const totalWidthMm = Number(config.widthMm) || 2400; const pushPanelPiece = (id, desc, dims, machining) => { const piece = { id, elementType, description: desc, finishId: finish.finishId, finishLabel: finish.label, widthMm: Math.round(dims.width), heightMm: Math.round(dims.height), thicknessMm: Math.round(dims.thickness ?? PANEL_THICKNESS), quantity: dims.quantity, unit: dims.unit || 'pcs', edgeBanding, machining, surfaceM2: calculateSurface(dims.width, dims.height, dims.quantity) }; const raw = computeRawCutDimensions(piece); piece.rawCutWidthMm = raw.rawCutWidthMm; piece.rawCutHeightMm = raw.rawCutHeightMm; piece.piecePlanSvg = generatePiecePlanSvg(piece); piece.piecePlanViews = generatePiecePlanViews(piece); pieces.push(piece); }; // 1) Faces extérieures: 1 pièce par caisson block ; colonnes en séparations contiguës fusionnées. // Côtés extérieurs = montants block (jonction à l'angle) même sans dos. const mainLevel = elementType === 'dessus' ? 'top' : 'bottom'; const sideAssembly = getExteriorSideAssembly(config); if (mods.length <= 1) { const mainMach = getMachining(elementType, dimensions, { ...config, _grooveLevel: mainLevel }, modules); pushPanelPiece( `piece-${elementType}`, elementType === 'dessus' ? 'Panneau du dessus (toit)' : 'Panneau du fond (bas)', dimensions, mainMach ); } else { const runLayouts = horizontalRunLayouts(mods, config, totalWidthMm, mainLevel); for (const run of runLayouts) { const dims = { ...dimensions, width: run.widthMm, quantity: 1 }; const axiloSpan = elementType === 'fond' ? axiloSpanConfigForRun(runLayouts, run) : {}; let machining = getMachining( elementType, dims, { ...config, _grooveForce: true, _axiloFeetEnabled: elementType === 'fond', _horizontalAssemblyLeft: run.start === 0 && sideAssembly.left, _horizontalAssemblyRight: run.end === mods.length - 1 && sideAssembly.right, ...axiloSpan }, mods.slice(run.start, run.end + 1).map((module) => ({ ...module, widthMm: moduleWidthMm(module, totalWidthMm, mods.length) })) ); if (machining?.groove && !run.hasBack) machining = stripBackGroove(machining); const colLabel = run.start === run.end ? `colonne ${run.start + 1}` : `colonnes ${run.start + 1}–${run.end + 1}`; pushPanelPiece( `piece-${elementType}-col${run.start + 1}${run.start === run.end ? '' : `-${run.end + 1}`}`, `${elementType === 'dessus' ? 'Panneau du dessus (toit)' : 'Panneau du fond (bas)'} — ${colLabel}`, dims, machining ); } } // 2) Jonctions: pour chaque colonne empilée, 1 dessus par block sauf le dernier // (resp. 1 fond par block sauf le premier), regroupées par (largeur, rainure). const jonctionLevel = elementType === 'dessus' ? 'bottom' : 'top'; const groups = new Map(); // `${w}|${groove}|${leftFace}|${rightFace}` const addJonction = (w, groove, leftFace, rightFace) => { const key = `${w}|${groove}|${leftFace}|${rightFace}`; const entry = groups.get(key) || { width: w, groove, leftFace, rightFace, qty: 0 }; entry.qty += 1; groups.set(key, entry); }; if (mods.length === 0) { const legacyCount = Math.max(0, moduleBlockHeightsMm(null, config).length - 1); const w = Math.max(50, Math.round(totalWidthMm - PANEL_THICKNESS * 2)); const groove = anyBackAtLevel(config, modules, jonctionLevel); for (let k = 0; k < legacyCount; k++) { addJonction(w, groove, sideAssembly.left, sideAssembly.right); } } else { for (let moduleIndex = 0; moduleIndex < mods.length; moduleIndex++) { const m = mods[moduleIndex]; const heights = moduleBlockHeightsMm(m, config); if (heights.length < 2) continue; const backs = moduleBlockBacks(m, config, heights.length); const rawW = Number.isFinite(Number(m?.widthMm)) && Number(m.widthMm) > 0 ? Number(m.widthMm) : totalWidthMm / Math.max(1, mods.length); const leftFace = moduleIndex === 0 && sideAssembly.left; const rightFace = moduleIndex === mods.length - 1 && sideAssembly.right; const leftInset = horizontalInsetMm(mods, config, moduleIndex, 'left'); const rightInset = horizontalInsetMm(mods, config, moduleIndex, 'right'); const w = Math.max(50, Math.round(rawW - leftInset - rightInset)); // dessus: blocks 0..n-2 (dessus du block inférieur de chaque jonction); // fond: blocks 1..n-1 (fond du block supérieur de chaque jonction). const boundaries = [0]; heights.forEach((height) => boundaries.push(boundaries[boundaries.length - 1] + height)); const from = elementType === 'dessus' ? 0 : 1; const to = elementType === 'dessus' ? heights.length - 2 : heights.length - 1; for (let k = from; k <= to; k++) { const boundaryMm = elementType === 'dessus' ? boundaries[k + 1] : boundaries[k]; if (spanReplacesHorizontalFace(spanCtx, moduleIndex, boundaryMm, elementType)) continue; addJonction(w, backs[k], leftFace, rightFace); } } } let gi = 0; for (const entry of groups.values()) { gi += 1; const dims = { ...dimensions, width: entry.width, quantity: entry.qty }; // _grooveForce: la rainure de la jonction dépend du dos du block concerné (entry.groove), // pas des niveaux bas/haut globaux — on force puis on retire si le block n'a pas de dos. let machining = getMachining( elementType, dims, { ...config, _grooveForce: true, _axiloFeetEnabled: false, _horizontalAssemblyLeft: entry.leftFace, _horizontalAssemblyRight: entry.rightFace }, [{ widthMm: entry.width }] ); if (machining?.groove && !entry.groove) machining = stripBackGroove(machining); const jonctionDesc = elementType === 'dessus' ? 'Panneau du dessus — block bas (jonction empilage)' : 'Panneau du fond — block haut (jonction empilage)'; pushPanelPiece( `piece-${elementType}-jonction${groups.size > 1 ? `-${gi}` : ''}`, `${jonctionDesc}${entry.qty > 1 ? ` (${entry.qty})` : ''}${entry.groove ? '' : ' — sans rainure dos'}`, dims, machining ); } continue; } const dimensions = getElementDimensions(elementType, config, modules); if (!dimensions) continue; const finish = colors[elementType] || ((elementType === 'fileurCotes' || elementType === 'fileurHaut') ? colors.fileur : null) || ((elementType === 'plintheGauche' || elementType === 'plintheDroite') ? colors.plintheBas : null) || { finishId: null, label: 'Non défini' }; const edgeBanding = getEdgeBanding(elementType); // Habillage vertical > panneau max: découpé en segments (jonction horizontale), // segment haut = onglet éventuel, segment bas = pied. if ( (elementType === 'habillageFinitionGauche' || elementType === 'habillageFinitionDroite') && dimensions.height > STACK_BLOCK_MAX_MM ) { const nSeg = Math.ceil(dimensions.height / STACK_BLOCK_MAX_MM); const baseH = Math.floor(dimensions.height / nSeg); let segBottomMm = 0; for (let sIdx = 0; sIdx < nSeg; sIdx++) { const segH = sIdx === nSeg - 1 ? dimensions.height - baseH * (nSeg - 1) : baseH; const segDims = { ...dimensions, height: segH, description: `${dimensions.description} — partie ${sIdx + 1}/${nSeg}${sIdx === nSeg - 1 ? ' (haut)' : sIdx === 0 ? ' (bas)' : ''}` }; const segMach = getMachining( elementType, segDims, { ...config, _habillageSegmentBottomMm: segBottomMm }, modules ); segBottomMm += segH; const segPiece = { id: `piece-${elementType}-p${sIdx + 1}`, elementType, description: segDims.description, finishId: finish.finishId, finishLabel: finish.label, widthMm: Math.round(segDims.width), heightMm: Math.round(segDims.height), thicknessMm: Math.round(segDims.thickness ?? PANEL_THICKNESS), quantity: segDims.quantity, unit: segDims.unit || 'pcs', edgeBanding, machining: segMach, surfaceM2: calculateSurface(segDims.width, segDims.height, segDims.quantity) }; // Onglet haut uniquement sur le segment supérieur. segPiece.habillageSideTopMiter = sIdx === nSeg - 1 && config?.topFinishEnabled !== false; const segRaw = computeRawCutDimensions(segPiece); segPiece.rawCutWidthMm = segRaw.rawCutWidthMm; segPiece.rawCutHeightMm = segRaw.rawCutHeightMm; segPiece.piecePlanSvg = generatePiecePlanSvg(segPiece); segPiece.piecePlanViews = generatePiecePlanViews(segPiece); pieces.push(segPiece); } continue; } 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); } if (spanCtx) { const addSpanPiece = ({ span, role, elementType, widthMm, heightMm, thicknessMm, machining, idSuffix = '', description, metadata = {} }) => { const finish = colors[elementType] || (elementType === 'dos' ? colors.dos : colors[elementType === 'fond' ? 'fond' : 'dessus']) || { finishId: null, label: 'Non défini' }; const coveredColumns = span.moduleIndexes.map((index) => index + 1).join('–'); const safeSpanId = encodeURIComponent(String(span.span.id)).replace(/%/g, '_'); const roleLabels = { bottom: 'Panneau horizontal bas', top: 'Panneau horizontal haut', back: 'Dos horizontal partagé', door: 'Porte de portée horizontale' }; const piece = { id: `piece-horizontalSpan-${safeSpanId}-${role}${idSuffix}`, elementType, description: description || `${roleLabels[role]} — colonnes ${coveredColumns} — portée ${Math.round(span.widthMm)} mm`, finishId: finish.finishId, finishLabel: finish.label, widthMm: Math.round(widthMm), heightMm: Math.round(heightMm), thicknessMm: Math.round(thicknessMm), quantity: 1, unit: 'pcs', edgeBanding: getEdgeBanding(elementType), machining, horizontalSpanId: span.span.id, moduleIds: [...span.moduleIds], bottomMm: span.bottomMm, topMm: span.topMm, horizontalSpanRole: role, ...metadata, surfaceM2: calculateSurface(widthMm, heightMm, 1) }; const raw = computeRawCutDimensions(piece); piece.rawCutWidthMm = raw.rawCutWidthMm; piece.rawCutHeightMm = raw.rawCutHeightMm; piece.piecePlanSvg = generatePiecePlanSvg(piece); piece.piecePlanViews = generatePiecePlanViews(piece); pieces.push(piece); }; const sideAssembly = getExteriorSideAssembly(config); for (const span of spanCtx.resolved) { const firstIdx = span.moduleIndexes[0]; const lastIdx = span.moduleIndexes[span.moduleIndexes.length - 1]; const leftInset = horizontalInsetMm(spanCtx.modules, config, firstIdx, 'left'); const rightInset = horizontalInsetMm(spanCtx.modules, config, lastIdx, 'right'); const panelWidthMm = Math.max(50, span.widthMm - leftInset - rightInset); const backPanelWidthMm = panelWidthMm; const touchesLeftExterior = firstIdx === 0; const touchesRightExterior = lastIdx === spanCtx.modules.length - 1; const leftFace = touchesLeftExterior && sideAssembly.left; const rightFace = touchesRightExterior && sideAssembly.right; const coveredColumns = span.moduleIndexes.map((index) => ({ id: spanCtx.modules[index]?.id, widthMm: index === span.moduleIndexes[0] ? panelWidthMm : 0, hasBack: Boolean(span.span.hasBack), blockHeightsMm: [span.topMm - span.bottomMm], blockDoors: [false] })); const machiningModules = coveredColumns.slice(0, 1); const machiningConfig = { ...config, widthMm: panelWidthMm, modules: machiningModules, horizontalSpans: [], _axiloFeetEnabled: false, _grooveForce: Boolean(span.span.hasBack), _horizontalAssemblyLeft: leftFace, _horizontalAssemblyRight: rightFace }; const bodyMm = carcassBodyMm(config); for (const role of ['bottom', 'top']) { // Sol / toit de caisse: le panneau structurel (fond / dessus) porte déjà // ce rôle — pas de deuxième panneau horizontal, et AXILO reste sous le fond. if (role === 'bottom' && Number(span.bottomMm) <= 0.5) continue; if (role === 'top' && Number(span.topMm) >= bodyMm - 0.5) continue; const elementType = role === 'bottom' ? 'fond' : 'dessus'; const dimensions = { width: panelWidthMm, height: Math.max(40, Number(config.depthMm || 600)), thickness: PANEL_THICKNESS, quantity: 1 }; let machining = getMachining(elementType, dimensions, machiningConfig, machiningModules); if (!span.span.hasBack) machining = stripBackGroove(machining); addSpanPiece({ span, role, elementType, widthMm: dimensions.width, heightMm: dimensions.height, thicknessMm: dimensions.thickness, machining }); } if (span.span.hasBack) { addSpanPiece({ span, role: 'back', elementType: 'dos', widthMm: backPanelWidthMm, heightMm: Math.max(50, span.topMm - span.bottomMm - PANEL_THICKNESS * 2), thicknessMm: BACK_PANEL_THICKNESS, machining: null }); } const doorMode = span.span.doorMode || 'none'; if (config.doorType !== 'sliding' && doorMode !== 'none') { const hw = config.hardware || {}; const widthDeductionMm = hw.handleType === 'full_height' ? HANDLE.fullHeightWidthDeductionMm : 0; const doorHeightMm = Math.max(120, Math.round(span.topMm - span.bottomMm - 4)); const doorDefs = doorMode === 'single' ? [{ moduleId: null, moduleIndex: span.moduleIndexes[0], widthMm: Math.max(100, Math.floor(span.widthMm) - 4 - widthDeductionMm) }] : [ { moduleId: null, moduleIndex: span.moduleIndexes[0], hingeSide: 'left', widthMm: Math.max( 100, Math.floor(span.widthMm / 2) - 4 - widthDeductionMm ) }, { moduleId: null, moduleIndex: span.moduleIndexes[span.moduleIndexes.length - 1], hingeSide: 'right', widthMm: Math.max( 100, Math.floor(span.widthMm / 2) - 4 - widthDeductionMm ) } ]; const doorCount = doorDefs.length; doorDefs.forEach((door, doorIndex) => { const hingeSide = door.hingeSide || getDoorHingeSide(doorIndex, doorCount, hw); const dimensions = { width: door.widthMm, height: doorHeightMm, thickness: PANEL_THICKNESS, quantity: 1 }; const machining = getMachining('portes', dimensions, config, modules); addSpanPiece({ span, role: 'door', elementType: 'portes', widthMm: dimensions.width, heightMm: dimensions.height, thicknessMm: dimensions.thickness, machining, idSuffix: `-${doorIndex + 1}`, description: `Porte portée horizontale ${doorIndex + 1}/${doorCount} (${doorMode}) — ` + `colonnes ${span.moduleIndexes.map((index) => index + 1).join('–')}`, metadata: { doorMode, doorIndex, doorNumber: doorIndex + 1, doorCount, moduleId: door.moduleId, doorModuleId: door.moduleId, hingeSide, doorPlanHints: { doorCount: 1, hingeSides: [hingeSide], hingeSide, hardware: hw, fullHeightHandle: hw.handleType === 'full_height' } } }); }); } } } // 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(`Surcote sur-mesure: +${CUTTING_SURCOTE_MM} mm en longueur et en largeur (incluse dans les dim. brutes)`); 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 (surcote +10) | Ép. | Qté | Surface | Chants (sens) | Usinage'); for (const piece of group.pieces) { const machiningStr = piece.machining ? Object.keys(piece.machining).join(', ') : '-'; const bandingStr = piece.edgeBanding && piece.edgeBanding.type !== 'none' ? `${piece.edgeBanding.sides} côtés (${piece.edgeBanding.direction?.label || '-'})` : '-'; 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² | ${bandingStr} | ${machiningStr}` ); } lines.push(`Sous-total: ${group.pieceCount} pièces, ${group.totalSurfaceM2.toFixed(2)} m²`); lines.push(''); } return lines.join('\n'); } export default { generateCuttingList, formatCuttingListForExport };