Files
apex/server/services/cuttingEngine.js
T
lejayandCursor 6c0d7e47b8 v2.0
Co-authored-by: Cursor <[email protected]>
2026-05-28 07:20:38 +02:00

2975 lines
142 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 += `<path d="M ${x - halfW} ${y0} L ${x - halfW} ${y1} L ${x + halfW} ${y1} L ${x + halfW} ${y0}" fill="none" stroke="#64748b" stroke-width="0.95" stroke-dasharray="4 3" stroke-linejoin="round"/>`;
s += `<line x1="${x}" y1="${y0}" x2="${x}" y2="${y1}" stroke="#334155" stroke-width="0.45" stroke-dasharray="2 2 1 2" opacity="0.9"/>`;
}
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 += `<g class="grooveDosFaceA"><title>Rainure dos ${fmt(gw)}×${fmt(gd)} mm · axe ${fmt(axisMm)} mm du bord dos · retrait ${fmt(endInsetMm)} mm en bout</title>`;
s += `<rect x="${gx}" y="${pAy + insetV}" width="${gw * sa}" height="${Math.max(2, hA - 2 * insetV)}" class="grooveFill" stroke="#0d9488" stroke-width="0.7"/>`;
s += `<text x="${gx + (gw * sa) / 2}" y="${pAy + insetV + Math.min(11, hA * 0.04)}" text-anchor="middle" class="txt" fill="#0f766e" font-size="7.5" font-weight="600">dos</text>`;
s += `</g>`;
} 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 += `<g class="grooveDosFaceA"><title>Rainure dos ${fmt(gw)}×${fmt(gd)} mm · axe ${fmt(axisMm)} mm du bord dos · pleine longueur</title>`;
s += `<rect x="${xin}" y="${gy}" width="${win}" height="${gw * sa}" class="grooveFill" stroke="#0d9488" stroke-width="0.7"/>`;
s += `</g>`;
}
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 += `<rect x="${gx}" y="${gy}" width="${grooveW}" height="${grooveD}" class="grooveFill"/>`;
if (fromBottom) {
s += `<line x1="${midX}" y1="${pCy + tC + 2}" x2="${midX}" y2="${pCy + tC + 18}" class="guide"/>`;
s += `<text x="${midX}" y="${pCy + tC + 30}" text-anchor="middle" class="txt" fill="#334155" font-size="9">Axe → ${fmt(axisMm)} mm du bord dos</text>`;
} else {
s += `<line x1="${midX}" y1="${pCy - 18}" x2="${midX}" y2="${pCy - 2}" class="guide"/>`;
s += `<text x="${midX}" y="${pCy - 26}" text-anchor="middle" class="txt" fill="#334155" font-size="8.5">Ouverture face caisse · ${fmt(axisMm)} mm du dos</text>`;
}
s += `<text x="${pCx + wC - 6}" y="${pCy + tC + 12}" text-anchor="end" class="txt" fill="#64748b" font-size="8.5">Bord dos</text>`;
s += `<text x="${pCx + 4}" y="${pCy - 6}" class="txt" fill="#0f766e" font-size="9">${fmt(m.groove.width || GROOVE.back.width)} × ${fmt(depthMm)} mm</text>`;
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 += `<text x="${x0}" y="${y0 - 14}" font-family="Arial,sans-serif" font-size="11" font-weight="bold" fill="#111">A — Coupe sur épaisseur (dos à droite)</text>`;
o += `<rect x="${x0}" y="${y0}" width="${Wsec}" height="${Ht}" fill="#faf6ef" stroke="#1f2937" stroke-width="1.2"/>`;
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 += `<rect x="${gx}" y="${gy}" width="${gwp}" height="${gdp}" fill="#2a9d8f" fill-opacity="0.4" stroke="#0f766e" stroke-width="1"/>`;
o += `<line x1="${gx}" y1="${y0 + Ht + 14}" x2="${gx + gwp}" y2="${y0 + Ht + 14}" stroke="#0f766e" stroke-width="0.9"/>`;
o += `<line x1="${gx}" y1="${y0 + Ht + 8}" x2="${gx}" y2="${y0 + Ht + 20}" stroke="#0f766e"/>`;
o += `<line x1="${gx + gwp}" y1="${y0 + Ht + 8}" x2="${gx + gwp}" y2="${y0 + Ht + 20}" stroke="#0f766e"/>`;
o += `<text x="${gx + gwp / 2}" y="${y0 + Ht + 32}" text-anchor="middle" font-family="Arial,sans-serif" font-size="9.5" fill="#0f766e">${fmt(gw)} ±0.3</text>`;
o += `<line x1="${x0 - 12}" y1="${y0}" x2="${x0 - 12}" y2="${y0 + Ht}" stroke="#0f766e" stroke-width="0.9"/>`;
o += `<line x1="${x0 - 18}" y1="${y0}" x2="${x0 - 6}" y2="${y0}" stroke="#0f766e"/>`;
o += `<line x1="${x0 - 18}" y1="${y0 + Ht}" x2="${x0 - 6}" y2="${y0 + Ht}" stroke="#0f766e"/>`;
o += `<text x="${x0 - 22}" y="${y0 + Ht / 2 + 3}" text-anchor="middle" font-family="Arial,sans-serif" font-size="9.5" fill="#0f766e" transform="rotate(-90 ${x0 - 22} ${y0 + Ht / 2})">ép. ${fmt(tMm)}</text>`;
const depLineX = gx + gwp + 28;
const y1d = fromBottom ? y0 + Ht : y0;
const y2d = fromBottom ? gy : y0 + gdp;
o += `<line x1="${depLineX}" y1="${y1d}" x2="${depLineX}" y2="${y2d}" stroke="#0f766e" stroke-width="0.9"/>`;
o += `<text x="${depLineX + 5}" y="${(y1d + y2d) / 2 + 3}" font-family="Arial,sans-serif" font-size="9" fill="#0f766e">${fmt(gd)}</text>`;
const x1 = 400;
const y1 = 58;
const Lface = 300;
const faceH = Math.max(36, Ht + 16);
o += `<text x="${x1}" y="${y1 - 14}" font-family="Arial,sans-serif" font-size="11" font-weight="bold" fill="#111">B — Face intérieure (rainure en trait mixte)</text>`;
o += `<path d="M ${x1} ${y1 + faceH / 2} l 8 -6 l -4 6 l 4 6 l -8 -6" fill="none" stroke="#64748b" stroke-width="0.8"/>`;
o += `<path d="M ${x1 + Lface} ${y1 + faceH / 2} l -8 -6 l 4 6 l -4 6 l 8 -6" fill="none" stroke="#64748b" stroke-width="0.8"/>`;
o += `<rect x="${x1 + 20}" y="${y1}" width="${Lface - 40}" height="${faceH}" fill="#faf6ef" stroke="#1f2937" stroke-width="1.2"/>`;
if (BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(piece.elementType)) {
const fw = Lface - 40;
const gx2 = x1 + 20 + fw - axisPx - gwp / 2;
o += `<rect x="${gx2}" y="${y1 + 6}" width="${gwp}" height="${faceH - 12}" fill="#2a9d8f" fill-opacity="0.35" stroke="#0f766e" stroke-width="0.9" stroke-dasharray="4 3"/>`;
o += `<line x1="${gx2}" y1="${y1 + faceH + 10}" x2="${x1 + 20 + fw}" y2="${y1 + faceH + 10}" stroke="#0f766e"/>`;
o += `<text x="${(gx2 + x1 + 20 + fw) / 2}" y="${y1 + faceH + 22}" text-anchor="middle" font-family="Arial,sans-serif" font-size="9" fill="#0f766e">${fmt(ax)} mm (axe au bord dos)</text>`;
} else {
const fw = Lface - 40;
const cy = y1 + axisPx;
const gy2 = cy - gwp / 2;
o += `<rect x="${x1 + 24}" y="${gy2}" width="${fw - 8}" height="${gwp}" fill="#2a9d8f" fill-opacity="0.35" stroke="#0f766e" stroke-width="0.9" stroke-dasharray="4 3"/>`;
o += `<line x1="${x1 + 16}" y1="${y1}" x2="${x1 + 16}" y2="${cy}" stroke="#0f766e"/>`;
o += `<text x="${x1 + 4}" y="${(y1 + cy) / 2}" font-family="Arial,sans-serif" font-size="9" fill="#0f766e" transform="rotate(-90 ${x1 + 4} ${(y1 + cy) / 2})">${fmt(ax)} mm</text>`;
}
o += `<text x="${x0}" y="${y0 + Ht + 58}" font-family="Arial,sans-serif" font-size="9" fill="#475569">Rainure réception dos · cotation indicative ±0.3 mm sur largeur de fraisage</text>`;
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 += `<line x1="${pBx}" y1="${axisCenter}" x2="${pBx + longB}" y2="${axisCenter}" class="guide"/>`;
s += `<circle cx="${posIx}" cy="${axisCenter}" r="${rIx}" class="connector"/>`;
} else {
s += `<line x1="${axisCenter}" y1="${pBy}" x2="${axisCenter}" y2="${pBy + longB}" class="guide"/>`;
s += `<circle cx="${axisCenter}" cy="${posIx}" r="${rIx}" class="connector"/>`;
}
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 += `<circle cx="${pos}" cy="${axisCenter}" r="${rT}" class="connectorTourillon"/>`;
} else {
s += `<circle cx="${axisCenter}" cy="${pos}" r="${rT}" class="connectorTourillon"/>`;
}
}
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 += `<text x="${pBx}" y="${pBy + tB + 14}" class="txt">${rf.connectorModel || 'SC 8/60'} (par chant) axe ${fmt(ixFromTop)} mm</text>`;
s += `<text x="${pBx}" y="${pBy + tB + 26}" class="txt">Tourillons Ø${rf.tourillonDiameter || 8}: ${axesText} mm depuis haut</text>`;
// 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 += `<line x1="${leftX}" y1="${dimY20}" x2="${firstX}" y2="${dimY20}" stroke="#0f766e" stroke-width="0.9"/>`;
s += `<line x1="${leftX}" y1="${dimY20 - 4}" x2="${leftX}" y2="${dimY20 + 4}" stroke="#0f766e" stroke-width="0.9"/>`;
s += `<line x1="${firstX}" y1="${dimY20 - 4}" x2="${firstX}" y2="${dimY20 + 4}" stroke="#0f766e" stroke-width="0.9"/>`;
s += `<text x="${(leftX + firstX) / 2}" y="${dimY20 - 4}" text-anchor="middle" class="txt" fill="#0f766e" font-size="8">${fmt(leftEdgeOffsetMm)} mm</text>`;
s += `<line x1="${lastX}" y1="${dimY20}" x2="${rightX}" y2="${dimY20}" stroke="#0f766e" stroke-width="0.9"/>`;
s += `<line x1="${lastX}" y1="${dimY20 - 4}" x2="${lastX}" y2="${dimY20 + 4}" stroke="#0f766e" stroke-width="0.9"/>`;
s += `<line x1="${rightX}" y1="${dimY20 - 4}" x2="${rightX}" y2="${dimY20 + 4}" stroke="#0f766e" stroke-width="0.9"/>`;
s += `<text x="${(lastX + rightX) / 2}" y="${dimY20 - 4}" text-anchor="middle" class="txt" fill="#0f766e" font-size="8">${fmt(rightEdgeOffsetMm)} mm</text>`;
}
// Center hole (Ixconnect) dimension from edge: typically 50 mm on 100 mm reinforcement width.
s += `<line x1="${pBx}" y1="${dimY50}" x2="${posIx}" y2="${dimY50}" stroke="#0369a1" stroke-width="0.9"/>`;
s += `<line x1="${pBx}" y1="${dimY50 - 4}" x2="${pBx}" y2="${dimY50 + 4}" stroke="#0369a1" stroke-width="0.9"/>`;
s += `<line x1="${posIx}" y1="${dimY50 - 4}" x2="${posIx}" y2="${dimY50 + 4}" stroke="#0369a1" stroke-width="0.9"/>`;
s += `<text x="${(pBx + posIx) / 2}" y="${dimY50 - 4}" text-anchor="middle" class="txt" fill="#0369a1" font-size="8">${fmt(ixFromTop)} mm</text>`;
} else {
s += `<text x="${pBx + tB + 8}" y="${pBy + longB * 0.48}" class="txt">${rf.connectorModel || 'SC 8/60'} (par chant) axe ${fmt(ixFromTop)} mm</text>`;
s += `<text x="${pBx + tB + 8}" y="${pBy + longB * 0.48 + 12}" class="txt">Tourillons Ø${rf.tourillonDiameter || 8}: ${axesText} mm depuis haut</text>`;
}
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 += `<circle cx="${cupX}" cy="${y}" r="${cupR}" class="hinge"/><text x="${hingeSide === 'right' ? cupX - cupR - 28 : cupX + cupR + 5}" y="${y + 3}" class="txt">${Math.round(pos)}mm</text>`;
viewAOps += `<circle cx="${screwX}" cy="${y - halfSpPx}" r="${screwR}" class="hinge"/>`;
viewAOps += `<circle cx="${screwX}" cy="${y + halfSpPx}" r="${screwR}" class="hinge"/>`;
}
}
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 += `<line x1="${gx}" y1="${y1}" x2="${gx}" y2="${y2}" stroke="#0d9488" stroke-width="1.2" stroke-dasharray="4,3"/>`;
viewAOps += `<text x="${gx + (hingeSide === 'right' ? 8 : -8)}" y="${pAy + hA * 0.5}" class="txt" text-anchor="${hingeSide === 'right' ? 'start' : 'end'}" fill="#0d9488">Rainure TH · ${fmt(gw)}×${fmt(gd)} mm · retrait ${fmt(off)} mm H/B</text>`;
const dx = pAx - 6;
const mida = (y0, y1, label) => {
const ym = (y0 + y1) / 2;
return `<line x1="${dx}" y1="${y0}" x2="${dx}" y2="${y1}" stroke="#0f766e" stroke-width="0.6"/><text x="${dx - 4}" y="${ym + 3}" font-size="7.5" fill="#0f766e" text-anchor="end">${label} mm</text>`;
};
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 &amp; perçages`
: 'Détail usinage porte — rainure &amp; perçages';
s += `<text x="${x1}" y="${yTitle}" font-family="Arial,sans-serif" font-size="11.5" font-weight="bold" fill="#1f2937">${titleText}</text>`;
// 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 += `<text x="${px}" y="${py}" font-family="Arial,sans-serif" font-size="9.5" fill="#64748b">Coupe sur chant poignée (profil rainure dans l'épaisseur)</text>`;
const barY = py + 10;
s += `<rect x="${px}" y="${barY}" width="${barW}" height="${barH}" fill="#faf6ef" stroke="#333" stroke-width="0.9"/>`;
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 += `<rect x="${gX}" y="${gY}" width="${gDpx}" height="${ghVis}" fill="#2a9d8f" opacity="0.38" stroke="#0d9488" stroke-width="0.85"/>`;
s += `<text x="${px}" y="${barY + barH + 14}" font-family="Arial,sans-serif" font-size="9.5" fill="#0f766e">Ouverture rainure ${fmt(gw)} mm · Profondeur ${fmt(gd)} mm</text>`;
s += `<text x="${px}" y="${barY + barH + 26}" font-family="Arial,sans-serif" font-size="8.5" fill="#64748b">Épaisseur panneau ${fmt(tMm)} mm</text>`;
// Petites cotes sur la coupe
s += `<line x1="${gX + gDpx}" y1="${barY + barH + 4}" x2="${gX + gDpx}" y2="${barY + barH + 14}" stroke="#0f766e" stroke-width="0.5"/>`;
s += `<line x1="${gX}" y1="${barY + barH + 4}" x2="${gX + gDpx}" y2="${barY + barH + 4}" stroke="#0f766e" stroke-width="0.5"/>`;
s += `<text x="${gX + gDpx / 2}" y="${barY + barH + 26}" font-size="8" fill="#0f766e" text-anchor="middle">${fmt(gd)}</text>`;
s += `<text x="${gX + gDpx + 8}" y="${gY + gWpx / 2}" font-size="8" fill="#0f766e">${fmt(gw)}</text>`;
// Colonne 2 — Schéma hauteur rainure TH
const vx = x2 + 14;
const vy = barY;
s += `<text x="${x2}" y="${py}" font-family="Arial,sans-serif" font-size="9.5" fill="#64748b">Hauteur rainure (poignée TH)</text>`;
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 += `<line x1="${vx}" y1="${vy}" x2="${vx}" y2="${vy + vLen}" stroke="#334155" stroke-width="1.2"/>`;
s += `<line x1="${vx - 5}" y1="${vy}" x2="${vx + 5}" y2="${vy}" stroke="#334155"/>`;
s += `<line x1="${vx - 5}" y1="${vy + vLen}" x2="${vx + 5}" y2="${vy + vLen}" stroke="#334155"/>`;
s += `<line x1="${vx - 5}" y1="${vy + oPx}" x2="${vx + 5}" y2="${vy + oPx}" stroke="#0d9488"/>`;
s += `<line x1="${vx - 5}" y1="${vy + vLen - oPx}" x2="${vx + 5}" y2="${vy + vLen - oPx}" stroke="#0d9488"/>`;
s += `<text x="${vx + 10}" y="${vy + oPx / 2 + 3}" font-size="8.5" fill="#0d9488">Retrait ${fmt(off)} mm</text>`;
s += `<text x="${vx + 10}" y="${vy + oPx + (vLen - 2 * oPx) / 2}" font-size="8.5" fill="#334155">Utile rainure ${fmt(grooveLen)} mm</text>`;
s += `<text x="${vx + 10}" y="${vy + vLen - oPx / 2 + 3}" font-size="8.5" fill="#0d9488">Retrait ${fmt(off)} mm</text>`;
} else {
s += `<text x="${x2}" y="${vy + 24}" font-size="9" fill="#94a3b8">Poignée standard — pas de rainure TH</text>`;
}
// Colonne 3 — Perçages
let ly = y0 + 8;
s += `<text x="${x3}" y="${py}" font-family="Arial,sans-serif" font-size="9.5" fill="#64748b">Perçages</text>`;
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 += `<text x="${x3}" y="${ly + 8}" font-size="9.5" fill="#1f2937"><tspan font-weight="600">Charnières</tspan> — cuvette Ø${hc.diameter} mm, prof. ${hc.cupDepth} mm</text>`;
s += `<text x="${x3}" y="${ly + 22}" font-size="9" fill="#475569">Axes depuis haut : ${pos} mm — vis Ø${sd}×${sz} mm (${ssp} mm c/c, ${sof} mm du centre cuvette)</text>`;
ly += 36;
}
if (fs) {
s += `<text x="${x3}" y="${ly + 8}" font-size="9.5" fill="#1f2937"><tspan font-weight="600">${fs.model || 'SC 8/25'}</tspan> — face Ø${fs.faceHole?.diameter || 8} × prof. ${fs.faceHole?.depth || 12.5} mm</text>`;
s += `<text x="${x3}" y="${ly + 22}" font-size="9" fill="#475569">Axes depuis face intérieure : ${fs.axes?.back ?? 30} mm (arrière) / ${fs.axes?.front ?? 43} mm (avant)</text>`;
}
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 += `<circle cx="${frontX}" cy="${y}" r="${r}" class="hole"/><circle cx="${backX}" cy="${y}" r="${r}" class="hole"/>`;
}
viewAOps += `<text x="${pAx + wA / 2}" y="${pAy - 8}" class="txtCenter">System 32: Ø${m.system32.holeDiameter}${m.system32.throughHole ? ' traversant' : ` prof. ${m.system32.holeDepth || 12}`}</text>`;
}
// 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 += `<circle cx="${x}" cy="${y}" r="${rFace}" class="shelfFaceHole"/>`;
}
const z = Number(m.mobileShelfSystem?.faceHole?.depthMm ?? (piece.elementType === 'rayonFixe' ? 20 : 15));
viewAOps += `<text x="${pAx + wA / 2}" y="${pAy - 8}" class="txtCenter">${piece.elementType === 'rayonFixe' ? 'Rayon fixe' : 'Rayons mobiles'} SP 15/8/5: Face Ø20 · prof. ${Math.round(z)} (dessous)</text>`;
}
}
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 += `<circle cx="${x}" cy="${y}" r="${r}" class="connector"/>`;
}
viewAOps += `<text x="${pAx + wA / 2}" y="${pAy - 8}" class="txtCenter">${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</text>`;
}
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 += `<rect x="${x0}" y="${doorMulti.baseAy}" width="${doorMulti.eachW}" height="${doorMulti.hPanel}" class="panel"/>`;
viewAOps += `<line x1="${x0}" y1="${doorMulti.baseAy}" x2="${x0 + doorMulti.eachW}" y2="${doorMulti.baseAy}" class="edge"/>`;
viewAOps += `<text x="${x0 + doorMulti.eachW / 2}" y="${doorMulti.baseAy - 5}" class="txt" text-anchor="middle" fill="#475569">Vantail ${j + 1} · ch. ${hingeSidesList[j] === 'right' ? 'droite' : 'gauche'}</text>`;
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 += `<path d="M ${pAx} ${pAy} L ${pAx + k} ${pAy + k}" class="miter"/>`;
viewAMiterMarks += `<path d="M ${pAx + wA} ${pAy} L ${pAx + wA - k} ${pAy + k}" class="miter"/>`;
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 += `<path d="M ${pAx} ${pAy} L ${pAx + k} ${pAy + k}" class="miter"/>`;
viewAMiterMarks += `<path d="M ${pAx} ${pAy + hA} L ${pAx + k} ${pAy + hA - k}" class="miter"/>`;
viewAMiterMarks += `<path d="M ${pAx + wA} ${pAy} L ${pAx + wA - k} ${pAy + k}" class="miter"/>`;
viewAMiterMarks += `<path d="M ${pAx + wA} ${pAy + hA} L ${pAx + wA - k} ${pAy + hA - k}" class="miter"/>`;
miterInfoLine = "Coupe d'onglet 45° : chants gauche & droit (jonction avec habillages côtés)";
}
viewAOps += `<text x="${pAx + 6}" y="${pAy + 14}" class="txt" fill="#0f172a">${miterInfoLine}</text>`;
}
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 += `<line x1="${x}" y1="${dimPay}" x2="${x}" y2="${dimPay + dimHa}" class="guide"/>`;
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 += `<circle cx="${x}" cy="${y}" r="${rr}" class="${cls}" />`;
}
}
}
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 += `<circle cx="${x}" cy="${y}" r="${h.r}" class="${h.cls}" />`;
}
}
}
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 += `<line x1="${yDimX}" y1="${a}" x2="${yDimX}" y2="${b}" stroke="#0f766e" stroke-width="0.85"/>`;
viewAOps += `<line x1="${yDimX - 4}" y1="${a}" x2="${yDimX + 4}" y2="${a}" stroke="#0f766e"/>`;
viewAOps += `<line x1="${yDimX - 4}" y1="${b}" x2="${yDimX + 4}" y2="${b}" stroke="#0f766e"/>`;
viewAOps += `<text x="${yDimX + 6}" y="${(a + b) / 2 + 3}" font-size="8.5" fill="#0f766e">${fmt(vals[i])}</text>`;
}
} 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 += `<line x1="${yDimX}" y1="${a}" x2="${yDimX}" y2="${b}" stroke="#0f766e" stroke-width="0.85"/>`;
viewAOps += `<line x1="${yDimX - 4}" y1="${a}" x2="${yDimX + 4}" y2="${a}" stroke="#0f766e"/>`;
viewAOps += `<line x1="${yDimX - 4}" y1="${b}" x2="${yDimX + 4}" y2="${b}" stroke="#0f766e"/>`;
viewAOps += `<text x="${yDimX + 6}" y="${(a + b) / 2 + 3}" font-size="8.5" fill="#0f766e">${fmt(vals[i])}</text>`;
}
}
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 += `<line x1="${yDimX + 18}" y1="${ya}" x2="${yDimX + 18}" y2="${yb}" stroke="#64748b" stroke-width="0.85" stroke-dasharray="3,2"/>`;
viewAOps += `<text x="${yDimX + 22}" y="${(ya + yb) / 2 + 3}" font-size="8.5" fill="#64748b">2×${off} = ${fmt(spanMm)} mm</text>`;
}
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 += `<text x="${dimPax + dimWa / 2}" y="${patternTextY}" class="txtCenter">${jointAxes.length} jonctions · Ferrures corps: ${ferrRule} · prof ${depL}/${depT} mm · 2 tourillons/ferrure ±${off} mm (centre ferrure) · Ø${d}×${dep}</text>`;
viewAOps += `<text x="${dimPax + dimWa / 2}" y="${patternTextY + 13}" class="txtCenter">Ixconnect (axes bord avant, autre jeu): extrémités T@20·IX@50·T@80 · internes IX milieu + T ±30 mm</text>`;
} else {
viewAOps += `<text x="${dimPax + dimWa / 2}" y="${patternTextY}" class="txtCenter">${jointAxes.length} jonctions · par jonction: ${ferrRule} + 2 tourillons/ferrure (±${off})</text>`;
viewAOps += `<text x="${dimPax + dimWa / 2}" y="${patternTextY + 13}" class="txtCenter">Ferrure: prof. ${depL} mm (longueur) / ${depT} mm (épaisseur) · Tourillons: 2× Ø${d}×${dep}</text>`;
}
} 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 += `<text x="${dimPax + dimWa / 2}" y="${patternTextY}" class="txtCenter">${ixLeg}</text>`;
} 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 += `<circle cx="${x}" cy="${y}" r="${hole.r}" class="${hole.cls}" />`;
}
}
viewAOps += `<text x="${dimPax + dimWa / 2}" y="${patternTextY}" class="txtCenter">Ixconnect face: T@${p.tourillon1Axis} · IX@${p.ixconnectAxis} · T@${p.tourillon2Axis} mm (haut & bas, depuis bord avant)</text>`;
}
}
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 += `<line x1="${dimPax}" y1="${dimY}" x2="${dimPax + dimWa}" y2="${dimY}" class="dimLine"/>`;
viewAOps += `<line x1="${dimPax}" y1="${dimPay}" x2="${dimPax}" y2="${dimPay + dimHa}" class="dimLine"/>`;
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 += `<text x="${dimPax + dimWa / 2}" y="${widthDimTextY}" class="txtCenter">${widthDimLabel}</text>`;
viewAOps += `<text x="${dimX}" y="${dimPay + dimHa / 2}" class="txt" transform="rotate(-90 ${dimX} ${dimPay + dimHa / 2})">H finie ${fmt(heightMm)} mm · H brut ${fmt(piece.rawCutHeightMm)} mm</text>`;
// 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 += `<line x1="${pBx + 1}" y1="${pBy + hB * 0.2}" x2="${pBx + holeLen}" y2="${pBy + hB * 0.2}" class="depthLine"/>`;
viewBOps += `<line x1="${pBx + 1}" y1="${pBy + hB * 0.55}" x2="${pBx + holeLen}" y2="${pBy + hB * 0.55}" class="depthLine"/>`;
const tx = Math.min(xB + viewW - 110, pBx + holeLen + 6);
viewBOps += `<text x="${tx}" y="${pBy + hB * 0.2 + 3}" class="txt">Ø${m.system32.holeDiameter} · ${m.system32.throughHole ? 'TRAV.' : `prof ${fmt(s32d)}mm`}</text>`;
}
if (m.hingeCups && !isReinforcementPiece) {
const cupDepth = m.hingeCups.cupDepth || 12;
const cupLen = Math.min(tB * 0.95, cupDepth * scaleB);
viewBOps += `<line x1="${pBx + 1}" y1="${pBy + hB * 0.82}" x2="${pBx + cupLen}" y2="${pBy + hB * 0.82}" class="hingeDepthLine"/>`;
const tx = Math.min(xB + viewW - 125, pBx + cupLen + 6);
viewBOps += `<text x="${tx}" y="${pBy + hB * 0.82 + 3}" class="txt">Cuvette Ø${m.hingeCups.diameter} · prof ${fmt(cupDepth)}mm</text>`;
}
if (isReinforcementPiece && m.reinforcementFixing) {
viewBOps += svgReinforcementFixingEdgeViewB(m.reinforcementFixing, pBx, pBy, tB, wBh, scaleB, fmt, true);
}
if (isReinforcementPiece) {
viewBOps += `<line x1="${pBx}" y1="${pBy - 14}" x2="${pBx + wBh}" y2="${pBy - 14}" class="dimLine"/>`;
viewBOps += `<text x="${pBx + wBh / 2}" y="${pBy - 18}" class="txtCenter">Hauteur face chant ${fmt(heightMm)} mm</text>`;
viewBOps += `<line x1="${pBx + wBh + 14}" y1="${pBy}" x2="${pBx + wBh + 14}" y2="${pBy + hBh}" class="dimLine"/>`;
viewBOps += `<text x="${pBx + wBh + 26}" y="${pBy + hBh / 2}" class="txt" transform="rotate(-90 ${pBx + wBh + 26} ${pBy + hBh / 2})">Épaisseur ${fmt(thicknessMm)} mm</text>`;
} else {
viewBOps += `<line x1="${pBx}" y1="${pBy - 14}" x2="${pBx + tB}" y2="${pBy - 14}" class="dimLine"/>`;
viewBOps += `<text x="${pBx + tB / 2}" y="${pBy - 18}" class="txtCenter">Épaisseur ${fmt(thicknessMm)} mm</text>`;
viewBOps += `<line x1="${pBx + tB + 14}" y1="${pBy}" x2="${pBx + tB + 14}" y2="${pBy + hB}" class="dimLine"/>`;
viewBOps += `<text x="${pBx + tB + 26}" y="${pBy + hB / 2}" class="txt" transform="rotate(-90 ${pBx + tB + 26} ${pBy + hB / 2})">Hauteur ${fmt(heightMm)} mm</text>`;
}
// 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 += `<text x="${pCx + 4}" y="${pCy + tC + 18}" class="txt" fill="#0f766e">Tourillons (vue C) : 1 logement/jonction, traitillé depuis la face haut · Ø${fmt(dTourMm)} mm, prof. ${fmt(depthTourMm)} mm · Ferrure ${fmt(depL)} / ${fmt(depT)} mm</text>`;
}
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 += `<line x1="${pCx + 4}" y1="${pCy + tC / 2}" x2="${pCx + 4 + len}" y2="${pCy + tC / 2}" class="connectorDepthLine"/>`;
viewCOps += `<text x="${pCx + 8 + len}" y="${pCy + tC / 2 + 4}" class="txt">Ixconnect about Ø${fmt(diam)} · prof ${fmt(depth)}mm</text>`;
if (m.connectorHoles?.faceHole) {
viewCOps += `<text x="${pCx + 8 + len}" y="${pCy + tC / 2 + 16}" class="txt">Face Ø${fmt(m.connectorHoles.faceHole.diameter || 8)} · prof ${fmt(m.connectorHoles.faceHole.depth || 12)}mm</text>`;
}
}
viewCOps += `<line x1="${pCx}" y1="${pCy + tC + 44}" x2="${pCx + wC}" y2="${pCy + tC + 44}" class="dimLine"/>`;
viewCOps += `<text x="${pCx + wC / 2}" y="${pCy + tC + 58}" class="txtCenter">Largeur ${fmt(widthMm)} mm</text>`;
viewCOps += `<line x1="${pCx - 14}" y1="${pCy}" x2="${pCx - 14}" y2="${pCy + tC}" class="dimLine"/>`;
viewCOps += `<text x="${pCx - 26}" y="${pCy + tC / 2}" class="txt" transform="rotate(-90 ${pCx - 26} ${pCy + tC / 2})">Épaisseur ${fmt(thicknessMm)} mm</text>`;
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 <text> se superposent au titre / vues A–C
const doorBandW = svgW - margin * 2;
const doorDetailInner = isDoorPiece
? (doorBandCount > 1
? `<g transform="translate(${margin}, ${doorRowY})">
<rect x="0" y="0" width="${doorBandW}" height="${doorRowH}" fill="#f8fafc" stroke="#cbd5e1" stroke-width="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 `<g transform="translate(0, ${yOff + 6})">
<rect x="8" y="0" width="${doorBandW - 16}" height="${blockH}" fill="#ffffff" stroke="#e2e8f0" stroke-width="0.9"/>
${doorMachiningDetailMarkup(piece, fmt, doorBandW - 16, blockH - 4, side, label)}
</g>`;
}).join('')}
</g>`
: `<g transform="translate(${margin}, ${doorRowY})">
<rect x="0" y="0" width="${doorBandW}" height="${doorRowH}" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
${doorMachiningDetailMarkup(piece, fmt, doorBandW, doorRowH)}
</g>`)
: '';
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgW} ${svgH}" width="${svgW}" height="${svgH}">
<style>
.title{font-family:Arial,sans-serif;font-size:16px;font-weight:bold;fill:#111}
.sub{font-family:Arial,sans-serif;font-size:11px;fill:#555}
.box{fill:#fff;stroke:#d1d5db;stroke-width:1}
.boxTitle{font-family:Arial,sans-serif;font-size:12px;font-weight:bold;fill:#1f2937}
.panel{fill:#eadac0;stroke:#1f2937;stroke-width:1.35}
.edge{stroke:#ff9f1c;stroke-width:2.2}
.miter{stroke:#0f172a;stroke-width:1.4;stroke-dasharray:4 3;opacity:0.95}
.hole{fill:none;stroke:#d62828;stroke-width:0.9}
.hinge{fill:none;stroke:#f77f00;stroke-width:1.1}
.grooveFill{fill:#2a9d8f;opacity:0.35;stroke:#2a9d8f;stroke-width:1}
.connector{fill:#6c5ce7;stroke:#312e81;stroke-width:1}
.connectorSmall{fill:#8d99ae}
.connectorTourillon{fill:#8ba0bf;stroke:#334155;stroke-width:1.05}
.connectorFerrure{fill:#a78bfa;stroke:#6d28d9;stroke-width:0.9}
.shelfFaceHole{fill:none;stroke:#ea580c;stroke-width:1.15}
.txt{font-family:Arial,sans-serif;font-size:10px;fill:#333}
.txtCenter{font-family:Arial,sans-serif;font-size:10px;fill:#333;text-anchor:middle}
.depthLine{stroke:#c1121f;stroke-width:1.2}
.hingeDepthLine{stroke:#f77f00;stroke-width:1.2}
.connectorDepthLine{stroke:#6c5ce7;stroke-width:1.2}
.guide{stroke:#64748b;stroke-dasharray:2,2;stroke-width:1}
.dimLine{stroke:#0f766e;stroke-width:1;stroke-dasharray:3,2}
.infoBox{fill:#f8fafc;stroke:#cbd5e1;stroke-width:1}
.infoTxt{font-family:Arial,sans-serif;font-size:10.5px;fill:#1f2937}
</style>
<text x="${margin}" y="24" class="title">${piece.description}</text>
<text x="${margin}" y="38" class="sub">Plan pièce multi-vues · fabrication CNC</text>
<defs>
<clipPath id="clipA"><rect x="${xA + 1}" y="${yA + 20}" width="${viewW - 2}" height="${viewH - 22}"/></clipPath>
<clipPath id="clipB"><rect x="${xB + 1}" y="${yB + 20}" width="${viewW - 2}" height="${viewH - 22}"/></clipPath>
<clipPath id="clipC"><rect x="${xC + 1}" y="${yC + 20}" width="${viewW - 2}" height="${viewH - 22}"/></clipPath>
</defs>
<rect x="${xA}" y="${yA}" width="${viewW}" height="${viewH}" class="box"/>
<text x="${xA + 10}" y="${yA + 16}" class="boxTitle">Vue A - Face usinage</text>
<g clip-path="url(#clipA)">
${doorMulti ? '' : `<rect x="${pAx}" y="${pAy}" width="${wA}" height="${hA}" class="panel"/>
<line x1="${pAx}" y1="${pAy}" x2="${pAx + wA}" y2="${pAy}" class="edge"/>`}
${viewAMiterMarks}
${viewAOps}
</g>
<rect x="${xB}" y="${yB}" width="${viewW}" height="${viewH}" class="box"/>
<text x="${xB + 10}" y="${yB + 16}" class="boxTitle">Vue B - Chant / profondeurs</text>
<g clip-path="url(#clipB)">
<rect x="${pBx}" y="${pBy}" width="${wBh}" height="${hBh}" class="panel"/>
${viewBOps}
</g>
<rect x="${xC}" y="${yC}" width="${viewW}" height="${viewH}" class="box"/>
<text x="${xC + 10}" y="${yC + 16}" class="boxTitle">${piece.elementType === 'portes' ? 'Vue C — Coupe largeur × épaisseur (rainure TH → voir bandeau / Vue D)' : 'Vue C — Coupe L×ép. · rainure côté dos (face caisse)'}</text>
<g clip-path="url(#clipC)">
<rect x="${pCx}" y="${pCy}" width="${wC}" height="${tC}" class="panel"/>
${viewCOps}
</g>
${doorDetailInner}
<rect x="${margin}" y="${infoY}" width="${svgW - margin * 2}" height="${infoBlockH - 12}" class="infoBox"/>
${opsLines.map((line, idx) => `<text x="${margin + 10}" y="${infoY + 22 + idx * 18}" class="infoTxt">${line}</text>`).join('')}
</svg>`;
}
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 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" width="760" height="${svgHeight}">
<style>
.title{font-family:Arial,sans-serif;font-size:18px;font-weight:bold;fill:#111}
.sub{font-family:Arial,sans-serif;font-size:12px;fill:#555}
.panel{fill:#eadac0;stroke:#1f2937;stroke-width:1.45}
.edge{stroke:#ff9f1c;stroke-width:2.2}
.hole{fill:none;stroke:#d62828;stroke-width:1}
.hinge{fill:none;stroke:#f77f00;stroke-width:1.2}
.grooveFill{fill:#2a9d8f;opacity:0.35;stroke:#2a9d8f;stroke-width:1}
.connector{fill:#6c5ce7;stroke:#312e81;stroke-width:1}
.connectorSmall{fill:#8d99ae}
.connectorTourillon{fill:#8ba0bf;stroke:#334155;stroke-width:1.05}
.connectorFerrure{fill:#a78bfa;stroke:#6d28d9;stroke-width:0.9}
.shelfFaceHole{fill:none;stroke:#ea580c;stroke-width:1.2}
.txt{font-family:Arial,sans-serif;font-size:11px;fill:#222}
.guide{stroke:#64748b;stroke-dasharray:3,3;stroke-width:1}
.lineRed{stroke:#c1121f;stroke-width:1.4}
.lineOrange{stroke:#f77f00;stroke-width:1.4}
</style>
<text x="24" y="30" class="title">${title}</text>
<text x="24" y="48" class="sub">${subtitle}</text>
${content}
</svg>`;
}
// 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 += `<rect x="${x0}" y="${doorMultiPv.baseAy}" width="${doorMultiPv.eachW}" height="${doorMultiPv.hPanel}" class="panel"/>`;
aOps += `<line x1="${x0}" y1="${doorMultiPv.baseAy}" x2="${x0 + doorMultiPv.eachW}" y2="${doorMultiPv.baseAy}" class="edge"/>`;
aOps += `<text x="${x0 + doorMultiPv.eachW / 2}" y="${doorMultiPv.baseAy - 6}" class="txt" text-anchor="middle" fill="#475569" font-size="10">V${j + 1} · ch. ${hingeSidesPv[j] === 'right' ? 'dr.' : 'g.'}</text>`;
aOps += doorFaceUsinageSvg({
pAx: x0,
pAy: doorMultiPv.baseAy,
wA: doorMultiPv.eachW,
hA: doorMultiPv.hPanel,
scaleA: doorMultiPv.scaleMulti,
m,
hingeSide: hingeSidesPv[j],
heightMm,
fmt
});
}
} else {
aOps = `<rect x="${xA}" y="${yA}" width="${wA}" height="${hA}" class="panel"/><line x1="${xA}" y1="${yA}" x2="${xA + wA}" y2="${yA}" class="edge"/>`;
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 += `<circle cx="${frontX}" cy="${y}" r="${r}" class="hole"/><circle cx="${backX}" cy="${y}" r="${r}" class="hole"/>`;
}
}
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 += `<circle cx="${x}" cy="${y}" r="${rFace}" class="shelfFaceHole"/>`;
}
}
}
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 += `<line x1="${x}" y1="${dimAy}" x2="${x}" y2="${dimAy + dimAh}" class="guide"/>`;
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 += `<circle cx="${x}" cy="${y}" r="${rr}" class="${cls}" />`;
}
}
}
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 += `<circle cx="${x}" cy="${y}" r="${h.r}" class="${h.cls}" />`;
}
}
}
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 += `<line x1="${xDim}" y1="${a}" x2="${xDim}" y2="${b}" stroke="#0f766e" stroke-width="0.85"/>`;
aOps += `<text x="${xDim + 6}" y="${(a + b) / 2 + 3}" font-size="8.5" fill="#0f766e">${fmt(segs[i])}</text>`;
}
} 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 += `<line x1="${xDim}" y1="${a}" x2="${xDim}" y2="${b}" stroke="#0f766e" stroke-width="0.85"/>`;
aOps += `<text x="${xDim + 6}" y="${(a + b) / 2 + 3}" font-size="8.5" fill="#0f766e">${fmt(segs[i])}</text>`;
}
}
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 += `<text x="${dimAx + dimAw / 2}" y="${legendY}" font-size="8.5" fill="#64748b" text-anchor="middle">Tourillons ferrure milieu: 2×${off} = ${fmt(spanMm)} mm</text>`;
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 += `<text x="${dimAx + dimAw / 2}" y="${legendY}" class="txt" text-anchor="middle">${jointAxes.length} jonctions · Ferrures corps: ${ferrRulePv} · prof ${depL}/${depT} mm · 2 tourillons/ferrure ±${off} mm (centre ferrure) · Ø${dPv}×${depPv}</text>`;
legendY += 16;
aOps += `<text x="${dimAx + dimAw / 2}" y="${legendY}" class="txt" text-anchor="middle">Ixconnect (axes bord avant, autre jeu): extrémités T@20·IX@50·T@80 · internes IX milieu + T ±30 mm</text>`;
} else {
aOps += `<text x="${dimAx + dimAw / 2}" y="${legendY}" class="txt" text-anchor="middle">${jointAxes.length} jonctions · ${ferrRulePv} · 2 tourillons/ferrure (±${off}) · prof ferrure ${depL}/${depT}</text>`;
}
} 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 += `<text x="${dimAx + dimAw / 2}" y="${widthLabelY + 18}" class="txt" text-anchor="middle">${ixLegPv}</text>`;
} 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 += `<circle cx="${x}" cy="${y}" r="${hole.r}" class="${hole.cls}" />`;
}
}
aOps += `<text x="${dimAx + dimAw / 2}" y="${widthLabelY + 18}" class="txt" text-anchor="middle">Ixconnect: T@${p.tourillon1Axis} · IX@${p.ixconnectAxis} · T@${p.tourillon2Axis} mm (haut & bas)</text>`;
}
}
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 += `<line x1="${dimAx}" y1="${dimAy + dimAh + 12}" x2="${dimAx + dimAw}" y2="${dimAy + dimAh + 12}" class="guide"/>`;
aOps += `<text x="${dimAx + dimAw / 2}" y="${widthLabelY}" class="txt" text-anchor="middle">${widthLine}</text>`;
aOps += `<line x1="${dimAx - 12}" y1="${dimAy}" x2="${dimAx - 12}" y2="${dimAy + dimAh}" class="guide"/>`;
aOps += `<text x="${dimAx - 22}" y="${dimAy + dimAh / 2}" class="txt" transform="rotate(-90 ${dimAx - 22} ${dimAy + dimAh / 2})">H ${fmt(heightMm)} mm · Brut ${fmt(piece.rawCutHeightMm)} mm</text>`;
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 = `<rect x="${xB}" y="${yB}" width="${wBhPv}" height="${hBhPv}" class="panel"/>`;
if (m.system32 && !isReinforcementPv) {
const s32d = m.system32.holeDepth || thicknessMm;
const len = Math.min(tB * 0.95, s32d * scaleB);
bOps += `<line x1="${xB + 1}" y1="${yB + hB * 0.25}" x2="${xB + len}" y2="${yB + hB * 0.25}" class="lineRed"/><text x="${xB + len + 8}" y="${yB + hB * 0.25 + 4}" class="txt">System32 Ø${m.system32.holeDiameter} · ${m.system32.throughHole ? 'TRAV.' : `prof ${fmt(s32d)}mm`}</text>`;
}
if (m.hingeCups && !isReinforcementPv) {
const d = m.hingeCups.cupDepth || 12;
const len = Math.min(tB * 0.95, d * scaleB);
bOps += `<line x1="${xB + 1}" y1="${yB + hB * 0.75}" x2="${xB + len}" y2="${yB + hB * 0.75}" class="lineOrange"/><text x="${xB + len + 8}" y="${yB + hB * 0.75 + 4}" class="txt">Cuvette Ø${m.hingeCups.diameter} · prof ${fmt(d)}mm</text>`;
}
if (isReinforcementPv && m.reinforcementFixing) {
bOps += svgReinforcementFixingEdgeViewB(m.reinforcementFixing, xB, yB, tB, wBhPv, scaleB, fmt, true);
}
if (isReinforcementPv) {
bOps += `<line x1="${xB}" y1="${yB - 16}" x2="${xB + wBhPv}" y2="${yB - 16}" class="guide"/>`;
bOps += `<text x="${xB + wBhPv / 2}" y="${yB - 22}" class="txt" text-anchor="middle">Hauteur face chant ${fmt(heightMm)} mm</text>`;
bOps += `<line x1="${xB + wBhPv + 14}" y1="${yB}" x2="${xB + wBhPv + 14}" y2="${yB + hBhPv}" class="guide"/>`;
bOps += `<text x="${xB + wBhPv + 24}" y="${yB + hBhPv / 2}" class="txt" transform="rotate(-90 ${xB + wBhPv + 24} ${yB + hBhPv / 2})">Épaisseur ${fmt(thicknessMm)} mm</text>`;
} else {
bOps += `<line x1="${xB}" y1="${yB - 16}" x2="${xB + tB}" y2="${yB - 16}" class="guide"/>`;
bOps += `<text x="${xB + tB / 2}" y="${yB - 22}" class="txt" text-anchor="middle">Épaisseur ${fmt(thicknessMm)} mm</text>`;
bOps += `<line x1="${xB + tB + 14}" y1="${yB}" x2="${xB + tB + 14}" y2="${yB + hB}" class="guide"/>`;
bOps += `<text x="${xB + tB + 24}" y="${yB + hB / 2}" class="txt" transform="rotate(-90 ${xB + tB + 24} ${yB + hB / 2})">Hauteur ${fmt(heightMm)} mm</text>`;
}
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 = `<rect x="${xC}" y="${yC}" width="${wC}" height="${tC}" class="panel"/>`;
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 += `<text x="${xC}" y="${yC + tC + 64}" class="txt" fill="#0f766e">Tourillons : 1/jonction (traitillé, face haut) · Ø${fmt(dTourMm)} mm, prof. ${fmt(depthTourMm)} mm · Ferrure ${fmt(depL)} / ${fmt(depT)} mm</text>`;
}
if (m.connectorHoles?.edgeHole) {
cOps += `<text x="${xC}" y="${yC + tC + 70}" class="txt">Ixconnect about Ø${fmt(m.connectorHoles.edgeHole.diameter || 8)} · prof ${fmt(m.connectorHoles.edgeHole.depth || 45)} mm</text>`;
if (m.connectorHoles?.faceHole) {
cOps += `<text x="${xC}" y="${yC + tC + 86}" class="txt">Ixconnect face Ø${fmt(m.connectorHoles.faceHole.diameter || 8)} · prof ${fmt(m.connectorHoles.faceHole.depth || 12)} mm</text>`;
}
}
cOps += `<line x1="${xC}" y1="${yC + tC + 20}" x2="${xC + wC}" y2="${yC + tC + 20}" class="guide"/>`;
cOps += `<text x="${xC + wC / 2}" y="${yC + tC + 34}" class="txt" text-anchor="middle">Largeur ${fmt(widthMm)} mm</text>`;
cOps += `<line x1="${xC - 12}" y1="${yC}" x2="${xC - 12}" y2="${yC + tC}" class="guide"/>`;
cOps += `<text x="${xC - 22}" y="${yC + tC / 2}" class="txt" transform="rotate(-90 ${xC - 22} ${yC + tC / 2})">Épaisseur ${fmt(thicknessMm)} mm</text>`;
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 `<g transform="translate(16, ${20 + i * (blockH + 14)})"><rect x="0" y="0" width="720" height="${blockH}" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>${doorMachiningDetailMarkup(piece, fmt, 720, blockH - 10, side, label)}</g>`;
}).join('');
} else {
viewDContent = `<g transform="translate(16, 52)">${doorMachiningDetailMarkup(piece, fmt, 720, 300)}</g>`;
}
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 };