Files
apex/server/services/cuttingEngine.js
T
lejayandCursor 30921aade6 v2.1
Ship configurator updates with catalog DB included for server deploy.

Co-authored-by: Cursor <[email protected]>
2026-10-07 17:13:03 +02:00

4762 lines
219 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Cutting Engine - Generate cutting list (liste de débitage) for wardrobe production
* Calculates all panel dimensions, edge banding, machining operations, and
* reinforcement panels based on APEX 4.0 manufacturing specifications.
*/
import {
PANEL, EDGE_BANDING, 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 += `<path d="M ${x - halfW} ${y0} L ${x - halfW} ${y1} L ${x + halfW} ${y1} L ${x + halfW} ${y0}" fill="none" stroke="#64748b" stroke-width="0.95" stroke-dasharray="4 3" stroke-linejoin="round"/>`;
s += `<line x1="${x}" y1="${y0}" x2="${x}" y2="${y1}" stroke="#334155" stroke-width="0.45" stroke-dasharray="2 2 1 2" opacity="0.9"/>`;
}
return s;
}
/** Dessus/fond/bandeau : en coupe L×ép., rainure ouverte depuis la face haute du rectangle (épaisseur), comme les montants. */
function backGrooveOpensFromBottomThickEdge(_piece) {
return false;
}
/**
* Vue A — rainure réception dos sur la face usinée (même repère que détail côté : dos à droite pour montants).
*/
function svgBackGrooveFaceViewA(piece, m, pAx, pAy, wA, hA, scaleA, fmt) {
const axisMm = m.groove.axisFromPanelEdge ?? m.groove.axisFromBackEdge;
if (axisMm == null) return '';
const gw = m.groove.width ?? GROOVE.back.width;
const gd = m.groove.depth ?? GROOVE.back.depth;
const sa = scaleA;
let s = '';
if (BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(piece.elementType)) {
const gx = pAx + wA - axisMm * sa - (gw * sa) / 2;
s += `<g class="grooveDosFaceA"><title>Rainure dos ${fmt(gw)}×${fmt(gd)} mm · axe ${fmt(axisMm)} mm du bord dos · pleine longueur</title>`;
s += `<rect x="${gx}" y="${pAy}" width="${gw * sa}" height="${Math.max(2, hA)}" class="grooveFill" stroke="#0d9488" stroke-width="0.7"/>`;
s += `<text x="${gx + (gw * sa) / 2}" y="${pAy + Math.min(11, hA * 0.04)}" text-anchor="middle" class="txt" fill="#0f766e" font-size="7.5" font-weight="600">dos</text>`;
s += `</g>`;
} else if (BACK_GROOVE_PLAN_HORIZONTAL_TYPES.includes(piece.elementType)) {
const centerY = pAy + axisMm * sa;
const gy = centerY - (gw * sa) / 2;
/* Dessus/fond/bandeau : rainure sur toute la longueur utile du bord (joint dos), sans retrait latéral. */
const xin = pAx;
const win = Math.max(0.5, wA);
s += `<g class="grooveDosFaceA"><title>Rainure dos ${fmt(gw)}×${fmt(gd)} mm · axe ${fmt(axisMm)} mm du bord dos · pleine longueur</title>`;
s += `<rect x="${xin}" y="${gy}" width="${win}" height="${gw * sa}" class="grooveFill" stroke="#0d9488" stroke-width="0.7"/>`;
s += `</g>`;
}
return s;
}
/** Vue C — coupe largeur pièce × épaisseur : rainure côté dos (droite), ouverte depuis la face caisse. */
function svgBackGrooveSectionViewC(pCx, pCy, wC, tC, scaleC, piece, m, fmt) {
const axisMm = m.groove.axisFromPanelEdge ?? m.groove.axisFromBackEdge;
if (axisMm == null) return '';
const axis = Math.min(wC, Math.max(0, axisMm * scaleC));
const grooveW = Math.max(2, (m.groove.width || GROOVE.back.width) * scaleC);
const depthMm = m.groove.depth ?? GROOVE.back.depth;
const grooveD = Math.max(2, Math.min(tC - 2, depthMm * scaleC));
const gx = pCx + wC - axis - grooveW / 2;
const fromBottom = backGrooveOpensFromBottomThickEdge(piece);
const gy = fromBottom ? pCy + tC - grooveD : pCy;
const midX = gx + grooveW / 2;
let s = '';
s += `<rect x="${gx}" y="${gy}" width="${grooveW}" height="${grooveD}" class="grooveFill"/>`;
if (fromBottom) {
s += `<line x1="${midX}" y1="${pCy + tC + 2}" x2="${midX}" y2="${pCy + tC + 18}" class="guide"/>`;
s += `<text x="${midX}" y="${pCy + tC + 30}" text-anchor="middle" class="txt" fill="#334155" font-size="9">Axe → ${fmt(axisMm)} mm du bord dos</text>`;
} else {
s += `<line x1="${midX}" y1="${pCy - 18}" x2="${midX}" y2="${pCy - 2}" class="guide"/>`;
s += `<text x="${midX}" y="${pCy - 26}" text-anchor="middle" class="txt" fill="#334155" font-size="8.5">Ouverture face caisse · ${fmt(axisMm)} mm du dos</text>`;
}
s += `<text x="${pCx + wC - 6}" y="${pCy + tC + 12}" text-anchor="end" class="txt" fill="#64748b" font-size="8.5">Bord dos</text>`;
s += `<text x="${pCx + 4}" y="${pCy - 6}" class="txt" fill="#0f766e" font-size="9">${fmt(m.groove.width || GROOVE.back.width)} × ${fmt(depthMm)} mm</text>`;
return s;
}
/**
* Vue D — double schéma type plan technique : coupe sur épaisseur + vue sur face rainurée.
*/
function svgBackGrooveTechnicalDetail(piece, m, fmt) {
const tMm = Math.max(1, Number(piece.thicknessMm) || PANEL_THICKNESS);
const gw = m.groove.width ?? GROOVE.back.width;
const gd = m.groove.depth ?? GROOVE.back.depth;
const ax = m.groove.axisFromPanelEdge ?? m.groove.axisFromBackEdge ?? GROOVE.back.axisFromPanelEdge;
const fromBottom = backGrooveOpensFromBottomThickEdge(piece);
const sPx = 5.2;
const Wsec = 210;
const Ht = Math.max(30, Math.min(110, tMm * sPx));
const x0 = 36;
const y0 = 58;
let o = '';
o += `<text x="${x0}" y="${y0 - 14}" font-family="Arial,sans-serif" font-size="11" font-weight="bold" fill="#111">A — Coupe sur épaisseur (dos à droite)</text>`;
o += `<rect x="${x0}" y="${y0}" width="${Wsec}" height="${Ht}" fill="#faf6ef" stroke="#1f2937" stroke-width="1.2"/>`;
const axisPx = ax * sPx;
const gwp = gw * sPx;
const gdp = Math.min(gd * sPx, Ht - 1.5);
const gx = x0 + Wsec - axisPx - gwp / 2;
const gy = fromBottom ? y0 + Ht - gdp : y0;
o += `<rect x="${gx}" y="${gy}" width="${gwp}" height="${gdp}" fill="#2a9d8f" fill-opacity="0.4" stroke="#0f766e" stroke-width="1"/>`;
o += `<line x1="${gx}" y1="${y0 + Ht + 14}" x2="${gx + gwp}" y2="${y0 + Ht + 14}" stroke="#0f766e" stroke-width="0.9"/>`;
o += `<line x1="${gx}" y1="${y0 + Ht + 8}" x2="${gx}" y2="${y0 + Ht + 20}" stroke="#0f766e"/>`;
o += `<line x1="${gx + gwp}" y1="${y0 + Ht + 8}" x2="${gx + gwp}" y2="${y0 + Ht + 20}" stroke="#0f766e"/>`;
o += `<text x="${gx + gwp / 2}" y="${y0 + Ht + 32}" text-anchor="middle" font-family="Arial,sans-serif" font-size="9.5" fill="#0f766e">${fmt(gw)} ±0.3</text>`;
o += `<line x1="${x0 - 12}" y1="${y0}" x2="${x0 - 12}" y2="${y0 + Ht}" stroke="#0f766e" stroke-width="0.9"/>`;
o += `<line x1="${x0 - 18}" y1="${y0}" x2="${x0 - 6}" y2="${y0}" stroke="#0f766e"/>`;
o += `<line x1="${x0 - 18}" y1="${y0 + Ht}" x2="${x0 - 6}" y2="${y0 + Ht}" stroke="#0f766e"/>`;
o += `<text x="${x0 - 22}" y="${y0 + Ht / 2 + 3}" text-anchor="middle" font-family="Arial,sans-serif" font-size="9.5" fill="#0f766e" transform="rotate(-90 ${x0 - 22} ${y0 + Ht / 2})">ép. ${fmt(tMm)}</text>`;
const depLineX = gx + gwp + 28;
const y1d = fromBottom ? y0 + Ht : y0;
const y2d = fromBottom ? gy : y0 + gdp;
o += `<line x1="${depLineX}" y1="${y1d}" x2="${depLineX}" y2="${y2d}" stroke="#0f766e" stroke-width="0.9"/>`;
o += `<text x="${depLineX + 5}" y="${(y1d + y2d) / 2 + 3}" font-family="Arial,sans-serif" font-size="9" fill="#0f766e">${fmt(gd)}</text>`;
const x1 = 400;
const y1 = 58;
const Lface = 300;
const faceH = Math.max(36, Ht + 16);
o += `<text x="${x1}" y="${y1 - 14}" font-family="Arial,sans-serif" font-size="11" font-weight="bold" fill="#111">B — Face intérieure (rainure en trait mixte)</text>`;
o += `<path d="M ${x1} ${y1 + faceH / 2} l 8 -6 l -4 6 l 4 6 l -8 -6" fill="none" stroke="#64748b" stroke-width="0.8"/>`;
o += `<path d="M ${x1 + Lface} ${y1 + faceH / 2} l -8 -6 l 4 6 l -4 6 l 8 -6" fill="none" stroke="#64748b" stroke-width="0.8"/>`;
o += `<rect x="${x1 + 20}" y="${y1}" width="${Lface - 40}" height="${faceH}" fill="#faf6ef" stroke="#1f2937" stroke-width="1.2"/>`;
if (BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(piece.elementType)) {
const fw = Lface - 40;
const gx2 = x1 + 20 + fw - axisPx - gwp / 2;
o += `<rect x="${gx2}" y="${y1 + 6}" width="${gwp}" height="${faceH - 12}" fill="#2a9d8f" fill-opacity="0.35" stroke="#0f766e" stroke-width="0.9" stroke-dasharray="4 3"/>`;
o += `<line x1="${gx2}" y1="${y1 + faceH + 10}" x2="${x1 + 20 + fw}" y2="${y1 + faceH + 10}" stroke="#0f766e"/>`;
o += `<text x="${(gx2 + x1 + 20 + fw) / 2}" y="${y1 + faceH + 22}" text-anchor="middle" font-family="Arial,sans-serif" font-size="9" fill="#0f766e">${fmt(ax)} mm (axe au bord dos)</text>`;
} else {
const fw = Lface - 40;
const cy = y1 + axisPx;
const gy2 = cy - gwp / 2;
o += `<rect x="${x1 + 24}" y="${gy2}" width="${fw - 8}" height="${gwp}" fill="#2a9d8f" fill-opacity="0.35" stroke="#0f766e" stroke-width="0.9" stroke-dasharray="4 3"/>`;
o += `<line x1="${x1 + 16}" y1="${y1}" x2="${x1 + 16}" y2="${cy}" stroke="#0f766e"/>`;
o += `<text x="${x1 + 4}" y="${(y1 + cy) / 2}" font-family="Arial,sans-serif" font-size="9" fill="#0f766e" transform="rotate(-90 ${x1 + 4} ${(y1 + cy) / 2})">${fmt(ax)} mm</text>`;
}
o += `<text x="${x0}" y="${y0 + Ht + 58}" font-family="Arial,sans-serif" font-size="9" fill="#475569">Rainure réception dos · cotation indicative ±0.3 mm sur largeur de fraisage</text>`;
return o;
}
/**
* Vue B — chant d'extrémité du renfort (H×ép.) avec perçages Ixconnect/tourillons.
*/
function svgReinforcementFixingEdgeViewB(rf, pBx, pBy, tB, longB, scaleB, fmt, horizontal = false) {
if (!rf) return '';
const ixFromTop = Number(rf.ixconnectAxisFromTopMm) || 0;
const axisCenter = horizontal ? (pBy + tB / 2) : (pBx + tB / 2);
const posIx = horizontal
? (pBx + Math.min(longB, Math.max(0, ixFromTop * scaleB)))
: (pBy + Math.min(longB, Math.max(0, ixFromTop * scaleB)));
const rIx = Math.max(3, (rf.edgeHole?.diameter || 8) * 0.4 * scaleB);
const rT = Math.max(2.1, (rf.tourillonDiameter || 8) * 0.3 * scaleB);
let s = '';
if (horizontal) {
s += `<line x1="${pBx}" y1="${axisCenter}" x2="${pBx + longB}" y2="${axisCenter}" class="guide"/>`;
s += `<circle cx="${posIx}" cy="${axisCenter}" r="${rIx}" class="connector"/>`;
} else {
s += `<line x1="${axisCenter}" y1="${pBy}" x2="${axisCenter}" y2="${pBy + longB}" class="guide"/>`;
s += `<circle cx="${axisCenter}" cy="${posIx}" r="${rIx}" class="connector"/>`;
}
for (const yTop of rf.tourillonAxesFromTopMm || []) {
const pos = horizontal
? (pBx + Math.min(longB, Math.max(0, Number(yTop) * scaleB)))
: (pBy + Math.min(longB, Math.max(0, Number(yTop) * scaleB)));
if (horizontal) {
s += `<circle cx="${pos}" cy="${axisCenter}" r="${rT}" class="connectorTourillon"/>`;
} else {
s += `<circle cx="${axisCenter}" cy="${pos}" r="${rT}" class="connectorTourillon"/>`;
}
}
const axesText = (rf.tourillonAxesFromTopMm || []).map((v) => fmt(v)).join(' / ');
const tourillonAxes = (rf.tourillonAxesFromTopMm || [])
.map((v) => Number(v))
.filter((v) => Number.isFinite(v))
.sort((a, b) => a - b);
const leftEdgeOffsetMm = tourillonAxes.length ? tourillonAxes[0] : null;
const rightEdgeOffsetMm = tourillonAxes.length
? Number(rf.tourillonEdgeOffset ?? leftEdgeOffsetMm)
: null;
if (horizontal) {
s += `<text x="${pBx}" y="${pBy + tB + 14}" class="txt">${rf.connectorModel || 'SC 8/60'} (par chant) axe ${fmt(ixFromTop)} mm</text>`;
s += `<text x="${pBx}" y="${pBy + tB + 26}" class="txt">Tourillons Ø${rf.tourillonDiameter || 8}: ${axesText} mm depuis haut</text>`;
// Dimension lines inside the piece (between top edge and hole axis; SVG y grows downward).
const dimY20 = pBy + Math.max(3, tB * 0.22);
const dimY50 = pBy + Math.max(3, tB * 0.42);
if (leftEdgeOffsetMm != null && tourillonAxes.length >= 2) {
const leftX = pBx;
const firstX = pBx + leftEdgeOffsetMm * scaleB;
const rightX = pBx + longB;
const lastX = pBx + tourillonAxes[tourillonAxes.length - 1] * scaleB;
s += `<line x1="${leftX}" y1="${dimY20}" x2="${firstX}" y2="${dimY20}" stroke="#0f766e" stroke-width="0.9"/>`;
s += `<line x1="${leftX}" y1="${dimY20 - 4}" x2="${leftX}" y2="${dimY20 + 4}" stroke="#0f766e" stroke-width="0.9"/>`;
s += `<line x1="${firstX}" y1="${dimY20 - 4}" x2="${firstX}" y2="${dimY20 + 4}" stroke="#0f766e" stroke-width="0.9"/>`;
s += `<text x="${(leftX + firstX) / 2}" y="${dimY20 - 4}" text-anchor="middle" class="txt" fill="#0f766e" font-size="8">${fmt(leftEdgeOffsetMm)} mm</text>`;
s += `<line x1="${lastX}" y1="${dimY20}" x2="${rightX}" y2="${dimY20}" stroke="#0f766e" stroke-width="0.9"/>`;
s += `<line x1="${lastX}" y1="${dimY20 - 4}" x2="${lastX}" y2="${dimY20 + 4}" stroke="#0f766e" stroke-width="0.9"/>`;
s += `<line x1="${rightX}" y1="${dimY20 - 4}" x2="${rightX}" y2="${dimY20 + 4}" stroke="#0f766e" stroke-width="0.9"/>`;
s += `<text x="${(lastX + rightX) / 2}" y="${dimY20 - 4}" text-anchor="middle" class="txt" fill="#0f766e" font-size="8">${fmt(rightEdgeOffsetMm)} mm</text>`;
}
// Center hole (Ixconnect) dimension from edge: typically 50 mm on 100 mm reinforcement width.
s += `<line x1="${pBx}" y1="${dimY50}" x2="${posIx}" y2="${dimY50}" stroke="#0369a1" stroke-width="0.9"/>`;
s += `<line x1="${pBx}" y1="${dimY50 - 4}" x2="${pBx}" y2="${dimY50 + 4}" stroke="#0369a1" stroke-width="0.9"/>`;
s += `<line x1="${posIx}" y1="${dimY50 - 4}" x2="${posIx}" y2="${dimY50 + 4}" stroke="#0369a1" stroke-width="0.9"/>`;
s += `<text x="${(pBx + posIx) / 2}" y="${dimY50 - 4}" text-anchor="middle" class="txt" fill="#0369a1" font-size="8">${fmt(ixFromTop)} mm</text>`;
} else {
s += `<text x="${pBx + tB + 8}" y="${pBy + longB * 0.48}" class="txt">${rf.connectorModel || 'SC 8/60'} (par chant) axe ${fmt(ixFromTop)} mm</text>`;
s += `<text x="${pBx + tB + 8}" y="${pBy + longB * 0.48 + 12}" class="txt">Tourillons Ø${rf.tourillonDiameter || 8}: ${axesText} mm depuis haut</text>`;
}
return s;
}
/**
* Usinage face avant (cuvettes + rainure TH) pour une orientation de charnières donnée.
*/
function doorFaceUsinageSvg({ pAx, pAy, wA, hA, scaleA, m, hingeSide, heightMm, fmt }) {
let viewAOps = '';
if (m.hingeCups?.positionsFromTop?.length) {
const cupR = Math.max(2.5, (m.hingeCups.diameter || 35) * scaleA * 0.5);
const cupInset = Math.min(22 * scaleA, wA * 0.13);
const cupX = hingeSide === 'right' ? pAx + wA - cupInset : pAx + cupInset;
const sd = m.hingeCups.screwDiameterMm ?? HINGE.cupScrewDiameter;
const screwR = Math.max(1.2, sd * scaleA * 0.5);
const offPx = (m.hingeCups.screwOffsetFromCupCenterMm ?? HINGE.cupScrewOffsetFromCupCenter) * scaleA;
const halfSpPx = ((m.hingeCups.screwSpacingMm ?? HINGE.cupScrewSpacing) / 2) * scaleA;
const screwX = hingeSide === 'right' ? cupX - offPx : cupX + offPx;
for (const pos of m.hingeCups.positionsFromTop) {
const y = pAy + Math.min(hA, Math.max(0, pos * scaleA));
viewAOps += `<circle cx="${cupX}" cy="${y}" r="${cupR}" class="hinge"/><text x="${hingeSide === 'right' ? cupX - cupR - 28 : cupX + cupR + 5}" y="${y + 3}" class="txt">${Math.round(pos)}mm</text>`;
viewAOps += `<circle cx="${screwX}" cy="${y - halfSpPx}" r="${screwR}" class="hinge"/>`;
viewAOps += `<circle cx="${screwX}" cy="${y + halfSpPx}" r="${screwR}" class="hinge"/>`;
}
}
if (m.fullHeightHandleGroove) {
const off = m.fullHeightHandleGroove.topBottomOffsetMm ?? HANDLE.fullHeightTopBottomOffset;
const gw = m.fullHeightHandleGroove.grooveWidthMm ?? HANDLE.fullHeightGroove.width;
const gd = m.fullHeightHandleGroove.grooveDepthMm ?? HANDLE.fullHeightGroove.depth;
const axisFromHandleEdge = Math.max(2, (gw / 2) * scaleA);
const gx = hingeSide === 'right' ? pAx + axisFromHandleEdge : pAx + wA - axisFromHandleEdge;
const y1 = pAy + off * scaleA;
const y2 = pAy + hA - off * scaleA;
viewAOps += `<line x1="${gx}" y1="${y1}" x2="${gx}" y2="${y2}" stroke="#0d9488" stroke-width="1.2" stroke-dasharray="4,3"/>`;
viewAOps += `<text x="${gx + (hingeSide === 'right' ? 8 : -8)}" y="${pAy + hA * 0.5}" class="txt" text-anchor="${hingeSide === 'right' ? 'start' : 'end'}" fill="#0d9488">Rainure TH · ${fmt(gw)}×${fmt(gd)} mm · retrait ${fmt(off)} mm H/B</text>`;
const dx = pAx - 6;
const mida = (y0, y1, label) => {
const ym = (y0 + y1) / 2;
return `<line x1="${dx}" y1="${y0}" x2="${dx}" y2="${y1}" stroke="#0f766e" stroke-width="0.6"/><text x="${dx - 4}" y="${ym + 3}" font-size="7.5" fill="#0f766e" text-anchor="end">${label} mm</text>`;
};
viewAOps += mida(pAy, y1, `${fmt(off)}`);
viewAOps += mida(y1, y2, `${fmt(heightMm - 2 * off)}`);
viewAOps += mida(y2, pAy + hA, `${fmt(off)}`);
}
return viewAOps;
}
/**
* Détail fabrication porte : coupe rainure TH (cotes), schéma hauteur, perçages.
* Utilisé en bandeau sous les vues A/B/C et en vue D plein écran.
*/
function doorMachiningDetailMarkup(piece, fmt, boxW, boxH, hingeSideOverride, vantailLabel) {
const m = piece.machining || {};
const hingeSide = hingeSideOverride ?? piece.doorPlanHints?.hingeSide ?? 'left';
const fh = m.fullHeightHandleGroove;
const gw = fh ? (fh.grooveWidthMm ?? HANDLE.fullHeightGroove.width) : HANDLE.fullHeightGroove.width;
const gd = fh ? (fh.grooveDepthMm ?? HANDLE.fullHeightGroove.depth) : HANDLE.fullHeightGroove.depth;
const off = fh ? (fh.topBottomOffsetMm ?? HANDLE.fullHeightTopBottomOffset) : HANDLE.fullHeightTopBottomOffset;
const H = piece.heightMm;
const tMm = piece.thicknessMm;
const grooveLen = fh ? Math.max(0, H - 2 * off) : 0;
const handleRight = hingeSide === 'left';
const fs = m.finishConnectors;
const hc = m.hingeCups;
const col1W = Math.min(240, boxW * 0.34);
const col2W = Math.min(200, boxW * 0.28);
const x1 = 8;
const x2 = x1 + col1W + 16;
const x3 = x2 + col2W + 16;
const yTitle = 18;
const y0 = 36;
let s = '';
const titleText = vantailLabel
? `Détail usinage — ${vantailLabel} — rainure &amp; perçages`
: 'Détail usinage porte — rainure &amp; perçages';
s += `<text x="${x1}" y="${yTitle}" font-family="Arial,sans-serif" font-size="11.5" font-weight="bold" fill="#1f2937">${titleText}</text>`;
// Colonne 1 — Coupe sur épaisseur (chant poignée), même logique que rainure dos : larg. × prof.
const sc = Math.min(110 / tMm, 95 / Math.max(gd, 1));
const barW = tMm * sc;
const barH = 34;
const px = x1;
const py = y0;
s += `<text x="${px}" y="${py}" font-family="Arial,sans-serif" font-size="9.5" fill="#64748b">Coupe sur chant poignée (profil rainure dans l'épaisseur)</text>`;
const barY = py + 10;
s += `<rect x="${px}" y="${barY}" width="${barW}" height="${barH}" fill="#faf6ef" stroke="#333" stroke-width="0.9"/>`;
const gDpx = gd * sc;
const gWpx = gw * sc;
const gX = handleRight ? px + barW - gDpx : px;
const ghVis = Math.min(gWpx, barH - 2);
const gY = barY + barH - ghVis;
s += `<rect x="${gX}" y="${gY}" width="${gDpx}" height="${ghVis}" fill="#2a9d8f" opacity="0.38" stroke="#0d9488" stroke-width="0.85"/>`;
s += `<text x="${px}" y="${barY + barH + 14}" font-family="Arial,sans-serif" font-size="9.5" fill="#0f766e">Ouverture rainure ${fmt(gw)} mm · Profondeur ${fmt(gd)} mm</text>`;
s += `<text x="${px}" y="${barY + barH + 26}" font-family="Arial,sans-serif" font-size="8.5" fill="#64748b">Épaisseur panneau ${fmt(tMm)} mm</text>`;
// Petites cotes sur la coupe
s += `<line x1="${gX + gDpx}" y1="${barY + barH + 4}" x2="${gX + gDpx}" y2="${barY + barH + 14}" stroke="#0f766e" stroke-width="0.5"/>`;
s += `<line x1="${gX}" y1="${barY + barH + 4}" x2="${gX + gDpx}" y2="${barY + barH + 4}" stroke="#0f766e" stroke-width="0.5"/>`;
s += `<text x="${gX + gDpx / 2}" y="${barY + barH + 26}" font-size="8" fill="#0f766e" text-anchor="middle">${fmt(gd)}</text>`;
s += `<text x="${gX + gDpx + 8}" y="${gY + gWpx / 2}" font-size="8" fill="#0f766e">${fmt(gw)}</text>`;
// Colonne 2 — Schéma hauteur rainure TH
const vx = x2 + 14;
const vy = barY;
s += `<text x="${x2}" y="${py}" font-family="Arial,sans-serif" font-size="9.5" fill="#64748b">Hauteur rainure (poignée TH)</text>`;
if (fh) {
const maxH = Math.min(120, boxH - 50);
const vScale = maxH / Math.max(H, 1);
const vLen = H * vScale;
const oPx = off * vScale;
s += `<line x1="${vx}" y1="${vy}" x2="${vx}" y2="${vy + vLen}" stroke="#334155" stroke-width="1.2"/>`;
s += `<line x1="${vx - 5}" y1="${vy}" x2="${vx + 5}" y2="${vy}" stroke="#334155"/>`;
s += `<line x1="${vx - 5}" y1="${vy + vLen}" x2="${vx + 5}" y2="${vy + vLen}" stroke="#334155"/>`;
s += `<line x1="${vx - 5}" y1="${vy + oPx}" x2="${vx + 5}" y2="${vy + oPx}" stroke="#0d9488"/>`;
s += `<line x1="${vx - 5}" y1="${vy + vLen - oPx}" x2="${vx + 5}" y2="${vy + vLen - oPx}" stroke="#0d9488"/>`;
s += `<text x="${vx + 10}" y="${vy + oPx / 2 + 3}" font-size="8.5" fill="#0d9488">Retrait ${fmt(off)} mm</text>`;
s += `<text x="${vx + 10}" y="${vy + oPx + (vLen - 2 * oPx) / 2}" font-size="8.5" fill="#334155">Utile rainure ${fmt(grooveLen)} mm</text>`;
s += `<text x="${vx + 10}" y="${vy + vLen - oPx / 2 + 3}" font-size="8.5" fill="#0d9488">Retrait ${fmt(off)} mm</text>`;
} else {
s += `<text x="${x2}" y="${vy + 24}" font-size="9" fill="#94a3b8">Poignée standard — pas de rainure TH</text>`;
}
// Colonne 3 — Perçages
let ly = y0 + 8;
s += `<text x="${x3}" y="${py}" font-family="Arial,sans-serif" font-size="9.5" fill="#64748b">Perçages</text>`;
if (hc) {
const pos = (hc.positionsFromTop || []).map((v) => `${Math.round(v)}`).join(', ');
const sd = hc.screwDiameterMm ?? HINGE.cupScrewDiameter;
const sz = hc.screwDepthMm ?? HINGE.cupScrewDepth;
const ssp = hc.screwSpacingMm ?? HINGE.cupScrewSpacing;
const sof = hc.screwOffsetFromCupCenterMm ?? HINGE.cupScrewOffsetFromCupCenter;
s += `<text x="${x3}" y="${ly + 8}" font-size="9.5" fill="#1f2937"><tspan font-weight="600">Charnières</tspan> — cuvette Ø${hc.diameter} mm, prof. ${hc.cupDepth} mm</text>`;
s += `<text x="${x3}" y="${ly + 22}" font-size="9" fill="#475569">Axes depuis haut : ${pos} mm — vis Ø${sd}×${sz} mm (${ssp} mm c/c, ${sof} mm du centre cuvette)</text>`;
ly += 36;
}
if (fs) {
s += `<text x="${x3}" y="${ly + 8}" font-size="9.5" fill="#1f2937"><tspan font-weight="600">${fs.model || 'SC 8/25'}</tspan> — face Ø${fs.faceHole?.diameter || 8} × prof. ${fs.faceHole?.depth || 12.5} mm</text>`;
s += `<text x="${x3}" y="${ly + 22}" font-size="9" fill="#475569">Axes depuis face intérieure : ${fs.axes?.back ?? 30} mm (arrière) / ${fs.axes?.front ?? 43} mm (avant)</text>`;
}
return s;
}
function generatePiecePlanSvg(piece) {
const widthMm = Math.max(1, Number(piece.widthMm) || 1);
const heightMm = Math.max(1, Number(piece.heightMm) || 1);
const thicknessMm = Math.max(1, Number(piece.thicknessMm) || 1);
const margin = 28;
const panelGap = 24;
const viewW = 330;
const viewH = 220;
const infoBlockH = 180;
const xA = margin;
const yA = 42;
const xB = xA + viewW + panelGap;
const yB = yA;
const xC = xB + viewW + panelGap;
const yC = yA;
const svgW = xC + viewW + margin;
const m = piece.machining || {};
const fmt = (v) => Number(v).toFixed(Number(v) % 1 === 0 ? 0 : 1);
// View A: front view with drilling positions
const scaleA = Math.min((viewW - 40) / widthMm, (viewH - 40) / heightMm);
const wA = widthMm * scaleA;
const hA = heightMm * scaleA;
const pAx = xA + (viewW - wA) / 2;
const pAy = yA + 24 + (viewH - 24 - hA) / 2;
let viewAOps = '';
const isHabFinishSide = piece.elementType === 'habillageFinitionGauche' || piece.elementType === 'habillageFinitionDroite';
const isHabFinishTop = piece.elementType === 'habillageFinitionDessus';
let viewAMiterMarks = '';
let miterInfoLine = '';
if (m.system32?.positions?.length) {
const frontX = pAx + Math.min(37 * scaleA, wA * 0.2);
const backX = pAx + wA - Math.min(37 * scaleA, wA * 0.2);
const r = Math.max(1.2, (m.system32.holeDiameter || 5) * scaleA * 0.5);
for (const pos of m.system32.positions) {
const y = pAy + Math.min(hA, Math.max(0, pos * scaleA));
viewAOps += `<circle cx="${frontX}" cy="${y}" r="${r}" class="hole"/><circle cx="${backX}" cy="${y}" r="${r}" class="hole"/>`;
}
viewAOps += `<text x="${pAx + wA / 2}" y="${pAy - 8}" class="txtCenter">System 32: Ø${m.system32.holeDiameter}${m.system32.throughHole ? ' traversant' : ` prof. ${m.system32.holeDepth || 12}`}</text>`;
}
// Rayons (SP 15/8/5): perçages face dessous Ø20×(15 ou 20 selon type)
if (
['etageres', 'rayonFixe'].includes(piece.elementType) &&
['sp_15_8_5', 'sp_15_8_5_face_only'].includes(m.mobileShelfSystem?.model)
) {
const specs = buildShelfMobileHoleSpecsMm(piece, widthMm, heightMm);
const face = specs.filter((h) => h.face === 'face');
if (face.length) {
const rFace = Math.max(2.0, (20 * 0.5) * scaleA);
for (const h of face) {
const x = pAx + Math.min(wA, Math.max(0, Number(h.xMm) * scaleA));
const y = pAy + Math.min(hA, Math.max(0, Number(h.yMm) * scaleA));
viewAOps += `<circle cx="${x}" cy="${y}" r="${rFace}" class="shelfFaceHole"/>`;
}
const z = Number(m.mobileShelfSystem?.faceHole?.depthMm ?? (piece.elementType === 'rayonFixe' ? 20 : 15));
viewAOps += `<text x="${pAx + wA / 2}" y="${pAy - 8}" class="txtCenter">${piece.elementType === 'rayonFixe' ? 'Rayon fixe' : 'Rayons mobiles'} SP 15/8/5: Face Ø20 · prof. ${Math.round(z)} (dessous)</text>`;
}
}
if (piece.elementType === 'renfortsTiroir' && m.drawerReinforcement?.ixconnectFixing?.holes?.length) {
const ix = m.drawerReinforcement.ixconnectFixing;
const d = Number(ix.holeDiameterMm) || 8;
const r = Math.max(1.5, (d * 0.5) * scaleA);
for (const h of ix.holes) {
const x = pAx + Math.min(wA, Math.max(0, Number(h.xMm) * scaleA));
const y = pAy + Math.min(hA, Math.max(0, Number(h.yMm) * scaleA));
viewAOps += `<circle cx="${x}" cy="${y}" r="${r}" class="connector"/>`;
}
viewAOps += `<text x="${pAx + wA / 2}" y="${pAy - 8}" class="txtCenter">${ix.model || 'SC 8/25'} — 4× face Ø${d} · prof. ${ix.depthInReinforcementMm} mm (montant/séparateur Ø${d} · prof. ${ix.depthInVerticalPanelMm} mm) · ${ix.cornerInsetMm} mm des coins · repère Y depuis haut</text>`;
}
// 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 += `<circle cx="${x}" cy="${y}" r="${r}" class="hole"/>`;
}
}
}
viewAOps += `<text x="${pAx + wA / 2}" y="${pAy + hA + 14}" class="txtCenter">Coulisses Legrabox ${sd.loadKg || 40} kg — Ø${sd.holeDiameterMm}×${sd.holeDepthMm} · X: ${(sd.positionsXFromFrontMm || []).join(', ')} mm du chant avant</text>`;
}
// 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 += `<circle cx="${x}" cy="${y}" r="${r}" class="connector"/>`;
}
viewAOps += `<text x="${pAx + wA / 2}" y="${pAy - 8}" class="txtCenter">INSERTA (système ${ins.systemKey}) — Ø${ins.holeDiameterMm} · prof. ${ins.holeDepthMm} mm · repère Y depuis bas façade</text>`;
}
const isDoor = piece.elementType === 'portes';
const doorCount = isDoor ? Math.max(1, piece.doorPlanHints?.doorCount || 1) : 1;
const hingeSidesList = isDoor
? (piece.doorPlanHints?.hingeSides?.length === doorCount
? piece.doorPlanHints.hingeSides
: Array.from({ length: doorCount }, (_, i) => getDoorHingeSide(i, doorCount, piece.doorPlanHints?.hardware || {})))
: ['left'];
let doorMulti = null;
if (isDoor && doorCount > 1) {
const gapPx = 10;
const totalWmm = widthMm * doorCount;
const scaleMulti = Math.min((viewW - 36) / totalWmm, (viewH - 44) / heightMm);
const eachW = widthMm * scaleMulti;
const hPanel = heightMm * scaleMulti;
const totalPx = eachW * doorCount + gapPx * (doorCount - 1);
const baseAx = xA + (viewW - totalPx) / 2;
const baseAy = yA + 24 + (viewH - 24 - hPanel) / 2;
doorMulti = { gapPx, scaleMulti, eachW, hPanel, baseAx, baseAy };
}
if (doorMulti) {
for (let j = 0; j < doorCount; j++) {
const x0 = doorMulti.baseAx + j * (doorMulti.eachW + doorMulti.gapPx);
viewAOps += `<rect x="${x0}" y="${doorMulti.baseAy}" width="${doorMulti.eachW}" height="${doorMulti.hPanel}" class="panel"/>`;
viewAOps += `<line x1="${x0}" y1="${doorMulti.baseAy}" x2="${x0 + doorMulti.eachW}" y2="${doorMulti.baseAy}" class="edge"/>`;
viewAOps += `<text x="${x0 + doorMulti.eachW / 2}" y="${doorMulti.baseAy - 5}" class="txt" text-anchor="middle" fill="#475569">Vantail ${j + 1} · ch. ${hingeSidesList[j] === 'right' ? 'droite' : 'gauche'}</text>`;
viewAOps += doorFaceUsinageSvg({
pAx: x0,
pAy: doorMulti.baseAy,
wA: doorMulti.eachW,
hA: doorMulti.hPanel,
scaleA: doorMulti.scaleMulti,
m,
hingeSide: hingeSidesList[j],
heightMm,
fmt
});
}
} else {
const hingeOne = hingeSidesList[0] || 'left';
viewAOps += doorFaceUsinageSvg({
pAx,
pAy,
wA,
hA,
scaleA,
m,
hingeSide: hingeOne,
heightMm,
fmt
});
}
if (!doorMulti && (isHabFinishSide || isHabFinishTop)) {
const k = Math.min(18, Math.max(10, Math.round(Math.min(wA, hA) * 0.08)));
if (isHabFinishSide) {
viewAMiterMarks += `<path d="M ${pAx} ${pAy} L ${pAx + k} ${pAy + k}" class="miter"/>`;
viewAMiterMarks += `<path d="M ${pAx + wA} ${pAy} L ${pAx + wA - k} ${pAy + k}" class="miter"/>`;
miterInfoLine = "Coupe d'onglet 45° : chant haut (jonction avec habillage dessus)";
} else if (isHabFinishTop) {
// Mark left/right edges with corner chevrons + explicit text (avoid ambiguity).
viewAMiterMarks += `<path d="M ${pAx} ${pAy} L ${pAx + k} ${pAy + k}" class="miter"/>`;
viewAMiterMarks += `<path d="M ${pAx} ${pAy + hA} L ${pAx + k} ${pAy + hA - k}" class="miter"/>`;
viewAMiterMarks += `<path d="M ${pAx + wA} ${pAy} L ${pAx + wA - k} ${pAy + k}" class="miter"/>`;
viewAMiterMarks += `<path d="M ${pAx + wA} ${pAy + hA} L ${pAx + wA - k} ${pAy + hA - k}" class="miter"/>`;
miterInfoLine = "Coupe d'onglet 45° : chants gauche & droit (jonction avec habillages côtés)";
}
viewAOps += `<text x="${pAx + 6}" y="${pAy + 14}" class="txt" fill="#0f172a">${miterInfoLine}</text>`;
}
const dimPax = doorMulti ? doorMulti.baseAx : pAx;
const dimPay = doorMulti ? doorMulti.baseAy : pAy;
const dimWa = doorMulti ? doorMulti.eachW * doorCount + doorMulti.gapPx * (doorCount - 1) : wA;
const dimHa = doorMulti ? doorMulti.hPanel : hA;
const widthDimTextY = dimPay + dimHa + 28;
const patternTextY = widthDimTextY + 16;
const widthLabelY = yA + dimHa + 26;
const patternLabelY = widthLabelY + 16;
const scConn = doorMulti ? doorMulti.scaleMulti : scaleA;
if (['dessus', 'fond'].includes(piece.elementType) && (m.carcassFasteners?.dowelStations?.length || m.carcassFasteners?.positionsAlongJointMm?.length)) {
const cf = m.carcassFasteners;
const edgeL = Math.max(1, Number(cf.edgeLengthMm) || heightMm);
const stations = getCarcassDowelStationsForPlan(cf, edgeL);
const jointAxes = (cf.junctionAxesAcrossWidthMm || [0, widthMm]).map((xmm) => {
const edgeInset = PANEL_THICKNESS * 0.5;
const clamped = Math.max(0, Math.min(widthMm, xmm));
if (clamped <= 0) return edgeInset;
if (clamped >= widthMm) return Math.max(0, widthMm - edgeInset);
return clamped;
});
const ixList = m.connectorHoles?.ixconnectJointsAlongDepth || getIxconnectJointsForPlan(m, cf, heightMm);
const dTourMm = cf.dowelDiameter || 8;
const rTourPx = Math.max(1.2, (dTourMm * 0.5) * scConn);
const rFerrPx = Math.max(2.4, 7 * scConn); // perçage ferrure ~Ø14 mm vs tourillon Ø8
const mapJunctionXmm = (xmm) => {
const edgeInset = PANEL_THICKNESS * 0.5;
const clamped = Math.max(0, Math.min(widthMm, Number(xmm) || 0));
if (clamped <= 0) return edgeInset;
if (clamped >= widthMm) return Math.max(0, widthMm - edgeInset);
return clamped;
};
for (const xAxisMm of jointAxes) {
const x = dimPax + Math.min(dimWa, Math.max(0, xAxisMm * scConn));
viewAOps += `<line x1="${x}" y1="${dimPay}" x2="${x}" y2="${dimPay + dimHa}" class="guide"/>`;
for (const st of stations) {
const along = st.holesAlongMm || [];
for (let hi = 0; hi < along.length; hi++) {
const alongMm = along[hi];
const y = dimPay + Math.min(dimHa, Math.max(0, dimHa - alongMm * scConn));
const isTourillon = hi !== 1; // [tourillon, ferrure, tourillon]
const rr = isTourillon ? rTourPx : rFerrPx;
const cls = isTourillon ? 'connectorTourillon' : 'connectorFerrure';
viewAOps += `<circle cx="${x}" cy="${y}" r="${rr}" class="${cls}" />`;
}
}
}
if (ixList?.length) {
const rIxPx = Math.max(2.4, 6.8 * scConn);
for (const jix of ixList) {
const xMm = mapJunctionXmm(jix.axisAcrossWidthMm);
const x = dimPax + Math.min(dimWa, Math.max(0, xMm * scConn));
const triple = [
{ pos: jix.tourillon1Axis, cls: 'connectorTourillon', r: rTourPx },
{ pos: jix.ixconnectAxis, cls: 'connector', r: rIxPx },
{ pos: jix.tourillon2Axis, cls: 'connectorTourillon', r: rTourPx }
];
for (const h of triple) {
const y = dimPay + Math.min(dimHa, Math.max(0, dimHa - h.pos * scConn));
viewAOps += `<circle cx="${x}" cy="${y}" r="${h.r}" class="${h.cls}" />`;
}
}
}
const yDimX = dimPax + dimWa + 18;
const centers = cf.positionsAlongJointMm || [];
if (centers.length >= 3) {
const y0 = dimPay + dimHa;
const y1 = dimPay + dimHa - centers[0] * scConn;
const y2 = dimPay + dimHa - centers[1] * scConn;
const y3 = dimPay + dimHa - centers[2] * scConn;
const y4 = dimPay;
const segs = [[y0, y1], [y1, y2], [y2, y3], [y3, y4]];
const vals = [centers[0], centers[1] - centers[0], centers[2] - centers[1], heightMm - centers[2]];
for (let i = 0; i < segs.length; i++) {
const [a, b] = segs[i];
viewAOps += `<line x1="${yDimX}" y1="${a}" x2="${yDimX}" y2="${b}" stroke="#0f766e" stroke-width="0.85"/>`;
viewAOps += `<line x1="${yDimX - 4}" y1="${a}" x2="${yDimX + 4}" y2="${a}" stroke="#0f766e"/>`;
viewAOps += `<line x1="${yDimX - 4}" y1="${b}" x2="${yDimX + 4}" y2="${b}" stroke="#0f766e"/>`;
viewAOps += `<text x="${yDimX + 6}" y="${(a + b) / 2 + 3}" font-size="8.5" fill="#0f766e">${fmt(vals[i])}</text>`;
}
} else if (centers.length === 2) {
const y0 = dimPay + dimHa;
const y1 = dimPay + dimHa - centers[0] * scConn;
const y2 = dimPay + dimHa - centers[1] * scConn;
const y3 = dimPay;
const segs = [[y0, y1], [y1, y2], [y2, y3]];
const vals = [centers[0], centers[1] - centers[0], heightMm - centers[1]];
for (let i = 0; i < segs.length; i++) {
const [a, b] = segs[i];
viewAOps += `<line x1="${yDimX}" y1="${a}" x2="${yDimX}" y2="${b}" stroke="#0f766e" stroke-width="0.85"/>`;
viewAOps += `<line x1="${yDimX - 4}" y1="${a}" x2="${yDimX + 4}" y2="${a}" stroke="#0f766e"/>`;
viewAOps += `<line x1="${yDimX - 4}" y1="${b}" x2="${yDimX + 4}" y2="${b}" stroke="#0f766e"/>`;
viewAOps += `<text x="${yDimX + 6}" y="${(a + b) / 2 + 3}" font-size="8.5" fill="#0f766e">${fmt(vals[i])}</text>`;
}
}
const depL = cf.ferrureDepthAlongLengthMm ?? 46;
const depT = cf.ferrureDepthInThicknessMm ?? 12;
const d = cf.dowelDiameter || 8;
const dep = tourillonFaceDrillDepthMm(thicknessMm);
const off = cf.dowelSideOffset ?? CARCASS_FASTENERS.dowel.sideOffset;
const midStation = centers.length >= 3 ? stations[1] : null;
if (midStation?.holesAlongMm?.length >= 3) {
const ya = dimPay + dimHa - midStation.holesAlongMm[0] * scConn;
const yb = dimPay + dimHa - midStation.holesAlongMm[2] * scConn;
const spanMm = midStation.holesAlongMm[2] - midStation.holesAlongMm[0];
viewAOps += `<line x1="${yDimX + 18}" y1="${ya}" x2="${yDimX + 18}" y2="${yb}" stroke="#64748b" stroke-width="0.85" stroke-dasharray="3,2"/>`;
viewAOps += `<text x="${yDimX + 22}" y="${(ya + yb) / 2 + 3}" font-size="8.5" fill="#64748b">2×${off} = ${fmt(spanMm)} mm</text>`;
}
const nFerr = centers.length;
const ferrRule =
nFerr >= 3
? '3 ferrures (50 / milieu / 50)'
: nFerr === 2
? `2 ferrures (50/50, sans milieu si arête ≤ ${CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM} mm)`
: `${nFerr} ferrure(s)`;
if (ixList?.length) {
viewAOps += `<text x="${dimPax + dimWa / 2}" y="${patternTextY}" class="txtCenter">${jointAxes.length} jonctions · Ferrures corps: ${ferrRule} · prof ${depL}/${depT} mm · 2 tourillons/ferrure ±${off} mm (centre ferrure) · Ø${d}×${dep}</text>`;
viewAOps += `<text x="${dimPax + dimWa / 2}" y="${patternTextY + 13}" class="txtCenter">Ixconnect (axes bord avant, autre jeu): extrémités T@20·IX@50·T@80 · internes IX milieu + T ±30 mm</text>`;
} else {
viewAOps += `<text x="${dimPax + dimWa / 2}" y="${patternTextY}" class="txtCenter">${jointAxes.length} jonctions · par jonction: ${ferrRule} + 2 tourillons/ferrure (±${off})</text>`;
viewAOps += `<text x="${dimPax + dimWa / 2}" y="${patternTextY + 13}" class="txtCenter">Ferrure: prof. ${depL} mm (longueur) / ${depT} mm (épaisseur) · Tourillons: 2× Ø${d}×${dep}</text>`;
}
} else if (m.connectorHoles?.jointPattern) {
const p = m.connectorHoles.jointPattern;
const 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 += `<text x="${dimPax + dimWa / 2}" y="${patternTextY}" class="txtCenter">${ixLeg}</text>`;
} else {
const rowInsetPx = IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM * scConn;
const yTop = dimPay + rowInsetPx;
const yBottom = dimPay + dimHa - rowInsetPx;
const holes = [
{ pos: p.tourillon1Axis, cls: 'connectorTourillon', r: 2.2 },
{ pos: p.ixconnectAxis, cls: 'connector', r: 3.4 },
{ pos: p.tourillon2Axis, cls: 'connectorTourillon', r: 2.2 }
];
for (const y of [yTop, yBottom]) {
for (const hole of holes) {
const x = dimPax + Math.min(dimWa, Math.max(0, hole.pos * scConn));
viewAOps += `<circle cx="${x}" cy="${y}" r="${hole.r}" class="${hole.cls}" />`;
}
}
viewAOps += `<text x="${dimPax + dimWa / 2}" y="${patternTextY}" class="txtCenter">Ixconnect face: T@${p.tourillon1Axis} · IX@${p.ixconnectAxis} · T@${p.tourillon2Axis} mm (haut & bas, depuis bord avant)</text>`;
}
}
if (!isDoor && (m.groove?.axisFromPanelEdge || m.groove?.axisFromBackEdge)) {
viewAOps += svgBackGrooveFaceViewA(piece, m, dimPax, dimPay, dimWa, dimHa, scConn, fmt);
}
// Explicit finished/raw dimensions on drawing
const dimY = dimPay + dimHa + 16;
const dimX = dimPax - 16;
viewAOps += `<line x1="${dimPax}" y1="${dimY}" x2="${dimPax + dimWa}" y2="${dimY}" class="dimLine"/>`;
viewAOps += `<line x1="${dimPax}" y1="${dimPay}" x2="${dimPax}" y2="${dimPay + dimHa}" class="dimLine"/>`;
const widthDimLabel = doorMulti
? `L finie ${fmt(widthMm)} mm / vantail · L brut ${fmt(piece.rawCutWidthMm)} mm · ${doorCount} vantaux (orientation ci-dessus)`
: `L finie ${fmt(widthMm)} mm · L brut ${fmt(piece.rawCutWidthMm)} mm`;
viewAOps += `<text x="${dimPax + dimWa / 2}" y="${widthDimTextY}" class="txtCenter">${widthDimLabel}</text>`;
viewAOps += `<text x="${dimX}" y="${dimPay + dimHa / 2}" class="txt" transform="rotate(-90 ${dimX} ${dimPay + dimHa / 2})">H finie ${fmt(heightMm)} mm · H brut ${fmt(piece.rawCutHeightMm)} mm</text>`;
// View B: edge view for drilling depths
const isReinforcementPiece = piece.elementType === 'renforts';
const scaleB = Math.min((viewW - 60) / heightMm, (viewH - 40) / thicknessMm);
const hB = heightMm * scaleB;
const tB = Math.max(8, thicknessMm * scaleB);
const wBh = isReinforcementPiece ? hB : tB; // long side horizontal for reinforcements
const hBh = isReinforcementPiece ? tB : hB;
const pBx = xB + (viewW - wBh) / 2;
const pBy = yB + 24 + (viewH - 24 - hBh) / 2;
let viewBOps = '';
if (m.system32 && !isReinforcementPiece) {
const s32d = m.system32.holeDepth || thicknessMm;
const holeLen = Math.min(tB * 0.9, s32d * scaleB);
viewBOps += `<line x1="${pBx + 1}" y1="${pBy + hB * 0.2}" x2="${pBx + holeLen}" y2="${pBy + hB * 0.2}" class="depthLine"/>`;
viewBOps += `<line x1="${pBx + 1}" y1="${pBy + hB * 0.55}" x2="${pBx + holeLen}" y2="${pBy + hB * 0.55}" class="depthLine"/>`;
const tx = Math.min(xB + viewW - 110, pBx + holeLen + 6);
viewBOps += `<text x="${tx}" y="${pBy + hB * 0.2 + 3}" class="txt">Ø${m.system32.holeDiameter} · ${m.system32.throughHole ? 'TRAV.' : `prof ${fmt(s32d)}mm`}</text>`;
}
if (m.hingeCups && !isReinforcementPiece) {
const cupDepth = m.hingeCups.cupDepth || 12;
const cupLen = Math.min(tB * 0.95, cupDepth * scaleB);
viewBOps += `<line x1="${pBx + 1}" y1="${pBy + hB * 0.82}" x2="${pBx + cupLen}" y2="${pBy + hB * 0.82}" class="hingeDepthLine"/>`;
const tx = Math.min(xB + viewW - 125, pBx + cupLen + 6);
viewBOps += `<text x="${tx}" y="${pBy + hB * 0.82 + 3}" class="txt">Cuvette Ø${m.hingeCups.diameter} · prof ${fmt(cupDepth)}mm</text>`;
}
if (isReinforcementPiece && m.reinforcementFixing) {
viewBOps += svgReinforcementFixingEdgeViewB(m.reinforcementFixing, pBx, pBy, tB, wBh, scaleB, fmt, true);
}
if (isReinforcementPiece) {
viewBOps += `<line x1="${pBx}" y1="${pBy - 14}" x2="${pBx + wBh}" y2="${pBy - 14}" class="dimLine"/>`;
viewBOps += `<text x="${pBx + wBh / 2}" y="${pBy - 18}" class="txtCenter">Hauteur face chant ${fmt(heightMm)} mm</text>`;
viewBOps += `<line x1="${pBx + wBh + 14}" y1="${pBy}" x2="${pBx + wBh + 14}" y2="${pBy + hBh}" class="dimLine"/>`;
viewBOps += `<text x="${pBx + wBh + 26}" y="${pBy + hBh / 2}" class="txt" transform="rotate(-90 ${pBx + wBh + 26} ${pBy + hBh / 2})">Épaisseur ${fmt(thicknessMm)} mm</text>`;
} else {
viewBOps += `<line x1="${pBx}" y1="${pBy - 14}" x2="${pBx + tB}" y2="${pBy - 14}" class="dimLine"/>`;
viewBOps += `<text x="${pBx + tB / 2}" y="${pBy - 18}" class="txtCenter">Épaisseur ${fmt(thicknessMm)} mm</text>`;
viewBOps += `<line x1="${pBx + tB + 14}" y1="${pBy}" x2="${pBx + tB + 14}" y2="${pBy + hB}" class="dimLine"/>`;
viewBOps += `<text x="${pBx + tB + 26}" y="${pBy + hB / 2}" class="txt" transform="rotate(-90 ${pBx + tB + 26} ${pBy + hB / 2})">Hauteur ${fmt(heightMm)} mm</text>`;
}
// View C: groove cross section + connector depths
const scaleC = Math.min((viewW - 60) / widthMm, (viewH - 40) / thicknessMm);
const wC = widthMm * scaleC;
const tC = Math.max(10, thicknessMm * scaleC);
const pCx = xC + (viewW - wC) / 2;
const pCy = yC + 60;
let viewCOps = '';
if (m.groove?.axisFromPanelEdge || m.groove?.axisFromBackEdge) {
viewCOps += svgBackGrooveSectionViewC(pCx, pCy, wC, tC, scaleC, piece, m, fmt);
}
if (['dessus', 'fond'].includes(piece.elementType) && m.carcassFasteners) {
const cf = m.carcassFasteners;
const depL = cf.ferrureDepthAlongLengthMm ?? 46;
const depT = cf.ferrureDepthInThicknessMm ?? 12;
const dTourMm = cf.dowelDiameter || 8;
const depthTourMm = tourillonFaceDrillDepthMm(thicknessMm);
viewCOps += svgCarcassTourillonSlotsSectionView(pCx, pCy, scaleC, tC, widthMm, thicknessMm, cf);
viewCOps += `<text x="${pCx + 4}" y="${pCy + tC + 18}" class="txt" fill="#0f766e">Tourillons (vue C) : 1 logement/jonction, traitillé depuis la face haut · Ø${fmt(dTourMm)} mm, prof. ${fmt(depthTourMm)} mm · Ferrure ${fmt(depL)} / ${fmt(depT)} mm</text>`;
}
if (m.connectorHoles?.edgeHole) {
const depth = m.connectorHoles.edgeHole.depth || 45;
const diam = m.connectorHoles.edgeHole.diameter || 8;
const len = Math.min(wC * 0.35, depth * scaleC);
viewCOps += `<line x1="${pCx + 4}" y1="${pCy + tC / 2}" x2="${pCx + 4 + len}" y2="${pCy + tC / 2}" class="connectorDepthLine"/>`;
viewCOps += `<text x="${pCx + 8 + len}" y="${pCy + tC / 2 + 4}" class="txt">Ixconnect about Ø${fmt(diam)} · prof ${fmt(depth)}mm</text>`;
if (m.connectorHoles?.faceHole) {
viewCOps += `<text x="${pCx + 8 + len}" y="${pCy + tC / 2 + 16}" class="txt">Face Ø${fmt(m.connectorHoles.faceHole.diameter || 8)} · prof ${fmt(m.connectorHoles.faceHole.depth || 12)}mm</text>`;
}
}
viewCOps += `<line x1="${pCx}" y1="${pCy + tC + 44}" x2="${pCx + wC}" y2="${pCy + tC + 44}" class="dimLine"/>`;
viewCOps += `<text x="${pCx + wC / 2}" y="${pCy + tC + 58}" class="txtCenter">Largeur ${fmt(widthMm)} mm</text>`;
viewCOps += `<line x1="${pCx - 14}" y1="${pCy}" x2="${pCx - 14}" y2="${pCy + tC}" class="dimLine"/>`;
viewCOps += `<text x="${pCx - 26}" y="${pCy + tC / 2}" class="txt" transform="rotate(-90 ${pCx - 26} ${pCy + tC / 2})">Épaisseur ${fmt(thicknessMm)} mm</text>`;
const opsLines = [];
opsLines.push(`Pièce finie: ${piece.widthMm} x ${piece.heightMm} x ${piece.thicknessMm} mm · Qté ${piece.quantity}`);
opsLines.push(`Brut CNC: ${piece.rawCutWidthMm} x ${piece.rawCutHeightMm} mm (déduction chants incluse)`);
opsLines.push(`Chants: ${piece.edgeBanding?.sides || 0} côté(s), ép. ${piece.edgeBanding?.thickness || 0}mm`);
if (miterInfoLine) opsLines.push(miterInfoLine);
if (m.system32) {
opsLines.push(`System 32: Ø${m.system32.holeDiameter} · pas ${m.system32.pitch || 32} · ${m.system32.throughHole ? 'traversant' : `prof ${m.system32.holeDepth || 12}mm`}`);
}
if (m.hingeCups) {
const pos = (m.hingeCups.positionsFromTop || []).slice(0, 6).map(v => `${Math.round(v)}mm`).join(', ');
const sd = m.hingeCups.screwDiameterMm ?? HINGE.cupScrewDiameter;
const sz = m.hingeCups.screwDepthMm ?? HINGE.cupScrewDepth;
const ssp = m.hingeCups.screwSpacingMm ?? HINGE.cupScrewSpacing;
const sof = m.hingeCups.screwOffsetFromCupCenterMm ?? HINGE.cupScrewOffsetFromCupCenter;
opsLines.push(
`Charnières: cuvette Ø${m.hingeCups.diameter} prof ${m.hingeCups.cupDepth} · axes: ${pos}${(m.hingeCups.positionsFromTop || []).length > 6 ? ', ...' : ''} · vis Ø${sd}×${sz} (${ssp} c/c, ${sof} du centre)`
);
}
if (isDoor && doorCount > 1) {
const sideLabel = (s) => (s === 'right' ? 'droite' : 'gauche');
opsLines.push(
`Orientation charnières (vue extérieure) : ${hingeSidesList.map((s, i) => `V${i + 1} ${sideLabel(s)}`).join(' · ')}`
);
}
if (m.fullHeightHandleGroove) {
const off = m.fullHeightHandleGroove.topBottomOffsetMm ?? HANDLE.fullHeightTopBottomOffset;
const gw = m.fullHeightHandleGroove.grooveWidthMm ?? HANDLE.fullHeightGroove.width;
const gd = m.fullHeightHandleGroove.grooveDepthMm ?? HANDLE.fullHeightGroove.depth;
opsLines.push(`Poignée toute hauteur: rainure ${gw}×${gd} mm, côté opposé charnières, retrait mécanique ${off} mm haut et bas`);
}
if (m.carcassFasteners?.positionsAlongJointMm?.length && ['dessus', 'fond'].includes(piece.elementType)) {
const cf = m.carcassFasteners;
const ax = (cf.positionsAlongJointMm || []).map((v) => `${Math.round(v)}`).join(', ');
const off = cf.dowelSideOffset ?? CARCASS_FASTENERS.dowel.sideOffset;
const depL = cf.ferrureDepthAlongLengthMm ?? 46;
const depT = cf.ferrureDepthInThicknessMm ?? 12;
const nAx = (cf.positionsAlongJointMm || []).length;
const pat =
nAx >= 3
? '50/milieu/50'
: nAx === 2
? `50/50 (sans milieu si arête ≤ ${CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM} mm)`
: 'répartition selon cotes';
const ixJ = m.connectorHoles?.ixconnectJointsAlongDepth;
const ixSuffix =
ixJ?.length && ['dessus', 'fond'].includes(piece.elementType)
? ` · Ixconnect (2e jeu, axes bord avant): extrémités T@20·IX@50·T@80 · internes IX milieu + T ±30 mm (${ixJ.length} ligne(s))`
: '';
opsLines.push(
`Ferrures corps (${CARCASS_FASTENERS.model}): ${cf.jointCount || 1} jonction(s) côtés/séparateurs↔dessus/fond · par jonction centres ${ax} mm (${pat}) · perçage ferrure ${depL} / ${depT} mm · +2 tourillons Ø${cf.dowelDiameter || 8}×${tourillonFaceDrillDepthMm(piece.thicknessMm)} (face) à ±${off} mm du centre ferrure · ${CARCASS_FASTENERS.productUrl}${ixSuffix}`
);
}
if (m.connectorHoles) {
opsLines.push(`Connecteurs: about Ø${m.connectorHoles.edgeHole?.diameter || 8} prof ${m.connectorHoles.edgeHole?.depth || 45} · face Ø${m.connectorHoles.faceHole?.diameter || 8} prof ${m.connectorHoles.faceHole?.depth || 12}`);
const ixJ = m.connectorHoles.ixconnectJointsAlongDepth;
const mergedIx =
m.carcassFasteners?.positionsAlongJointMm?.length && ['dessus', 'fond'].includes(piece.elementType);
if (ixJ?.length && ['dessus', 'fond'].includes(piece.elementType) && !mergedIx) {
opsLines.push(
`Ixconnect axes (bord avant profondeur): extrémités T@20·IX@50·T@80 · jonctions internes IX au milieu + T ±30 mm — ${ixJ.length} ligne(s) de perçage`
);
}
}
if (m.groove) {
opsLines.push(`Rainure: ${m.groove.width || 9} x ${m.groove.depth || 10.5}mm · position ${m.groove.axisFromPanelEdge || 16}mm du bord`);
}
if (m.mobileShelfSystem?.model === 'sp_15_8_5') {
opsLines.push('Rayons mobiles SP 15/8/5: face dessous Ø20×15 (axe 9.5) · chant Ø8×37 (à 6mm du dessous) · avant 32 / arrière 37');
} else if (m.mobileShelfSystem?.model === 'sp_15_8_5_face_only') {
opsLines.push('Rayon fixe: face dessous Ø20×14.5 (axe 9.5) · avant 32 / arrière 37');
}
if (m.finishConnectors) {
const fc = m.finishConnectors;
opsLines.push(`${fc.model || 'SC 8/25'}: face Ø${fc.faceHole?.diameter || 8} prof. ${fc.faceHole?.depth || 12.5} mm · axes ${fc.axes?.back ?? 30}/${fc.axes?.front ?? 43} mm (arrière/avant)`);
}
if (m.reinforcementFixing) {
const rf = m.reinforcementFixing;
const tour = (rf.tourillonAxesFromTopMm || []).join(' / ');
const sides = rf.sideCount || 2;
opsLines.push(
`Renfort: ${sides} chants extrémité · par chant 1× ${rf.connectorModel || 'SC 8/60'} axe ${rf.ixconnectAxisFromTopMm ?? '—'} mm + ${rf.tourillonsPerSide || 2} tourillons Ø${rf.tourillonDiameter || 8} aux axes ${tour} mm · about Ø${rf.edgeHole?.diameter || 8}×${rf.edgeHole?.depth || 45} · face Ø${rf.faceHole?.diameter || 8}×${rf.faceHole?.depth || 12}`
);
}
if (m.drawerReinforcement?.ixconnectFixing) {
const ix = m.drawerReinforcement.ixconnectFixing;
opsLines.push(
`Renfort tiroir: ${ix.model || 'SC 8/25'} — 4× Ø${ix.holeDiameterMm || 8} prof. ${ix.depthInReinforcementMm} mm face renfort · prof. ${ix.depthInVerticalPanelMm} mm face montant/séparateur · ${ix.cornerInsetMm} mm des coins (repère Y depuis haut)`
);
}
const isDoorPiece = piece.elementType === 'portes';
const doorBandCount = isDoorPiece ? Math.max(1, piece.doorPlanHints?.doorCount || 1) : 1;
const doorBandSides = isDoorPiece
? (piece.doorPlanHints?.hingeSides?.length === doorBandCount
? piece.doorPlanHints.hingeSides
: hingeSidesList)
: [];
const blockH = isDoorPiece && doorBandCount > 1
? Math.min(188, Math.max(148, Math.floor(520 / doorBandCount)))
: 200;
const doorRowH = isDoorPiece ? (doorBandCount > 1 ? doorBandCount * (blockH + 10) + 12 : 200) : 0;
const doorRowY = yA + viewH + 10;
const infoY = doorRowY + doorRowH + (doorRowH ? 14 : 10);
const svgH = infoY + (infoBlockH - 12) + margin;
// Contenu du bandeau en coordonnées locales (0,0) — sinon les <text> se superposent au titre / vues A–C
const doorBandW = svgW - margin * 2;
const doorDetailInner = isDoorPiece
? (doorBandCount > 1
? `<g transform="translate(${margin}, ${doorRowY})">
<rect x="0" y="0" width="${doorBandW}" height="${doorRowH}" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
${Array.from({ length: doorBandCount }, (_, i) => {
const yOff = i * (blockH + 10);
const side = doorBandSides[i] || 'left';
const label = `vantail ${i + 1} (charnières ${side === 'right' ? 'droite' : 'gauche'})`;
return `<g transform="translate(0, ${yOff + 6})">
<rect x="8" y="0" width="${doorBandW - 16}" height="${blockH}" fill="#ffffff" stroke="#e2e8f0" stroke-width="0.9"/>
${doorMachiningDetailMarkup(piece, fmt, doorBandW - 16, blockH - 4, side, label)}
</g>`;
}).join('')}
</g>`
: `<g transform="translate(${margin}, ${doorRowY})">
<rect x="0" y="0" width="${doorBandW}" height="${doorRowH}" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
${doorMachiningDetailMarkup(piece, fmt, doorBandW, doorRowH)}
</g>`)
: '';
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgW} ${svgH}" width="${svgW}" height="${svgH}">
<style>
.title{font-family:Arial,sans-serif;font-size:16px;font-weight:bold;fill:#111}
.sub{font-family:Arial,sans-serif;font-size:11px;fill:#555}
.box{fill:#fff;stroke:#d1d5db;stroke-width:1}
.boxTitle{font-family:Arial,sans-serif;font-size:12px;font-weight:bold;fill:#1f2937}
.panel{fill:#eadac0;stroke:#1f2937;stroke-width:1.35}
.edge{stroke:#ff9f1c;stroke-width:2.2}
.miter{stroke:#0f172a;stroke-width:1.4;stroke-dasharray:4 3;opacity:0.95}
.hole{fill:none;stroke:#d62828;stroke-width:0.9}
.hinge{fill:none;stroke:#f77f00;stroke-width:1.1}
.grooveFill{fill:#2a9d8f;opacity:0.35;stroke:#2a9d8f;stroke-width:1}
.connector{fill:#6c5ce7;stroke:#312e81;stroke-width:1}
.connectorSmall{fill:#8d99ae}
.connectorTourillon{fill:#8ba0bf;stroke:#334155;stroke-width:1.05}
.connectorFerrure{fill:#a78bfa;stroke:#6d28d9;stroke-width:0.9}
.shelfFaceHole{fill:none;stroke:#ea580c;stroke-width:1.15}
.txt{font-family:Arial,sans-serif;font-size:10px;fill:#333}
.txtCenter{font-family:Arial,sans-serif;font-size:10px;fill:#333;text-anchor:middle}
.depthLine{stroke:#c1121f;stroke-width:1.2}
.hingeDepthLine{stroke:#f77f00;stroke-width:1.2}
.connectorDepthLine{stroke:#6c5ce7;stroke-width:1.2}
.guide{stroke:#64748b;stroke-dasharray:2,2;stroke-width:1}
.dimLine{stroke:#0f766e;stroke-width:1;stroke-dasharray:3,2}
.infoBox{fill:#f8fafc;stroke:#cbd5e1;stroke-width:1}
.infoTxt{font-family:Arial,sans-serif;font-size:10.5px;fill:#1f2937}
</style>
<text x="${margin}" y="24" class="title">${piece.description}</text>
<text x="${margin}" y="38" class="sub">Plan pièce multi-vues · fabrication CNC</text>
<defs>
<clipPath id="clipA"><rect x="${xA + 1}" y="${yA + 20}" width="${viewW - 2}" height="${viewH - 22}"/></clipPath>
<clipPath id="clipB"><rect x="${xB + 1}" y="${yB + 20}" width="${viewW - 2}" height="${viewH - 22}"/></clipPath>
<clipPath id="clipC"><rect x="${xC + 1}" y="${yC + 20}" width="${viewW - 2}" height="${viewH - 22}"/></clipPath>
</defs>
<rect x="${xA}" y="${yA}" width="${viewW}" height="${viewH}" class="box"/>
<text x="${xA + 10}" y="${yA + 16}" class="boxTitle">Vue A - Face usinage</text>
<g clip-path="url(#clipA)">
${doorMulti ? '' : `<rect x="${pAx}" y="${pAy}" width="${wA}" height="${hA}" class="panel"/>
<line x1="${pAx}" y1="${pAy}" x2="${pAx + wA}" y2="${pAy}" class="edge"/>`}
${viewAMiterMarks}
${viewAOps}
</g>
<rect x="${xB}" y="${yB}" width="${viewW}" height="${viewH}" class="box"/>
<text x="${xB + 10}" y="${yB + 16}" class="boxTitle">Vue B - Chant / profondeurs</text>
<g clip-path="url(#clipB)">
<rect x="${pBx}" y="${pBy}" width="${wBh}" height="${hBh}" class="panel"/>
${viewBOps}
</g>
<rect x="${xC}" y="${yC}" width="${viewW}" height="${viewH}" class="box"/>
<text x="${xC + 10}" y="${yC + 16}" class="boxTitle">${piece.elementType === 'portes' ? 'Vue C — Coupe largeur × épaisseur (rainure TH → voir bandeau / Vue D)' : 'Vue C — Coupe L×ép. · rainure côté dos (face caisse)'}</text>
<g clip-path="url(#clipC)">
<rect x="${pCx}" y="${pCy}" width="${wC}" height="${tC}" class="panel"/>
${viewCOps}
</g>
${doorDetailInner}
<rect x="${margin}" y="${infoY}" width="${svgW - margin * 2}" height="${infoBlockH - 12}" class="infoBox"/>
${opsLines.map((line, idx) => `<text x="${margin + 10}" y="${infoY + 22 + idx * 18}" class="infoTxt">${line}</text>`).join('')}
</svg>`;
}
function generatePiecePlanViews(piece) {
const widthMm = Math.max(1, Number(piece.widthMm) || 1);
const heightMm = Math.max(1, Number(piece.heightMm) || 1);
const thicknessMm = Math.max(1, Number(piece.thicknessMm) || 1);
const m = piece.machining || {};
const fmt = (v) => Number(v).toFixed(Number(v) % 1 === 0 ? 0 : 1);
function wrapSvg(title, subtitle, content, viewBox = '0 0 760 460', svgHeight = 460) {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" width="760" height="${svgHeight}">
<style>
.title{font-family:Arial,sans-serif;font-size:18px;font-weight:bold;fill:#111}
.sub{font-family:Arial,sans-serif;font-size:12px;fill:#555}
.panel{fill:#eadac0;stroke:#1f2937;stroke-width:1.45}
.edge{stroke:#ff9f1c;stroke-width:2.2}
.hole{fill:none;stroke:#d62828;stroke-width:1}
.hinge{fill:none;stroke:#f77f00;stroke-width:1.2}
.grooveFill{fill:#2a9d8f;opacity:0.35;stroke:#2a9d8f;stroke-width:1}
.connector{fill:#6c5ce7;stroke:#312e81;stroke-width:1}
.connectorSmall{fill:#8d99ae}
.connectorTourillon{fill:#8ba0bf;stroke:#334155;stroke-width:1.05}
.connectorFerrure{fill:#a78bfa;stroke:#6d28d9;stroke-width:0.9}
.shelfFaceHole{fill:none;stroke:#ea580c;stroke-width:1.2}
.txt{font-family:Arial,sans-serif;font-size:11px;fill:#222}
.guide{stroke:#64748b;stroke-dasharray:3,3;stroke-width:1}
.lineRed{stroke:#c1121f;stroke-width:1.4}
.lineOrange{stroke:#f77f00;stroke-width:1.4}
</style>
<text x="24" y="30" class="title">${title}</text>
<text x="24" y="48" class="sub">${subtitle}</text>
${content}
</svg>`;
}
// Vue A
const isDoorPv = piece.elementType === 'portes';
const doorCountPv = isDoorPv ? Math.max(1, piece.doorPlanHints?.doorCount || 1) : 1;
const hingeSidesPv = isDoorPv
? (piece.doorPlanHints?.hingeSides?.length === doorCountPv
? piece.doorPlanHints.hingeSides
: Array.from({ length: doorCountPv }, (_, i) => getDoorHingeSide(i, doorCountPv, piece.doorPlanHints?.hardware || {})))
: ['left'];
let doorMultiPv = null;
if (isDoorPv && doorCountPv > 1) {
const gapPx = 10;
const totalWmm = widthMm * doorCountPv;
const scaleMulti = Math.min(600 / totalWmm, 320 / heightMm);
const eachW = widthMm * scaleMulti;
const hPanel = heightMm * scaleMulti;
const totalPx = eachW * doorCountPv + gapPx * (doorCountPv - 1);
const baseAx = 70 + (620 - totalPx) / 2;
const baseAy = 70 + (340 - hPanel) / 2;
doorMultiPv = { gapPx, scaleMulti, eachW, hPanel, baseAx, baseAy };
}
const scaleA = Math.min(620 / widthMm, 340 / heightMm);
const wA = widthMm * scaleA;
const hA = heightMm * scaleA;
const xA = 70 + (620 - wA) / 2;
const yA = 70 + (340 - hA) / 2;
let aOps = '';
if (doorMultiPv) {
for (let j = 0; j < doorCountPv; j++) {
const x0 = doorMultiPv.baseAx + j * (doorMultiPv.eachW + doorMultiPv.gapPx);
aOps += `<rect x="${x0}" y="${doorMultiPv.baseAy}" width="${doorMultiPv.eachW}" height="${doorMultiPv.hPanel}" class="panel"/>`;
aOps += `<line x1="${x0}" y1="${doorMultiPv.baseAy}" x2="${x0 + doorMultiPv.eachW}" y2="${doorMultiPv.baseAy}" class="edge"/>`;
aOps += `<text x="${x0 + doorMultiPv.eachW / 2}" y="${doorMultiPv.baseAy - 6}" class="txt" text-anchor="middle" fill="#475569" font-size="10">V${j + 1} · ch. ${hingeSidesPv[j] === 'right' ? 'dr.' : 'g.'}</text>`;
aOps += doorFaceUsinageSvg({
pAx: x0,
pAy: doorMultiPv.baseAy,
wA: doorMultiPv.eachW,
hA: doorMultiPv.hPanel,
scaleA: doorMultiPv.scaleMulti,
m,
hingeSide: hingeSidesPv[j],
heightMm,
fmt
});
}
} else {
aOps = `<rect x="${xA}" y="${yA}" width="${wA}" height="${hA}" class="panel"/><line x1="${xA}" y1="${yA}" x2="${xA + wA}" y2="${yA}" class="edge"/>`;
if (m.system32?.positions?.length) {
const frontX = xA + Math.min(37 * scaleA, wA * 0.2);
const backX = xA + wA - Math.min(37 * scaleA, wA * 0.2);
const r = Math.max(1.5, (m.system32.holeDiameter || 5) * scaleA * 0.5);
for (const pos of m.system32.positions) {
const y = yA + Math.min(hA, Math.max(0, pos * scaleA));
aOps += `<circle cx="${frontX}" cy="${y}" r="${r}" class="hole"/><circle cx="${backX}" cy="${y}" r="${r}" class="hole"/>`;
}
}
aOps += doorFaceUsinageSvg({
pAx: xA,
pAy: yA,
wA,
hA,
scaleA,
m,
hingeSide: hingeSidesPv[0] || 'left',
heightMm,
fmt
});
}
// Rayons (SP 15/8/5): perçages face dessous Ø20×(15 ou 20) (vue A)
if (
['etageres', 'rayonFixe'].includes(piece.elementType) &&
['sp_15_8_5', 'sp_15_8_5_face_only'].includes(m.mobileShelfSystem?.model) &&
!doorMultiPv
) {
const specs = buildShelfMobileHoleSpecsMm(piece, widthMm, heightMm);
const face = specs.filter((h) => h.face === 'face');
if (face.length) {
const rFace = Math.max(2.2, (20 * 0.5) * scaleA);
for (const h of face) {
const x = xA + Math.min(wA, Math.max(0, Number(h.xMm) * scaleA));
const y = yA + Math.min(hA, Math.max(0, Number(h.yMm) * scaleA));
aOps += `<circle cx="${x}" cy="${y}" r="${rFace}" class="shelfFaceHole"/>`;
}
}
}
const dimAx = doorMultiPv ? doorMultiPv.baseAx : xA;
const dimAy = doorMultiPv ? doorMultiPv.baseAy : yA;
const dimAw = doorMultiPv ? doorMultiPv.eachW * doorCountPv + doorMultiPv.gapPx * (doorCountPv - 1) : wA;
const dimAh = doorMultiPv ? doorMultiPv.hPanel : hA;
const widthLabelY = dimAy + dimAh + 26;
const scaleConnPv = doorMultiPv ? doorMultiPv.scaleMulti : scaleA;
if (['dessus', 'fond'].includes(piece.elementType) && (m.carcassFasteners?.dowelStations?.length || m.carcassFasteners?.positionsAlongJointMm?.length)) {
const cf = m.carcassFasteners;
const edgeL = Math.max(1, Number(cf.edgeLengthMm) || heightMm);
const stations = getCarcassDowelStationsForPlan(cf, edgeL);
const jointAxes = (cf.junctionAxesAcrossWidthMm || [0, widthMm]).map((xmm) => {
const edgeInset = PANEL_THICKNESS * 0.5;
const clamped = Math.max(0, Math.min(widthMm, xmm));
if (clamped <= 0) return edgeInset;
if (clamped >= widthMm) return Math.max(0, widthMm - edgeInset);
return clamped;
});
const ixListPv = m.connectorHoles?.ixconnectJointsAlongDepth || getIxconnectJointsForPlan(m, cf, heightMm);
const dTourMmPv = cf.dowelDiameter || 8;
const rTourPxPv = Math.max(1.3, (dTourMmPv * 0.5) * scaleConnPv);
const rFerrPxPv = Math.max(2.5, 7 * scaleConnPv);
const mapJunctionXmmPv = (xmm) => {
const edgeInset = PANEL_THICKNESS * 0.5;
const clamped = Math.max(0, Math.min(widthMm, Number(xmm) || 0));
if (clamped <= 0) return edgeInset;
if (clamped >= widthMm) return Math.max(0, widthMm - edgeInset);
return clamped;
};
for (const xAxisMm of jointAxes) {
const x = dimAx + Math.min(dimAw, Math.max(0, xAxisMm * scaleConnPv));
aOps += `<line x1="${x}" y1="${dimAy}" x2="${x}" y2="${dimAy + dimAh}" class="guide"/>`;
for (const st of stations) {
const along = st.holesAlongMm || [];
for (let hi = 0; hi < along.length; hi++) {
const alongMm = along[hi];
const y = dimAy + Math.min(dimAh, Math.max(0, dimAh - alongMm * scaleConnPv));
const isTourillon = hi !== 1;
const rr = isTourillon ? rTourPxPv : rFerrPxPv;
const cls = isTourillon ? 'connectorTourillon' : 'connectorFerrure';
aOps += `<circle cx="${x}" cy="${y}" r="${rr}" class="${cls}" />`;
}
}
}
if (ixListPv?.length) {
const rIxPxPv = Math.max(2.5, 7 * scaleConnPv);
for (const jix of ixListPv) {
const xMm = mapJunctionXmmPv(jix.axisAcrossWidthMm);
const x = dimAx + Math.min(dimAw, Math.max(0, xMm * scaleConnPv));
const triple = [
{ pos: jix.tourillon1Axis, cls: 'connectorTourillon', r: rTourPxPv },
{ pos: jix.ixconnectAxis, cls: 'connector', r: rIxPxPv },
{ pos: jix.tourillon2Axis, cls: 'connectorTourillon', r: rTourPxPv }
];
for (const h of triple) {
const y = dimAy + Math.min(dimAh, Math.max(0, dimAh - h.pos * scaleConnPv));
aOps += `<circle cx="${x}" cy="${y}" r="${h.r}" class="${h.cls}" />`;
}
}
}
const centers = cf.positionsAlongJointMm || [];
const yChain = dimAy + dimAh + 26;
if (centers.length >= 3) {
const ys = [dimAy + dimAh, ...centers.map((c) => dimAy + dimAh - c * scaleConnPv), dimAy];
const segs = [centers[0], centers[1] - centers[0], centers[2] - centers[1], heightMm - centers[2]];
const xDim = dimAx + dimAw + 20;
for (let i = 0; i < segs.length; i++) {
const a = ys[i];
const b = ys[i + 1];
aOps += `<line x1="${xDim}" y1="${a}" x2="${xDim}" y2="${b}" stroke="#0f766e" stroke-width="0.85"/>`;
aOps += `<text x="${xDim + 6}" y="${(a + b) / 2 + 3}" font-size="8.5" fill="#0f766e">${fmt(segs[i])}</text>`;
}
} else if (centers.length === 2) {
const ys = [dimAy + dimAh, dimAy + dimAh - centers[0] * scaleConnPv, dimAy + dimAh - centers[1] * scaleConnPv, dimAy];
const segs = [centers[0], centers[1] - centers[0], heightMm - centers[1]];
const xDim = dimAx + dimAw + 20;
for (let i = 0; i < segs.length; i++) {
const a = ys[i];
const b = ys[i + 1];
aOps += `<line x1="${xDim}" y1="${a}" x2="${xDim}" y2="${b}" stroke="#0f766e" stroke-width="0.85"/>`;
aOps += `<text x="${xDim + 6}" y="${(a + b) / 2 + 3}" font-size="8.5" fill="#0f766e">${fmt(segs[i])}</text>`;
}
}
const off = cf.dowelSideOffset ?? CARCASS_FASTENERS.dowel.sideOffset;
const depL = cf.ferrureDepthAlongLengthMm ?? 46;
const depT = cf.ferrureDepthInThicknessMm ?? 12;
const midStation = centers.length >= 3 ? stations[1] : null;
/** Légende sous le panneau : une ligne après l’autre (évite chevauchement L / tourillons / ferrures). */
let legendY = yChain + 18;
if (midStation?.holesAlongMm?.length >= 3) {
const spanMm = midStation.holesAlongMm[2] - midStation.holesAlongMm[0];
aOps += `<text x="${dimAx + dimAw / 2}" y="${legendY}" font-size="8.5" fill="#64748b" text-anchor="middle">Tourillons ferrure milieu: 2×${off} = ${fmt(spanMm)} mm</text>`;
legendY += 16;
}
const nFerrPv = centers.length;
const ferrRulePv =
nFerrPv >= 3
? '3 ferrures (50/milieu/50)'
: nFerrPv === 2
? `2 ferrures (50/50, sans milieu ≤${CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM} mm)`
: `${nFerrPv} ferrure(s)`;
const dPv = cf.dowelDiameter || 8;
const depPv = tourillonFaceDrillDepthMm(thicknessMm);
if (ixListPv?.length) {
aOps += `<text x="${dimAx + dimAw / 2}" y="${legendY}" class="txt" text-anchor="middle">${jointAxes.length} jonctions · Ferrures corps: ${ferrRulePv} · prof ${depL}/${depT} mm · 2 tourillons/ferrure ±${off} mm (centre ferrure) · Ø${dPv}×${depPv}</text>`;
legendY += 16;
aOps += `<text x="${dimAx + dimAw / 2}" y="${legendY}" class="txt" text-anchor="middle">Ixconnect (axes bord avant, autre jeu): extrémités T@20·IX@50·T@80 · internes IX milieu + T ±30 mm</text>`;
} else {
aOps += `<text x="${dimAx + dimAw / 2}" y="${legendY}" class="txt" text-anchor="middle">${jointAxes.length} jonctions · ${ferrRulePv} · 2 tourillons/ferrure (±${off}) · prof ferrure ${depL}/${depT}</text>`;
}
} else if (m.connectorHoles?.jointPattern) {
const p = m.connectorHoles.jointPattern;
const 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 += `<text x="${dimAx + dimAw / 2}" y="${widthLabelY + 18}" class="txt" text-anchor="middle">${ixLegPv}</text>`;
} else {
const rowInsetPx = IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM * scaleConnPv;
const yTop = dimAy + rowInsetPx;
const yBottom = dimAy + dimAh - rowInsetPx;
const triple = [
{ pos: p.tourillon1Axis, cls: 'connectorTourillon', r: 2.4 },
{ pos: p.ixconnectAxis, cls: 'connector', r: 3.6 },
{ pos: p.tourillon2Axis, cls: 'connectorTourillon', r: 2.4 }
];
for (const y of [yTop, yBottom]) {
for (const hole of triple) {
const x = dimAx + Math.min(dimAw, Math.max(0, hole.pos * scaleConnPv));
aOps += `<circle cx="${x}" cy="${y}" r="${hole.r}" class="${hole.cls}" />`;
}
}
aOps += `<text x="${dimAx + dimAw / 2}" y="${widthLabelY + 18}" class="txt" text-anchor="middle">Ixconnect: T@${p.tourillon1Axis} · IX@${p.ixconnectAxis} · T@${p.tourillon2Axis} mm (haut & bas)</text>`;
}
}
if (!isDoorPv && (m.groove?.axisFromPanelEdge || m.groove?.axisFromBackEdge)) {
aOps += svgBackGrooveFaceViewA(piece, m, dimAx, dimAy, dimAw, dimAh, scaleConnPv, fmt);
}
const widthLine = doorMultiPv
? `L ${fmt(widthMm)} mm/vant · ${doorCountPv} vantaux · Brut ${fmt(piece.rawCutWidthMm)} mm`
: `L ${fmt(widthMm)} mm · Brut ${fmt(piece.rawCutWidthMm)} mm`;
aOps += `<line x1="${dimAx}" y1="${dimAy + dimAh + 12}" x2="${dimAx + dimAw}" y2="${dimAy + dimAh + 12}" class="guide"/>`;
aOps += `<text x="${dimAx + dimAw / 2}" y="${widthLabelY}" class="txt" text-anchor="middle">${widthLine}</text>`;
aOps += `<line x1="${dimAx - 12}" y1="${dimAy}" x2="${dimAx - 12}" y2="${dimAy + dimAh}" class="guide"/>`;
aOps += `<text x="${dimAx - 22}" y="${dimAy + dimAh / 2}" class="txt" transform="rotate(-90 ${dimAx - 22} ${dimAy + dimAh / 2})">H ${fmt(heightMm)} mm · Brut ${fmt(piece.rawCutHeightMm)} mm</text>`;
const viewA = wrapSvg(
`${piece.description} - Vue A`,
`Face usinage · ${piece.widthMm} x ${piece.heightMm} mm`,
aOps
);
// Vue B
const scaleB = Math.min(620 / heightMm, 300 / thicknessMm);
const hB = heightMm * scaleB;
const tB = Math.max(10, thicknessMm * scaleB);
const xB = 140;
const isReinforcementPv = piece.elementType === 'renforts';
const wBhPv = isReinforcementPv ? hB : tB;
const hBhPv = isReinforcementPv ? tB : hB;
const yB = 90 + (300 - hBhPv) / 2;
let bOps = `<rect x="${xB}" y="${yB}" width="${wBhPv}" height="${hBhPv}" class="panel"/>`;
if (m.system32 && !isReinforcementPv) {
const s32d = m.system32.holeDepth || thicknessMm;
const len = Math.min(tB * 0.95, s32d * scaleB);
bOps += `<line x1="${xB + 1}" y1="${yB + hB * 0.25}" x2="${xB + len}" y2="${yB + hB * 0.25}" class="lineRed"/><text x="${xB + len + 8}" y="${yB + hB * 0.25 + 4}" class="txt">System32 Ø${m.system32.holeDiameter} · ${m.system32.throughHole ? 'TRAV.' : `prof ${fmt(s32d)}mm`}</text>`;
}
if (m.hingeCups && !isReinforcementPv) {
const d = m.hingeCups.cupDepth || 12;
const len = Math.min(tB * 0.95, d * scaleB);
bOps += `<line x1="${xB + 1}" y1="${yB + hB * 0.75}" x2="${xB + len}" y2="${yB + hB * 0.75}" class="lineOrange"/><text x="${xB + len + 8}" y="${yB + hB * 0.75 + 4}" class="txt">Cuvette Ø${m.hingeCups.diameter} · prof ${fmt(d)}mm</text>`;
}
if (isReinforcementPv && m.reinforcementFixing) {
bOps += svgReinforcementFixingEdgeViewB(m.reinforcementFixing, xB, yB, tB, wBhPv, scaleB, fmt, true);
}
if (isReinforcementPv) {
bOps += `<line x1="${xB}" y1="${yB - 16}" x2="${xB + wBhPv}" y2="${yB - 16}" class="guide"/>`;
bOps += `<text x="${xB + wBhPv / 2}" y="${yB - 22}" class="txt" text-anchor="middle">Hauteur face chant ${fmt(heightMm)} mm</text>`;
bOps += `<line x1="${xB + wBhPv + 14}" y1="${yB}" x2="${xB + wBhPv + 14}" y2="${yB + hBhPv}" class="guide"/>`;
bOps += `<text x="${xB + wBhPv + 24}" y="${yB + hBhPv / 2}" class="txt" transform="rotate(-90 ${xB + wBhPv + 24} ${yB + hBhPv / 2})">Épaisseur ${fmt(thicknessMm)} mm</text>`;
} else {
bOps += `<line x1="${xB}" y1="${yB - 16}" x2="${xB + tB}" y2="${yB - 16}" class="guide"/>`;
bOps += `<text x="${xB + tB / 2}" y="${yB - 22}" class="txt" text-anchor="middle">Épaisseur ${fmt(thicknessMm)} mm</text>`;
bOps += `<line x1="${xB + tB + 14}" y1="${yB}" x2="${xB + tB + 14}" y2="${yB + hB}" class="guide"/>`;
bOps += `<text x="${xB + tB + 24}" y="${yB + hB / 2}" class="txt" transform="rotate(-90 ${xB + tB + 24} ${yB + hB / 2})">Hauteur ${fmt(heightMm)} mm</text>`;
}
const viewB = wrapSvg(
`${piece.description} - Vue B`,
`Vue de chant · épaisseur ${piece.thicknessMm} mm`,
bOps
);
// Vue C
const scaleC = Math.min(620 / widthMm, 230 / thicknessMm);
const wC = widthMm * scaleC;
const tC = Math.max(12, thicknessMm * scaleC);
const xC = 70 + (620 - wC) / 2;
const yC = 150;
let cOps = `<rect x="${xC}" y="${yC}" width="${wC}" height="${tC}" class="panel"/>`;
if (m.groove?.axisFromPanelEdge || m.groove?.axisFromBackEdge) {
cOps += svgBackGrooveSectionViewC(xC, yC, wC, tC, scaleC, piece, m, fmt);
}
if (['dessus', 'fond'].includes(piece.elementType) && m.carcassFasteners) {
const cf = m.carcassFasteners;
const depL = cf.ferrureDepthAlongLengthMm ?? 46;
const depT = cf.ferrureDepthInThicknessMm ?? 12;
const dTourMm = cf.dowelDiameter || 8;
const depthTourMm = tourillonFaceDrillDepthMm(thicknessMm);
cOps += svgCarcassTourillonSlotsSectionView(xC, yC, scaleC, tC, widthMm, thicknessMm, cf);
cOps += `<text x="${xC}" y="${yC + tC + 64}" class="txt" fill="#0f766e">Tourillons : 1/jonction (traitillé, face haut) · Ø${fmt(dTourMm)} mm, prof. ${fmt(depthTourMm)} mm · Ferrure ${fmt(depL)} / ${fmt(depT)} mm</text>`;
}
if (m.connectorHoles?.edgeHole) {
cOps += `<text x="${xC}" y="${yC + tC + 70}" class="txt">Ixconnect about Ø${fmt(m.connectorHoles.edgeHole.diameter || 8)} · prof ${fmt(m.connectorHoles.edgeHole.depth || 45)} mm</text>`;
if (m.connectorHoles?.faceHole) {
cOps += `<text x="${xC}" y="${yC + tC + 86}" class="txt">Ixconnect face Ø${fmt(m.connectorHoles.faceHole.diameter || 8)} · prof ${fmt(m.connectorHoles.faceHole.depth || 12)} mm</text>`;
}
}
cOps += `<line x1="${xC}" y1="${yC + tC + 20}" x2="${xC + wC}" y2="${yC + tC + 20}" class="guide"/>`;
cOps += `<text x="${xC + wC / 2}" y="${yC + tC + 34}" class="txt" text-anchor="middle">Largeur ${fmt(widthMm)} mm</text>`;
cOps += `<line x1="${xC - 12}" y1="${yC}" x2="${xC - 12}" y2="${yC + tC}" class="guide"/>`;
cOps += `<text x="${xC - 22}" y="${yC + tC / 2}" class="txt" transform="rotate(-90 ${xC - 22} ${yC + tC / 2})">Épaisseur ${fmt(thicknessMm)} mm</text>`;
const viewC = wrapSvg(
`${piece.description} - Vue C`,
'Coupe L×ép. · rainure côté dos (ouverture face caisse)',
cOps
);
const views = [
{ id: 'view-a', label: 'Vue A', title: 'Face usinage', svg: viewA },
{ id: 'view-b', label: 'Vue B', title: 'Chant / profondeurs', svg: viewB },
{ id: 'view-c', label: 'Vue C', title: 'Coupe rainure / connecteurs', svg: viewC }
];
if (
piece.elementType !== 'portes' &&
m.groove &&
(BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(piece.elementType) ||
BACK_GROOVE_PLAN_HORIZONTAL_TYPES.includes(piece.elementType))
) {
views.push({
id: 'view-d',
label: 'Vue D',
title: 'Détail rainure dos',
svg: wrapSvg(
`${piece.description} — Vue D`,
'Coupe et cotation (réf. type plan technique)',
svgBackGrooveTechnicalDetail(piece, m, fmt),
'0 0 760 440',
440
)
});
}
if (piece.elementType === 'portes') {
const dc = Math.max(1, piece.doorPlanHints?.doorCount || 1);
const sidesD = piece.doorPlanHints?.hingeSides?.length === dc
? piece.doorPlanHints.hingeSides
: Array.from({ length: dc }, (_, i) => getDoorHingeSide(i, dc, piece.doorPlanHints?.hardware || {}));
let viewDContent;
let vbH = 400;
let outH = 400;
if (dc > 1) {
const blockH = Math.min(260, Math.max(200, Math.floor(880 / dc)));
vbH = 20 + dc * (blockH + 14);
outH = Math.min(1200, vbH);
viewDContent = sidesD.map((side, i) => {
const label = `vantail ${i + 1} (charnières ${side === 'right' ? 'droite' : 'gauche'})`;
return `<g transform="translate(16, ${20 + i * (blockH + 14)})"><rect x="0" y="0" width="720" height="${blockH}" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>${doorMachiningDetailMarkup(piece, fmt, 720, blockH - 10, side, label)}</g>`;
}).join('');
} else {
viewDContent = `<g transform="translate(16, 52)">${doorMachiningDetailMarkup(piece, fmt, 720, 300)}</g>`;
}
const viewD = wrapSvg(
`${piece.description} - Vue D`,
'Détail rainure (cotes) · schéma hauteur · perçages',
viewDContent,
dc > 1 ? `0 0 760 ${vbH}` : '0 0 760 400',
dc > 1 ? outH : 400
);
views.push({ id: 'view-d', label: 'Vue D', title: 'Rainure & perçages', svg: viewD });
}
return views;
}
function coteFaceHasRenfortHoles(faceBlock) {
return Array.isArray(faceBlock?.holes) && faceBlock.holes.length > 0;
}
function separatorBlockHasRenfort(block) {
return Array.isArray(block?.holes) && block.holes.length > 0;
}
function separatorHasRenfort(sep) {
if (!sep) return false;
return separatorBlockHasRenfort(sep.faceTowardLowerModule) || separatorBlockHasRenfort(sep.faceTowardHigherModule);
}
/** 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 };