/**
* Cutting Engine - Generate cutting list (liste de débitage) for wardrobe production
* Calculates all panel dimensions, edge banding, machining operations, and
* reinforcement panels based on APEX 4.0 manufacturing specifications.
*/
import {
PANEL, EDGE_BANDING, CUTTING_SURCOTE_MM, GROOVE, CONNECTORS, SYSTEM32, SYSTEM32_SEPARATORS,
HINGE, REINFORCEMENT_MACHINING, SHELF_MACHINING, getHingeCountForDoor, getHingeCupPositions,
getReinforcementEndFixingAxesFromTopMm,
DRAWERS, DRAWER_REINFORCEMENT, DRAWER_RENFORT_PANEL_IXCONNECT, drawerStackHeightMm, drawerNicheHeightsMm, getDrawerReinforcementIxconnectHolePositionsMm, buildDrawerRenfortPanelIxconnectCornersMm, NICHE, HANDLE, BLOCK_ASSEMBLY, CARCASS_FASTENERS, CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM,
REINFORCEMENT, getDoorHingeSide, getCarcassJointPositions, getCarcassFerrureDowelDrillings,
getSeparatorDepthMm, getIxconnectJointPatternAlongDepthMm, getJunctionAxesAcrossWidthMm,
getBackGrooveInnerEndInsetFromBackEdgeMm,
tourillonFaceDrillDepthMm,
BACK_GROOVE_PLAN_VERTICAL_TYPES,
BACK_GROOVE_PLAN_HORIZONTAL_TYPES,
PLINTH,
MAX_PANEL_DIMENSION_MM,
getAxiloPlinthPanelHeightMm,
getAxiloPlinthReturnLengthMm,
getAxiloPlinthFrontSideDeductionMm,
getAxiloPlateCentersMm,
getAxiloLateralCenterInsetMm,
hasSidePlinthReturn,
resolveHorizontalSpan,
validateHorizontalSpans,
getSeparatorVerticalSegments,
usesBlockConstruction,
horizontalConstructionRuns
} from '../../shared/manufacturing.js';
import {
IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM,
IXCONNECT_SEPARATOR_BACK_ROW_MIN_WIDTH_MM,
buildShelfMobileHoleSpecsMm
} from '../../shared/cncPanelHoles.js';
import {
LEGRABOX_PANEL_THICKNESS_MM,
LEGRABOX_RAIL_DRILLING,
LEGRABOX_FACADE_INSERTA,
getLegraboxSystemForSideHeight,
getLegraboxRailHolePositionsMm,
getLegraboxNominalLengthMm,
legraboxBottomPanelMm,
legraboxBackPanelMm,
buildLegraboxFacadeInsertaHolePositionsMm
} from '../../shared/blumLegrabox.js';
// Aliases for readability
const PANEL_THICKNESS = PANEL.structure;
const BACK_PANEL_THICKNESS = PANEL.back;
/**
* Use the most reliable module array for counts/dimensions.
* Some API calls pass `modules=[]` but store modules inside `config.modules`.
*/
function effectiveModules(modules = [], config = {}) {
const m = Array.isArray(modules) ? modules.filter(Boolean) : [];
if (m.length > 0) return m;
const cm = Array.isArray(config?.modules) ? config.modules.filter(Boolean) : [];
if (cm.length > 0) return cm;
const blocks = Array.isArray(config?.blocks) ? config.blocks.filter(Boolean) : [];
return blocks;
}
/** Les côtés structurels passent entre dessus/fond uniquement derrière un habillage latéral libre. */
function getExteriorSideAssembly(config = {}) {
const sideDefaultsEnabled = config?.sideFinishesEnabled !== false;
const finishesEnabled =
config?.installationType === 'free' && config?.exteriorFinishesEnabled !== false;
return {
left: finishesEnabled && (config?.sideFinishLeftEnabled ?? sideDefaultsEnabled),
right: finishesEnabled && (config?.sideFinishRightEnabled ?? sideDefaultsEnabled)
};
}
/**
* Géométrie d'un panneau horizontal et modes de jonction de ses montants extérieurs.
* Les axes historiques restent inchangés sans habillage; l'extension est ajoutée du côté habillé.
*/
function getHorizontalPanelJunctionLayout(widthMm, modules = [], config = {}) {
const natural = getExteriorSideAssembly(config);
const leftFace = config?._horizontalAssemblyLeft ?? natural.left;
const rightFace = config?._horizontalAssemblyRight ?? natural.right;
const axes = [...getJunctionAxesAcrossWidthMm(widthMm, modules, config)];
if (axes.length > 0) {
axes[0] = leftFace ? PANEL_THICKNESS / 2 : 0;
axes[axes.length - 1] = rightFace
? Math.max(0, Number(widthMm) - PANEL_THICKNESS / 2)
: Number(widthMm);
}
const faceModes = axes.map((_, index) => (
index === 0 ? (leftFace ? 'face' : 'end')
: index === axes.length - 1 ? (rightFace ? 'face' : 'end')
: 'face'
));
return { axes, faceModes, leftFace, rightFace };
}
/**
* Resolve only production-valid spans. The legacy cutting path remains byte-for-byte
* equivalent when no spans are configured.
*/
function horizontalSpanContext(config, modules = []) {
if (!Array.isArray(config?.horizontalSpans) || config.horizontalSpans.length === 0) return null;
const mods = effectiveModules(modules, config);
const spanConfig = { ...config, modules: mods };
const invalidIds = new Set(
validateHorizontalSpans(spanConfig)
.map((issue) => issue?.spanId)
.filter((id) => id != null)
.map(String)
);
const resolved = config.horizontalSpans
.filter((span) => span?.id != null && !invalidIds.has(String(span.id)))
.map((span) => resolveHorizontalSpan(span, spanConfig))
.filter(Boolean);
return resolved.length ? { config: spanConfig, modules: mods, resolved } : null;
}
function spanCoversModuleAt(spanCtx, moduleIndex, bottomMm, topMm) {
if (!spanCtx) return false;
const eps = 0.5;
return spanCtx.resolved.some((span) =>
span.moduleIndexes.includes(moduleIndex) &&
Number(bottomMm) >= span.bottomMm - eps &&
Number(topMm) <= span.topMm + eps
);
}
function spanCoversHeight(spanCtx, moduleIndex, heightMm) {
if (!spanCtx) return false;
const eps = 0.5;
return spanCtx.resolved.some((span) =>
span.moduleIndexes.includes(moduleIndex) &&
heightMm > span.bottomMm + eps &&
heightMm < span.topMm - eps
);
}
function spanReplacesHorizontalFace(spanCtx, moduleIndex, boundaryMm, elementType) {
if (!spanCtx) return false;
const eps = 0.5;
return spanCtx.resolved.some((span) => {
if (!span.moduleIndexes.includes(moduleIndex)) return false;
const replacedBoundary = elementType === 'fond' ? span.bottomMm : span.topMm;
return Math.abs(boundaryMm - replacedBoundary) <= eps;
});
}
function drawerTypeForModule(config, mod) {
const value = mod?.drawerType ?? config?.hardware?.drawerType ?? 'visible';
return value === 'hidden' || value === 'hidden_with_handle' ? 'hidden' : 'visible';
}
function moduleHasHiddenDrawers(config, mod) {
return Math.max(0, Number(mod?.drawers) || 0) > 0 && drawerTypeForModule(config, mod) === 'hidden';
}
/** Zone renfort (hauteur + bas depuis bas panneau) pour le module i — tiroirs cachés uniquement. */
function moduleRenfortZoneForDrawerRenfort(config, mods, i) {
const mod = mods?.[i];
const n = Math.max(0, Number(mod?.drawers) || 0);
if (n <= 0) return null;
const hw = config.hardware || {};
if (!moduleHasHiddenDrawers(config, mod)) return null;
const drawerHeightsMm = Array.isArray(hw.drawerHeightsMm) ? hw.drawerHeightsMm : [90, 128, 177, 240];
const stack = drawerStackHeightMm(n, drawerHeightsMm, 8);
const hMm = stack - DRAWER_REINFORCEMENT.topFrontRadiusMm;
if (hMm <= 0) return null;
const zoneBottomMm = PANEL_THICKNESS;
return { hMm, zoneBottomMm };
}
/** Profondeur utile tiroir (mm) — même logique que useWardrobeConfig / configValidation. */
function effectiveDrawerDepthMmForConfig(config, mod = null) {
const depthMm = Number(config?.depthMm || 600);
const rawGap = depthMm - NICHE.drawerBackGap;
const clamped = Math.max(DRAWERS.minDepth, Math.min(DRAWERS.maxDepth, rawGap));
const stepped = Math.round(clamped / DRAWERS.depthStep) * DRAWERS.depthStep;
const reduction =
drawerTypeForModule(config, mod) === 'hidden' ? DRAWERS.hiddenHandleDepthReduction : 0;
return Math.max(350, stepped - reduction);
}
/**
* Spécifications Blum Legrabox pure par colonne à tiroirs.
* Chaque tiroir occupe sa hauteur de NICHE (SMS) ; empilés depuis le fond de caisse.
* @returns {Array<{ moduleIndex:number, moduleWidthMm:number, internalWidthMm:number,
* loadKg:40|70, nominalLengthMm:number|null, drawers:Array<{ index:number, systemKey:string,
* sideHeightMm:number, nicheHeightMm:number, nicheBottomFromPanelBottomMm:number }> }>}
*/
function legraboxColumnsForConfig(config, modules = []) {
const mods = effectiveModules(modules, config);
const hw = config?.hardware || {};
const drawerHeightsMm = Array.isArray(hw.drawerHeightsMm) ? hw.drawerHeightsMm : [90, 128, 177, 240];
const loadKg = Number(hw.drawerLoadKg) === 70 ? 70 : 40;
const countModules = Math.max(1, mods.length || 0);
const columns = [];
for (let i = 0; i < countModules; i++) {
const n = Math.max(0, Number(mods?.[i]?.drawers) || 0);
if (n <= 0) continue;
const effDepth = effectiveDrawerDepthMmForConfig(config, mods[i]);
const modW = Number(mods?.[i]?.widthMm);
const rawW = Number.isFinite(modW) && modW > 0 ? modW : (Number(config?.widthMm) || 2400) / countModules;
/** largeur_libre_corps_X = largeur extérieure − 2× épaisseur montants (consigne Blum). */
const internalWidthMm = Math.max(0, Math.round(rawW - 2 * PANEL_THICKNESS));
const niches = drawerNicheHeightsMm(n, drawerHeightsMm);
const drawers = [];
let cursor = PANEL_THICKNESS; // fond de caisse = bas de la zone tiroirs sur le panneau côté
for (let di = 0; di < n; di++) {
const rawH = Number(drawerHeightsMm[Math.min(di, drawerHeightsMm.length - 1)]) || 128;
const sideHeightMm = rawH === 60 ? 66 : rawH;
const sys = getLegraboxSystemForSideHeight(sideHeightMm);
drawers.push({
index: di,
systemKey: sys?.key || 'K',
sideHeightMm,
nicheHeightMm: niches[di],
nicheBottomFromPanelBottomMm: cursor
});
cursor += niches[di];
}
/** NL max dispo pour la colonne : plus petite valeur commune aux systèmes présents (prof. int. min = NL + 3). */
let nominalLengthMm = null;
for (const d of drawers) {
const nl = getLegraboxNominalLengthMm(d.systemKey, effDepth - 3, loadKg);
if (nl != null && (nominalLengthMm == null || nl < nominalLengthMm)) nominalLengthMm = nl;
}
columns.push({ moduleIndex: i, moduleWidthMm: Math.round(rawW), internalWidthMm, loadKg, nominalLengthMm, drawers });
}
return columns;
}
function shelfCenterYsMm({ shelfCount, heightMm, plinthHeightMm, shelfGapMm }) {
const T = PANEL_THICKNESS;
const height = Number(heightMm) || 0;
const plinthH = Number(plinthHeightMm) || 0;
const bodyH = height - plinthH;
const H_inner = bodyH - 2 * T;
const yBase = -height / 2 + plinthH + T;
if (shelfCount <= 0) return [];
const equal = () =>
Array.from({ length: shelfCount }, (_, i) =>
yBase + (H_inner / (shelfCount + 1)) * (i + 1)
);
if (shelfCount === 1 || shelfGapMm == null || !Number.isFinite(Number(shelfGapMm))) {
return equal();
}
const n = shelfCount;
const gReq = Number(shelfGapMm);
const maxG = n > 1 ? (H_inner - n * T) / (n - 1) : 0;
if (n > 1 && !(maxG > 0)) return equal();
const gUse = n > 1 ? Math.max(0, Math.min(gReq, maxG)) : 0;
const needed = n * T + (n > 1 ? (n - 1) * gUse : 0);
const m = (H_inner - needed) / 2;
return Array.from({ length: n }, (_, i) =>
yBase + m + T / 2 + i * (T + (n > 1 ? gUse : 0))
);
}
function structuralMiddleShelfIndex(mod, config) {
const shelfCount = Math.max(0, Number(mod?.shelves) || 0);
if (shelfCount <= 0) return null;
// Colonne empilée: la jonction bas/haut (double panneau) joue le rôle de rayon structurel.
if (moduleStacked(mod, config)) return null;
const heightMm = Number(config?.heightMm) || 0;
const plinthHeightMm = Number(config?.plinthHeightMm) || 0;
const needs = heightMm > 2000;
if (!needs) return null;
const ys = shelfCenterYsMm({ shelfCount, heightMm, plinthHeightMm, shelfGapMm: mod?.shelfGapMm });
const bottomInside = -heightMm / 2 + plinthHeightMm + PANEL_THICKNESS;
const innerSpan = (heightMm - plinthHeightMm) - 2 * PANEL_THICKNESS;
const yMiddle = bottomInside + innerSpan / 2;
let best = Infinity;
let idx = null;
for (let i = 0; i < ys.length; i++) {
const d = Math.abs(Number(ys[i]) - yMiddle);
if (d < best) { best = d; idx = i; }
}
return idx;
}
/**
* Perçages ferrure + tourillons sur les plans SVG : toujours recalculés depuis les centres
* ferrure et le décalage courant (évite trous figés si données stockées sont anciennes).
*/
function getCarcassDowelStationsForPlan(cf, edgeLengthMm) {
const L = Math.max(1, Number(cf.jointEdgeUsableLengthMm ?? edgeLengthMm) || 0);
const off = cf.dowelSideOffset ?? CARCASS_FASTENERS.dowel.sideOffset;
let positions = cf.positionsAlongJointMm;
if (!positions?.length && cf.dowelStations?.length) {
positions = cf.dowelStations.map((s) => s.centerMm).filter((v) => Number.isFinite(Number(v)));
}
return getCarcassFerrureDowelDrillings(positions || [], L, off);
}
/** Ixconnect sur plans : positions depuis règles atelier (profondeur = arête de jonction). */
function getIxconnectJointsForPlan(machining, cf, depthAlongJointMm) {
if (!machining?.connectorHoles || !cf?.junctionAxesAcrossWidthMm?.length) return null;
const jax = cf.junctionAxesAcrossWidthMm;
const d = Math.max(1, Number(depthAlongJointMm) || 0);
return jax.map((xAxis, idx) => ({
axisAcrossWidthMm: xAxis,
junctionFace: cf.junctionFaceModes?.[idx],
...getIxconnectJointPatternAlongDepthMm(d, idx === 0 || idx === jax.length - 1 ? 'edge' : 'middle', {
horizontalPanel: true
})
}));
}
/**
* Dos optionnel PAR ZONE (colonne).
* Construction: dos → caisson block ; sinon côté (montant extérieur) ou rayonnage → block ; sinon séparations.
* Legacy: `mod.hasBack` absent → `config.hasBack` global; les deux absents (anciennes commandes) → dos présent.
*/
function moduleHasBack(mod, config) {
if (mod && typeof mod.hasBack === 'boolean') return mod.hasBack;
if (typeof config?.hasBack === 'boolean') return config.hasBack;
return true;
}
/** Hauteur de caisse (hors plinthe). */
function carcassBodyMm(config) {
return (Number(config?.heightMm) || 0) - (Number(config?.plinthHeightMm) || 0);
}
/** Empilage vertical: bornes de hauteur de chaque block (caisse). max = panneau max. */
const STACK_BLOCK_MIN_MM = 300;
const STACK_BLOCK_MAX_MM = MAX_PANEL_DIMENSION_MM;
const STACK_BLOCK_STEP_MM = 50;
/**
* Empilage vertical PAR COLONNE — modèle N blocks.
* Canonique: `mod.blockHeightsMm` (bas → haut). Legacy 2 blocks: `mod.stacked` + `mod.stackBottomHeightMm`
* (défauts config.stackEnabled / config.stackBottomHeightMm). Hauteurs normalisées, somme = caisse.
*/
function moduleBlockHeightsMm(mod, config) {
const body = carcassBodyMm(config);
const min = STACK_BLOCK_MIN_MM;
if (!(body >= min * 2)) return [Math.max(0, body)];
const maxBlocks = Math.max(1, Math.floor(body / min));
const minBlocks = Math.max(1, Math.ceil(body / STACK_BLOCK_MAX_MM));
let raw = Array.isArray(mod?.blockHeightsMm)
? mod.blockHeightsMm.map(Number).filter((v) => Number.isFinite(v) && v > 0)
: null;
if (!raw || raw.length === 0) {
const stacked = mod && typeof mod.stacked === 'boolean' ? mod.stacked : config?.stackEnabled === true;
if (stacked && maxBlocks >= 2) {
const b = Number(mod?.stackBottomHeightMm ?? config?.stackBottomHeightMm);
const bottom = Math.min(Math.max(min, body - min), Math.max(min, Number.isFinite(b) ? b : 1000));
raw = [bottom, body - bottom];
} else {
raw = [body];
}
}
let count = Math.min(Math.max(raw.length, minBlocks), maxBlocks);
if (count <= 1) return [body];
if (raw.length !== count) {
// Nombre de blocks imposé/écrêté → répartition égale.
const even = body / count;
raw = Array.from({ length: count }, () => even);
}
const out = [];
let remaining = body;
for (let i = 0; i < count - 1; i++) {
const blocksAfter = count - i - 1;
const maxH = Math.min(STACK_BLOCK_MAX_MM, remaining - min * blocksAfter);
const stepped = Math.round((Number(raw[i]) || min) / STACK_BLOCK_STEP_MM) * STACK_BLOCK_STEP_MM;
const h = Math.min(maxH, Math.max(min, stepped));
out.push(h);
remaining -= h;
}
out.push(remaining);
return out;
}
/** Porte par block (bas → haut). Défaut: porte sur chaque block (block sans porte = niche ouverte). */
function moduleBlockDoors(mod, config, blockCount = null) {
const n = blockCount ?? moduleBlockHeightsMm(mod, config).length;
const arr = Array.isArray(mod?.blockDoors) ? mod.blockDoors : null;
return Array.from({ length: n }, (_, i) => (arr && typeof arr[i] === 'boolean' ? arr[i] : true));
}
/** Dos par block (bas → haut). Legacy: hasBack (block 1 / défaut), hasBackTop (dernier block). */
function moduleBlockBacks(mod, config, blockCount = null) {
const n = blockCount ?? moduleBlockHeightsMm(mod, config).length;
const arr = Array.isArray(mod?.blockBacks) ? mod.blockBacks : null;
const base = moduleHasBack(mod, config);
return Array.from({ length: n }, (_, i) => {
if (arr && typeof arr[i] === 'boolean') return arr[i];
if (i === n - 1 && n >= 2 && mod && typeof mod.hasBackTop === 'boolean') return mod.hasBackTop;
return base;
});
}
/** Colonne empilée = au moins 2 blocks en hauteur. */
function moduleStacked(mod, config) {
return moduleBlockHeightsMm(mod, config).length >= 2;
}
/**
* Segments verticaux (blocks) d'une colonne, bas → haut:
* [{ index, count, heightMm, hasBack, level }] — level: null (1 block) | 'bottom' | 'top' | 'mid'.
*/
function moduleStackSegments(mod, config) {
const heights = moduleBlockHeightsMm(mod, config);
const backs = moduleBlockBacks(mod, config, heights.length);
if (heights.length <= 1) {
return [{ index: 0, count: 1, heightMm: heights[0], hasBack: backs[0], level: null }];
}
return heights.map((h, k) => ({
index: k,
count: heights.length,
heightMm: h,
hasBack: backs[k],
level: k === 0 ? 'bottom' : k === heights.length - 1 ? 'top' : 'mid'
}));
}
/** Au moins une colonne empilée (fallback legacy: config.stackEnabled sans modules). */
function anyStackedColumn(config, modules) {
const mods = effectiveModules(modules, config);
if (mods.length === 0) return moduleStacked(null, config);
return mods.some((m) => moduleStacked(m, config));
}
/** Dos d'une zone (colonne × niveau). level: 'bottom' | 'top' (premier / dernier block). */
function moduleHasBackAt(mod, config, level) {
const backs = moduleBlockBacks(mod, config);
return level === 'top' ? backs[backs.length - 1] : backs[0];
}
/** Au moins une zone avec dos au niveau donné ('bottom' | 'top'). */
function anyBackAtLevel(config, modules, level) {
const mods = effectiveModules(modules, config);
if (mods.length === 0) return typeof config?.hasBack === 'boolean' ? config.hasBack : true;
return mods.some((m) => moduleHasBackAt(m, config, level));
}
/** Dos au niveau donné sur la colonne d'extrémité gauche/droite. */
function backAtSideLevel(config, modules, side, level) {
const mods = effectiveModules(modules, config);
if (mods.length === 0) return typeof config?.hasBack === 'boolean' ? config.hasBack : true;
const mod = side === 'left' ? mods[0] : mods[mods.length - 1];
return moduleHasBackAt(mod, config, level);
}
/** Au moins une zone avec dos, tous blocks confondus (pilote rainures + pièce dos). */
function anyBackPanel(config, modules) {
const mods = effectiveModules(modules, config);
if (mods.length === 0) return typeof config?.hasBack === 'boolean' ? config.hasBack : true;
return mods.some((m) => moduleBlockBacks(m, config).some(Boolean));
}
/** Toutes les zones (tous blocks) avec dos. */
function allBackPanels(config, modules) {
const mods = effectiveModules(modules, config);
if (mods.length === 0) return typeof config?.hasBack === 'boolean' ? config.hasBack : true;
return mods.every((m) => moduleBlockBacks(m, config).every(Boolean));
}
/** Dos requis côté extérieur gauche / droit, à n'importe quel niveau. */
function backAtSide(config, modules, side) {
const mods = effectiveModules(modules, config);
if (mods.length === 0) return typeof config?.hasBack === 'boolean' ? config.hasBack : true;
const mod = side === 'left' ? mods[0] : mods[mods.length - 1];
return moduleBlockBacks(mod, config).some(Boolean);
}
function columnIsBlock(mod, config) {
return usesBlockConstruction(mod, moduleBlockBacks(mod, config).some(Boolean));
}
function horizontalInsetMm(mods, config, moduleIndex, side) {
const neighbor = side === 'left' ? moduleIndex - 1 : moduleIndex + 1;
if (neighbor < 0 || neighbor >= mods.length) return PANEL_THICKNESS;
if (!columnIsBlock(mods[moduleIndex], config)) return 0;
return PANEL_THICKNESS + (
columnIsBlock(mods[neighbor], config)
? (BLOCK_ASSEMBLY.jointGapMm || 3) / 2
: 0
);
}
/**
* Layout of dessus/fond construction runs in wardrobe X: origin of each
* panel (after montant insets) so AXILO feet can be spaced on the armoire
* then assigned to the matching bottom board.
*/
function horizontalRunLayouts(mods, config, totalWidthMm, level) {
const runs = horizontalConstructionRuns(mods, config, level);
let moduleCursor = 0;
const layouts = [];
for (const run of runs) {
let rawW = 0;
for (let i = run.start; i <= run.end; i++) {
rawW += moduleWidthMm(mods[i], totalWidthMm, mods.length);
}
const leftInset = horizontalInsetMm(mods, config, run.start, 'left');
const rightInset = horizontalInsetMm(mods, config, run.end, 'right');
const widthMm = Math.max(50, Math.round(rawW - leftInset - rightInset));
layouts.push({
...run,
widthMm,
originMm: moduleCursor + leftInset,
leftInset,
rightInset
});
for (let i = run.start; i <= run.end; i++) {
moduleCursor += moduleWidthMm(mods[i], totalWidthMm, mods.length);
}
}
return layouts;
}
function axiloSpanConfigForRun(layouts, run) {
if (!layouts.length) return {};
const spanOrigin = layouts[0].originMm;
const last = layouts[layouts.length - 1];
const spanWidthMm = last.originMm + last.widthMm - spanOrigin;
const index = layouts.findIndex((layout) => layout.start === run.start && layout.end === run.end);
if (index < 0) return { _axiloSpanWidthMm: spanWidthMm, _axiloPanelOffsetMm: 0 };
const layout = layouts[index];
const offset = layout.originMm - spanOrigin;
const prevEnd = index > 0
? layouts[index - 1].originMm - spanOrigin + layouts[index - 1].widthMm
: null;
const nextStart = index < layouts.length - 1
? layouts[index + 1].originMm - spanOrigin
: null;
return {
_axiloSpanWidthMm: spanWidthMm,
_axiloPanelOffsetMm: offset,
_axiloAssignMinMm: prevEnd == null ? 0 : (prevEnd + offset) / 2,
_axiloAssignMaxMm: nextStart == null ? spanWidthMm : (offset + layout.widthMm + nextStart) / 2,
_axiloAssignMaxInclusive: nextStart == null
};
}
function moduleWidthMm(mod, totalWidthMm, moduleCount) {
return Number.isFinite(Number(mod?.widthMm)) && Number(mod.widthMm) > 0
? Number(mod.widthMm)
: totalWidthMm / Math.max(1, moduleCount);
}
/**
* Existe-t-il une jonction caisson↔caisson (vis d'assemblage block) ?
* Deux colonnes adjacentes en block (dos ou rayonnage). Legacy sans flags: columnMode.
*/
function hasBlockJoint(config, modules) {
const mods = effectiveModules(modules, config);
const anyExplicit =
typeof config?.hasBack === 'boolean' ||
mods.some((m) => typeof m?.hasBack === 'boolean' || typeof m?.hasBackTop === 'boolean' || Array.isArray(m?.blockBacks));
if (!anyExplicit) {
if (config?.columnMode === 'block') return mods.length >= 2;
return mods.some((m, i) => i + 1 < mods.length && columnIsBlock(m, config) && columnIsBlock(mods[i + 1], config));
}
for (let i = 0; i + 1 < mods.length; i++) {
if (columnIsBlock(mods[i], config) && columnIsBlock(mods[i + 1], config)) return true;
}
return false;
}
/**
* Element dimensions calculator
* Returns the dimensions for each element type based on wardrobe config
*/
function getElementDimensions(elementType, config, modules = []) {
const { widthMm, heightMm, depthMm, plinthHeightMm, installationType } = config;
const bodyHeight = heightMm - plinthHeightMm;
const innerWidth = widthMm - (PANEL_THICKNESS * 2);
const innerDepth = depthMm - PANEL_THICKNESS; // Back panel space
const nicheMeasurements = config?.nicheMeasurements || {};
const smallestPositive = (values, fallback) => {
const valid = values.map(Number).filter((value) => Number.isFinite(value) && value > 0);
return valid.length > 0 ? Math.min(...valid) : fallback;
};
const sanitaryGapMm = Math.max(0, Number(config?.sanitaryGapMm) || 30);
const nicheWidthMm = smallestPositive(
[
nicheMeasurements.widthTopMm,
nicheMeasurements.widthMidMm,
nicheMeasurements.widthBottomMm
],
widthMm + sanitaryGapMm * 2
);
const nicheHeightMm = smallestPositive(
[nicheMeasurements.heightLeftMm, nicheMeasurements.heightRightMm],
heightMm + sanitaryGapMm
);
const isNiche = installationType === 'niche';
const isFree = installationType === 'free';
const exteriorFinishesEnabled = config?.exteriorFinishesEnabled !== false;
const sideDefaultsEnabled = config?.sideFinishesEnabled !== false;
const sideFinishLeftEnabled = config?.sideFinishLeftEnabled ?? sideDefaultsEnabled;
const sideFinishRightEnabled = config?.sideFinishRightEnabled ?? sideDefaultsEnabled;
const topFinishEnabled = config?.topFinishEnabled !== false;
switch (elementType) {
case 'plintheBas': {
// Habillage au nu, ou retour reculé de 20 mm : la face avant s'arrête entre les deux.
// In a niche, the front plinth always spans the complete wardrobe width.
const sideDeduction = isNiche
? 0
: getAxiloPlinthFrontSideDeductionMm(config, 'left')
+ getAxiloPlinthFrontSideDeductionMm(config, 'right');
return {
width: widthMm - sideDeduction,
height: getAxiloPlinthPanelHeightMm(plinthHeightMm),
thickness: PLINTH.panelThicknessMm,
quantity: 1,
description: `Plinthe AXILO 78 — hauteur cible ${plinthHeightMm} mm (jeu supérieur ${PLINTH.panelTopClearanceMm} mm)`
};
}
case 'plintheGauche': {
if (!hasSidePlinthReturn(config, 'left')) return null;
return {
width: getAxiloPlinthReturnLengthMm(depthMm),
height: getAxiloPlinthPanelHeightMm(plinthHeightMm),
thickness: PLINTH.panelThicknessMm,
quantity: 1,
description: 'Plinthe AXILO 78 — retour gauche'
};
}
case 'plintheDroite': {
if (!hasSidePlinthReturn(config, 'right')) return null;
return {
width: getAxiloPlinthReturnLengthMm(depthMm),
height: getAxiloPlinthPanelHeightMm(plinthHeightMm),
thickness: PLINTH.panelThicknessMm,
quantity: 1,
description: 'Plinthe AXILO 78 — retour droit'
};
}
case 'bandeauHaut':
return {
width: widthMm,
height: 100,
thickness: PANEL_THICKNESS,
quantity: 1,
description: 'Bandeau supérieur'
};
case 'dessus':
return {
width: innerWidth,
// Pleine profondeur caisse : le dos 8 mm s'engage dans la rainure, il ne raccourcit pas le panneau.
height: depthMm,
thickness: PANEL_THICKNESS,
quantity: 1,
description: 'Panneau du dessus'
};
case 'fond':
return {
width: innerWidth,
height: depthMm,
thickness: PANEL_THICKNESS,
quantity: 1,
description: 'Panneau du fond (bas)'
};
case 'cotes':
return {
width: depthMm,
height: bodyHeight,
thickness: PANEL_THICKNESS,
quantity: 2,
description: 'Côtés extérieurs (gauche et droit)'
};
case 'cotesDoublage':
// Only for niche installation
if (!isNiche) return null;
return {
width: depthMm,
height: bodyHeight,
thickness: PANEL_THICKNESS,
quantity: 2,
description: 'Côtés de doublage (niche)'
};
case 'fileurCotes':
if (!isNiche) return null;
return {
width: 50,
height: nicheHeightMm,
thickness: PANEL_THICKNESS,
quantity: 2,
description: 'Fileurs niche gauche et droit — brut 50 mm, à ajuster sur chantier'
};
case 'fileurHaut':
if (!isNiche) return null;
return {
width: nicheWidthMm,
height: 50,
thickness: PANEL_THICKNESS,
quantity: 1,
description: 'Fileur niche haut — brut 50 mm, à ajuster sur chantier'
};
case 'habillageFinitionGauche':
if (!isFree || !exteriorFinishesEnabled || !sideFinishLeftEnabled) return null;
{
const topFinishEnabled = config?.topFinishEnabled !== false;
return {
width: depthMm,
// Jusqu'au sol (remplace le retour de plinthe) + onglet haut si bandeau.
height: heightMm + (topFinishEnabled ? PANEL_THICKNESS : 0),
thickness: PANEL_THICKNESS,
quantity: 1,
description: 'Habillage finition GAUCHE'
};
}
case 'habillageFinitionDroite':
if (!isFree || !exteriorFinishesEnabled || !sideFinishRightEnabled) return null;
{
const topFinishEnabled = config?.topFinishEnabled !== false;
return {
width: depthMm,
height: heightMm + (topFinishEnabled ? PANEL_THICKNESS : 0),
thickness: PANEL_THICKNESS,
quantity: 1,
description: 'Habillage finition DROITE'
};
}
case 'habillageFinitionDessus':
if (!isFree || !exteriorFinishesEnabled || !topFinishEnabled) return null;
return {
width: widthMm,
height: depthMm,
thickness: PANEL_THICKNESS,
quantity: 1,
description: 'Habillage finition DESSUS'
};
// 'dos' : émis par zone (colonne avec dos) dans generateCuttingList.
case 'dos':
return null;
case 'renforts': {
// Renforts Forteresse (règle atelier):
// - largeur = largeur armoire - 2 × épaisseur côtés
// - 1 en haut à 19 mm + 1 en bas à 19 mm
// - 3e renfort si hauteur armoire > 1500 mm
const reinforcementCount = heightMm > REINFORCEMENT.thirdRequiredAboveHeightMm ? 3 : 2;
return {
width: innerWidth,
height: PANEL.reinforcementWidth,
thickness: PANEL.reinforcement,
quantity: reinforcementCount,
description: `Renforts Forteresse 16mm (${reinforcementCount}) — haut/bas à ${REINFORCEMENT.topBottomOffsetMm} mm${reinforcementCount === 3 ? ' + 1 intermédiaire' : ''}`
};
}
case 'portes': {
const doorCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2);
const isSliding = config.doorType === 'sliding';
if (isSliding) {
const panelCount = config.slidingPanelsCount || 2;
const panelWidth = Math.ceil(widthMm / panelCount) + 50; // overlap
return {
width: panelWidth,
height: bodyHeight - 20, // clearance
thickness: PANEL_THICKNESS,
quantity: panelCount,
description: `Portes coulissantes (${panelCount} vantaux)`
};
} else {
// Hinged doors - one per module/block
const fullHeightHandle = config.hardware?.handleType === 'full_height';
const widthDeduction = fullHeightHandle ? HANDLE.fullHeightWidthDeductionMm : 0;
return {
width: Math.max(100, Math.floor(widthMm / doorCount) - 4 - widthDeduction), // 2mm gap each side
height: bodyHeight - 4,
thickness: PANEL_THICKNESS,
quantity: doorCount,
description: `Portes battantes (${doorCount} portes)`
};
}
}
case 'panneauxInterieurs': {
// Vertical separators between modules
const separatorCount = modules.length > 1
? modules.length - 1
: (config.blocks?.length || 1) - 1;
if (separatorCount <= 0) return null;
return {
width: getSeparatorDepthMm(depthMm),
height: bodyHeight - (PANEL_THICKNESS * 2),
thickness: PANEL_THICKNESS,
quantity: separatorCount,
description: `Séparateurs verticaux (${separatorCount}) — sans rainure dos, plaqués contre le dos`
};
}
case 'etageres': {
// Count mobile shelves from modules (fallback to config.modules when API passes modules=[])
const mods = effectiveModules(modules, config);
const totalShelvesBase = mods.reduce((sum, m) => sum + (Number(m?.shelves) || 0), 0);
const structuralExisting = mods.reduce((sum, m) => sum + (structuralMiddleShelfIndex(m, config) != null ? 1 : 0), 0);
const totalRayonsMobiles = Math.max(0, totalShelvesBase - structuralExisting);
if (totalRayonsMobiles <= 0) return null;
// Average shelf width based on modules
const avgModuleWidth = mods.length > 0
? mods.reduce((sum, m) => sum + (Number(m?.widthMm) || 0), 0) / mods.length
: innerWidth;
return {
// APEX 4.0 — Règles rayons mobiles: L = L.int - 2mm ; P = P.int - 5mm
width: avgModuleWidth - (PANEL_THICKNESS * 2) - 2,
height: innerDepth - 5,
thickness: PANEL_THICKNESS,
quantity: totalRayonsMobiles,
description: `Rayons mobiles (${totalRayonsMobiles})`
};
}
// Rayon fixe (structure): emitted per-module width in `generateCuttingList`.
case 'rayonFixe':
return null;
case 'champs': {
// Edge banding - calculated as linear meters
// Include all visible edges
const perimeter = (widthMm * 2) + (heightMm * 4) + (bodyHeight * 4);
return {
width: EDGE_BANDING.abs,
height: perimeter,
thickness: 0,
quantity: 1,
unit: 'ml',
description: 'Chant ABS 2 mm visible'
};
}
case 'caisse':
// The carcass/body - this is the assembled unit, not a cut piece
return null;
default:
return null;
}
}
/**
* Get edge banding requirements for an element.
* Per APEX 4.0 Cahier des Charges:
* - Portes, côtés, plinthe, bandeau: ABS 2mm on 4 edges
* - Dessus/Fond: ABS 2mm faces avant ET arrière (2 long edges)
* - Étagères: ABS 2mm face avant uniquement (1 edge)
* - Séparateurs, Renforts: ABS 2mm face avant (1 edge)
* - Dos: ABS 2mm on 2 long edges (haut + bas)
*/
function getEdgeBanding(elementType) {
const fullEdges = ['portes', 'cotes', 'plintheBas', 'plintheGauche', 'plintheDroite', 'bandeauHaut'];
if (fullEdges.includes(elementType)) {
return { type: 'visible', thickness: EDGE_BANDING.abs, sides: 4 };
} else if (elementType === 'dessus' || elementType === 'fond') {
// APEX 4.0: "Placage ABS 2mm systématique sur les faces avant et arrière"
return { type: 'visible', thickness: EDGE_BANDING.abs, sides: EDGE_BANDING.topBottomSides };
} else if (elementType === 'tiroirFacade') {
// Façade tiroir Legrabox : ABS 2 mm sur les 4 chants (comme les portes)
return { type: 'visible', thickness: EDGE_BANDING.abs, sides: 4 };
} else if (elementType === 'tiroirFond' || elementType === 'tiroirDos') {
// Fond / paroi arrière 16 mm : invisibles dans la caisse métallique — pas de chant
return { type: 'none', thickness: 0, sides: 0 };
} else if (elementType === 'etageres' || elementType === 'rayonFixe') {
// APEX 4.0: ABS face avant uniquement
return { type: 'visible', thickness: EDGE_BANDING.abs, sides: EDGE_BANDING.shelfSides };
} else if (['panneauxInterieurs', 'renforts', 'renfortsTiroir'].includes(elementType)) {
return { type: 'visible', thickness: EDGE_BANDING.abs, sides: 1 }; // front edge only
} else if (elementType === 'dos') {
// APEX 4.0: dos gets ABS on 2 long edges (haut + bas)
return { type: 'visible', thickness: EDGE_BANDING.abs, sides: EDGE_BANDING.backPanelSides };
}
return { type: 'hidden', thickness: EDGE_BANDING.hidden, sides: 2 };
}
/**
* Calculate machining operations for a piece based on APEX 4.0 specs.
*
* Key corrections from Dossier Technique Maître:
* - System 32 on side panels: Ø5mm borgne (blind), depth 12mm, first/last hole 51mm
* - System 32 on separators: Ø5mm TRAVERSANTS (through), first hole 32mm (51-19)
* - SC 8/60 connector: edge Ø8×45mm, face Ø8×12mm
* - Joint Ixconnect: montants (face haut/bas, bord avant) · séparateurs verticaux (face haut/bas, bords avant & arrière profondeur si largeur ≥ seuil) · horizontaux: ixconnectJointsAlongDepth
* - Groove axis: 25mm from back edge
* - Reinforcements: 1× SC 8/60 center + 2× tourillons at 20mm from edges (axes from piece width)
*/
function getMachining(elementType, dimensions, config, modules = []) {
const machining = {};
const isFree = config?.installationType === 'free';
const exteriorFinishesEnabled = config?.exteriorFinishesEnabled !== false;
const sideDefaultsEnabled = config?.sideFinishesEnabled !== false;
const sideFinishLeftEnabled = config?.sideFinishLeftEnabled ?? sideDefaultsEnabled;
const sideFinishRightEnabled = config?.sideFinishRightEnabled ?? sideDefaultsEnabled;
const topFinishEnabled = config?.topFinishEnabled !== false;
const underCabinetHeightMm = Math.max(0, Number(config?.plinthHeightMm) || 0);
const bottomHasBackGroove =
elementType === 'fond' &&
(
config?._grooveForce === true ||
(config?._grooveLevel
? anyBackAtLevel(config, modules, config._grooveLevel)
: anyBackAtLevel(config, modules, 'bottom'))
);
// Häfele AXILO 78 à planter — platines sous le fond bas uniquement.
if (elementType === 'fond' && config?._axiloFeetEnabled !== false) {
const leftHasSideCladding =
!isFree || (exteriorFinishesEnabled && sideFinishLeftEnabled);
const rightHasSideCladding =
!isFree || (exteriorFinishesEnabled && sideFinishRightEnabled);
const leftEdgeCenterInsetMm = getAxiloLateralCenterInsetMm(leftHasSideCladding);
const rightEdgeCenterInsetMm = getAxiloLateralCenterInsetMm(rightHasSideCladding);
const rearCenterFromRearMm =
PLINTH.mountingPlate.rearCenterFromRearMm +
(bottomHasBackGroove ? getBackGrooveInnerEndInsetFromBackEdgeMm() : 0);
const centers = getAxiloPlateCentersMm(
dimensions.width,
dimensions.height,
{
leftEdgeCenterInsetMm,
rightEdgeCenterInsetMm,
spanWidthMm: config?._axiloSpanWidthMm,
panelOffsetMm: config?._axiloPanelOffsetMm,
assignMinMm: config?._axiloAssignMinMm,
assignMaxMm: config?._axiloAssignMaxMm,
assignMaxInclusive: config?._axiloAssignMaxInclusive,
rearCenterFromRearMm
}
);
if (centers.length > 0) {
machining.axiloFootPlates = {
model: PLINTH.model,
articleNumber: PLINTH.mountingPlate.articleNumber,
targetHeightMm: Number(config?.plinthHeightMm) || 100,
face: PLINTH.mountingPlate.face,
holeDiameterMm: PLINTH.mountingPlate.holeDiameterMm,
holeDepthMm: PLINTH.mountingPlate.holeDepthMm,
holeOffsetsFromCenterMm: PLINTH.mountingPlate.holeOffsetsFromCenterMm.map((p) => ({ ...p })),
lateralCenterInsetsMm: {
left: leftEdgeCenterInsetMm,
right: rightEdgeCenterInsetMm
},
rearCenterFromRearMm,
maxSpacingMm: PLINTH.maxFootSpacingMm,
centers,
frontFootCount: centers.filter((c) => c.row === 'front').length,
rearFootCount: centers.filter((c) => c.row === 'rear').length,
note: `Sous-face du fond — 4 pieds d'armoire + 2 tous les ${PLINTH.maxFootSpacingMm} mm, platine Press-fit entraxe 64 mm`
};
}
}
// System 32 drilling — side panels (blind holes)
if (elementType === 'cotes') {
const panelHeight = dimensions.height;
const usableHeight = panelHeight - SYSTEM32.firstHoleOffset * 2; // first AND last at 51mm
const holeCount = Math.max(0, Math.floor(usableHeight / SYSTEM32.pitch) + 1);
const positions = [];
for (let i = 0; i < holeCount; i++) {
positions.push(SYSTEM32.firstHoleOffset + i * SYSTEM32.pitch);
}
machining.system32 = {
holeDiameter: SYSTEM32.holeDiameter,
holeDepth: SYSTEM32.holeDepth,
throughHole: SYSTEM32.throughHole,
count: holeCount,
rows: 2, // front and back row
positions,
note: 'Borgne (blind) — reversible (first/last at 51mm)',
};
}
// Coulisses Blum Legrabox — perçages cumulés depuis le chant avant (Système 32, Ø5×13)
// sur montants extérieurs et séparateurs, par tiroir (ligne de vissage du profil de corps).
if (['cotes', 'panneauxInterieurs'].includes(elementType)) {
const columns = legraboxColumnsForConfig(config, modules);
if (columns.length) {
const modsAll = effectiveModules(modules, config);
const countModules = Math.max(1, modsAll.length || 0);
const panelDepthMm = Number(config.depthMm || dimensions.width || 600);
const byModuleIndex = new Map(columns.map((c) => [c.moduleIndex, c]));
const R = LEGRABOX_RAIL_DRILLING;
const faceForColumn = (col) => {
if (!col) return null;
const nl = col.nominalLengthMm;
const maxX = Math.min(nl ?? Infinity, panelDepthMm - NICHE.drawerBackGap);
const xs = getLegraboxRailHolePositionsMm(col.loadKg, maxX);
if (!xs.length) return null;
return {
railAxesYMm: col.drawers.map((d) =>
Math.round((d.nicheBottomFromPanelBottomMm + R.screwLineAboveNicheBottomMm) * 10) / 10
),
moduleIndex: col.moduleIndex,
positionsXFromFrontMm: xs
};
};
const base = {
system: 'legrabox_pure',
holeDiameterMm: R.holeDiameterMm,
holeDepthMm: R.holeDepthMm,
note:
`Coulisses Legrabox — positions cumulées depuis le chant avant (Ø${R.holeDiameterMm}×${R.holeDepthMm} mm), ` +
`ligne de vissage à ${R.screwLineAboveNicheBottomMm} mm au-dessus du bas de niche`
};
if (elementType === 'cotes') {
const colL = byModuleIndex.get(0) || null;
const colR = byModuleIndex.get(countModules - 1) || null;
const left = faceForColumn(colL);
const right = countModules > 1 ? faceForColumn(colR) : left;
if (left || right) {
const cotes = {};
if (left) cotes.leftInnerFace = left;
if (right) cotes.rightInnerFace = right;
machining.drawerSlideDrilling = {
...base,
positionsXFromFrontMm: (left || right).positionsXFromFrontMm,
loadKg: (colL || colR).loadKg,
nominalLengthMm: (colL || colR).nominalLengthMm,
cotes
};
}
} else {
const sepCount = Math.max(0, countModules - 1);
const separators = [];
for (let s = 0; s < sepCount; s++) {
const lower = faceForColumn(byModuleIndex.get(s) || null);
const higher = faceForColumn(byModuleIndex.get(s + 1) || null);
separators.push({
separatorIndex: s,
faceTowardLowerModule: lower,
faceTowardHigherModule: higher
});
}
if (separators.some((x) => x.faceTowardLowerModule || x.faceTowardHigherModule)) {
const anyFace = separators.map((x) => x.faceTowardLowerModule || x.faceTowardHigherModule).find(Boolean);
machining.drawerSlideDrilling = {
...base,
positionsXFromFrontMm: anyFace.positionsXFromFrontMm,
loadKg: columns[0].loadKg,
nominalLengthMm: columns[0].nominalLengthMm,
separators
};
}
}
}
}
// Habillages de finition Premium — Ixconnect SC 8/25 (démontable, invisible)
// - Caisson structurel (cotes) : faces extérieures, avant+arrière, y=50 depuis bas/haut + 2 points équidistants (par bord)
// - Caisson structurel (dessus) : face supérieure, 1 paire avant/arrière centrée par colonne (accouplement bandeau)
// - Joues de doublage : perçages centrés sur chaque block de la colonne d'extrémité (pas la hauteur d'armoire)
// - Dessus de doublage : perçages centrés sur chaque colonne / block (pas la largeur d'armoire)
if (isFree && exteriorFinishesEnabled && ['cotes', 'dessus', 'habillageFinitionGauche', 'habillageFinitionDroite', 'habillageFinitionDessus'].includes(elementType)) {
const W = Math.max(1, Number(dimensions.width) || 1);
const H = Math.max(1, Number(dimensions.height) || 1);
const clampX = (v) => Math.max(0, Math.min(W, Number(v) || 0));
const clampY = (v) => Math.max(0, Math.min(H, Number(v) || 0));
const uniq = (arr) =>
arr
.map((v) => Math.round(Number(v) * 1000) / 1000)
.filter((v, i, a) => a.findIndex((u) => Math.abs(u - v) < 1e-6) === i)
.sort((a, b) => a - b);
const connector = CONNECTORS.ixconnectFinish;
const d = Number(connector.faceHole?.diameter || 8);
const z = Number(connector.faceHole?.depth || 12.5);
const dTour = 8;
const zTourEnd = 15;
const holes = [];
const pushFaceHole = (xMm, yMm, type = 'ixconnect', face = 'face') => {
holes.push({
xMm: clampX(xMm),
yMm: clampY(yMm),
diameterMm: d,
depthMm: z,
type,
face
});
};
const pushEndHole = (end, xMm, yMm, diameterMm, depthMm, type) => {
holes.push({
xMm: clampX(xMm),
yMm: clampY(yMm),
diameterMm,
depthMm,
type,
face: 'end',
end
});
};
const addEquidistantInner = (yBottom, yTop, innerCount = 2) => {
const y0 = Number(yBottom);
const y1 = Number(yTop);
if (!Number.isFinite(y0) || !Number.isFinite(y1)) return [];
const lo = Math.min(y0, y1);
const hi = Math.max(y0, y1);
const span = hi - lo;
if (span < (innerCount + 1) * 6) return [];
const step = span / (innerCount + 1);
return Array.from({ length: innerCount }, (_, i) => lo + step * (i + 1));
};
/** Stations strictly inside a span, always including the midpoint (odd count). */
const centeredStationsInSpan = (startMm, endMm, minSpacingMm = 500) => {
const lo = Number(startMm);
const hi = Number(endMm);
if (!Number.isFinite(lo) || !Number.isFinite(hi)) return [];
const span = Math.abs(hi - lo);
if (span < 6) return uniq([(lo + hi) / 2]);
let count = Math.max(1, Math.floor(span / Math.max(1, minSpacingMm)));
if (count % 2 === 0) count += 1;
return uniq(addEquidistantInner(lo, hi, count));
};
if (elementType === 'cotes') {
const xFront = 50;
const xBack = W - 50;
const yBottom = 50;
const yTop = H - 50;
const yInner = addEquidistantInner(yBottom, yTop, 2);
const yAll = uniq([yBottom, ...yInner, yTop]);
for (const yMm of yAll) {
// Tous les SC 8/25 entrent depuis la face extérieure, côté habillage.
// FACE=back est opposée à la face intérieure caisse / System 32.
pushFaceHole(xFront, yMm, 'ixconnect', 'back');
pushFaceHole(xBack, yMm, 'ixconnect', 'back');
}
machining.finishHabillage = {
model: connector.model,
faceHole: { ...connector.faceHole },
note:
'Habillage finition — perçages SC 8/25 sur caisson (cotes) : 50mm bords + 2 équidistants avant/arrière · tous depuis la face extérieure (SC vers habillage)',
holes
};
} else if (elementType === 'dessus' && topFinishEnabled) {
// Accouplement bandeau : même géométrie que l'habillage dessus (72 / 70), centrée par colonne
// sur la largeur du panneau structurel — perçage face supérieure (FACE=face).
const yFront = 72;
const yBack = H - 70;
const junctionAxes = getJunctionAxesAcrossWidthMm(W, modules, config);
const xCenters = [];
for (let i = 0; i < junctionAxes.length - 1; i++) {
xCenters.push((Number(junctionAxes[i]) + Number(junctionAxes[i + 1])) / 2);
}
for (const xCenter of uniq(xCenters)) {
pushFaceHole(xCenter, yFront, 'ixconnect_finish', 'face');
pushFaceHole(xCenter, yBack, 'ixconnect_finish', 'face');
}
machining.finishHabillage = {
model: connector.model,
faceHole: { ...connector.faceHole },
note:
'Habillage finition — caisson dessus (face supérieure): Ixconnect SC 8/25 centrés par colonne (avant 72 / arrière 70), accouplement bandeau',
holes
};
} else if (elementType === 'habillageFinitionGauche' || elementType === 'habillageFinitionDroite') {
// Référentiel: X=0 bord AVANT, X=W bord ARRIÈRE ; Y=0 bas de CE panneau (segment ou pièce entière).
const xFrontL = 72;
const xBackL = W - 70;
const mirror = elementType === 'habillageFinitionDroite';
const xFront = mirror ? W - xFrontL : xFrontL;
const xBack = mirror ? W - xBackL : xBackL;
const mods = effectiveModules(modules, config);
const sideMod = elementType === 'habillageFinitionGauche'
? (mods[0] ?? null)
: (mods.length ? mods[mods.length - 1] : null);
const segs = moduleStackSegments(sideMod, config);
const fullH = (Number(config?.heightMm) || H) + (topFinishEnabled ? PANEL_THICKNESS : 0);
const segBottom = Math.max(0, Number(config?._habillageSegmentBottomMm) || 0);
const pieceTop = segBottom + H;
const minSpacingMm = 500;
let blockBottom = underCabinetHeightMm;
for (const seg of segs) {
const blockTop = blockBottom + Number(seg.heightMm);
const yRows = centeredStationsInSpan(blockBottom, blockTop, minSpacingMm);
for (const yFull of yRows) {
const yMm = yFull - segBottom;
if (yMm < -0.5 || yMm > H + 0.5) continue;
pushFaceHole(xFront, yMm, 'ixconnect');
pushFaceHole(xBack, yMm, 'ixconnect');
}
const yMid = (blockBottom + blockTop) / 2 - segBottom;
if (yMid >= -0.5 && yMid <= H + 0.5) {
pushFaceHole(W / 2, yMid, 'ixconnect');
}
blockBottom = blockTop;
}
// Tourillons sur coupe d'onglet 45° (chant haut) seulement si ce panneau porte le joint avec le bandeau.
if (topFinishEnabled && pieceTop >= fullH - 0.5) {
pushEndHole('top', 50, H, dTour, zTourEnd, 'tourillon_miter');
pushEndHole('top', Math.max(0, W - 50), H, dTour, zTourEnd, 'tourillon_miter');
}
machining.finishHabillage = {
model: connector.model,
faceHole: { ...connector.faceHole },
underCabinetHeightMm,
mirrored: mirror,
note:
'Habillage finition — côté: Ixconnect centrés par block' +
(topFinishEnabled ? ' + tourillons sur onglet haut (avant/arrière 50)' : ' (sans onglet haut — pas de bandeau dessus)'),
holes
};
} else if (elementType === 'habillageFinitionDessus') {
// Dessus de doublage (face intérieure) :
// 1 paire avant/arrière (72 / 70 mm) centrée sur chaque colonne — pas au milieu de l'armoire.
const miterLeft = config?.sideFinishLeftEnabled ?? sideDefaultsEnabled;
const miterRight = config?.sideFinishRightEnabled ?? sideDefaultsEnabled;
const yFront = 72;
const yBack = H - 70;
const junctionAxes = getJunctionAxesAcrossWidthMm(W, modules, config);
const xCenters = [];
for (let i = 0; i < junctionAxes.length - 1; i++) {
xCenters.push((Number(junctionAxes[i]) + Number(junctionAxes[i + 1])) / 2);
}
for (const xCenter of uniq(xCenters)) {
pushFaceHole(xCenter, yFront, 'ixconnect');
pushFaceHole(xCenter, yBack, 'ixconnect');
}
// Tourillons sur coupes d'onglet 45° uniquement si habillage latéral correspondant (joint avec joue).
if (miterLeft) {
pushEndHole('left', 0, 50, dTour, zTourEnd, 'tourillon_miter');
pushEndHole('left', 0, Math.max(0, H - 50), dTour, zTourEnd, 'tourillon_miter');
}
if (miterRight) {
pushEndHole('right', W, 50, dTour, zTourEnd, 'tourillon_miter');
pushEndHole('right', W, Math.max(0, H - 50), dTour, zTourEnd, 'tourillon_miter');
}
machining.finishHabillage = {
model: connector.model,
faceHole: { ...connector.faceHole },
note:
'Habillage finition — dessus doublage (face intérieure): Ixconnect SC 8/25 centrés par colonne (avant 72 / arrière 70)' +
(miterLeft || miterRight ? ' + tourillons onglet sur coupes côtés (selon habillages latéraux)' : ''),
holes
};
}
}
// System 32 drilling — vertical separators (THROUGH holes, offset 32mm)
if (elementType === 'panneauxInterieurs') {
const panelHeight = dimensions.height;
const usableHeight = panelHeight - SYSTEM32_SEPARATORS.firstHoleOffset * 2;
const holeCount = Math.max(0, Math.floor(usableHeight / SYSTEM32_SEPARATORS.pitch) + 1);
const positions = [];
for (let i = 0; i < holeCount; i++) {
positions.push(SYSTEM32_SEPARATORS.firstHoleOffset + i * SYSTEM32_SEPARATORS.pitch);
}
machining.system32 = {
holeDiameter: SYSTEM32_SEPARATORS.holeDiameter,
throughHole: SYSTEM32_SEPARATORS.throughHole,
count: holeCount,
rows: 2,
positions,
note: 'TRAVERSANTS (through) — first hole at 32mm (51-19)',
};
}
// Hinge holes — applies to side panels (hinged doors only)
if (elementType === 'cotes' && config.doorType !== 'sliding') {
const doorCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2);
const hingesTotal = doorCount * HINGE.perDoor;
machining.hingeHoles = {
count: hingesTotal,
axisOffset: HINGE.axisOffset,
perDoor: HINGE.perDoor,
};
}
// Ixconnect — panneaux horizontaux (ixconnectJointsAlongDepth) + montants / séparateurs (jointPattern face haut & bas)
// Arête extrémité: T@20 → IX@50 → T@80 mm depuis bord avant · Jonction interne: IX au milieu profondeur + T ±30 mm
if (['dessus', 'fond', 'cotes', 'panneauxInterieurs'].includes(elementType)) {
const connector = CONNECTORS.ixconnect;
const depthAlongJointMm = ['dessus', 'fond'].includes(elementType) ? dimensions.height : dimensions.width;
const edgePattern = getIxconnectJointPatternAlongDepthMm(depthAlongJointMm, 'edge', {
horizontalPanel: ['dessus', 'fond'].includes(elementType)
});
const horizontalJunctionLayout =
elementType === 'dessus' || elementType === 'fond'
? getHorizontalPanelJunctionLayout(dimensions.width, modules, config)
: null;
const junctionAxesForHorizontals = horizontalJunctionLayout?.axes || null;
const coteAssemblySide = config?._coteAssemblySide;
const coteUsesEdgeJoint =
elementType === 'cotes' &&
(coteAssemblySide === 'left'
? getExteriorSideAssembly(config).left
: coteAssemblySide === 'right'
? getExteriorSideAssembly(config).right
: getExteriorSideAssembly(config).left && getExteriorSideAssembly(config).right);
machining.connectorHoles = {
model: connector.model,
edgeHole: { ...connector.edgeHole }, // Ø8 × 45mm
faceHole: { ...connector.faceHole }, // Ø8 × 12mm
lockScrewInbus: { ...connector.lockScrewInbus }, // Ø6 × 9mm — accès sur la pièce percée du long Ø8 en chant
jointPattern: {
tourillon1Axis: edgePattern.tourillon1Axis,
ixconnectAxis: edgePattern.ixconnectAxis,
tourillon2Axis: edgePattern.tourillon2Axis
},
tourillonDiameter: CONNECTORS.tourillonDiameter,
jointsPerPiece: junctionAxesForHorizontals ? junctionAxesForHorizontals.length : 1,
connectorsPerJoint: 1, // 1 Ixconnect + 2 tourillons per joint
tourillonsPerJoint: 2,
...(elementType === 'cotes'
? { verticalJointMode: coteUsesEdgeJoint ? 'edge' : 'face' }
: {}),
};
if (junctionAxesForHorizontals) {
machining.connectorHoles.ixconnectJointsAlongDepth = junctionAxesForHorizontals.map((xAxis, idx) => ({
axisAcrossWidthMm: xAxis,
junctionFace: horizontalJunctionLayout.faceModes[idx],
...getIxconnectJointPatternAlongDepthMm(
depthAlongJointMm,
idx === 0 || idx === junctionAxesForHorizontals.length - 1 ? 'edge' : 'middle',
{ horizontalPanel: true }
)
}));
}
}
// Groove for back panel — montants, habillage niche (fileurs, doublage), bandeau, dessus, fond (pas séparateurs)
// Uniquement au droit des zones avec dos. En cas d'empilage: dessus (toit) → zones du niveau haut,
// fond → zones du niveau bas; `config._grooveLevel` force le niveau (pièces de jonction).
{
const grooveLevel = config?._grooveLevel;
const grooveNeeded =
config?._grooveForce === true
? true
: elementType === 'cotes'
? backAtSide(config, modules, 'left') || backAtSide(config, modules, 'right')
: grooveLevel
? anyBackAtLevel(config, modules, grooveLevel)
: elementType === 'dessus'
? anyBackAtLevel(config, modules, 'top')
: elementType === 'fond'
? anyBackAtLevel(config, modules, 'bottom')
: anyBackPanel(config, modules);
if (
grooveNeeded &&
(BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(elementType) ||
BACK_GROOVE_PLAN_HORIZONTAL_TYPES.includes(elementType))
) {
machining.groove = {
width: GROOVE.back.width,
depth: GROOVE.back.depth,
axisFromPanelEdge: GROOVE.back.axisFromPanelEdge,
position: 'back',
};
if (
BACK_GROOVE_PLAN_HORIZONTAL_TYPES.includes(elementType) &&
!allBackPanels(config, modules)
) {
machining.groove.note = 'Rainure dos uniquement au droit des zones avec dos (configuration mixte)';
}
}
}
// Reinforcement: sur CHAQUE petit chant d'extrémité (jonction côtés)
// 1× SC 8/60 centre + 2× tourillons à 20 mm des bords haut/bas.
if (elementType === 'renfortsTiroir') {
const dr = DRAWER_REINFORCEMENT;
const w = Math.max(1, Number(dimensions.width) || 1);
const h = Math.max(1, Number(dimensions.height) || 1);
const ixHoles = getDrawerReinforcementIxconnectHolePositionsMm(w, h);
machining.drawerReinforcement = {
thicknessMm: dr.thicknessMm,
backInsetFromRearMm: dr.backInsetFromRearMm,
hingeFrontClearanceAppliedMm: dr.hingeFrontClearanceMm,
topFrontRadiusDeductionMm: dr.topFrontRadiusMm,
note:
`Renfort tiroir ${dr.thicknessMm} mm (tiroirs cachés derrière porte) — du fond +${dr.backInsetFromRearMm} mm au bord avant` +
` · réserve charnières porte −${dr.hingeFrontClearanceMm} mm · hauteur = pile tiroirs − R${dr.topFrontRadiusMm} mm (façade tiroir supérieure)`,
ixconnectFixing: {
model: dr.ixconnectModel,
holeDiameterMm: dr.ixconnectHoleDiameterMm,
depthInReinforcementMm: dr.ixconnectDepthInReinforcementMm,
depthInVerticalPanelMm: dr.ixconnectDepthInVerticalPanelMm,
cornerInsetMm: dr.ixconnectCornerInsetMm,
holes: ixHoles,
note:
`4× ${dr.ixconnectModel} — Ø${dr.ixconnectHoleDiameterMm} à ${dr.ixconnectCornerInsetMm} mm des bords (chaque coin) · prof. ${dr.ixconnectDepthInReinforcementMm} mm dans le renfort · prof. ${dr.ixconnectDepthInVerticalPanelMm} mm en face montant/séparateur`
}
};
}
{
const modsR = effectiveModules(modules, config);
const depthMmR = Number(config.depthMm || 600);
const sepDepthMmR = getSeparatorDepthMm(depthMmR);
const ixP = DRAWER_RENFORT_PANEL_IXCONNECT;
if (elementType === 'cotes') {
const zL = moduleRenfortZoneForDrawerRenfort(config, modsR, 0);
const zR =
modsR.length > 0 ? moduleRenfortZoneForDrawerRenfort(config, modsR, modsR.length - 1) : null;
const cotes = {};
if (zL) {
cotes.leftInnerFace = {
holes: buildDrawerRenfortPanelIxconnectCornersMm(depthMmR, true, zL.zoneBottomMm, zL.hMm),
panelKind: 'cote'
};
}
if (zR) {
cotes.rightInnerFace = {
holes: buildDrawerRenfortPanelIxconnectCornersMm(depthMmR, true, zR.zoneBottomMm, zR.hMm),
panelKind: 'cote'
};
}
if (cotes.leftInnerFace || cotes.rightInnerFace) {
machining.drawerRenfortPanelIxconnect = {
model: ixP.model,
faceHole: { diameter: ixP.faceHoleDiameterMm, depth: ixP.faceHoleDepthMm },
cotes,
note:
`Montants extérieurs — face intérieure caisse : Ixconnect ${ixP.model} Ø${ixP.faceHoleDiameterMm}×${ixP.faceHoleDepthMm} mm · ${ixP.frontAxesFromFrontMm} mm du devant · ${ixP.backAxesFromRearCoteMm} mm du fond (19+9+50) · ${ixP.verticalInsetMm} mm haut/bas zone renfort · Y depuis bas panneau`
};
}
}
if (elementType === 'panneauxInterieurs') {
const sepCount = Math.max(0, modsR.length - 1);
const separators = [];
for (let s = 0; s < sepCount; s++) {
const zs = moduleRenfortZoneForDrawerRenfort(config, modsR, s);
const zs1 = moduleRenfortZoneForDrawerRenfort(config, modsR, s + 1);
separators.push({
separatorIndex: s,
faceTowardLowerModule: zs
? {
holes: buildDrawerRenfortPanelIxconnectCornersMm(sepDepthMmR, false, zs.zoneBottomMm, zs.hMm),
moduleIndex: s
}
: null,
faceTowardHigherModule: zs1
? {
holes: buildDrawerRenfortPanelIxconnectCornersMm(sepDepthMmR, false, zs1.zoneBottomMm, zs1.hMm),
moduleIndex: s + 1
}
: null
});
}
if (separators.some((x) => x.faceTowardLowerModule || x.faceTowardHigherModule)) {
machining.drawerRenfortPanelIxconnect = {
model: ixP.model,
faceHole: { diameter: ixP.faceHoleDiameterMm, depth: ixP.faceHoleDepthMm },
separators,
note:
`Séparateurs — faces intérieures vers colonnes : Ixconnect ${ixP.model} Ø${ixP.faceHoleDiameterMm}×${ixP.faceHoleDepthMm} mm · ${ixP.frontAxesFromFrontMm} mm du devant · ${ixP.backAxesFromRearSeparatorMm} mm du fond · ${ixP.verticalInsetMm} mm haut/bas zone renfort · Y depuis bas panneau`
};
}
}
}
if (elementType === 'renforts') {
const axes = getReinforcementEndFixingAxesFromTopMm(dimensions.height);
const conn = REINFORCEMENT_MACHINING.connector;
machining.reinforcementFixing = {
connectorModel: REINFORCEMENT_MACHINING.connectorModel,
edgeHole: { ...conn.edgeHole },
faceHole: { ...conn.faceHole },
ixconnectCenter: REINFORCEMENT_MACHINING.ixconnectCenter,
sideCount: 2,
ixconnectPerSide: 1,
tourillonsPerSide: REINFORCEMENT_MACHINING.tourillonCount,
ixconnectAxisFromTopMm: axes.ixconnectAxisFromTopMm,
tourillonCount: REINFORCEMENT_MACHINING.tourillonCount,
tourillonEdgeOffset: REINFORCEMENT_MACHINING.tourillonEdgeOffset,
tourillonAxesFromTopMm: axes.tourillonAxesFromTopMm,
tourillonDiameter: REINFORCEMENT_MACHINING.tourillonDiameter,
note: `Par côté: 1× ${REINFORCEMENT_MACHINING.connectorModel} centre + 2× tourillons Ø8 à ${REINFORCEMENT_MACHINING.tourillonEdgeOffset} mm des bords`,
};
}
// SC 8/25 connector holes — finish panels (doors)
if (elementType === 'portes') {
const sc825 = CONNECTORS.ixconnectFinish;
machining.finishConnectors = {
model: sc825.model,
faceHole: { ...sc825.faceHole }, // Ø8 × 12.5mm
axes: { ...sc825.axes }, // back: 30mm, front: 43mm
note: 'SC 8/25 pour panneaux de finition',
};
if (config.doorType !== 'sliding') {
const hingeCount = getHingeCountForDoor({ heightMm: dimensions.height });
machining.hingeCups = {
diameter: 35,
cupDepth: 12,
axisOffsetFromEdge: HINGE.axisOffset,
count: hingeCount,
positionsFromTop: getHingeCupPositions(dimensions.height, hingeCount),
screwDiameterMm: HINGE.cupScrewDiameter,
screwDepthMm: HINGE.cupScrewDepth,
screwSpacingMm: HINGE.cupScrewSpacing,
screwOffsetFromCupCenterMm: HINGE.cupScrewOffsetFromCupCenter,
note:
'Charnières sur axe 103 mm (réversible), répartition intermédiaire sur grille 32 ; par cuvette : 2× Ø8×13 mm (45 mm c/c, 9,5 mm du centre cuvette)'
};
if (config.hardware?.handleType === 'full_height') {
machining.fullHeightHandleGroove = {
oppositeToHinges: true,
topBottomOffsetMm: HANDLE.fullHeightTopBottomOffset,
grooveWidthMm: HANDLE.fullHeightGroove.width,
grooveDepthMm: HANDLE.fullHeightGroove.depth,
note: 'Rainure poignée toute hauteur côté opposé aux charnières'
};
}
}
}
// Shelf machining: tourillons Ø8 for pin alignment
if (elementType === 'etageres') {
machining.shelfPins = {
tourillonDiameter: SHELF_MACHINING.tourillonDiameter,
tourillonCount: SHELF_MACHINING.tourillonCount,
shelvesPerStratum: SHELF_MACHINING.shelvesPerStratum,
note: '4× tourillons Ø8 (2 avant + 2 arrière) — 2 rayons/strate',
};
machining.mobileShelfSystem = {
model: 'sp_15_8_5',
faceHole: { diameterMm: 20, depthMm: 15, axisFromSideMm: 9.5 },
edgeHole: { diameterMm: 8, depthMm: 37, axisFromBottomFaceMm: 6 },
positions: { fromFrontMm: 32, fromBackMm: 37 },
note: 'Rayons mobiles (SP 15/8/5): Ø20×15 dessous + Ø8×37 chant à 6mm de la face inférieure'
};
}
if (elementType === 'rayonFixe') {
// Fixed shelf: same face holes as mobile shelves, but Ø20 only, deeper (20mm), no edge holes.
machining.mobileShelfSystem = {
model: 'sp_15_8_5_face_only',
faceHole: { diameterMm: 20, depthMm: 14.5, axisFromSideMm: 9.5 },
positions: { fromFrontMm: 32, fromBackMm: 37 },
note: 'Rayon fixe: perçages face dessous Ø20 · prof. 14.5 (positions SP 15/8/5)'
};
machining.fixedShelfAssembly = {
model: 'Rasant-Tab',
pilotHoleDiametersMm: [3, 5],
note: 'Rayon fixe de structure — ferrures Rasant-Tab à visser (pré-perçage Ø3 ou Ø5 selon vis)'
};
}
if (hasBlockJoint(config, modules) && ['panneauxInterieurs', 'cotes'].includes(elementType)) {
machining.blockAssembly = {
middleCount: BLOCK_ASSEMBLY.middleCount,
topBottomOffset: BLOCK_ASSEMBLY.topBottomOffset,
fromBack: BLOCK_ASSEMBLY.sideOffsetFromBack,
fromFace: BLOCK_ASSEMBLY.sideOffsetFromFace,
note: 'Vis de jonction pour assemblage de caisses'
};
}
if (['fond', 'dessus'].includes(elementType)) {
// Ferrures d’assemblage de corps:
// connectent panneaux horizontaux (dessus/fond) avec côtés + séparateurs (pas le dos).
const edgeLenMm = dimensions.height; // sens longueur de perçage (profondeur pièce)
const grooveInsetMm = getBackGrooveInnerEndInsetFromBackEdgeMm();
const jointEdgeUsableLengthMm = Math.max(0, edgeLenMm - grooveInsetMm);
const positionsAlongJointMm = getCarcassJointPositions(jointEdgeUsableLengthMm);
if (
bottomHasBackGroove &&
config?._axiloFeetEnabled !== false &&
positionsAlongJointMm.length > 0
) {
const rearIndex = positionsAlongJointMm.length - 1;
positionsAlongJointMm[rearIndex] = Math.max(
0,
positionsAlongJointMm[rearIndex] - CARCASS_FASTENERS.axiloGrooveRearRowShiftMm
);
}
const dowelStations = getCarcassFerrureDowelDrillings(
positionsAlongJointMm,
jointEdgeUsableLengthMm,
CARCASS_FASTENERS.dowel.sideOffset
);
const horizontalJunctionLayout =
getHorizontalPanelJunctionLayout(dimensions.width, modules, config);
const junctionAxesAcrossWidthMm = [...horizontalJunctionLayout.axes];
machining.carcassFasteners = {
piecesPerJoint: CARCASS_FASTENERS.piecesPerJoint,
jointCount: junctionAxesAcrossWidthMm.length,
jointEdgeLabel: 'jonctions avec côtés + séparateurs (hors dos)',
edgeLengthMm: edgeLenMm,
jointEdgeUsableLengthMm,
junctionAxesAcrossWidthMm,
junctionFaceModes: [...horizontalJunctionLayout.faceModes],
positionsAlongJointMm,
dowelStations,
dowelDiameter: CARCASS_FASTENERS.dowel.diameter,
dowelDepth: CARCASS_FASTENERS.dowel.depth,
dowelSideOffset: CARCASS_FASTENERS.dowel.sideOffset,
ferrureDepthAlongLengthMm: CARCASS_FASTENERS.ferrureDepthAlongLengthMm,
ferrureDepthInThicknessMm: CARCASS_FASTENERS.ferrureDepthInThicknessMm,
note:
`Par jonction: 2 ou 3 ferrures selon longueur d’arête (milieu si arête > ${CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM} mm) + 2 tourillons Ø8×${CARCASS_FASTENERS.dowel.faceDrillDepthMm} (face) à ±${CARCASS_FASTENERS.dowel.sideOffset} mm par ferrure`,
};
}
return Object.keys(machining).length > 0 ? machining : null;
}
function getEdgeBandingAxesByType(elementType) {
switch (elementType) {
case 'portes':
case 'cotes':
case 'plintheBas':
case 'plintheGauche':
case 'plintheDroite':
case 'bandeauHaut':
case 'cotesDoublage':
case 'fileur':
case 'fileurCotes':
case 'fileurHaut':
case 'habillageFinitionGauche':
case 'habillageFinitionDroite':
case 'habillageFinitionDessus':
case 'tiroirFacade':
return { widthEdges: 2, heightEdges: 2 };
case 'dessus':
case 'fond':
return { widthEdges: 2, heightEdges: 0 };
case 'etageres':
case 'rayonFixe':
case 'panneauxInterieurs':
case 'renforts':
case 'renfortsTiroir':
return { widthEdges: 1, heightEdges: 0 };
case 'dos':
return { widthEdges: 2, heightEdges: 0 };
default:
return { widthEdges: 0, heightEdges: 0 };
}
}
/**
* Sens de collage des bandes de chant : indique le long de quel axe chaque chant
* est appliqué (Longueur = axe hauteur de la pièce, Largeur = axe largeur de la pièce).
* @returns {{ widthEdges:number, heightEdges:number, label:string }}
*/
function describeEdgeBandingDirection(elementType) {
const axes = getEdgeBandingAxesByType(elementType);
const parts = [];
// widthEdges = chants dont la bande court le long de la LARGEUR de la pièce.
if (axes.widthEdges > 0) parts.push(`${axes.widthEdges} sur largeur`);
// heightEdges = chants dont la bande court le long de la LONGUEUR (hauteur) de la pièce.
if (axes.heightEdges > 0) parts.push(`${axes.heightEdges} sur longueur`);
return {
widthEdges: axes.widthEdges,
heightEdges: axes.heightEdges,
label: parts.length ? parts.join(' + ') : 'aucun'
};
}
function computeRawCutDimensions(piece) {
const axes = getEdgeBandingAxesByType(piece.elementType);
const deductionPerEdge = EDGE_BANDING.abs;
// Sens de collage des chants (Longueur/Largeur) attaché à la pièce.
if (piece.edgeBanding && piece.edgeBanding.type !== 'none') {
piece.edgeBanding.direction = describeEdgeBandingDirection(piece.elementType);
}
piece.surcoteMm = CUTTING_SURCOTE_MM;
// Surcote sur-mesure de 10 mm en longueur et en largeur, en plus de la
// compensation chant (2 mm par chant ABS appliqué sur l'axe concerné).
return {
rawCutWidthMm: piece.widthMm + axes.widthEdges * deductionPerEdge + CUTTING_SURCOTE_MM,
rawCutHeightMm: piece.heightMm + axes.heightEdges * deductionPerEdge + CUTTING_SURCOTE_MM,
surcoteMm: CUTTING_SURCOTE_MM
};
}
/**
* Coupe largeur × épaisseur (dessus/fond) : un logement tourillon par jonction (côtés + séparateurs),
* traitillé depuis la face supérieure (arête vue en coupe), axe en pointillés.
*/
function svgCarcassTourillonSlotsSectionView(origX, origY, scaleC, thicknessPx, widthMm, thicknessMm, cf) {
if (!cf?.junctionAxesAcrossWidthMm?.length) return '';
const edgeInset = PANEL_THICKNESS * 0.5;
const wMm = Math.max(1, Number(widthMm) || 0);
const tMm = Math.max(1, Number(thicknessMm) || 0);
const mapXmm = (xmm) => {
const clamped = Math.max(0, Math.min(wMm, Number(xmm) || 0));
if (clamped <= 0) return edgeInset;
if (clamped >= wMm) return Math.max(0, wMm - edgeInset);
return clamped;
};
const joints = cf.junctionAxesAcrossWidthMm.map(mapXmm);
const dMm = cf.dowelDiameter || 8;
const depthMm = tourillonFaceDrillDepthMm(tMm);
const hPx = Math.min(depthMm * scaleC, thicknessPx - 1.5);
const halfW = Math.max(1.2, (dMm * 0.5) * scaleC);
let s = '';
for (const xmm of joints) {
const x = origX + xmm * scaleC;
const y0 = origY;
const y1 = origY + hPx;
s += ``;
s += ``;
}
return s;
}
/** Dessus/fond/bandeau : en coupe L×ép., rainure ouverte depuis la face haute du rectangle (épaisseur), comme les montants. */
function backGrooveOpensFromBottomThickEdge(_piece) {
return false;
}
/**
* Vue A — rainure réception dos sur la face usinée (même repère que détail côté : dos à droite pour montants).
*/
function svgBackGrooveFaceViewA(piece, m, pAx, pAy, wA, hA, scaleA, fmt) {
const axisMm = m.groove.axisFromPanelEdge ?? m.groove.axisFromBackEdge;
if (axisMm == null) return '';
const gw = m.groove.width ?? GROOVE.back.width;
const gd = m.groove.depth ?? GROOVE.back.depth;
const sa = scaleA;
let s = '';
if (BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(piece.elementType)) {
const gx = pAx + wA - axisMm * sa - (gw * sa) / 2;
s += `Rainure dos ${fmt(gw)}×${fmt(gd)} mm · axe ${fmt(axisMm)} mm du bord dos · pleine longueur`;
s += ``;
s += `dos`;
s += ``;
} else if (BACK_GROOVE_PLAN_HORIZONTAL_TYPES.includes(piece.elementType)) {
const centerY = pAy + axisMm * sa;
const gy = centerY - (gw * sa) / 2;
/* Dessus/fond/bandeau : rainure sur toute la longueur utile du bord (joint dos), sans retrait latéral. */
const xin = pAx;
const win = Math.max(0.5, wA);
s += `Rainure dos ${fmt(gw)}×${fmt(gd)} mm · axe ${fmt(axisMm)} mm du bord dos · pleine longueur`;
s += ``;
s += ``;
}
return s;
}
/** Vue C — coupe largeur pièce × épaisseur : rainure côté dos (droite), ouverte depuis la face caisse. */
function svgBackGrooveSectionViewC(pCx, pCy, wC, tC, scaleC, piece, m, fmt) {
const axisMm = m.groove.axisFromPanelEdge ?? m.groove.axisFromBackEdge;
if (axisMm == null) return '';
const axis = Math.min(wC, Math.max(0, axisMm * scaleC));
const grooveW = Math.max(2, (m.groove.width || GROOVE.back.width) * scaleC);
const depthMm = m.groove.depth ?? GROOVE.back.depth;
const grooveD = Math.max(2, Math.min(tC - 2, depthMm * scaleC));
const gx = pCx + wC - axis - grooveW / 2;
const fromBottom = backGrooveOpensFromBottomThickEdge(piece);
const gy = fromBottom ? pCy + tC - grooveD : pCy;
const midX = gx + grooveW / 2;
let s = '';
s += ``;
if (fromBottom) {
s += ``;
s += `Axe → ${fmt(axisMm)} mm du bord dos`;
} else {
s += ``;
s += `Ouverture face caisse · ${fmt(axisMm)} mm du dos`;
}
s += `Bord dos`;
s += `${fmt(m.groove.width || GROOVE.back.width)} × ${fmt(depthMm)} mm`;
return s;
}
/**
* Vue D — double schéma type plan technique : coupe sur épaisseur + vue sur face rainurée.
*/
function svgBackGrooveTechnicalDetail(piece, m, fmt) {
const tMm = Math.max(1, Number(piece.thicknessMm) || PANEL_THICKNESS);
const gw = m.groove.width ?? GROOVE.back.width;
const gd = m.groove.depth ?? GROOVE.back.depth;
const ax = m.groove.axisFromPanelEdge ?? m.groove.axisFromBackEdge ?? GROOVE.back.axisFromPanelEdge;
const fromBottom = backGrooveOpensFromBottomThickEdge(piece);
const sPx = 5.2;
const Wsec = 210;
const Ht = Math.max(30, Math.min(110, tMm * sPx));
const x0 = 36;
const y0 = 58;
let o = '';
o += `A — Coupe sur épaisseur (dos à droite)`;
o += ``;
const axisPx = ax * sPx;
const gwp = gw * sPx;
const gdp = Math.min(gd * sPx, Ht - 1.5);
const gx = x0 + Wsec - axisPx - gwp / 2;
const gy = fromBottom ? y0 + Ht - gdp : y0;
o += ``;
o += ``;
o += ``;
o += ``;
o += `${fmt(gw)} ±0.3`;
o += ``;
o += ``;
o += ``;
o += `ép. ${fmt(tMm)}`;
const depLineX = gx + gwp + 28;
const y1d = fromBottom ? y0 + Ht : y0;
const y2d = fromBottom ? gy : y0 + gdp;
o += ``;
o += `${fmt(gd)}`;
const x1 = 400;
const y1 = 58;
const Lface = 300;
const faceH = Math.max(36, Ht + 16);
o += `B — Face intérieure (rainure en trait mixte)`;
o += ``;
o += ``;
o += ``;
if (BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(piece.elementType)) {
const fw = Lface - 40;
const gx2 = x1 + 20 + fw - axisPx - gwp / 2;
o += ``;
o += ``;
o += `${fmt(ax)} mm (axe au bord dos)`;
} else {
const fw = Lface - 40;
const cy = y1 + axisPx;
const gy2 = cy - gwp / 2;
o += ``;
o += ``;
o += `${fmt(ax)} mm`;
}
o += `Rainure réception dos · cotation indicative ±0.3 mm sur largeur de fraisage`;
return o;
}
/**
* Vue B — chant d'extrémité du renfort (H×ép.) avec perçages Ixconnect/tourillons.
*/
function svgReinforcementFixingEdgeViewB(rf, pBx, pBy, tB, longB, scaleB, fmt, horizontal = false) {
if (!rf) return '';
const ixFromTop = Number(rf.ixconnectAxisFromTopMm) || 0;
const axisCenter = horizontal ? (pBy + tB / 2) : (pBx + tB / 2);
const posIx = horizontal
? (pBx + Math.min(longB, Math.max(0, ixFromTop * scaleB)))
: (pBy + Math.min(longB, Math.max(0, ixFromTop * scaleB)));
const rIx = Math.max(3, (rf.edgeHole?.diameter || 8) * 0.4 * scaleB);
const rT = Math.max(2.1, (rf.tourillonDiameter || 8) * 0.3 * scaleB);
let s = '';
if (horizontal) {
s += ``;
s += ``;
} else {
s += ``;
s += ``;
}
for (const yTop of rf.tourillonAxesFromTopMm || []) {
const pos = horizontal
? (pBx + Math.min(longB, Math.max(0, Number(yTop) * scaleB)))
: (pBy + Math.min(longB, Math.max(0, Number(yTop) * scaleB)));
if (horizontal) {
s += ``;
} else {
s += ``;
}
}
const axesText = (rf.tourillonAxesFromTopMm || []).map((v) => fmt(v)).join(' / ');
const tourillonAxes = (rf.tourillonAxesFromTopMm || [])
.map((v) => Number(v))
.filter((v) => Number.isFinite(v))
.sort((a, b) => a - b);
const leftEdgeOffsetMm = tourillonAxes.length ? tourillonAxes[0] : null;
const rightEdgeOffsetMm = tourillonAxes.length
? Number(rf.tourillonEdgeOffset ?? leftEdgeOffsetMm)
: null;
if (horizontal) {
s += `${rf.connectorModel || 'SC 8/60'} (par chant) axe ${fmt(ixFromTop)} mm`;
s += `Tourillons Ø${rf.tourillonDiameter || 8}: ${axesText} mm depuis haut`;
// Dimension lines inside the piece (between top edge and hole axis; SVG y grows downward).
const dimY20 = pBy + Math.max(3, tB * 0.22);
const dimY50 = pBy + Math.max(3, tB * 0.42);
if (leftEdgeOffsetMm != null && tourillonAxes.length >= 2) {
const leftX = pBx;
const firstX = pBx + leftEdgeOffsetMm * scaleB;
const rightX = pBx + longB;
const lastX = pBx + tourillonAxes[tourillonAxes.length - 1] * scaleB;
s += ``;
s += ``;
s += ``;
s += `${fmt(leftEdgeOffsetMm)} mm`;
s += ``;
s += ``;
s += ``;
s += `${fmt(rightEdgeOffsetMm)} mm`;
}
// Center hole (Ixconnect) dimension from edge: typically 50 mm on 100 mm reinforcement width.
s += ``;
s += ``;
s += ``;
s += `${fmt(ixFromTop)} mm`;
} else {
s += `${rf.connectorModel || 'SC 8/60'} (par chant) axe ${fmt(ixFromTop)} mm`;
s += `Tourillons Ø${rf.tourillonDiameter || 8}: ${axesText} mm depuis haut`;
}
return s;
}
/**
* Usinage face avant (cuvettes + rainure TH) pour une orientation de charnières donnée.
*/
function doorFaceUsinageSvg({ pAx, pAy, wA, hA, scaleA, m, hingeSide, heightMm, fmt }) {
let viewAOps = '';
if (m.hingeCups?.positionsFromTop?.length) {
const cupR = Math.max(2.5, (m.hingeCups.diameter || 35) * scaleA * 0.5);
const cupInset = Math.min(22 * scaleA, wA * 0.13);
const cupX = hingeSide === 'right' ? pAx + wA - cupInset : pAx + cupInset;
const sd = m.hingeCups.screwDiameterMm ?? HINGE.cupScrewDiameter;
const screwR = Math.max(1.2, sd * scaleA * 0.5);
const offPx = (m.hingeCups.screwOffsetFromCupCenterMm ?? HINGE.cupScrewOffsetFromCupCenter) * scaleA;
const halfSpPx = ((m.hingeCups.screwSpacingMm ?? HINGE.cupScrewSpacing) / 2) * scaleA;
const screwX = hingeSide === 'right' ? cupX - offPx : cupX + offPx;
for (const pos of m.hingeCups.positionsFromTop) {
const y = pAy + Math.min(hA, Math.max(0, pos * scaleA));
viewAOps += `${Math.round(pos)}mm`;
viewAOps += ``;
viewAOps += ``;
}
}
if (m.fullHeightHandleGroove) {
const off = m.fullHeightHandleGroove.topBottomOffsetMm ?? HANDLE.fullHeightTopBottomOffset;
const gw = m.fullHeightHandleGroove.grooveWidthMm ?? HANDLE.fullHeightGroove.width;
const gd = m.fullHeightHandleGroove.grooveDepthMm ?? HANDLE.fullHeightGroove.depth;
const axisFromHandleEdge = Math.max(2, (gw / 2) * scaleA);
const gx = hingeSide === 'right' ? pAx + axisFromHandleEdge : pAx + wA - axisFromHandleEdge;
const y1 = pAy + off * scaleA;
const y2 = pAy + hA - off * scaleA;
viewAOps += ``;
viewAOps += `Rainure TH · ${fmt(gw)}×${fmt(gd)} mm · retrait ${fmt(off)} mm H/B`;
const dx = pAx - 6;
const mida = (y0, y1, label) => {
const ym = (y0 + y1) / 2;
return `${label} mm`;
};
viewAOps += mida(pAy, y1, `${fmt(off)}`);
viewAOps += mida(y1, y2, `${fmt(heightMm - 2 * off)}`);
viewAOps += mida(y2, pAy + hA, `${fmt(off)}`);
}
return viewAOps;
}
/**
* Détail fabrication porte : coupe rainure TH (cotes), schéma hauteur, perçages.
* Utilisé en bandeau sous les vues A/B/C et en vue D plein écran.
*/
function doorMachiningDetailMarkup(piece, fmt, boxW, boxH, hingeSideOverride, vantailLabel) {
const m = piece.machining || {};
const hingeSide = hingeSideOverride ?? piece.doorPlanHints?.hingeSide ?? 'left';
const fh = m.fullHeightHandleGroove;
const gw = fh ? (fh.grooveWidthMm ?? HANDLE.fullHeightGroove.width) : HANDLE.fullHeightGroove.width;
const gd = fh ? (fh.grooveDepthMm ?? HANDLE.fullHeightGroove.depth) : HANDLE.fullHeightGroove.depth;
const off = fh ? (fh.topBottomOffsetMm ?? HANDLE.fullHeightTopBottomOffset) : HANDLE.fullHeightTopBottomOffset;
const H = piece.heightMm;
const tMm = piece.thicknessMm;
const grooveLen = fh ? Math.max(0, H - 2 * off) : 0;
const handleRight = hingeSide === 'left';
const fs = m.finishConnectors;
const hc = m.hingeCups;
const col1W = Math.min(240, boxW * 0.34);
const col2W = Math.min(200, boxW * 0.28);
const x1 = 8;
const x2 = x1 + col1W + 16;
const x3 = x2 + col2W + 16;
const yTitle = 18;
const y0 = 36;
let s = '';
const titleText = vantailLabel
? `Détail usinage — ${vantailLabel} — rainure & perçages`
: 'Détail usinage porte — rainure & perçages';
s += `${titleText}`;
// Colonne 1 — Coupe sur épaisseur (chant poignée), même logique que rainure dos : larg. × prof.
const sc = Math.min(110 / tMm, 95 / Math.max(gd, 1));
const barW = tMm * sc;
const barH = 34;
const px = x1;
const py = y0;
s += `Coupe sur chant poignée (profil rainure dans l'épaisseur)`;
const barY = py + 10;
s += ``;
const gDpx = gd * sc;
const gWpx = gw * sc;
const gX = handleRight ? px + barW - gDpx : px;
const ghVis = Math.min(gWpx, barH - 2);
const gY = barY + barH - ghVis;
s += ``;
s += `Ouverture rainure ${fmt(gw)} mm · Profondeur ${fmt(gd)} mm`;
s += `Épaisseur panneau ${fmt(tMm)} mm`;
// Petites cotes sur la coupe
s += ``;
s += ``;
s += `${fmt(gd)}`;
s += `${fmt(gw)}`;
// Colonne 2 — Schéma hauteur rainure TH
const vx = x2 + 14;
const vy = barY;
s += `Hauteur rainure (poignée TH)`;
if (fh) {
const maxH = Math.min(120, boxH - 50);
const vScale = maxH / Math.max(H, 1);
const vLen = H * vScale;
const oPx = off * vScale;
s += ``;
s += ``;
s += ``;
s += ``;
s += ``;
s += `Retrait ${fmt(off)} mm`;
s += `Utile rainure ${fmt(grooveLen)} mm`;
s += `Retrait ${fmt(off)} mm`;
} else {
s += `Poignée standard — pas de rainure TH`;
}
// Colonne 3 — Perçages
let ly = y0 + 8;
s += `Perçages`;
if (hc) {
const pos = (hc.positionsFromTop || []).map((v) => `${Math.round(v)}`).join(', ');
const sd = hc.screwDiameterMm ?? HINGE.cupScrewDiameter;
const sz = hc.screwDepthMm ?? HINGE.cupScrewDepth;
const ssp = hc.screwSpacingMm ?? HINGE.cupScrewSpacing;
const sof = hc.screwOffsetFromCupCenterMm ?? HINGE.cupScrewOffsetFromCupCenter;
s += `Charnières — cuvette Ø${hc.diameter} mm, prof. ${hc.cupDepth} mm`;
s += `Axes depuis haut : ${pos} mm — vis Ø${sd}×${sz} mm (${ssp} mm c/c, ${sof} mm du centre cuvette)`;
ly += 36;
}
if (fs) {
s += `${fs.model || 'SC 8/25'} — face Ø${fs.faceHole?.diameter || 8} × prof. ${fs.faceHole?.depth || 12.5} mm`;
s += `Axes depuis face intérieure : ${fs.axes?.back ?? 30} mm (arrière) / ${fs.axes?.front ?? 43} mm (avant)`;
}
return s;
}
function generatePiecePlanSvg(piece) {
const widthMm = Math.max(1, Number(piece.widthMm) || 1);
const heightMm = Math.max(1, Number(piece.heightMm) || 1);
const thicknessMm = Math.max(1, Number(piece.thicknessMm) || 1);
const margin = 28;
const panelGap = 24;
const viewW = 330;
const viewH = 220;
const infoBlockH = 180;
const xA = margin;
const yA = 42;
const xB = xA + viewW + panelGap;
const yB = yA;
const xC = xB + viewW + panelGap;
const yC = yA;
const svgW = xC + viewW + margin;
const m = piece.machining || {};
const fmt = (v) => Number(v).toFixed(Number(v) % 1 === 0 ? 0 : 1);
// View A: front view with drilling positions
const scaleA = Math.min((viewW - 40) / widthMm, (viewH - 40) / heightMm);
const wA = widthMm * scaleA;
const hA = heightMm * scaleA;
const pAx = xA + (viewW - wA) / 2;
const pAy = yA + 24 + (viewH - 24 - hA) / 2;
let viewAOps = '';
const isHabFinishSide = piece.elementType === 'habillageFinitionGauche' || piece.elementType === 'habillageFinitionDroite';
const isHabFinishTop = piece.elementType === 'habillageFinitionDessus';
let viewAMiterMarks = '';
let miterInfoLine = '';
if (m.system32?.positions?.length) {
const frontX = pAx + Math.min(37 * scaleA, wA * 0.2);
const backX = pAx + wA - Math.min(37 * scaleA, wA * 0.2);
const r = Math.max(1.2, (m.system32.holeDiameter || 5) * scaleA * 0.5);
for (const pos of m.system32.positions) {
const y = pAy + Math.min(hA, Math.max(0, pos * scaleA));
viewAOps += ``;
}
viewAOps += `System 32: Ø${m.system32.holeDiameter}${m.system32.throughHole ? ' traversant' : ` prof. ${m.system32.holeDepth || 12}`}`;
}
// Rayons (SP 15/8/5): perçages face dessous Ø20×(15 ou 20 selon type)
if (
['etageres', 'rayonFixe'].includes(piece.elementType) &&
['sp_15_8_5', 'sp_15_8_5_face_only'].includes(m.mobileShelfSystem?.model)
) {
const specs = buildShelfMobileHoleSpecsMm(piece, widthMm, heightMm);
const face = specs.filter((h) => h.face === 'face');
if (face.length) {
const rFace = Math.max(2.0, (20 * 0.5) * scaleA);
for (const h of face) {
const x = pAx + Math.min(wA, Math.max(0, Number(h.xMm) * scaleA));
const y = pAy + Math.min(hA, Math.max(0, Number(h.yMm) * scaleA));
viewAOps += ``;
}
const z = Number(m.mobileShelfSystem?.faceHole?.depthMm ?? (piece.elementType === 'rayonFixe' ? 20 : 15));
viewAOps += `${piece.elementType === 'rayonFixe' ? 'Rayon fixe' : 'Rayons mobiles'} SP 15/8/5: Face Ø20 · prof. ${Math.round(z)} (dessous)`;
}
}
if (piece.elementType === 'renfortsTiroir' && m.drawerReinforcement?.ixconnectFixing?.holes?.length) {
const ix = m.drawerReinforcement.ixconnectFixing;
const d = Number(ix.holeDiameterMm) || 8;
const r = Math.max(1.5, (d * 0.5) * scaleA);
for (const h of ix.holes) {
const x = pAx + Math.min(wA, Math.max(0, Number(h.xMm) * scaleA));
const y = pAy + Math.min(hA, Math.max(0, Number(h.yMm) * scaleA));
viewAOps += ``;
}
viewAOps += `${ix.model || 'SC 8/25'} — 4× face Ø${d} · prof. ${ix.depthInReinforcementMm} mm (montant/séparateur Ø${d} · prof. ${ix.depthInVerticalPanelMm} mm) · ${ix.cornerInsetMm} mm des coins · repère Y depuis haut`;
}
// Coulisses Legrabox — vissage sur montants/séparateurs (X cumulé depuis chant avant)
if (m.drawerSlideDrilling?.positionsXFromFrontMm?.length && ['cotes', 'panneauxInterieurs'].includes(piece.elementType)) {
const sd = m.drawerSlideDrilling;
const r = Math.max(1.2, ((Number(sd.holeDiameterMm) || 5) * 0.5) * scaleA);
const faces = [];
if (sd.cotes) {
if (sd.cotes.leftInnerFace) faces.push(sd.cotes.leftInnerFace);
else if (sd.cotes.rightInnerFace) faces.push(sd.cotes.rightInnerFace);
} else if (Array.isArray(sd.separators)) {
const sep = sd.separators.find((x) => x?.faceTowardLowerModule || x?.faceTowardHigherModule);
if (sep?.faceTowardLowerModule) faces.push(sep.faceTowardLowerModule);
if (sep?.faceTowardHigherModule) faces.push(sep.faceTowardHigherModule);
}
for (const f of faces) {
for (const y0 of f.railAxesYMm || []) {
const y = pAy + hA - Math.min(hA, Math.max(0, Number(y0) * scaleA));
for (const x0 of f.positionsXFromFrontMm || sd.positionsXFromFrontMm) {
const x = pAx + Math.min(wA, Math.max(0, Number(x0) * scaleA));
viewAOps += ``;
}
}
}
viewAOps += `Coulisses Legrabox ${sd.loadKg || 40} kg — Ø${sd.holeDiameterMm}×${sd.holeDepthMm} · X: ${(sd.positionsXFromFrontMm || []).join(', ')} mm du chant avant`;
}
// Façade tiroir Legrabox — perçages INSERTA (Ø10, prof. 13)
if (piece.elementType === 'tiroirFacade' && m.legraboxInserta?.holes?.length) {
const ins = m.legraboxInserta;
const r = Math.max(1.8, ((Number(ins.holeDiameterMm) || 10) * 0.5) * scaleA);
for (const h of ins.holes) {
const x = pAx + Math.min(wA, Math.max(0, Number(h.xMm) * scaleA));
const y = pAy + hA - Math.min(hA, Math.max(0, Number(h.yMm) * scaleA));
viewAOps += ``;
}
viewAOps += `INSERTA (système ${ins.systemKey}) — Ø${ins.holeDiameterMm} · prof. ${ins.holeDepthMm} mm · repère Y depuis bas façade`;
}
const isDoor = piece.elementType === 'portes';
const doorCount = isDoor ? Math.max(1, piece.doorPlanHints?.doorCount || 1) : 1;
const hingeSidesList = isDoor
? (piece.doorPlanHints?.hingeSides?.length === doorCount
? piece.doorPlanHints.hingeSides
: Array.from({ length: doorCount }, (_, i) => getDoorHingeSide(i, doorCount, piece.doorPlanHints?.hardware || {})))
: ['left'];
let doorMulti = null;
if (isDoor && doorCount > 1) {
const gapPx = 10;
const totalWmm = widthMm * doorCount;
const scaleMulti = Math.min((viewW - 36) / totalWmm, (viewH - 44) / heightMm);
const eachW = widthMm * scaleMulti;
const hPanel = heightMm * scaleMulti;
const totalPx = eachW * doorCount + gapPx * (doorCount - 1);
const baseAx = xA + (viewW - totalPx) / 2;
const baseAy = yA + 24 + (viewH - 24 - hPanel) / 2;
doorMulti = { gapPx, scaleMulti, eachW, hPanel, baseAx, baseAy };
}
if (doorMulti) {
for (let j = 0; j < doorCount; j++) {
const x0 = doorMulti.baseAx + j * (doorMulti.eachW + doorMulti.gapPx);
viewAOps += ``;
viewAOps += ``;
viewAOps += `Vantail ${j + 1} · ch. ${hingeSidesList[j] === 'right' ? 'droite' : 'gauche'}`;
viewAOps += doorFaceUsinageSvg({
pAx: x0,
pAy: doorMulti.baseAy,
wA: doorMulti.eachW,
hA: doorMulti.hPanel,
scaleA: doorMulti.scaleMulti,
m,
hingeSide: hingeSidesList[j],
heightMm,
fmt
});
}
} else {
const hingeOne = hingeSidesList[0] || 'left';
viewAOps += doorFaceUsinageSvg({
pAx,
pAy,
wA,
hA,
scaleA,
m,
hingeSide: hingeOne,
heightMm,
fmt
});
}
if (!doorMulti && (isHabFinishSide || isHabFinishTop)) {
const k = Math.min(18, Math.max(10, Math.round(Math.min(wA, hA) * 0.08)));
if (isHabFinishSide) {
viewAMiterMarks += ``;
viewAMiterMarks += ``;
miterInfoLine = "Coupe d'onglet 45° : chant haut (jonction avec habillage dessus)";
} else if (isHabFinishTop) {
// Mark left/right edges with corner chevrons + explicit text (avoid ambiguity).
viewAMiterMarks += ``;
viewAMiterMarks += ``;
viewAMiterMarks += ``;
viewAMiterMarks += ``;
miterInfoLine = "Coupe d'onglet 45° : chants gauche & droit (jonction avec habillages côtés)";
}
viewAOps += `${miterInfoLine}`;
}
const dimPax = doorMulti ? doorMulti.baseAx : pAx;
const dimPay = doorMulti ? doorMulti.baseAy : pAy;
const dimWa = doorMulti ? doorMulti.eachW * doorCount + doorMulti.gapPx * (doorCount - 1) : wA;
const dimHa = doorMulti ? doorMulti.hPanel : hA;
const widthDimTextY = dimPay + dimHa + 28;
const patternTextY = widthDimTextY + 16;
const widthLabelY = yA + dimHa + 26;
const patternLabelY = widthLabelY + 16;
const scConn = doorMulti ? doorMulti.scaleMulti : scaleA;
if (['dessus', 'fond'].includes(piece.elementType) && (m.carcassFasteners?.dowelStations?.length || m.carcassFasteners?.positionsAlongJointMm?.length)) {
const cf = m.carcassFasteners;
const edgeL = Math.max(1, Number(cf.edgeLengthMm) || heightMm);
const stations = getCarcassDowelStationsForPlan(cf, edgeL);
const jointAxes = (cf.junctionAxesAcrossWidthMm || [0, widthMm]).map((xmm) => {
const edgeInset = PANEL_THICKNESS * 0.5;
const clamped = Math.max(0, Math.min(widthMm, xmm));
if (clamped <= 0) return edgeInset;
if (clamped >= widthMm) return Math.max(0, widthMm - edgeInset);
return clamped;
});
const ixList = m.connectorHoles?.ixconnectJointsAlongDepth || getIxconnectJointsForPlan(m, cf, heightMm);
const dTourMm = cf.dowelDiameter || 8;
const rTourPx = Math.max(1.2, (dTourMm * 0.5) * scConn);
const rFerrPx = Math.max(2.4, 7 * scConn); // perçage ferrure ~Ø14 mm vs tourillon Ø8
const mapJunctionXmm = (xmm) => {
const edgeInset = PANEL_THICKNESS * 0.5;
const clamped = Math.max(0, Math.min(widthMm, Number(xmm) || 0));
if (clamped <= 0) return edgeInset;
if (clamped >= widthMm) return Math.max(0, widthMm - edgeInset);
return clamped;
};
for (const xAxisMm of jointAxes) {
const x = dimPax + Math.min(dimWa, Math.max(0, xAxisMm * scConn));
viewAOps += ``;
for (const st of stations) {
const along = st.holesAlongMm || [];
for (let hi = 0; hi < along.length; hi++) {
const alongMm = along[hi];
const y = dimPay + Math.min(dimHa, Math.max(0, dimHa - alongMm * scConn));
const isTourillon = hi !== 1; // [tourillon, ferrure, tourillon]
const rr = isTourillon ? rTourPx : rFerrPx;
const cls = isTourillon ? 'connectorTourillon' : 'connectorFerrure';
viewAOps += ``;
}
}
}
if (ixList?.length) {
const rIxPx = Math.max(2.4, 6.8 * scConn);
for (const jix of ixList) {
const xMm = mapJunctionXmm(jix.axisAcrossWidthMm);
const x = dimPax + Math.min(dimWa, Math.max(0, xMm * scConn));
const triple = [
{ pos: jix.tourillon1Axis, cls: 'connectorTourillon', r: rTourPx },
{ pos: jix.ixconnectAxis, cls: 'connector', r: rIxPx },
{ pos: jix.tourillon2Axis, cls: 'connectorTourillon', r: rTourPx }
];
for (const h of triple) {
const y = dimPay + Math.min(dimHa, Math.max(0, dimHa - h.pos * scConn));
viewAOps += ``;
}
}
}
const yDimX = dimPax + dimWa + 18;
const centers = cf.positionsAlongJointMm || [];
if (centers.length >= 3) {
const y0 = dimPay + dimHa;
const y1 = dimPay + dimHa - centers[0] * scConn;
const y2 = dimPay + dimHa - centers[1] * scConn;
const y3 = dimPay + dimHa - centers[2] * scConn;
const y4 = dimPay;
const segs = [[y0, y1], [y1, y2], [y2, y3], [y3, y4]];
const vals = [centers[0], centers[1] - centers[0], centers[2] - centers[1], heightMm - centers[2]];
for (let i = 0; i < segs.length; i++) {
const [a, b] = segs[i];
viewAOps += ``;
viewAOps += ``;
viewAOps += ``;
viewAOps += `${fmt(vals[i])}`;
}
} else if (centers.length === 2) {
const y0 = dimPay + dimHa;
const y1 = dimPay + dimHa - centers[0] * scConn;
const y2 = dimPay + dimHa - centers[1] * scConn;
const y3 = dimPay;
const segs = [[y0, y1], [y1, y2], [y2, y3]];
const vals = [centers[0], centers[1] - centers[0], heightMm - centers[1]];
for (let i = 0; i < segs.length; i++) {
const [a, b] = segs[i];
viewAOps += ``;
viewAOps += ``;
viewAOps += ``;
viewAOps += `${fmt(vals[i])}`;
}
}
const depL = cf.ferrureDepthAlongLengthMm ?? 46;
const depT = cf.ferrureDepthInThicknessMm ?? 12;
const d = cf.dowelDiameter || 8;
const dep = tourillonFaceDrillDepthMm(thicknessMm);
const off = cf.dowelSideOffset ?? CARCASS_FASTENERS.dowel.sideOffset;
const midStation = centers.length >= 3 ? stations[1] : null;
if (midStation?.holesAlongMm?.length >= 3) {
const ya = dimPay + dimHa - midStation.holesAlongMm[0] * scConn;
const yb = dimPay + dimHa - midStation.holesAlongMm[2] * scConn;
const spanMm = midStation.holesAlongMm[2] - midStation.holesAlongMm[0];
viewAOps += ``;
viewAOps += `2×${off} = ${fmt(spanMm)} mm`;
}
const nFerr = centers.length;
const ferrRule =
nFerr >= 3
? '3 ferrures (50 / milieu / 50)'
: nFerr === 2
? `2 ferrures (50/50, sans milieu si arête ≤ ${CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM} mm)`
: `${nFerr} ferrure(s)`;
if (ixList?.length) {
viewAOps += `${jointAxes.length} jonctions · Ferrures corps: ${ferrRule} · prof ${depL}/${depT} mm · 2 tourillons/ferrure ±${off} mm (centre ferrure) · Ø${d}×${dep}`;
viewAOps += `Ixconnect (axes bord avant, autre jeu): extrémités T@20·IX@50·T@80 · internes IX milieu + T ±30 mm`;
} else {
viewAOps += `${jointAxes.length} jonctions · par jonction: ${ferrRule} + 2 tourillons/ferrure (±${off})`;
viewAOps += `Ferrure: prof. ${depL} mm (longueur) / ${depT} mm (épaisseur) · Tourillons: 2× Ø${d}×${dep}`;
}
} else if (m.connectorHoles?.jointPattern) {
const p = m.connectorHoles.jointPattern;
const verticalEdgeJoint =
piece.elementType === 'panneauxInterieurs' ||
(piece.elementType === 'cotes' && m.connectorHoles.verticalJointMode === 'edge');
const mirrorSep =
verticalEdgeJoint &&
widthMm >= IXCONNECT_SEPARATOR_BACK_ROW_MIN_WIDTH_MM &&
Number(p.tourillon2Axis) > Number(p.tourillon1Axis);
if (verticalEdgeJoint) {
const panelLabel = piece.elementType === 'cotes' ? 'Côté habillé' : 'Séparateur';
const ixLeg = mirrorSep
? `${panelLabel} — Ixconnect + tourillons sur chants haut/bas · Y=0 / Y=H brut · avant & arrière profondeur · T@${p.tourillon1Axis} · IX@${p.ixconnectAxis} · T@${p.tourillon2Axis} mm — FACE=end END=top|bottom`
: `${panelLabel} — Ixconnect + tourillons sur chants haut/bas · Y=0 / Y=H brut · T@${p.tourillon1Axis} · IX@${p.ixconnectAxis} · T@${p.tourillon2Axis} mm — FACE=end END=top|bottom`;
viewAOps += `${ixLeg}`;
} else {
const rowInsetPx = IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM * scConn;
const yTop = dimPay + rowInsetPx;
const yBottom = dimPay + dimHa - rowInsetPx;
const holes = [
{ pos: p.tourillon1Axis, cls: 'connectorTourillon', r: 2.2 },
{ pos: p.ixconnectAxis, cls: 'connector', r: 3.4 },
{ pos: p.tourillon2Axis, cls: 'connectorTourillon', r: 2.2 }
];
for (const y of [yTop, yBottom]) {
for (const hole of holes) {
const x = dimPax + Math.min(dimWa, Math.max(0, hole.pos * scConn));
viewAOps += ``;
}
}
viewAOps += `Ixconnect face: T@${p.tourillon1Axis} · IX@${p.ixconnectAxis} · T@${p.tourillon2Axis} mm (haut & bas, depuis bord avant)`;
}
}
if (!isDoor && (m.groove?.axisFromPanelEdge || m.groove?.axisFromBackEdge)) {
viewAOps += svgBackGrooveFaceViewA(piece, m, dimPax, dimPay, dimWa, dimHa, scConn, fmt);
}
// Explicit finished/raw dimensions on drawing
const dimY = dimPay + dimHa + 16;
const dimX = dimPax - 16;
viewAOps += ``;
viewAOps += ``;
const widthDimLabel = doorMulti
? `L finie ${fmt(widthMm)} mm / vantail · L brut ${fmt(piece.rawCutWidthMm)} mm · ${doorCount} vantaux (orientation ci-dessus)`
: `L finie ${fmt(widthMm)} mm · L brut ${fmt(piece.rawCutWidthMm)} mm`;
viewAOps += `${widthDimLabel}`;
viewAOps += `H finie ${fmt(heightMm)} mm · H brut ${fmt(piece.rawCutHeightMm)} mm`;
// View B: edge view for drilling depths
const isReinforcementPiece = piece.elementType === 'renforts';
const scaleB = Math.min((viewW - 60) / heightMm, (viewH - 40) / thicknessMm);
const hB = heightMm * scaleB;
const tB = Math.max(8, thicknessMm * scaleB);
const wBh = isReinforcementPiece ? hB : tB; // long side horizontal for reinforcements
const hBh = isReinforcementPiece ? tB : hB;
const pBx = xB + (viewW - wBh) / 2;
const pBy = yB + 24 + (viewH - 24 - hBh) / 2;
let viewBOps = '';
if (m.system32 && !isReinforcementPiece) {
const s32d = m.system32.holeDepth || thicknessMm;
const holeLen = Math.min(tB * 0.9, s32d * scaleB);
viewBOps += ``;
viewBOps += ``;
const tx = Math.min(xB + viewW - 110, pBx + holeLen + 6);
viewBOps += `Ø${m.system32.holeDiameter} · ${m.system32.throughHole ? 'TRAV.' : `prof ${fmt(s32d)}mm`}`;
}
if (m.hingeCups && !isReinforcementPiece) {
const cupDepth = m.hingeCups.cupDepth || 12;
const cupLen = Math.min(tB * 0.95, cupDepth * scaleB);
viewBOps += ``;
const tx = Math.min(xB + viewW - 125, pBx + cupLen + 6);
viewBOps += `Cuvette Ø${m.hingeCups.diameter} · prof ${fmt(cupDepth)}mm`;
}
if (isReinforcementPiece && m.reinforcementFixing) {
viewBOps += svgReinforcementFixingEdgeViewB(m.reinforcementFixing, pBx, pBy, tB, wBh, scaleB, fmt, true);
}
if (isReinforcementPiece) {
viewBOps += ``;
viewBOps += `Hauteur face chant ${fmt(heightMm)} mm`;
viewBOps += ``;
viewBOps += `Épaisseur ${fmt(thicknessMm)} mm`;
} else {
viewBOps += ``;
viewBOps += `Épaisseur ${fmt(thicknessMm)} mm`;
viewBOps += ``;
viewBOps += `Hauteur ${fmt(heightMm)} mm`;
}
// View C: groove cross section + connector depths
const scaleC = Math.min((viewW - 60) / widthMm, (viewH - 40) / thicknessMm);
const wC = widthMm * scaleC;
const tC = Math.max(10, thicknessMm * scaleC);
const pCx = xC + (viewW - wC) / 2;
const pCy = yC + 60;
let viewCOps = '';
if (m.groove?.axisFromPanelEdge || m.groove?.axisFromBackEdge) {
viewCOps += svgBackGrooveSectionViewC(pCx, pCy, wC, tC, scaleC, piece, m, fmt);
}
if (['dessus', 'fond'].includes(piece.elementType) && m.carcassFasteners) {
const cf = m.carcassFasteners;
const depL = cf.ferrureDepthAlongLengthMm ?? 46;
const depT = cf.ferrureDepthInThicknessMm ?? 12;
const dTourMm = cf.dowelDiameter || 8;
const depthTourMm = tourillonFaceDrillDepthMm(thicknessMm);
viewCOps += svgCarcassTourillonSlotsSectionView(pCx, pCy, scaleC, tC, widthMm, thicknessMm, cf);
viewCOps += `Tourillons (vue C) : 1 logement/jonction, traitillé depuis la face haut · Ø${fmt(dTourMm)} mm, prof. ${fmt(depthTourMm)} mm · Ferrure ${fmt(depL)} / ${fmt(depT)} mm`;
}
if (m.connectorHoles?.edgeHole) {
const depth = m.connectorHoles.edgeHole.depth || 45;
const diam = m.connectorHoles.edgeHole.diameter || 8;
const len = Math.min(wC * 0.35, depth * scaleC);
viewCOps += ``;
viewCOps += `Ixconnect about Ø${fmt(diam)} · prof ${fmt(depth)}mm`;
if (m.connectorHoles?.faceHole) {
viewCOps += `Face Ø${fmt(m.connectorHoles.faceHole.diameter || 8)} · prof ${fmt(m.connectorHoles.faceHole.depth || 12)}mm`;
}
}
viewCOps += ``;
viewCOps += `Largeur ${fmt(widthMm)} mm`;
viewCOps += ``;
viewCOps += `Épaisseur ${fmt(thicknessMm)} mm`;
const opsLines = [];
opsLines.push(`Pièce finie: ${piece.widthMm} x ${piece.heightMm} x ${piece.thicknessMm} mm · Qté ${piece.quantity}`);
opsLines.push(`Brut CNC: ${piece.rawCutWidthMm} x ${piece.rawCutHeightMm} mm (déduction chants incluse)`);
opsLines.push(`Chants: ${piece.edgeBanding?.sides || 0} côté(s), ép. ${piece.edgeBanding?.thickness || 0}mm`);
if (miterInfoLine) opsLines.push(miterInfoLine);
if (m.system32) {
opsLines.push(`System 32: Ø${m.system32.holeDiameter} · pas ${m.system32.pitch || 32} · ${m.system32.throughHole ? 'traversant' : `prof ${m.system32.holeDepth || 12}mm`}`);
}
if (m.hingeCups) {
const pos = (m.hingeCups.positionsFromTop || []).slice(0, 6).map(v => `${Math.round(v)}mm`).join(', ');
const sd = m.hingeCups.screwDiameterMm ?? HINGE.cupScrewDiameter;
const sz = m.hingeCups.screwDepthMm ?? HINGE.cupScrewDepth;
const ssp = m.hingeCups.screwSpacingMm ?? HINGE.cupScrewSpacing;
const sof = m.hingeCups.screwOffsetFromCupCenterMm ?? HINGE.cupScrewOffsetFromCupCenter;
opsLines.push(
`Charnières: cuvette Ø${m.hingeCups.diameter} prof ${m.hingeCups.cupDepth} · axes: ${pos}${(m.hingeCups.positionsFromTop || []).length > 6 ? ', ...' : ''} · vis Ø${sd}×${sz} (${ssp} c/c, ${sof} du centre)`
);
}
if (isDoor && doorCount > 1) {
const sideLabel = (s) => (s === 'right' ? 'droite' : 'gauche');
opsLines.push(
`Orientation charnières (vue extérieure) : ${hingeSidesList.map((s, i) => `V${i + 1} ${sideLabel(s)}`).join(' · ')}`
);
}
if (m.fullHeightHandleGroove) {
const off = m.fullHeightHandleGroove.topBottomOffsetMm ?? HANDLE.fullHeightTopBottomOffset;
const gw = m.fullHeightHandleGroove.grooveWidthMm ?? HANDLE.fullHeightGroove.width;
const gd = m.fullHeightHandleGroove.grooveDepthMm ?? HANDLE.fullHeightGroove.depth;
opsLines.push(`Poignée toute hauteur: rainure ${gw}×${gd} mm, côté opposé charnières, retrait mécanique ${off} mm haut et bas`);
}
if (m.carcassFasteners?.positionsAlongJointMm?.length && ['dessus', 'fond'].includes(piece.elementType)) {
const cf = m.carcassFasteners;
const ax = (cf.positionsAlongJointMm || []).map((v) => `${Math.round(v)}`).join(', ');
const off = cf.dowelSideOffset ?? CARCASS_FASTENERS.dowel.sideOffset;
const depL = cf.ferrureDepthAlongLengthMm ?? 46;
const depT = cf.ferrureDepthInThicknessMm ?? 12;
const nAx = (cf.positionsAlongJointMm || []).length;
const pat =
nAx >= 3
? '50/milieu/50'
: nAx === 2
? `50/50 (sans milieu si arête ≤ ${CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM} mm)`
: 'répartition selon cotes';
const ixJ = m.connectorHoles?.ixconnectJointsAlongDepth;
const ixSuffix =
ixJ?.length && ['dessus', 'fond'].includes(piece.elementType)
? ` · Ixconnect (2e jeu, axes bord avant): extrémités T@20·IX@50·T@80 · internes IX milieu + T ±30 mm (${ixJ.length} ligne(s))`
: '';
opsLines.push(
`Ferrures corps (${CARCASS_FASTENERS.model}): ${cf.jointCount || 1} jonction(s) côtés/séparateurs↔dessus/fond · par jonction centres ${ax} mm (${pat}) · perçage ferrure ${depL} / ${depT} mm · +2 tourillons Ø${cf.dowelDiameter || 8}×${tourillonFaceDrillDepthMm(piece.thicknessMm)} (face) à ±${off} mm du centre ferrure · ${CARCASS_FASTENERS.productUrl}${ixSuffix}`
);
}
if (m.connectorHoles) {
opsLines.push(`Connecteurs: about Ø${m.connectorHoles.edgeHole?.diameter || 8} prof ${m.connectorHoles.edgeHole?.depth || 45} · face Ø${m.connectorHoles.faceHole?.diameter || 8} prof ${m.connectorHoles.faceHole?.depth || 12}`);
const ixJ = m.connectorHoles.ixconnectJointsAlongDepth;
const mergedIx =
m.carcassFasteners?.positionsAlongJointMm?.length && ['dessus', 'fond'].includes(piece.elementType);
if (ixJ?.length && ['dessus', 'fond'].includes(piece.elementType) && !mergedIx) {
opsLines.push(
`Ixconnect axes (bord avant profondeur): extrémités T@20·IX@50·T@80 · jonctions internes IX au milieu + T ±30 mm — ${ixJ.length} ligne(s) de perçage`
);
}
}
if (m.groove) {
opsLines.push(`Rainure: ${m.groove.width || 9} x ${m.groove.depth || 10.5}mm · position ${m.groove.axisFromPanelEdge || 16}mm du bord`);
}
if (m.mobileShelfSystem?.model === 'sp_15_8_5') {
opsLines.push('Rayons mobiles SP 15/8/5: face dessous Ø20×15 (axe 9.5) · chant Ø8×37 (à 6mm du dessous) · avant 32 / arrière 37');
} else if (m.mobileShelfSystem?.model === 'sp_15_8_5_face_only') {
opsLines.push('Rayon fixe: face dessous Ø20×14.5 (axe 9.5) · avant 32 / arrière 37');
}
if (m.finishConnectors) {
const fc = m.finishConnectors;
opsLines.push(`${fc.model || 'SC 8/25'}: face Ø${fc.faceHole?.diameter || 8} prof. ${fc.faceHole?.depth || 12.5} mm · axes ${fc.axes?.back ?? 30}/${fc.axes?.front ?? 43} mm (arrière/avant)`);
}
if (m.reinforcementFixing) {
const rf = m.reinforcementFixing;
const tour = (rf.tourillonAxesFromTopMm || []).join(' / ');
const sides = rf.sideCount || 2;
opsLines.push(
`Renfort: ${sides} chants extrémité · par chant 1× ${rf.connectorModel || 'SC 8/60'} axe ${rf.ixconnectAxisFromTopMm ?? '—'} mm + ${rf.tourillonsPerSide || 2} tourillons Ø${rf.tourillonDiameter || 8} aux axes ${tour} mm · about Ø${rf.edgeHole?.diameter || 8}×${rf.edgeHole?.depth || 45} · face Ø${rf.faceHole?.diameter || 8}×${rf.faceHole?.depth || 12}`
);
}
if (m.drawerReinforcement?.ixconnectFixing) {
const ix = m.drawerReinforcement.ixconnectFixing;
opsLines.push(
`Renfort tiroir: ${ix.model || 'SC 8/25'} — 4× Ø${ix.holeDiameterMm || 8} prof. ${ix.depthInReinforcementMm} mm face renfort · prof. ${ix.depthInVerticalPanelMm} mm face montant/séparateur · ${ix.cornerInsetMm} mm des coins (repère Y depuis haut)`
);
}
const isDoorPiece = piece.elementType === 'portes';
const doorBandCount = isDoorPiece ? Math.max(1, piece.doorPlanHints?.doorCount || 1) : 1;
const doorBandSides = isDoorPiece
? (piece.doorPlanHints?.hingeSides?.length === doorBandCount
? piece.doorPlanHints.hingeSides
: hingeSidesList)
: [];
const blockH = isDoorPiece && doorBandCount > 1
? Math.min(188, Math.max(148, Math.floor(520 / doorBandCount)))
: 200;
const doorRowH = isDoorPiece ? (doorBandCount > 1 ? doorBandCount * (blockH + 10) + 12 : 200) : 0;
const doorRowY = yA + viewH + 10;
const infoY = doorRowY + doorRowH + (doorRowH ? 14 : 10);
const svgH = infoY + (infoBlockH - 12) + margin;
// Contenu du bandeau en coordonnées locales (0,0) — sinon les se superposent au titre / vues A–C
const doorBandW = svgW - margin * 2;
const doorDetailInner = isDoorPiece
? (doorBandCount > 1
? `
${Array.from({ length: doorBandCount }, (_, i) => {
const yOff = i * (blockH + 10);
const side = doorBandSides[i] || 'left';
const label = `vantail ${i + 1} (charnières ${side === 'right' ? 'droite' : 'gauche'})`;
return `
${doorMachiningDetailMarkup(piece, fmt, doorBandW - 16, blockH - 4, side, label)}
`;
}).join('')}
`
: `
${doorMachiningDetailMarkup(piece, fmt, doorBandW, doorRowH)}
`)
: '';
return ``;
}
function generatePiecePlanViews(piece) {
const widthMm = Math.max(1, Number(piece.widthMm) || 1);
const heightMm = Math.max(1, Number(piece.heightMm) || 1);
const thicknessMm = Math.max(1, Number(piece.thicknessMm) || 1);
const m = piece.machining || {};
const fmt = (v) => Number(v).toFixed(Number(v) % 1 === 0 ? 0 : 1);
function wrapSvg(title, subtitle, content, viewBox = '0 0 760 460', svgHeight = 460) {
return ``;
}
// Vue A
const isDoorPv = piece.elementType === 'portes';
const doorCountPv = isDoorPv ? Math.max(1, piece.doorPlanHints?.doorCount || 1) : 1;
const hingeSidesPv = isDoorPv
? (piece.doorPlanHints?.hingeSides?.length === doorCountPv
? piece.doorPlanHints.hingeSides
: Array.from({ length: doorCountPv }, (_, i) => getDoorHingeSide(i, doorCountPv, piece.doorPlanHints?.hardware || {})))
: ['left'];
let doorMultiPv = null;
if (isDoorPv && doorCountPv > 1) {
const gapPx = 10;
const totalWmm = widthMm * doorCountPv;
const scaleMulti = Math.min(600 / totalWmm, 320 / heightMm);
const eachW = widthMm * scaleMulti;
const hPanel = heightMm * scaleMulti;
const totalPx = eachW * doorCountPv + gapPx * (doorCountPv - 1);
const baseAx = 70 + (620 - totalPx) / 2;
const baseAy = 70 + (340 - hPanel) / 2;
doorMultiPv = { gapPx, scaleMulti, eachW, hPanel, baseAx, baseAy };
}
const scaleA = Math.min(620 / widthMm, 340 / heightMm);
const wA = widthMm * scaleA;
const hA = heightMm * scaleA;
const xA = 70 + (620 - wA) / 2;
const yA = 70 + (340 - hA) / 2;
let aOps = '';
if (doorMultiPv) {
for (let j = 0; j < doorCountPv; j++) {
const x0 = doorMultiPv.baseAx + j * (doorMultiPv.eachW + doorMultiPv.gapPx);
aOps += ``;
aOps += ``;
aOps += `V${j + 1} · ch. ${hingeSidesPv[j] === 'right' ? 'dr.' : 'g.'}`;
aOps += doorFaceUsinageSvg({
pAx: x0,
pAy: doorMultiPv.baseAy,
wA: doorMultiPv.eachW,
hA: doorMultiPv.hPanel,
scaleA: doorMultiPv.scaleMulti,
m,
hingeSide: hingeSidesPv[j],
heightMm,
fmt
});
}
} else {
aOps = ``;
if (m.system32?.positions?.length) {
const frontX = xA + Math.min(37 * scaleA, wA * 0.2);
const backX = xA + wA - Math.min(37 * scaleA, wA * 0.2);
const r = Math.max(1.5, (m.system32.holeDiameter || 5) * scaleA * 0.5);
for (const pos of m.system32.positions) {
const y = yA + Math.min(hA, Math.max(0, pos * scaleA));
aOps += ``;
}
}
aOps += doorFaceUsinageSvg({
pAx: xA,
pAy: yA,
wA,
hA,
scaleA,
m,
hingeSide: hingeSidesPv[0] || 'left',
heightMm,
fmt
});
}
// Rayons (SP 15/8/5): perçages face dessous Ø20×(15 ou 20) (vue A)
if (
['etageres', 'rayonFixe'].includes(piece.elementType) &&
['sp_15_8_5', 'sp_15_8_5_face_only'].includes(m.mobileShelfSystem?.model) &&
!doorMultiPv
) {
const specs = buildShelfMobileHoleSpecsMm(piece, widthMm, heightMm);
const face = specs.filter((h) => h.face === 'face');
if (face.length) {
const rFace = Math.max(2.2, (20 * 0.5) * scaleA);
for (const h of face) {
const x = xA + Math.min(wA, Math.max(0, Number(h.xMm) * scaleA));
const y = yA + Math.min(hA, Math.max(0, Number(h.yMm) * scaleA));
aOps += ``;
}
}
}
const dimAx = doorMultiPv ? doorMultiPv.baseAx : xA;
const dimAy = doorMultiPv ? doorMultiPv.baseAy : yA;
const dimAw = doorMultiPv ? doorMultiPv.eachW * doorCountPv + doorMultiPv.gapPx * (doorCountPv - 1) : wA;
const dimAh = doorMultiPv ? doorMultiPv.hPanel : hA;
const widthLabelY = dimAy + dimAh + 26;
const scaleConnPv = doorMultiPv ? doorMultiPv.scaleMulti : scaleA;
if (['dessus', 'fond'].includes(piece.elementType) && (m.carcassFasteners?.dowelStations?.length || m.carcassFasteners?.positionsAlongJointMm?.length)) {
const cf = m.carcassFasteners;
const edgeL = Math.max(1, Number(cf.edgeLengthMm) || heightMm);
const stations = getCarcassDowelStationsForPlan(cf, edgeL);
const jointAxes = (cf.junctionAxesAcrossWidthMm || [0, widthMm]).map((xmm) => {
const edgeInset = PANEL_THICKNESS * 0.5;
const clamped = Math.max(0, Math.min(widthMm, xmm));
if (clamped <= 0) return edgeInset;
if (clamped >= widthMm) return Math.max(0, widthMm - edgeInset);
return clamped;
});
const ixListPv = m.connectorHoles?.ixconnectJointsAlongDepth || getIxconnectJointsForPlan(m, cf, heightMm);
const dTourMmPv = cf.dowelDiameter || 8;
const rTourPxPv = Math.max(1.3, (dTourMmPv * 0.5) * scaleConnPv);
const rFerrPxPv = Math.max(2.5, 7 * scaleConnPv);
const mapJunctionXmmPv = (xmm) => {
const edgeInset = PANEL_THICKNESS * 0.5;
const clamped = Math.max(0, Math.min(widthMm, Number(xmm) || 0));
if (clamped <= 0) return edgeInset;
if (clamped >= widthMm) return Math.max(0, widthMm - edgeInset);
return clamped;
};
for (const xAxisMm of jointAxes) {
const x = dimAx + Math.min(dimAw, Math.max(0, xAxisMm * scaleConnPv));
aOps += ``;
for (const st of stations) {
const along = st.holesAlongMm || [];
for (let hi = 0; hi < along.length; hi++) {
const alongMm = along[hi];
const y = dimAy + Math.min(dimAh, Math.max(0, dimAh - alongMm * scaleConnPv));
const isTourillon = hi !== 1;
const rr = isTourillon ? rTourPxPv : rFerrPxPv;
const cls = isTourillon ? 'connectorTourillon' : 'connectorFerrure';
aOps += ``;
}
}
}
if (ixListPv?.length) {
const rIxPxPv = Math.max(2.5, 7 * scaleConnPv);
for (const jix of ixListPv) {
const xMm = mapJunctionXmmPv(jix.axisAcrossWidthMm);
const x = dimAx + Math.min(dimAw, Math.max(0, xMm * scaleConnPv));
const triple = [
{ pos: jix.tourillon1Axis, cls: 'connectorTourillon', r: rTourPxPv },
{ pos: jix.ixconnectAxis, cls: 'connector', r: rIxPxPv },
{ pos: jix.tourillon2Axis, cls: 'connectorTourillon', r: rTourPxPv }
];
for (const h of triple) {
const y = dimAy + Math.min(dimAh, Math.max(0, dimAh - h.pos * scaleConnPv));
aOps += ``;
}
}
}
const centers = cf.positionsAlongJointMm || [];
const yChain = dimAy + dimAh + 26;
if (centers.length >= 3) {
const ys = [dimAy + dimAh, ...centers.map((c) => dimAy + dimAh - c * scaleConnPv), dimAy];
const segs = [centers[0], centers[1] - centers[0], centers[2] - centers[1], heightMm - centers[2]];
const xDim = dimAx + dimAw + 20;
for (let i = 0; i < segs.length; i++) {
const a = ys[i];
const b = ys[i + 1];
aOps += ``;
aOps += `${fmt(segs[i])}`;
}
} else if (centers.length === 2) {
const ys = [dimAy + dimAh, dimAy + dimAh - centers[0] * scaleConnPv, dimAy + dimAh - centers[1] * scaleConnPv, dimAy];
const segs = [centers[0], centers[1] - centers[0], heightMm - centers[1]];
const xDim = dimAx + dimAw + 20;
for (let i = 0; i < segs.length; i++) {
const a = ys[i];
const b = ys[i + 1];
aOps += ``;
aOps += `${fmt(segs[i])}`;
}
}
const off = cf.dowelSideOffset ?? CARCASS_FASTENERS.dowel.sideOffset;
const depL = cf.ferrureDepthAlongLengthMm ?? 46;
const depT = cf.ferrureDepthInThicknessMm ?? 12;
const midStation = centers.length >= 3 ? stations[1] : null;
/** Légende sous le panneau : une ligne après l’autre (évite chevauchement L / tourillons / ferrures). */
let legendY = yChain + 18;
if (midStation?.holesAlongMm?.length >= 3) {
const spanMm = midStation.holesAlongMm[2] - midStation.holesAlongMm[0];
aOps += `Tourillons ferrure milieu: 2×${off} = ${fmt(spanMm)} mm`;
legendY += 16;
}
const nFerrPv = centers.length;
const ferrRulePv =
nFerrPv >= 3
? '3 ferrures (50/milieu/50)'
: nFerrPv === 2
? `2 ferrures (50/50, sans milieu ≤${CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM} mm)`
: `${nFerrPv} ferrure(s)`;
const dPv = cf.dowelDiameter || 8;
const depPv = tourillonFaceDrillDepthMm(thicknessMm);
if (ixListPv?.length) {
aOps += `${jointAxes.length} jonctions · Ferrures corps: ${ferrRulePv} · prof ${depL}/${depT} mm · 2 tourillons/ferrure ±${off} mm (centre ferrure) · Ø${dPv}×${depPv}`;
legendY += 16;
aOps += `Ixconnect (axes bord avant, autre jeu): extrémités T@20·IX@50·T@80 · internes IX milieu + T ±30 mm`;
} else {
aOps += `${jointAxes.length} jonctions · ${ferrRulePv} · 2 tourillons/ferrure (±${off}) · prof ferrure ${depL}/${depT}`;
}
} else if (m.connectorHoles?.jointPattern) {
const p = m.connectorHoles.jointPattern;
const verticalEdgeJointPv =
piece.elementType === 'panneauxInterieurs' ||
(piece.elementType === 'cotes' && m.connectorHoles.verticalJointMode === 'edge');
const mirrorSepPv =
verticalEdgeJointPv &&
widthMm >= IXCONNECT_SEPARATOR_BACK_ROW_MIN_WIDTH_MM &&
Number(p.tourillon2Axis) > Number(p.tourillon1Axis);
if (verticalEdgeJointPv) {
const panelLabelPv = piece.elementType === 'cotes' ? 'Côté habillé' : 'Séparateur';
const ixLegPv = mirrorSepPv
? `${panelLabelPv} — Ixconnect + tourillons chants haut/bas · Y=0 / Y=H · avant & arrière prof. — END=top|bottom`
: `${panelLabelPv} — Ixconnect + tourillons chants haut/bas · Y=0 / Y=H — END=top|bottom`;
aOps += `${ixLegPv}`;
} else {
const rowInsetPx = IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM * scaleConnPv;
const yTop = dimAy + rowInsetPx;
const yBottom = dimAy + dimAh - rowInsetPx;
const triple = [
{ pos: p.tourillon1Axis, cls: 'connectorTourillon', r: 2.4 },
{ pos: p.ixconnectAxis, cls: 'connector', r: 3.6 },
{ pos: p.tourillon2Axis, cls: 'connectorTourillon', r: 2.4 }
];
for (const y of [yTop, yBottom]) {
for (const hole of triple) {
const x = dimAx + Math.min(dimAw, Math.max(0, hole.pos * scaleConnPv));
aOps += ``;
}
}
aOps += `Ixconnect: T@${p.tourillon1Axis} · IX@${p.ixconnectAxis} · T@${p.tourillon2Axis} mm (haut & bas)`;
}
}
if (!isDoorPv && (m.groove?.axisFromPanelEdge || m.groove?.axisFromBackEdge)) {
aOps += svgBackGrooveFaceViewA(piece, m, dimAx, dimAy, dimAw, dimAh, scaleConnPv, fmt);
}
const widthLine = doorMultiPv
? `L ${fmt(widthMm)} mm/vant · ${doorCountPv} vantaux · Brut ${fmt(piece.rawCutWidthMm)} mm`
: `L ${fmt(widthMm)} mm · Brut ${fmt(piece.rawCutWidthMm)} mm`;
aOps += ``;
aOps += `${widthLine}`;
aOps += ``;
aOps += `H ${fmt(heightMm)} mm · Brut ${fmt(piece.rawCutHeightMm)} mm`;
const viewA = wrapSvg(
`${piece.description} - Vue A`,
`Face usinage · ${piece.widthMm} x ${piece.heightMm} mm`,
aOps
);
// Vue B
const scaleB = Math.min(620 / heightMm, 300 / thicknessMm);
const hB = heightMm * scaleB;
const tB = Math.max(10, thicknessMm * scaleB);
const xB = 140;
const isReinforcementPv = piece.elementType === 'renforts';
const wBhPv = isReinforcementPv ? hB : tB;
const hBhPv = isReinforcementPv ? tB : hB;
const yB = 90 + (300 - hBhPv) / 2;
let bOps = ``;
if (m.system32 && !isReinforcementPv) {
const s32d = m.system32.holeDepth || thicknessMm;
const len = Math.min(tB * 0.95, s32d * scaleB);
bOps += `System32 Ø${m.system32.holeDiameter} · ${m.system32.throughHole ? 'TRAV.' : `prof ${fmt(s32d)}mm`}`;
}
if (m.hingeCups && !isReinforcementPv) {
const d = m.hingeCups.cupDepth || 12;
const len = Math.min(tB * 0.95, d * scaleB);
bOps += `Cuvette Ø${m.hingeCups.diameter} · prof ${fmt(d)}mm`;
}
if (isReinforcementPv && m.reinforcementFixing) {
bOps += svgReinforcementFixingEdgeViewB(m.reinforcementFixing, xB, yB, tB, wBhPv, scaleB, fmt, true);
}
if (isReinforcementPv) {
bOps += ``;
bOps += `Hauteur face chant ${fmt(heightMm)} mm`;
bOps += ``;
bOps += `Épaisseur ${fmt(thicknessMm)} mm`;
} else {
bOps += ``;
bOps += `Épaisseur ${fmt(thicknessMm)} mm`;
bOps += ``;
bOps += `Hauteur ${fmt(heightMm)} mm`;
}
const viewB = wrapSvg(
`${piece.description} - Vue B`,
`Vue de chant · épaisseur ${piece.thicknessMm} mm`,
bOps
);
// Vue C
const scaleC = Math.min(620 / widthMm, 230 / thicknessMm);
const wC = widthMm * scaleC;
const tC = Math.max(12, thicknessMm * scaleC);
const xC = 70 + (620 - wC) / 2;
const yC = 150;
let cOps = ``;
if (m.groove?.axisFromPanelEdge || m.groove?.axisFromBackEdge) {
cOps += svgBackGrooveSectionViewC(xC, yC, wC, tC, scaleC, piece, m, fmt);
}
if (['dessus', 'fond'].includes(piece.elementType) && m.carcassFasteners) {
const cf = m.carcassFasteners;
const depL = cf.ferrureDepthAlongLengthMm ?? 46;
const depT = cf.ferrureDepthInThicknessMm ?? 12;
const dTourMm = cf.dowelDiameter || 8;
const depthTourMm = tourillonFaceDrillDepthMm(thicknessMm);
cOps += svgCarcassTourillonSlotsSectionView(xC, yC, scaleC, tC, widthMm, thicknessMm, cf);
cOps += `Tourillons : 1/jonction (traitillé, face haut) · Ø${fmt(dTourMm)} mm, prof. ${fmt(depthTourMm)} mm · Ferrure ${fmt(depL)} / ${fmt(depT)} mm`;
}
if (m.connectorHoles?.edgeHole) {
cOps += `Ixconnect about Ø${fmt(m.connectorHoles.edgeHole.diameter || 8)} · prof ${fmt(m.connectorHoles.edgeHole.depth || 45)} mm`;
if (m.connectorHoles?.faceHole) {
cOps += `Ixconnect face Ø${fmt(m.connectorHoles.faceHole.diameter || 8)} · prof ${fmt(m.connectorHoles.faceHole.depth || 12)} mm`;
}
}
cOps += ``;
cOps += `Largeur ${fmt(widthMm)} mm`;
cOps += ``;
cOps += `Épaisseur ${fmt(thicknessMm)} mm`;
const viewC = wrapSvg(
`${piece.description} - Vue C`,
'Coupe L×ép. · rainure côté dos (ouverture face caisse)',
cOps
);
const views = [
{ id: 'view-a', label: 'Vue A', title: 'Face usinage', svg: viewA },
{ id: 'view-b', label: 'Vue B', title: 'Chant / profondeurs', svg: viewB },
{ id: 'view-c', label: 'Vue C', title: 'Coupe rainure / connecteurs', svg: viewC }
];
if (
piece.elementType !== 'portes' &&
m.groove &&
(BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(piece.elementType) ||
BACK_GROOVE_PLAN_HORIZONTAL_TYPES.includes(piece.elementType))
) {
views.push({
id: 'view-d',
label: 'Vue D',
title: 'Détail rainure dos',
svg: wrapSvg(
`${piece.description} — Vue D`,
'Coupe et cotation (réf. type plan technique)',
svgBackGrooveTechnicalDetail(piece, m, fmt),
'0 0 760 440',
440
)
});
}
if (piece.elementType === 'portes') {
const dc = Math.max(1, piece.doorPlanHints?.doorCount || 1);
const sidesD = piece.doorPlanHints?.hingeSides?.length === dc
? piece.doorPlanHints.hingeSides
: Array.from({ length: dc }, (_, i) => getDoorHingeSide(i, dc, piece.doorPlanHints?.hardware || {}));
let viewDContent;
let vbH = 400;
let outH = 400;
if (dc > 1) {
const blockH = Math.min(260, Math.max(200, Math.floor(880 / dc)));
vbH = 20 + dc * (blockH + 14);
outH = Math.min(1200, vbH);
viewDContent = sidesD.map((side, i) => {
const label = `vantail ${i + 1} (charnières ${side === 'right' ? 'droite' : 'gauche'})`;
return `${doorMachiningDetailMarkup(piece, fmt, 720, blockH - 10, side, label)}`;
}).join('');
} else {
viewDContent = `${doorMachiningDetailMarkup(piece, fmt, 720, 300)}`;
}
const viewD = wrapSvg(
`${piece.description} - Vue D`,
'Détail rainure (cotes) · schéma hauteur · perçages',
viewDContent,
dc > 1 ? `0 0 760 ${vbH}` : '0 0 760 400',
dc > 1 ? outH : 400
);
views.push({ id: 'view-d', label: 'Vue D', title: 'Rainure & perçages', svg: viewD });
}
return views;
}
function coteFaceHasRenfortHoles(faceBlock) {
return Array.isArray(faceBlock?.holes) && faceBlock.holes.length > 0;
}
function separatorBlockHasRenfort(block) {
return Array.isArray(block?.holes) && block.holes.length > 0;
}
function separatorHasRenfort(sep) {
if (!sep) return false;
return separatorBlockHasRenfort(sep.faceTowardLowerModule) || separatorBlockHasRenfort(sep.faceTowardHigherModule);
}
/** Retire la rainure dos du machining (côté sans zone à dos adjacente). */
function stripBackGroove(machining) {
if (!machining?.groove) return machining;
const { groove, ...rest } = machining;
return Object.keys(rest).length ? rest : null;
}
function scopeMachiningCotesDrawerRenfort(machining, includeLeft, includeRight) {
const ix = machining?.drawerRenfortPanelIxconnect;
if (!ix?.cotes) return machining;
const { leftInnerFace, rightInnerFace } = ix.cotes;
const cotes = {};
if (includeLeft && coteFaceHasRenfortHoles(leftInnerFace)) cotes.leftInnerFace = leftInnerFace;
if (includeRight && coteFaceHasRenfortHoles(rightInnerFace)) cotes.rightInnerFace = rightInnerFace;
const next = { ...machining };
if (Object.keys(cotes).length === 0) {
delete next.drawerRenfortPanelIxconnect;
} else {
next.drawerRenfortPanelIxconnect = { ...ix, cotes };
}
return next;
}
function scopeMachiningSeparatorsDrawerRenfort(machining, separatorIndex) {
const ix = machining?.drawerRenfortPanelIxconnect;
if (!ix?.separators?.length) return machining;
const sep = ix.separators[separatorIndex];
if (!separatorHasRenfort(sep)) {
const next = { ...machining };
delete next.drawerRenfortPanelIxconnect;
return next;
}
return {
...machining,
drawerRenfortPanelIxconnect: {
...ix,
separators: [sep]
}
};
}
function scopeSeparatorSegmentMachining(machining, separatorIndex, bottomMm, topMm) {
let next = scopeMachiningSeparatorsDrawerRenfort(machining, separatorIndex);
if (!next) return next;
next = { ...next };
const localOriginMm = Number(bottomMm) + PANEL_THICKNESS;
const localTopMm = Number(topMm) - PANEL_THICKNESS;
const translateAxes = (axes) =>
(Array.isArray(axes) ? axes : [])
.map(Number)
.filter((axis) => axis >= localOriginMm - 0.5 && axis <= localTopMm + 0.5)
.map((axis) => Math.round((axis - localOriginMm) * 10) / 10);
const translateHoles = (holes) =>
(Array.isArray(holes) ? holes : [])
.filter((hole) => Number(hole?.yMm) >= localOriginMm - 0.5 && Number(hole?.yMm) <= localTopMm + 0.5)
.map((hole) => ({ ...hole, yMm: Math.round((Number(hole.yMm) - localOriginMm) * 10) / 10 }));
const slides = next.drawerSlideDrilling;
if (slides?.separators?.length) {
const source = slides.separators[separatorIndex] || slides.separators[0];
const translateFace = (face) => {
if (!face) return null;
const railAxesYMm = translateAxes(face.railAxesYMm);
return railAxesYMm.length ? { ...face, railAxesYMm } : null;
};
const scoped = source
? {
...source,
faceTowardLowerModule: translateFace(source.faceTowardLowerModule),
faceTowardHigherModule: translateFace(source.faceTowardHigherModule)
}
: null;
if (scoped && (scoped.faceTowardLowerModule || scoped.faceTowardHigherModule)) {
next.drawerSlideDrilling = { ...slides, separators: [scoped] };
} else {
delete next.drawerSlideDrilling;
}
}
const renfort = next.drawerRenfortPanelIxconnect;
if (renfort?.separators?.length) {
const source = renfort.separators[0];
const translateFace = (face) => {
if (!face) return null;
const holes = translateHoles(face.holes);
return holes.length ? { ...face, holes } : null;
};
const scoped = {
...source,
faceTowardLowerModule: translateFace(source.faceTowardLowerModule),
faceTowardHigherModule: translateFace(source.faceTowardHigherModule)
};
if (scoped.faceTowardLowerModule || scoped.faceTowardHigherModule) {
next.drawerRenfortPanelIxconnect = { ...renfort, separators: [scoped] };
} else {
delete next.drawerRenfortPanelIxconnect;
}
}
return Object.keys(next).length ? next : null;
}
function stripDrawerRenfortPanelIxconnect(machining) {
if (!machining?.drawerRenfortPanelIxconnect) return machining;
const { drawerRenfortPanelIxconnect, ...rest } = machining;
return Object.keys(rest).length ? rest : null;
}
/**
* Generate complete cutting list for a wardrobe configuration
* @param {Object} config - Wardrobe configuration
* @param {Object} colors - Color/finish assignments per element
* @param {Array} modules - Module configurations (optional)
* @returns {Object} Cutting list with pieces grouped by finish
*/
export function generateCuttingList(config, colors = {}, modules = []) {
const pieces = [];
const spanCtx = horizontalSpanContext(config, modules);
const sideDefaultsEnabled = config?.sideFinishesEnabled !== false;
const sideFinishLeftEnabled = config?.sideFinishLeftEnabled ?? sideDefaultsEnabled;
const sideFinishRightEnabled = config?.sideFinishRightEnabled ?? sideDefaultsEnabled;
const topFinishEnabled = config?.topFinishEnabled !== false;
// All element types to process (includes renforts per APEX 4.0)
const elementTypes = [
'plintheBas',
'plintheGauche',
'plintheDroite',
'bandeauHaut',
'dessus',
'fond',
'cotes',
'dos',
'renforts',
'portes',
'panneauxInterieurs',
'rayonFixe',
'renfortsTiroir',
'tiroirFond',
'tiroirDos',
'tiroirFacade',
'etageres'
];
if (config.installationType === 'niche') {
elementTypes.splice(5, 0, 'cotesDoublage', 'fileurCotes', 'fileurHaut');
} else if (config.installationType === 'free' && config?.exteriorFinishesEnabled !== false) {
if (sideFinishLeftEnabled) elementTypes.splice(5, 0, 'habillageFinitionGauche');
if (sideFinishRightEnabled) elementTypes.splice(5, 0, 'habillageFinitionDroite');
if (topFinishEnabled) elementTypes.splice(5, 0, 'habillageFinitionDessus');
}
for (const elementType of elementTypes) {
// Special-case hinged doors: door height can vary per module when drawers exist.
// If dimensions differ, emit multiple "portes" pieces so cutting list + plans remain accurate.
if (elementType === 'portes' && config?.doorType !== 'sliding') {
const doorCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2);
const hw = config.hardware || {};
const fullHeightHandle = hw.handleType === 'full_height';
const widthDeduction = fullHeightHandle ? HANDLE.fullHeightWidthDeductionMm : 0;
const bodyHeightMm = (config.heightMm || 2400) - (config.plinthHeightMm || 100);
const baseDoorHeightMm = Math.max(80, Math.round(bodyHeightMm - 4));
const drawerHeightsMm = Array.isArray(hw.drawerHeightsMm) ? hw.drawerHeightsMm : [90, 128, 177, 240];
const overrideSides =
Array.isArray(hw.doorHingeSides) && hw.doorHingeSides.length === doorCount
? hw.doorHingeSides
: null;
const doorWidthForIndex = (i) => {
const modW = modules?.[i]?.widthMm;
const rawW = Number.isFinite(Number(modW)) && Number(modW) > 0 ? Number(modW) : (config.widthMm || 2400) / doorCount;
return Math.max(100, Math.floor(rawW) - 4 - widthDeduction);
};
const drawerStackHeightForIndex = (i) => {
const n = Math.max(0, Number(modules?.[i]?.drawers) || 0);
// Norme SMS / Legrabox : pile = somme des hauteurs de niche (jeux inclus dans la niche).
return drawerStackHeightMm(n, drawerHeightsMm);
};
const groups = new Map();
let totalDoors = 0;
for (let i = 0; i < doorCount; i++) {
const w = doorWidthForIndex(i);
const drawerDeduct = drawerStackHeightForIndex(i);
const hingeSide = overrideSides
? (overrideSides[i] === 'right' ? 'right' : 'left')
: getDoorHingeSide(i, doorCount, hw);
// Colonne empilée: 1 porte par block (chaque block = caisson avec sa propre façade).
// Block sans porte (mod.blockDoors) = niche ouverte → aucune pièce.
// Les tiroirs partent du bas → déduction sur la porte du block bas uniquement.
const segs = moduleStackSegments(modules?.[i] ?? null, config);
const doors = moduleBlockDoors(modules?.[i] ?? null, config, segs.length);
let segmentBottomMm = 0;
for (const seg of segs) {
const segmentTopMm = segmentBottomMm + seg.heightMm;
const coveredBySpan = spanCoversModuleAt(spanCtx, i, segmentBottomMm, segmentTopMm);
segmentBottomMm = segmentTopMm;
if (coveredBySpan) continue;
if (!doors[seg.index]) continue;
const deduct = seg.index === 0 ? drawerDeduct : 0;
const h = segs.length > 1
? Math.max(120, Math.round(seg.heightMm - 4 - deduct))
: Math.max(120, Math.round(baseDoorHeightMm - deduct));
const key = `${w}x${h}`;
const entry = groups.get(key) || { width: w, height: h, hingeSides: [] };
entry.hingeSides.push(hingeSide === 'right' ? 'right' : 'left');
groups.set(key, entry);
totalDoors += 1;
}
}
const finish = colors[elementType] || { finishId: null, label: 'Non défini' };
const edgeBanding = getEdgeBanding(elementType);
let gi = 0;
for (const entry of groups.values()) {
const dimensions = {
width: entry.width,
height: entry.height,
thickness: PANEL_THICKNESS,
quantity: entry.hingeSides.length,
description:
groups.size === 1
? `Portes battantes (${totalDoors} portes)`
: `Portes battantes (groupe ${++gi}/${groups.size})`
};
const machining = getMachining(elementType, dimensions, config, modules);
const piece = {
id: `piece-portes-${groups.size === 1 ? 'all' : gi}`,
elementType,
description: dimensions.description,
finishId: finish.finishId,
finishLabel: finish.label,
widthMm: Math.round(dimensions.width),
heightMm: Math.round(dimensions.height),
thicknessMm: Math.round(dimensions.thickness ?? PANEL_THICKNESS),
quantity: dimensions.quantity,
unit: dimensions.unit || 'pcs',
edgeBanding,
machining,
surfaceM2: calculateSurface(dimensions.width, dimensions.height, dimensions.quantity)
};
piece.doorPlanHints = {
doorCount: dimensions.quantity,
hingeSides: entry.hingeSides,
hingeSide: entry.hingeSides[0] || 'left',
hardware: hw,
fullHeightHandle: hw.handleType === 'full_height'
};
const raw = computeRawCutDimensions(piece);
piece.rawCutWidthMm = raw.rawCutWidthMm;
piece.rawCutHeightMm = raw.rawCutHeightMm;
piece.piecePlanSvg = generatePiecePlanSvg(piece);
piece.piecePlanViews = generatePiecePlanViews(piece);
pieces.push(piece);
}
continue;
}
if (elementType === 'etageres' && spanCtx) {
const mods = effectiveModules(modules, config);
const depthMm = Number(config.depthMm || 600);
const shelfDepthMm = Math.max(40, Math.round(depthMm - PANEL_THICKNESS - 5));
const groups = new Map();
for (let moduleIndex = 0; moduleIndex < mods.length; moduleIndex++) {
const mod = mods[moduleIndex];
const shelfCount = Math.max(0, Number(mod?.shelves) || 0);
if (shelfCount === 0) continue;
const structuralIndex = structuralMiddleShelfIndex(mod, config);
const centers = shelfCenterYsMm({
shelfCount,
heightMm: config.heightMm,
plinthHeightMm: config.plinthHeightMm,
shelfGapMm: mod?.shelfGapMm
});
const physicalCenters = centers.map(
(center) => center + Number(config.heightMm || 0) / 2 - Number(config.plinthHeightMm || 0)
);
const kept = physicalCenters.filter(
(center, shelfIndex) =>
shelfIndex !== structuralIndex &&
!spanCoversHeight(spanCtx, moduleIndex, center)
);
if (!kept.length) continue;
const rawWidthMm = Number(mod?.widthMm) || Number(config.widthMm || 0) / Math.max(1, mods.length);
const widthMm = Math.max(80, Math.round(rawWidthMm - PANEL_THICKNESS * 2 - 2));
const key = `${widthMm}x${shelfDepthMm}`;
const entry = groups.get(key) || { widthMm, quantity: 0 };
entry.quantity += kept.length;
groups.set(key, entry);
}
if (!groups.size) continue;
const finish = colors[elementType] || { finishId: null, label: 'Non défini' };
let groupIndex = 0;
for (const entry of groups.values()) {
groupIndex += 1;
const dimensions = {
width: entry.widthMm,
height: shelfDepthMm,
thickness: PANEL_THICKNESS,
quantity: entry.quantity
};
const piece = {
id: `piece-etageres-span-aware-${groupIndex}`,
elementType,
description: `Rayons mobiles hors portée horizontale (${entry.quantity})`,
finishId: finish.finishId,
finishLabel: finish.label,
widthMm: dimensions.width,
heightMm: dimensions.height,
thicknessMm: dimensions.thickness,
quantity: dimensions.quantity,
unit: 'pcs',
edgeBanding: getEdgeBanding(elementType),
machining: getMachining(elementType, dimensions, config, modules),
surfaceM2: calculateSurface(dimensions.width, dimensions.height, dimensions.quantity)
};
const raw = computeRawCutDimensions(piece);
piece.rawCutWidthMm = raw.rawCutWidthMm;
piece.rawCutHeightMm = raw.rawCutHeightMm;
piece.piecePlanSvg = generatePiecePlanSvg(piece);
piece.piecePlanViews = generatePiecePlanViews(piece);
pieces.push(piece);
}
continue;
}
if (elementType === 'rayonFixe') {
const mods = effectiveModules(modules, config);
const countModules = Math.max(1, mods.length || 0);
const depthMm = Number(config.depthMm || 600);
const innerDepth = depthMm - PANEL_THICKNESS;
const shelfDepthMm = Math.max(40, Math.round(innerDepth - 5)); // APEX 4.0: P = P.int - 5mm
const needsMiddle = (Number(config.heightMm) || 0) > 2000;
const hasDrawersForIndex = (i) => (Number(mods?.[i]?.drawers) || 0) > 0;
const shelfWidthForIndex = (i) => {
const modW = mods?.[i]?.widthMm;
const rawW =
Number.isFinite(Number(modW)) && Number(modW) > 0
? Number(modW)
: (config.widthMm || 2400) / Math.max(1, countModules);
// APEX 4.0: L = L.int - 2mm
return Math.max(80, Math.round(rawW - (PANEL_THICKNESS * 2) - 2));
};
// Merge all rayonFixe of same dimensions into one cutting-list line.
// Per module, qty = (middle structural if needed) + (above drawers if any).
const groups = new Map(); // key => { width, qty }
for (let i = 0; i < countModules; i++) {
// Colonne empilée: les jonctions (doubles panneaux) remplacent le rayon structurel.
const middleHeightMm = carcassBodyMm(config) / 2;
const addMiddle =
Boolean(needsMiddle) &&
!moduleStacked(mods?.[i] ?? null, config) &&
!spanCoversHeight(spanCtx, i, middleHeightMm);
const addAboveDrawers = hasDrawersForIndex(i);
const qty = (addMiddle ? 1 : 0) + (addAboveDrawers ? 1 : 0);
if (qty <= 0) continue;
const w = shelfWidthForIndex(i);
const key = `${w}x${shelfDepthMm}`;
const entry = groups.get(key) || { width: w, qty: 0 };
entry.qty += qty;
groups.set(key, entry);
}
if (groups.size === 0) continue;
const finish = colors[elementType] || colors.etageres || { finishId: null, label: 'Non défini' };
const edgeBanding = getEdgeBanding(elementType);
let gi = 0;
for (const entry of groups.values()) {
gi += 1;
const dimensions = {
width: entry.width,
height: shelfDepthMm,
thickness: PANEL_THICKNESS,
quantity: entry.qty,
description:
groups.size === 1
? `Rayon fixe (structure) (${entry.qty})`
: `Rayon fixe (structure) (groupe ${gi}/${groups.size})`
};
const machining = getMachining(elementType, dimensions, config, modules);
const piece = {
id: `piece-${elementType}-${groups.size === 1 ? 'all' : gi}`,
elementType,
description: dimensions.description,
finishId: finish.finishId,
finishLabel: finish.label,
widthMm: Math.round(dimensions.width),
heightMm: Math.round(dimensions.height),
thicknessMm: Math.round(dimensions.thickness ?? PANEL_THICKNESS),
quantity: dimensions.quantity,
unit: dimensions.unit || 'pcs',
edgeBanding,
machining,
surfaceM2: calculateSurface(dimensions.width, dimensions.height, dimensions.quantity)
};
const raw = computeRawCutDimensions(piece);
piece.rawCutWidthMm = raw.rawCutWidthMm;
piece.rawCutHeightMm = raw.rawCutHeightMm;
piece.piecePlanSvg = generatePiecePlanSvg(piece);
piece.piecePlanViews = generatePiecePlanViews(piece);
pieces.push(piece);
}
continue;
}
if (elementType === 'renfortsTiroir') {
const mods = effectiveModules(modules, config);
const hw = config.hardware || {};
if (!mods.some((mod) => moduleHasHiddenDrawers(config, mod))) continue;
const countModules = Math.max(1, mods.length);
const depthMm = Number(config.depthMm || 600);
const drawerHeightsMm = Array.isArray(hw.drawerHeightsMm) ? hw.drawerHeightsMm : [90, 128, 177, 240];
const drawerGapMm = 8;
const hingeClearanceMm = DRAWER_REINFORCEMENT.hingeFrontClearanceMm;
const sepDepthMm = getSeparatorDepthMm(depthMm);
const { backInsetFromRearMm, topFrontRadiusMm, thicknessMm } = DRAWER_REINFORCEMENT;
const groups = new Map();
for (let i = 0; i < countModules; i++) {
const n = Math.max(0, Number(mods?.[i]?.drawers) || 0);
if (n <= 0 || !moduleHasHiddenDrawers(config, mods[i])) continue;
const stackRaw = drawerStackHeightMm(n, drawerHeightsMm, drawerGapMm);
const hMm = stackRaw - topFrontRadiusMm;
if (hMm <= 0) continue;
const leftPanelDepth = i === 0 ? depthMm : sepDepthMm;
const rightPanelDepth = i === countModules - 1 ? depthMm : sepDepthMm;
const leftLen = leftPanelDepth - backInsetFromRearMm - hingeClearanceMm;
const rightLen = rightPanelDepth - backInsetFromRearMm - hingeClearanceMm;
const a = Math.max(0, leftLen);
const b = Math.max(0, rightLen);
if (a <= 0 && b <= 0) continue;
/** Les deux renforts d’une même colonne ont la même profondeur (largeur en plan) : minimum des deux côtés. */
const commonLen = a > 0 && b > 0 ? Math.min(a, b) : a + b;
const qtyAdd = a > 0 && b > 0 ? 2 : 1;
const key = `${commonLen}x${hMm}`;
const entry = groups.get(key) || { width: commonLen, height: hMm, qty: 0 };
entry.qty += qtyAdd;
groups.set(key, entry);
}
if (groups.size === 0) continue;
const finish = colors[elementType] || colors.tiroirs || { finishId: null, label: 'Non défini' };
const edgeBanding = getEdgeBanding(elementType);
let gi = 0;
for (const entry of groups.values()) {
gi += 1;
const dimensions = {
width: entry.width,
height: entry.height,
thickness: thicknessMm,
quantity: entry.qty,
description:
groups.size === 1
? `Renforts tiroir 25 mm — tiroirs cachés (${entry.qty})`
: `Renforts tiroir 25 mm — tiroirs cachés (groupe ${gi}/${groups.size})`
};
const machining = getMachining(elementType, dimensions, config, modules);
const piece = {
id: `piece-${elementType}-${groups.size === 1 ? 'all' : gi}`,
elementType,
description: dimensions.description,
finishId: finish.finishId,
finishLabel: finish.label,
widthMm: Math.round(dimensions.width),
heightMm: Math.round(dimensions.height),
thicknessMm: Math.round(dimensions.thickness ?? thicknessMm),
quantity: dimensions.quantity,
unit: dimensions.unit || 'pcs',
edgeBanding,
machining,
surfaceM2: calculateSurface(dimensions.width, dimensions.height, dimensions.quantity)
};
const raw = computeRawCutDimensions(piece);
piece.rawCutWidthMm = raw.rawCutWidthMm;
piece.rawCutHeightMm = raw.rawCutHeightMm;
piece.piecePlanSvg = generatePiecePlanSvg(piece);
piece.piecePlanViews = generatePiecePlanViews(piece);
pieces.push(piece);
}
continue;
}
// Blum Legrabox pure — pièces fabriquées du tiroir : fond 16 mm, paroi arrière 16 mm, façade.
if (['tiroirFond', 'tiroirDos', 'tiroirFacade'].includes(elementType)) {
const columns = legraboxColumnsForConfig(config, modules);
if (!columns.length) continue;
/** key => { width, height, thickness, qty, machining, systemKeys } */
const groups = new Map();
const addToGroup = (w, h, t, machiningEntry, sysKey) => {
const wi = Math.round(w);
const hi = Math.round(h);
if (wi <= 0 || hi <= 0) return;
const key = `${wi}x${hi}x${t}${sysKey ? `:${sysKey}` : ''}`;
const entry = groups.get(key) || { width: wi, height: hi, thickness: t, qty: 0, machining: machiningEntry, systemKeys: new Set() };
entry.qty += 1;
if (sysKey) entry.systemKeys.add(sysKey);
groups.set(key, entry);
};
for (const col of columns) {
const X = col.internalWidthMm;
const NL = col.nominalLengthMm;
for (const d of col.drawers) {
if (elementType === 'tiroirFond') {
const bottom = NL != null ? legraboxBottomPanelMm(NL, X) : null;
if (!bottom) continue;
addToGroup(bottom.lengthMm, bottom.widthMm, bottom.thicknessMm, {
legraboxBottom: {
nominalLengthMm: NL,
internalWidthMm: X,
rebate: bottom.rebate,
note: `Fond tiroir Legrabox 16 mm — L = NL − 10 (${NL} − 10), l = X − 35 (${X} − 35) · feuillure 38 × 8 mm G+D`
}
});
} else if (elementType === 'tiroirDos') {
const back = legraboxBackPanelMm(X, d.systemKey);
if (!back) continue;
addToGroup(back.widthMm, back.heightMm, back.thicknessMm, {
legraboxBack: {
systemKey: d.systemKey,
internalWidthMm: X,
note: `Paroi arrière tiroir Legrabox 16 mm — l = X − 38 (${X} − 38), h = ${back.heightMm} mm (système ${d.systemKey})`
}
}, d.systemKey);
} else {
/** Façade : largeur module − 4 (jeux), hauteur = niche SMS − 8 (jeu pris dans la niche). */
const fw = Math.max(100, Math.round(col.moduleWidthMm - 4));
const fh = Math.max(60, Math.round(d.nicheHeightMm - 8));
const insertaHoles = buildLegraboxFacadeInsertaHolePositionsMm(d.systemKey, fw, fh, X);
addToGroup(fw, fh, PANEL_THICKNESS, {
legraboxInserta: {
systemKey: d.systemKey,
holeDiameterMm: LEGRABOX_FACADE_INSERTA.holeDiameterMm,
holeDepthMm: LEGRABOX_FACADE_INSERTA.holeDepthMm,
holes: insertaHoles,
note: `Façade tiroir Legrabox (système ${d.systemKey}) — INSERTA : ${LEGRABOX_FACADE_INSERTA.toolNote}`
}
}, d.systemKey);
}
}
}
if (groups.size === 0) continue;
const labels = {
tiroirFond: 'Fond de tiroir 16 mm (Legrabox)',
tiroirDos: 'Paroi arrière tiroir 16 mm (Legrabox)',
tiroirFacade: 'Façade de tiroir (Legrabox)'
};
const finish = colors[elementType] || colors.tiroirs || { finishId: null, label: 'Non défini' };
const edgeBanding = getEdgeBanding(elementType);
let gi = 0;
for (const entry of groups.values()) {
gi += 1;
const sysSuffix = entry.systemKeys.size ? ` — ${Array.from(entry.systemKeys).join('/')}` : '';
const piece = {
id: `piece-${elementType}-${groups.size === 1 ? 'all' : gi}`,
elementType,
description:
groups.size === 1
? `${labels[elementType]}${sysSuffix} (${entry.qty})`
: `${labels[elementType]}${sysSuffix} (groupe ${gi}/${groups.size})`,
finishId: finish.finishId,
finishLabel: finish.label,
widthMm: entry.width,
heightMm: entry.height,
thicknessMm: entry.thickness,
quantity: entry.qty,
unit: 'pcs',
edgeBanding,
machining: entry.machining,
surfaceM2: calculateSurface(entry.width, entry.height, entry.qty)
};
const raw = computeRawCutDimensions(piece);
piece.rawCutWidthMm = raw.rawCutWidthMm;
piece.rawCutHeightMm = raw.rawCutHeightMm;
piece.piecePlanSvg = generatePiecePlanSvg(piece);
piece.piecePlanViews = generatePiecePlanViews(piece);
pieces.push(piece);
}
continue;
}
if (elementType === 'dos') {
// Dos PAR ZONE (colonne × niveau si empilage): 1 panneau 8 mm par zone avec dos, regroupés par dimensions.
const mods = effectiveModules(modules, config);
const bodyHeightMm = (Number(config.heightMm) || 2400) - (Number(config.plinthHeightMm) || 100);
const dosHeightMm = Math.max(50, Math.round(bodyHeightMm - PANEL_THICKNESS * 2));
const totalWidthMm = Number(config.widthMm) || 2400;
const hasExplicitBackFlags =
typeof config?.hasBack === 'boolean' ||
mods.some((m) => typeof m?.hasBack === 'boolean') ||
Boolean(spanCtx && mods.some((m) => Array.isArray(m?.blockBacks) || typeof m?.hasBackTop === 'boolean'));
const groups = new Map(); // `${w}x${h}` => { width, height, qty }
const addDos = (w, h) => {
const key = `${w}x${h}`;
const entry = groups.get(key) || { width: w, height: h, qty: 0 };
entry.qty += 1;
groups.set(key, entry);
};
if (mods.length === 0 || !hasExplicitBackFlags) {
// Anciennes commandes (sans flags) : dos unique pleine largeur (comportement historique).
if (anyBackPanel(config, modules)) {
const w = Math.max(50, Math.round(totalWidthMm - PANEL_THICKNESS * 2));
addDos(w, dosHeightMm);
}
} else {
for (let moduleIndex = 0; moduleIndex < mods.length; moduleIndex++) {
const m = mods[moduleIndex];
const rawW = Number.isFinite(Number(m?.widthMm)) && Number(m.widthMm) > 0
? Number(m.widthMm)
: totalWidthMm / Math.max(1, mods.length);
const w = Math.max(50, Math.round(rawW - PANEL_THICKNESS * 2));
// Colonne empilée: 1 dos potentiel par block, hauteurs propres à la colonne.
let blockBottomMm = 0;
for (const seg of moduleStackSegments(m, config)) {
const blockTopMm = blockBottomMm + seg.heightMm;
const covered = spanCoversModuleAt(spanCtx, moduleIndex, blockBottomMm, blockTopMm);
blockBottomMm = blockTopMm;
if (!seg.hasBack || covered) continue;
addDos(w, Math.max(50, Math.round(seg.heightMm - PANEL_THICKNESS * 2)));
}
}
}
if (groups.size === 0) continue;
const finish = colors[elementType] || { finishId: null, label: 'Non défini' };
const edgeBanding = getEdgeBanding(elementType);
let gi = 0;
for (const entry of groups.values()) {
gi += 1;
const dimensions = {
width: entry.width,
height: entry.height,
thickness: BACK_PANEL_THICKNESS,
quantity: entry.qty,
description:
groups.size === 1
? `Dos (panneau arrière 8 mm) — ${entry.qty} zone${entry.qty > 1 ? 's' : ''} avec dos`
: `Dos (panneau arrière 8 mm) — groupe ${gi}/${groups.size}`
};
const machining = getMachining(elementType, dimensions, config, modules);
const piece = {
id: `piece-dos-${groups.size === 1 ? 'all' : gi}`,
elementType,
description: dimensions.description,
finishId: finish.finishId,
finishLabel: finish.label,
widthMm: Math.round(dimensions.width),
heightMm: Math.round(dimensions.height),
thicknessMm: Math.round(dimensions.thickness),
quantity: dimensions.quantity,
unit: 'pcs',
edgeBanding,
machining,
surfaceM2: calculateSurface(dimensions.width, dimensions.height, dimensions.quantity)
};
const raw = computeRawCutDimensions(piece);
piece.rawCutWidthMm = raw.rawCutWidthMm;
piece.rawCutHeightMm = raw.rawCutHeightMm;
piece.piecePlanSvg = generatePiecePlanSvg(piece);
piece.piecePlanViews = generatePiecePlanViews(piece);
pieces.push(piece);
}
continue;
}
if (elementType === 'cotes') {
const baseDims = getElementDimensions(elementType, config, modules);
if (!baseDims) continue;
const finish = colors[elementType] || { finishId: null, label: 'Non défini' };
const edgeBanding = getEdgeBanding(elementType);
// Empilage vertical PAR COLONNE: chaque côté suit la colonne d'extrémité correspondante.
const modsForSides = effectiveModules(modules, config);
const leftMod = modsForSides[0] ?? null;
const rightMod = modsForSides.length ? modsForSides[modsForSides.length - 1] : null;
const sideAssembly = getExteriorSideAssembly(config);
const segsBySide = {
left: moduleStackSegments(leftMod, config),
right: moduleStackSegments(rightMod, config)
};
const sameStructure =
segsBySide.left.length === segsBySide.right.length &&
segsBySide.left.every((sg, k) => sg.heightMm === segsBySide.right[k].heightMm);
const segMeta = (seg) =>
seg.count <= 1
? { suffix: '', label: '', allowRenfort: true }
: {
suffix: `b${seg.index + 1}`,
label: `block ${seg.index + 1}${seg.index === 0 ? ' (bas)' : seg.index === seg.count - 1 ? ' (haut)' : ''}`,
// Renfort tiroirs: les tiroirs partent du bas → block bas uniquement.
allowRenfort: seg.index === 0
};
const pushCotePiece = (dims, opts) => {
const piece = {
id: opts.id,
elementType,
description: opts.description,
finishId: finish.finishId,
finishLabel: finish.label,
widthMm: Math.round(dims.width),
heightMm: Math.round(dims.height),
thicknessMm: Math.round(dims.thickness ?? PANEL_THICKNESS),
quantity: opts.quantity,
unit: dims.unit || 'pcs',
edgeBanding,
machining: opts.machining,
surfaceM2: calculateSurface(dims.width, dims.height, opts.quantity)
};
const raw = computeRawCutDimensions(piece);
piece.rawCutWidthMm = raw.rawCutWidthMm;
piece.rawCutHeightMm = raw.rawCutHeightMm;
piece.piecePlanSvg = generatePiecePlanSvg(piece);
piece.piecePlanViews = generatePiecePlanViews(piece);
pieces.push(piece);
};
/** Usinage d'un côté à la hauteur donnée; renfort tiroirs seulement en zone basse. */
const buildMachining = (heightMm, allowRenfort, side = 'both') => {
const inset =
side === 'left' ? sideAssembly.left
: side === 'right' ? sideAssembly.right
: sideAssembly.left && sideAssembly.right;
const dims = {
...baseDims,
height: Math.max(50, heightMm - (inset ? PANEL_THICKNESS * 2 : 0))
};
let machining = getMachining(
elementType,
dims,
{ ...config, _coteAssemblySide: side },
modules
);
if (!allowRenfort && machining?.drawerRenfortPanelIxconnect) {
machining = { ...machining };
delete machining.drawerRenfortPanelIxconnect;
}
return { dims, machining };
};
if (sameStructure) {
// Les 2 côtés ont la même structure (mêmes hauteurs de blocks) → pièces par block (paire ou split).
for (let k = 0; k < segsBySide.left.length; k++) {
const segL = segsBySide.left[k];
const segR = segsBySide.right[k];
const meta = segMeta(segL);
const split = segL.count > 1;
if (sideAssembly.left !== sideAssembly.right) {
for (const side of ['left', 'right']) {
const sideLabel = side === 'left' ? 'gauche' : 'droit';
const seg = side === 'left' ? segL : segR;
const { dims, machining: baseMach } =
buildMachining(seg.heightMm, meta.allowRenfort, side);
let machining = scopeMachiningCotesDrawerRenfort(
baseMach,
side === 'left',
side === 'right'
);
const drm = machining?.drawerRenfortPanelIxconnect?.cotes;
const hasRenfort = coteFaceHasRenfortHoles(
side === 'left' ? drm?.leftInnerFace : drm?.rightInnerFace
);
if (machining?.groove && !seg.hasBack) machining = stripBackGroove(machining);
let desc = `Côté extérieur ${sideLabel}${seg.count > 1 ? ` — ${meta.label}` : ''}`;
if (hasRenfort) {
desc += ' — perçages Ixconnect renfort tiroirs (face intérieure caisse)';
}
desc += seg.hasBack ? ' — avec rainure dos' : ' — sans rainure dos (zone sans dos)';
if (sideAssembly[side]) desc += ' — montage entre dessus et fond';
pushCotePiece(dims, {
id: `piece-cotes-${sideLabel}${seg.count > 1 ? `-${meta.suffix}` : ''}`,
description: desc,
quantity: 1,
machining
});
}
continue;
}
const { dims, machining: baseMach } =
buildMachining(segL.heightMm, meta.allowRenfort, 'both');
let machining = baseMach;
dims.description = split
? `Côtés extérieurs ${meta.label} (gauche et droit)`
: baseDims.description;
const drm = machining?.drawerRenfortPanelIxconnect?.cotes;
const hasL = coteFaceHasRenfortHoles(drm?.leftInnerFace);
const hasR = coteFaceHasRenfortHoles(drm?.rightInnerFace);
// Rainure dos par côté: seulement si le block d'extrémité correspondant a un dos.
const grooveL = segL.hasBack;
const grooveR = segR.hasBack;
if (machining?.groove && !grooveL && !grooveR) {
machining = stripBackGroove(machining);
}
const grooveMismatch = Boolean(machining?.groove) && grooveL !== grooveR;
const renfortMismatch = Boolean(drm) && hasL !== hasR;
const idBase = split ? `piece-cotes-${meta.suffix}` : 'piece-cotes';
if (!renfortMismatch && !grooveMismatch) {
const desc =
hasL && hasR
? `${dims.description} — perçages Ixconnect renfort tiroirs (faces intérieures caisse)`
: dims.description;
pushCotePiece(dims, {
id: idBase,
description: desc,
quantity: dims.quantity,
machining
});
} else {
const sideDesc = (label, hasRenfort, hasGroove) => {
let d = split ? `Côté extérieur ${label} — ${meta.label}` : `Côté extérieur ${label}`;
if (hasRenfort) d += ' — perçages Ixconnect renfort tiroirs (face intérieure caisse)';
if (machining?.groove) d += hasGroove ? ' — avec rainure dos' : ' — sans rainure dos (zone sans dos)';
return d;
};
const machiningFor = (includeLeft, includeRight, hasGroove) => {
const scoped = scopeMachiningCotesDrawerRenfort(machining, includeLeft, includeRight);
return hasGroove ? scoped : stripBackGroove(scoped);
};
pushCotePiece(dims, {
id: `${idBase}-gauche`,
description: sideDesc('gauche', hasL, grooveL),
quantity: 1,
machining: machiningFor(true, false, grooveL)
});
pushCotePiece(dims, {
id: `${idBase}-droit`,
description: sideDesc('droit', hasR, grooveR),
quantity: 1,
machining: machiningFor(false, true, grooveR)
});
}
}
} else {
// Structures différentes (empilage par colonne) → chaque côté émis séparément selon sa colonne.
for (const side of ['left', 'right']) {
const sideLabel = side === 'left' ? 'gauche' : 'droit';
for (const seg of segsBySide[side]) {
const meta = segMeta(seg);
const split = seg.count > 1;
const { dims, machining: baseMach } =
buildMachining(seg.heightMm, meta.allowRenfort, side);
let machining = scopeMachiningCotesDrawerRenfort(baseMach, side === 'left', side === 'right');
const drm = machining?.drawerRenfortPanelIxconnect?.cotes;
const hasRenfort = coteFaceHasRenfortHoles(side === 'left' ? drm?.leftInnerFace : drm?.rightInnerFace);
const groove = seg.hasBack;
if (machining?.groove && !groove) machining = stripBackGroove(machining);
let desc = `Côté extérieur ${sideLabel}${split ? ` — ${meta.label}` : ''}`;
if (hasRenfort) desc += ' — perçages Ixconnect renfort tiroirs (face intérieure caisse)';
desc += groove ? ' — avec rainure dos' : ' — sans rainure dos (zone sans dos)';
if (sideAssembly[side]) desc += ' — montage entre dessus et fond';
pushCotePiece(dims, {
id: `piece-cotes-${sideLabel}${split ? `-${meta.suffix}` : ''}`,
description: desc,
quantity: 1,
machining
});
}
}
}
continue;
}
if (elementType === 'panneauxInterieurs') {
const baseDims = getElementDimensions(elementType, config, modules);
if (!baseDims) continue;
const finish = colors[elementType] || { finishId: null, label: 'Non défini' };
const edgeBanding = getEdgeBanding(elementType);
const mods = effectiveModules(modules, config);
const sepCount = baseDims.quantity;
const pushSepPiece = (dimensions, opts) => {
const piece = {
id: opts.id,
elementType,
description: opts.description,
finishId: finish.finishId,
finishLabel: finish.label,
widthMm: Math.round(dimensions.width),
heightMm: Math.round(dimensions.height),
thicknessMm: Math.round(dimensions.thickness ?? PANEL_THICKNESS),
quantity: opts.quantity,
unit: dimensions.unit || 'pcs',
edgeBanding,
machining: opts.machining,
...(opts.metadata || {}),
surfaceM2: calculateSurface(dimensions.width, dimensions.height, opts.quantity)
};
const raw = computeRawCutDimensions(piece);
piece.rawCutWidthMm = raw.rawCutWidthMm;
piece.rawCutHeightMm = raw.rawCutHeightMm;
piece.piecePlanSvg = generatePiecePlanSvg(piece);
piece.piecePlanViews = generatePiecePlanViews(piece);
pieces.push(piece);
};
if (spanCtx) {
for (let s = 0; s < sepCount; s++) {
const segments = getSeparatorVerticalSegments(spanCtx.config, s);
for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) {
const segment = segments[segmentIndex];
const physicalHeightMm = segment.topMm - segment.bottomMm;
const heightMm = Math.max(50, physicalHeightMm - PANEL_THICKNESS * 2);
const dimensions = { ...baseDims, height: heightMm, quantity: 1 };
let machining = getMachining(elementType, dimensions, config, modules);
machining = scopeSeparatorSegmentMachining(
machining,
s,
segment.bottomMm,
segment.topMm
);
const split = segments.length > 1 || physicalHeightMm < carcassBodyMm(config) - 0.5;
pushSepPiece(dimensions, {
id: `piece-panneauxInterieurs-sep${s + 1}${split ? `-seg${segmentIndex + 1}` : ''}`,
description:
`Séparateur vertical ${s + 1}/${sepCount}` +
` — segment ${Math.round(segment.bottomMm)}–${Math.round(segment.topMm)} mm` +
' — sans rainure dos, plaqué contre le dos',
quantity: 1,
machining,
metadata: {
separatorIndex: s,
bottomMm: segment.bottomMm,
topMm: segment.topMm
}
});
}
}
continue;
}
/**
* Segments du séparateur s (entre colonnes s et s+1): 1 pièce par block si les deux
* colonnes adjacentes ont les mêmes blocks (mêmes hauteurs). Sinon: pleine hauteur si
* ≤ panneau max, ou suivant la colonne la plus découpée (pièces ≤ panneau max) au-delà.
*/
const segsForSeparator = (s) => {
const a = mods[s];
const b = mods[s + 1];
if (a && b) {
const sa = moduleStackSegments(a, config);
const sb = moduleStackSegments(b, config);
const aligned = sa.length === sb.length && sa.every((sg, k) => sg.heightMm === sb[k].heightMm);
if (aligned) return sa;
const body = carcassBodyMm(config);
if (body <= STACK_BLOCK_MAX_MM) {
return [{ index: 0, count: 1, heightMm: body, hasBack: false, level: null }];
}
return sa.length >= sb.length ? sa : sb;
}
return moduleStackSegments(a ?? b ?? null, config);
};
const anySplit = Array.from({ length: sepCount }, (_, s) => segsForSeparator(s))
.some((segs) => segs.length > 1);
if (!anySplit) {
// Aucune jonction d'empilage au droit des séparateurs → comportement historique (pleine hauteur).
const dimensions = baseDims;
const machining = getMachining(elementType, dimensions, config, modules);
const ixSep = machining?.drawerRenfortPanelIxconnect?.separators;
if (!ixSep || ixSep.length === 0) {
pushSepPiece(dimensions, {
id: 'piece-panneauxInterieurs',
description: dimensions.description,
quantity: sepCount,
machining
});
continue;
}
const flags = [];
for (let s = 0; s < sepCount; s++) {
flags.push(separatorHasRenfort(ixSep[s]));
}
const allSame = flags.length > 0 && flags.every((f) => f === flags[0]);
if (allSame) {
const desc = flags[0]
? `Séparateurs verticaux (${sepCount}) — sans rainure dos, plaqués contre le dos — perçages Ixconnect renfort tiroirs (faces vers colonnes)`
: dimensions.description;
pushSepPiece(dimensions, {
id: 'piece-panneauxInterieurs',
description: desc,
quantity: sepCount,
machining: flags[0] ? machining : stripDrawerRenfortPanelIxconnect(machining)
});
} else {
for (let s = 0; s < sepCount; s++) {
pushSepPiece(dimensions, {
id: `piece-panneauxInterieurs-sep${s + 1}`,
description: `Séparateur vertical ${s + 1}/${sepCount} — sans rainure dos, plaqués contre le dos${
flags[s] ? ' — perçages Ixconnect renfort tiroirs (faces vers colonnes)' : ''
}`,
quantity: 1,
machining: scopeMachiningSeparatorsDrawerRenfort(machining, s)
});
}
}
continue;
}
// Empilage par colonne: chaque séparateur émis selon ses propres segments (blocks).
for (let s = 0; s < sepCount; s++) {
for (const seg of segsForSeparator(s)) {
const split = seg.count > 1;
const heightMm = split
? Math.max(50, seg.heightMm - PANEL_THICKNESS * 2)
: baseDims.height;
const levelLabel = split
? ` — block ${seg.index + 1}${seg.index === 0 ? ' (bas)' : seg.index === seg.count - 1 ? ' (haut)' : ''}`
: '';
const dimensions = { ...baseDims, height: heightMm, quantity: 1 };
let machining = getMachining(elementType, dimensions, config, modules);
// Renfort tiroirs: les tiroirs partent du bas → perçages sur le block bas / pleine hauteur uniquement.
if (split && seg.index > 0 && machining?.drawerRenfortPanelIxconnect) {
machining = { ...machining };
delete machining.drawerRenfortPanelIxconnect;
} else if (machining?.drawerRenfortPanelIxconnect) {
machining = scopeMachiningSeparatorsDrawerRenfort(machining, s);
}
const hasRenfort = separatorHasRenfort(machining?.drawerRenfortPanelIxconnect?.separators?.[0]);
pushSepPiece(dimensions, {
id: `piece-panneauxInterieurs-sep${s + 1}${split ? `-b${seg.index + 1}` : ''}`,
description: `Séparateur vertical ${s + 1}/${sepCount}${levelLabel} — sans rainure dos, plaqués contre le dos${
hasRenfort ? ' — perçages Ixconnect renfort tiroirs (faces vers colonnes)' : ''
}`,
quantity: 1,
machining
});
}
}
continue;
}
if (
(elementType === 'dessus' || elementType === 'fond') &&
(anyStackedColumn(config, modules) || effectiveModules(modules, config).length > 1)
) {
// Faces extérieures selon construction: block (dos / rayonnage) vs séparations fusionnées.
// Côtés extérieurs restent des montants block (jonction à l'angle) même sans dos.
const dimensions = getElementDimensions(elementType, config, modules);
if (!dimensions) continue;
const finish = colors[elementType] || { finishId: null, label: 'Non défini' };
const edgeBanding = getEdgeBanding(elementType);
const mods = effectiveModules(modules, config);
const totalWidthMm = Number(config.widthMm) || 2400;
const pushPanelPiece = (id, desc, dims, machining) => {
const piece = {
id,
elementType,
description: desc,
finishId: finish.finishId,
finishLabel: finish.label,
widthMm: Math.round(dims.width),
heightMm: Math.round(dims.height),
thicknessMm: Math.round(dims.thickness ?? PANEL_THICKNESS),
quantity: dims.quantity,
unit: dims.unit || 'pcs',
edgeBanding,
machining,
surfaceM2: calculateSurface(dims.width, dims.height, dims.quantity)
};
const raw = computeRawCutDimensions(piece);
piece.rawCutWidthMm = raw.rawCutWidthMm;
piece.rawCutHeightMm = raw.rawCutHeightMm;
piece.piecePlanSvg = generatePiecePlanSvg(piece);
piece.piecePlanViews = generatePiecePlanViews(piece);
pieces.push(piece);
};
// 1) Faces extérieures: 1 pièce par caisson block ; colonnes en séparations contiguës fusionnées.
// Côtés extérieurs = montants block (jonction à l'angle) même sans dos.
const mainLevel = elementType === 'dessus' ? 'top' : 'bottom';
const sideAssembly = getExteriorSideAssembly(config);
if (mods.length <= 1) {
const mainMach = getMachining(elementType, dimensions, { ...config, _grooveLevel: mainLevel }, modules);
pushPanelPiece(
`piece-${elementType}`,
elementType === 'dessus' ? 'Panneau du dessus (toit)' : 'Panneau du fond (bas)',
dimensions,
mainMach
);
} else {
const runLayouts = horizontalRunLayouts(mods, config, totalWidthMm, mainLevel);
for (const run of runLayouts) {
const dims = { ...dimensions, width: run.widthMm, quantity: 1 };
const axiloSpan = elementType === 'fond' ? axiloSpanConfigForRun(runLayouts, run) : {};
let machining = getMachining(
elementType,
dims,
{
...config,
_grooveForce: true,
_axiloFeetEnabled: elementType === 'fond',
_horizontalAssemblyLeft: run.start === 0 && sideAssembly.left,
_horizontalAssemblyRight: run.end === mods.length - 1 && sideAssembly.right,
...axiloSpan
},
mods.slice(run.start, run.end + 1).map((module) => ({ ...module, widthMm: moduleWidthMm(module, totalWidthMm, mods.length) }))
);
if (machining?.groove && !run.hasBack) machining = stripBackGroove(machining);
const colLabel = run.start === run.end
? `colonne ${run.start + 1}`
: `colonnes ${run.start + 1}–${run.end + 1}`;
pushPanelPiece(
`piece-${elementType}-col${run.start + 1}${run.start === run.end ? '' : `-${run.end + 1}`}`,
`${elementType === 'dessus' ? 'Panneau du dessus (toit)' : 'Panneau du fond (bas)'} — ${colLabel}`,
dims,
machining
);
}
}
// 2) Jonctions: pour chaque colonne empilée, 1 dessus par block sauf le dernier
// (resp. 1 fond par block sauf le premier), regroupées par (largeur, rainure).
const jonctionLevel = elementType === 'dessus' ? 'bottom' : 'top';
const groups = new Map(); // `${w}|${groove}|${leftFace}|${rightFace}`
const addJonction = (w, groove, leftFace, rightFace) => {
const key = `${w}|${groove}|${leftFace}|${rightFace}`;
const entry = groups.get(key) || {
width: w,
groove,
leftFace,
rightFace,
qty: 0
};
entry.qty += 1;
groups.set(key, entry);
};
if (mods.length === 0) {
const legacyCount = Math.max(0, moduleBlockHeightsMm(null, config).length - 1);
const w = Math.max(50, Math.round(totalWidthMm - PANEL_THICKNESS * 2));
const groove = anyBackAtLevel(config, modules, jonctionLevel);
for (let k = 0; k < legacyCount; k++) {
addJonction(w, groove, sideAssembly.left, sideAssembly.right);
}
} else {
for (let moduleIndex = 0; moduleIndex < mods.length; moduleIndex++) {
const m = mods[moduleIndex];
const heights = moduleBlockHeightsMm(m, config);
if (heights.length < 2) continue;
const backs = moduleBlockBacks(m, config, heights.length);
const rawW = Number.isFinite(Number(m?.widthMm)) && Number(m.widthMm) > 0
? Number(m.widthMm)
: totalWidthMm / Math.max(1, mods.length);
const leftFace = moduleIndex === 0 && sideAssembly.left;
const rightFace = moduleIndex === mods.length - 1 && sideAssembly.right;
const leftInset = horizontalInsetMm(mods, config, moduleIndex, 'left');
const rightInset = horizontalInsetMm(mods, config, moduleIndex, 'right');
const w = Math.max(50, Math.round(rawW - leftInset - rightInset));
// dessus: blocks 0..n-2 (dessus du block inférieur de chaque jonction);
// fond: blocks 1..n-1 (fond du block supérieur de chaque jonction).
const boundaries = [0];
heights.forEach((height) => boundaries.push(boundaries[boundaries.length - 1] + height));
const from = elementType === 'dessus' ? 0 : 1;
const to = elementType === 'dessus' ? heights.length - 2 : heights.length - 1;
for (let k = from; k <= to; k++) {
const boundaryMm = elementType === 'dessus' ? boundaries[k + 1] : boundaries[k];
if (spanReplacesHorizontalFace(spanCtx, moduleIndex, boundaryMm, elementType)) continue;
addJonction(w, backs[k], leftFace, rightFace);
}
}
}
let gi = 0;
for (const entry of groups.values()) {
gi += 1;
const dims = { ...dimensions, width: entry.width, quantity: entry.qty };
// _grooveForce: la rainure de la jonction dépend du dos du block concerné (entry.groove),
// pas des niveaux bas/haut globaux — on force puis on retire si le block n'a pas de dos.
let machining = getMachining(
elementType,
dims,
{
...config,
_grooveForce: true,
_axiloFeetEnabled: false,
_horizontalAssemblyLeft: entry.leftFace,
_horizontalAssemblyRight: entry.rightFace
},
[{ widthMm: entry.width }]
);
if (machining?.groove && !entry.groove) machining = stripBackGroove(machining);
const jonctionDesc = elementType === 'dessus'
? 'Panneau du dessus — block bas (jonction empilage)'
: 'Panneau du fond — block haut (jonction empilage)';
pushPanelPiece(
`piece-${elementType}-jonction${groups.size > 1 ? `-${gi}` : ''}`,
`${jonctionDesc}${entry.qty > 1 ? ` (${entry.qty})` : ''}${entry.groove ? '' : ' — sans rainure dos'}`,
dims,
machining
);
}
continue;
}
const dimensions = getElementDimensions(elementType, config, modules);
if (!dimensions) continue;
const finish = colors[elementType]
|| ((elementType === 'fileurCotes' || elementType === 'fileurHaut') ? colors.fileur : null)
|| ((elementType === 'plintheGauche' || elementType === 'plintheDroite') ? colors.plintheBas : null)
|| { finishId: null, label: 'Non défini' };
const edgeBanding = getEdgeBanding(elementType);
// Habillage vertical > panneau max: découpé en segments (jonction horizontale),
// segment haut = onglet éventuel, segment bas = pied.
if (
(elementType === 'habillageFinitionGauche' || elementType === 'habillageFinitionDroite') &&
dimensions.height > STACK_BLOCK_MAX_MM
) {
const nSeg = Math.ceil(dimensions.height / STACK_BLOCK_MAX_MM);
const baseH = Math.floor(dimensions.height / nSeg);
let segBottomMm = 0;
for (let sIdx = 0; sIdx < nSeg; sIdx++) {
const segH = sIdx === nSeg - 1 ? dimensions.height - baseH * (nSeg - 1) : baseH;
const segDims = {
...dimensions,
height: segH,
description: `${dimensions.description} — partie ${sIdx + 1}/${nSeg}${sIdx === nSeg - 1 ? ' (haut)' : sIdx === 0 ? ' (bas)' : ''}`
};
const segMach = getMachining(
elementType,
segDims,
{ ...config, _habillageSegmentBottomMm: segBottomMm },
modules
);
segBottomMm += segH;
const segPiece = {
id: `piece-${elementType}-p${sIdx + 1}`,
elementType,
description: segDims.description,
finishId: finish.finishId,
finishLabel: finish.label,
widthMm: Math.round(segDims.width),
heightMm: Math.round(segDims.height),
thicknessMm: Math.round(segDims.thickness ?? PANEL_THICKNESS),
quantity: segDims.quantity,
unit: segDims.unit || 'pcs',
edgeBanding,
machining: segMach,
surfaceM2: calculateSurface(segDims.width, segDims.height, segDims.quantity)
};
// Onglet haut uniquement sur le segment supérieur.
segPiece.habillageSideTopMiter = sIdx === nSeg - 1 && config?.topFinishEnabled !== false;
const segRaw = computeRawCutDimensions(segPiece);
segPiece.rawCutWidthMm = segRaw.rawCutWidthMm;
segPiece.rawCutHeightMm = segRaw.rawCutHeightMm;
segPiece.piecePlanSvg = generatePiecePlanSvg(segPiece);
segPiece.piecePlanViews = generatePiecePlanViews(segPiece);
pieces.push(segPiece);
}
continue;
}
const machining = getMachining(elementType, dimensions, config, modules);
const piece = {
id: `piece-${elementType}`,
elementType,
description: dimensions.description,
finishId: finish.finishId,
finishLabel: finish.label,
widthMm: Math.round(dimensions.width),
heightMm: Math.round(dimensions.height),
thicknessMm: Math.round(dimensions.thickness ?? PANEL_THICKNESS),
quantity: dimensions.quantity,
unit: dimensions.unit || 'pcs',
edgeBanding,
machining,
surfaceM2: calculateSurface(dimensions.width, dimensions.height, dimensions.quantity)
};
if (elementType === 'habillageFinitionDessus') {
const sideDefaultsEnabled = config?.sideFinishesEnabled !== false;
piece.habillageTopMiterLeft = config?.sideFinishLeftEnabled ?? sideDefaultsEnabled;
piece.habillageTopMiterRight = config?.sideFinishRightEnabled ?? sideDefaultsEnabled;
}
if (elementType === 'habillageFinitionGauche' || elementType === 'habillageFinitionDroite') {
piece.habillageSideTopMiter = config?.topFinishEnabled !== false;
}
if (elementType === 'portes') {
const doorCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2);
const hw = config.hardware || {};
const isSide = (v) => v === 'left' || v === 'right';
const perDoor =
Array.isArray(hw.doorHingeSides) &&
hw.doorHingeSides.length === doorCount &&
hw.doorHingeSides.every((v) => isSide(v));
const hingeSides = perDoor
? hw.doorHingeSides.map((v) => (v === 'right' ? 'right' : 'left'))
: Array.from({ length: doorCount }, (_, i) => getDoorHingeSide(i, doorCount, hw));
piece.doorPlanHints = {
doorCount,
hingeSides,
hingeSide: hingeSides[0],
hardware: hw,
fullHeightHandle: config.hardware?.handleType === 'full_height'
};
}
const raw = computeRawCutDimensions(piece);
piece.rawCutWidthMm = raw.rawCutWidthMm;
piece.rawCutHeightMm = raw.rawCutHeightMm;
piece.piecePlanSvg = generatePiecePlanSvg(piece);
piece.piecePlanViews = generatePiecePlanViews(piece);
pieces.push(piece);
}
if (spanCtx) {
const addSpanPiece = ({
span,
role,
elementType,
widthMm,
heightMm,
thicknessMm,
machining,
idSuffix = '',
description,
metadata = {}
}) => {
const finish =
colors[elementType] ||
(elementType === 'dos' ? colors.dos : colors[elementType === 'fond' ? 'fond' : 'dessus']) ||
{ finishId: null, label: 'Non défini' };
const coveredColumns = span.moduleIndexes.map((index) => index + 1).join('–');
const safeSpanId = encodeURIComponent(String(span.span.id)).replace(/%/g, '_');
const roleLabels = {
bottom: 'Panneau horizontal bas',
top: 'Panneau horizontal haut',
back: 'Dos horizontal partagé',
door: 'Porte de portée horizontale'
};
const piece = {
id: `piece-horizontalSpan-${safeSpanId}-${role}${idSuffix}`,
elementType,
description: description || `${roleLabels[role]} — colonnes ${coveredColumns} — portée ${Math.round(span.widthMm)} mm`,
finishId: finish.finishId,
finishLabel: finish.label,
widthMm: Math.round(widthMm),
heightMm: Math.round(heightMm),
thicknessMm: Math.round(thicknessMm),
quantity: 1,
unit: 'pcs',
edgeBanding: getEdgeBanding(elementType),
machining,
horizontalSpanId: span.span.id,
moduleIds: [...span.moduleIds],
bottomMm: span.bottomMm,
topMm: span.topMm,
horizontalSpanRole: role,
...metadata,
surfaceM2: calculateSurface(widthMm, heightMm, 1)
};
const raw = computeRawCutDimensions(piece);
piece.rawCutWidthMm = raw.rawCutWidthMm;
piece.rawCutHeightMm = raw.rawCutHeightMm;
piece.piecePlanSvg = generatePiecePlanSvg(piece);
piece.piecePlanViews = generatePiecePlanViews(piece);
pieces.push(piece);
};
const sideAssembly = getExteriorSideAssembly(config);
for (const span of spanCtx.resolved) {
const firstIdx = span.moduleIndexes[0];
const lastIdx = span.moduleIndexes[span.moduleIndexes.length - 1];
const leftInset = horizontalInsetMm(spanCtx.modules, config, firstIdx, 'left');
const rightInset = horizontalInsetMm(spanCtx.modules, config, lastIdx, 'right');
const panelWidthMm = Math.max(50, span.widthMm - leftInset - rightInset);
const backPanelWidthMm = panelWidthMm;
const touchesLeftExterior = firstIdx === 0;
const touchesRightExterior = lastIdx === spanCtx.modules.length - 1;
const leftFace = touchesLeftExterior && sideAssembly.left;
const rightFace = touchesRightExterior && sideAssembly.right;
const coveredColumns = span.moduleIndexes.map((index) => ({
id: spanCtx.modules[index]?.id,
widthMm: index === span.moduleIndexes[0] ? panelWidthMm : 0,
hasBack: Boolean(span.span.hasBack),
blockHeightsMm: [span.topMm - span.bottomMm],
blockDoors: [false]
}));
const machiningModules = coveredColumns.slice(0, 1);
const machiningConfig = {
...config,
widthMm: panelWidthMm,
modules: machiningModules,
horizontalSpans: [],
_axiloFeetEnabled: false,
_grooveForce: Boolean(span.span.hasBack),
_horizontalAssemblyLeft: leftFace,
_horizontalAssemblyRight: rightFace
};
const bodyMm = carcassBodyMm(config);
for (const role of ['bottom', 'top']) {
// Sol / toit de caisse: le panneau structurel (fond / dessus) porte déjà
// ce rôle — pas de deuxième panneau horizontal, et AXILO reste sous le fond.
if (role === 'bottom' && Number(span.bottomMm) <= 0.5) continue;
if (role === 'top' && Number(span.topMm) >= bodyMm - 0.5) continue;
const elementType = role === 'bottom' ? 'fond' : 'dessus';
const dimensions = {
width: panelWidthMm,
height: Math.max(40, Number(config.depthMm || 600)),
thickness: PANEL_THICKNESS,
quantity: 1
};
let machining = getMachining(elementType, dimensions, machiningConfig, machiningModules);
if (!span.span.hasBack) machining = stripBackGroove(machining);
addSpanPiece({
span,
role,
elementType,
widthMm: dimensions.width,
heightMm: dimensions.height,
thicknessMm: dimensions.thickness,
machining
});
}
if (span.span.hasBack) {
addSpanPiece({
span,
role: 'back',
elementType: 'dos',
widthMm: backPanelWidthMm,
heightMm: Math.max(50, span.topMm - span.bottomMm - PANEL_THICKNESS * 2),
thicknessMm: BACK_PANEL_THICKNESS,
machining: null
});
}
const doorMode = span.span.doorMode || 'none';
if (config.doorType !== 'sliding' && doorMode !== 'none') {
const hw = config.hardware || {};
const widthDeductionMm =
hw.handleType === 'full_height' ? HANDLE.fullHeightWidthDeductionMm : 0;
const doorHeightMm = Math.max(120, Math.round(span.topMm - span.bottomMm - 4));
const doorDefs = doorMode === 'single'
? [{
moduleId: null,
moduleIndex: span.moduleIndexes[0],
widthMm: Math.max(100, Math.floor(span.widthMm) - 4 - widthDeductionMm)
}]
: [
{
moduleId: null,
moduleIndex: span.moduleIndexes[0],
hingeSide: 'left',
widthMm: Math.max(
100,
Math.floor(span.widthMm / 2) - 4 - widthDeductionMm
)
},
{
moduleId: null,
moduleIndex: span.moduleIndexes[span.moduleIndexes.length - 1],
hingeSide: 'right',
widthMm: Math.max(
100,
Math.floor(span.widthMm / 2) - 4 - widthDeductionMm
)
}
];
const doorCount = doorDefs.length;
doorDefs.forEach((door, doorIndex) => {
const hingeSide = door.hingeSide || getDoorHingeSide(doorIndex, doorCount, hw);
const dimensions = {
width: door.widthMm,
height: doorHeightMm,
thickness: PANEL_THICKNESS,
quantity: 1
};
const machining = getMachining('portes', dimensions, config, modules);
addSpanPiece({
span,
role: 'door',
elementType: 'portes',
widthMm: dimensions.width,
heightMm: dimensions.height,
thicknessMm: dimensions.thickness,
machining,
idSuffix: `-${doorIndex + 1}`,
description:
`Porte portée horizontale ${doorIndex + 1}/${doorCount} (${doorMode}) — ` +
`colonnes ${span.moduleIndexes.map((index) => index + 1).join('–')}`,
metadata: {
doorMode,
doorIndex,
doorNumber: doorIndex + 1,
doorCount,
moduleId: door.moduleId,
doorModuleId: door.moduleId,
hingeSide,
doorPlanHints: {
doorCount: 1,
hingeSides: [hingeSide],
hingeSide,
hardware: hw,
fullHeightHandle: hw.handleType === 'full_height'
}
}
});
});
}
}
}
// Calculate totals by finish
const byFinish = {};
for (const piece of pieces) {
const key = piece.finishLabel || 'Non défini';
if (!byFinish[key]) {
byFinish[key] = {
finishId: piece.finishId,
finishLabel: key,
pieces: [],
totalSurfaceM2: 0,
pieceCount: 0
};
}
byFinish[key].pieces.push(piece);
byFinish[key].totalSurfaceM2 += piece.surfaceM2;
byFinish[key].pieceCount += piece.quantity;
}
// Calculate edge banding totals
const edgeBandingMl = pieces.reduce((total, piece) => {
if (piece.edgeBanding.type !== 'none') {
const perimeter = (piece.widthMm + piece.heightMm) * 2 * piece.quantity / 1000; // convert to meters
return total + (perimeter * piece.edgeBanding.sides / 4);
}
return total;
}, 0);
return {
pieces,
byFinish: Object.values(byFinish),
summary: {
totalPieces: pieces.reduce((sum, p) => sum + p.quantity, 0),
totalSurfaceM2: pieces.reduce((sum, p) => sum + p.surfaceM2, 0),
edgeBandingMl: Math.round(edgeBandingMl * 10) / 10,
finishCount: Object.keys(byFinish).length
},
config: {
widthMm: config.widthMm,
heightMm: config.heightMm,
depthMm: config.depthMm,
installationType: config.installationType,
doorType: config.doorType
}
};
}
/**
* Calculate surface area in m²
*/
function calculateSurface(widthMm, heightMm, quantity = 1) {
return Math.round((widthMm * heightMm * quantity) / 1000000 * 1000) / 1000;
}
/**
* Format cutting list for display or export
*/
export function formatCuttingListForExport(cuttingList) {
const lines = [];
lines.push('LISTE DE DÉBITAGE — APEX 4.0');
lines.push('=============================');
lines.push('');
lines.push(`Configuration: ${cuttingList.config.widthMm}mm x ${cuttingList.config.heightMm}mm x ${cuttingList.config.depthMm}mm`);
lines.push(`Type: ${cuttingList.config.installationType === 'niche' ? 'Encastrée' : 'Libre'}`);
lines.push(`Portes: ${cuttingList.config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'}`);
lines.push('');
lines.push('RÉSUMÉ');
lines.push('------');
lines.push(`Nombre total de pièces: ${cuttingList.summary.totalPieces}`);
lines.push(`Surface totale: ${cuttingList.summary.totalSurfaceM2.toFixed(2)} m²`);
lines.push(`Chant ABS 2mm: ${cuttingList.summary.edgeBandingMl} ml`);
lines.push(`Finitions différentes: ${cuttingList.summary.finishCount}`);
lines.push(`Surcote sur-mesure: +${CUTTING_SURCOTE_MM} mm en longueur et en largeur (incluse dans les dim. brutes)`);
lines.push('');
for (const group of cuttingList.byFinish) {
lines.push(`FINITION: ${group.finishLabel}`);
lines.push('-'.repeat(40));
lines.push('Pièce | Dim. finies | Dim. brutes CNC (surcote +10) | Ép. | Qté | Surface | Chants (sens) | Usinage');
for (const piece of group.pieces) {
const machiningStr = piece.machining
? Object.keys(piece.machining).join(', ')
: '-';
const bandingStr = piece.edgeBanding && piece.edgeBanding.type !== 'none'
? `${piece.edgeBanding.sides} côtés (${piece.edgeBanding.direction?.label || '-'})`
: '-';
lines.push(
`${piece.description.padEnd(35)} | ${piece.widthMm}x${piece.heightMm} | ${piece.rawCutWidthMm}x${piece.rawCutHeightMm} | ${piece.thicknessMm}mm | x${piece.quantity} | ${piece.surfaceM2.toFixed(3)}m² | ${bandingStr} | ${machiningStr}`
);
}
lines.push(`Sous-total: ${group.pieceCount} pièces, ${group.totalSurfaceM2.toFixed(2)} m²`);
lines.push('');
}
return lines.join('\n');
}
export default { generateCuttingList, formatCuttingListForExport };