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