2975 lines
142 KiB
JavaScript
2975 lines
142 KiB
JavaScript
/**
|
||
* 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 & perçages`
|
||
: 'Détail usinage porte — rainure & 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 };
|