Ship configurator updates with catalog DB included for server deploy. Co-authored-by: Cursor <[email protected]>
4762 lines
219 KiB
JavaScript
4762 lines
219 KiB
JavaScript
/**
|
||
* Cutting Engine - Generate cutting list (liste de débitage) for wardrobe production
|
||
* Calculates all panel dimensions, edge banding, machining operations, and
|
||
* reinforcement panels based on APEX 4.0 manufacturing specifications.
|
||
*/
|
||
|
||
import {
|
||
PANEL, EDGE_BANDING, 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 & perçages`
|
||
: 'Détail usinage porte — rainure & perçages';
|
||
s += `<text x="${x1}" y="${yTitle}" font-family="Arial,sans-serif" font-size="11.5" font-weight="bold" fill="#1f2937">${titleText}</text>`;
|
||
|
||
// Colonne 1 — Coupe sur épaisseur (chant poignée), même logique que rainure dos : larg. × prof.
|
||
const sc = Math.min(110 / tMm, 95 / Math.max(gd, 1));
|
||
const barW = tMm * sc;
|
||
const barH = 34;
|
||
const px = x1;
|
||
const py = y0;
|
||
s += `<text x="${px}" y="${py}" font-family="Arial,sans-serif" font-size="9.5" fill="#64748b">Coupe sur chant poignée (profil rainure dans l'épaisseur)</text>`;
|
||
const barY = py + 10;
|
||
s += `<rect x="${px}" y="${barY}" width="${barW}" height="${barH}" fill="#faf6ef" stroke="#333" stroke-width="0.9"/>`;
|
||
const gDpx = gd * sc;
|
||
const gWpx = gw * sc;
|
||
const gX = handleRight ? px + barW - gDpx : px;
|
||
const ghVis = Math.min(gWpx, barH - 2);
|
||
const gY = barY + barH - ghVis;
|
||
s += `<rect x="${gX}" y="${gY}" width="${gDpx}" height="${ghVis}" fill="#2a9d8f" opacity="0.38" stroke="#0d9488" stroke-width="0.85"/>`;
|
||
s += `<text x="${px}" y="${barY + barH + 14}" font-family="Arial,sans-serif" font-size="9.5" fill="#0f766e">Ouverture rainure ${fmt(gw)} mm · Profondeur ${fmt(gd)} mm</text>`;
|
||
s += `<text x="${px}" y="${barY + barH + 26}" font-family="Arial,sans-serif" font-size="8.5" fill="#64748b">Épaisseur panneau ${fmt(tMm)} mm</text>`;
|
||
// Petites cotes sur la coupe
|
||
s += `<line x1="${gX + gDpx}" y1="${barY + barH + 4}" x2="${gX + gDpx}" y2="${barY + barH + 14}" stroke="#0f766e" stroke-width="0.5"/>`;
|
||
s += `<line x1="${gX}" y1="${barY + barH + 4}" x2="${gX + gDpx}" y2="${barY + barH + 4}" stroke="#0f766e" stroke-width="0.5"/>`;
|
||
s += `<text x="${gX + gDpx / 2}" y="${barY + barH + 26}" font-size="8" fill="#0f766e" text-anchor="middle">${fmt(gd)}</text>`;
|
||
s += `<text x="${gX + gDpx + 8}" y="${gY + gWpx / 2}" font-size="8" fill="#0f766e">${fmt(gw)}</text>`;
|
||
|
||
// Colonne 2 — Schéma hauteur rainure TH
|
||
const vx = x2 + 14;
|
||
const vy = barY;
|
||
s += `<text x="${x2}" y="${py}" font-family="Arial,sans-serif" font-size="9.5" fill="#64748b">Hauteur rainure (poignée TH)</text>`;
|
||
if (fh) {
|
||
const maxH = Math.min(120, boxH - 50);
|
||
const vScale = maxH / Math.max(H, 1);
|
||
const vLen = H * vScale;
|
||
const oPx = off * vScale;
|
||
s += `<line x1="${vx}" y1="${vy}" x2="${vx}" y2="${vy + vLen}" stroke="#334155" stroke-width="1.2"/>`;
|
||
s += `<line x1="${vx - 5}" y1="${vy}" x2="${vx + 5}" y2="${vy}" stroke="#334155"/>`;
|
||
s += `<line x1="${vx - 5}" y1="${vy + vLen}" x2="${vx + 5}" y2="${vy + vLen}" stroke="#334155"/>`;
|
||
s += `<line x1="${vx - 5}" y1="${vy + oPx}" x2="${vx + 5}" y2="${vy + oPx}" stroke="#0d9488"/>`;
|
||
s += `<line x1="${vx - 5}" y1="${vy + vLen - oPx}" x2="${vx + 5}" y2="${vy + vLen - oPx}" stroke="#0d9488"/>`;
|
||
s += `<text x="${vx + 10}" y="${vy + oPx / 2 + 3}" font-size="8.5" fill="#0d9488">Retrait ${fmt(off)} mm</text>`;
|
||
s += `<text x="${vx + 10}" y="${vy + oPx + (vLen - 2 * oPx) / 2}" font-size="8.5" fill="#334155">Utile rainure ${fmt(grooveLen)} mm</text>`;
|
||
s += `<text x="${vx + 10}" y="${vy + vLen - oPx / 2 + 3}" font-size="8.5" fill="#0d9488">Retrait ${fmt(off)} mm</text>`;
|
||
} else {
|
||
s += `<text x="${x2}" y="${vy + 24}" font-size="9" fill="#94a3b8">Poignée standard — pas de rainure TH</text>`;
|
||
}
|
||
|
||
// Colonne 3 — Perçages
|
||
let ly = y0 + 8;
|
||
s += `<text x="${x3}" y="${py}" font-family="Arial,sans-serif" font-size="9.5" fill="#64748b">Perçages</text>`;
|
||
if (hc) {
|
||
const pos = (hc.positionsFromTop || []).map((v) => `${Math.round(v)}`).join(', ');
|
||
const sd = hc.screwDiameterMm ?? HINGE.cupScrewDiameter;
|
||
const sz = hc.screwDepthMm ?? HINGE.cupScrewDepth;
|
||
const ssp = hc.screwSpacingMm ?? HINGE.cupScrewSpacing;
|
||
const sof = hc.screwOffsetFromCupCenterMm ?? HINGE.cupScrewOffsetFromCupCenter;
|
||
s += `<text x="${x3}" y="${ly + 8}" font-size="9.5" fill="#1f2937"><tspan font-weight="600">Charnières</tspan> — cuvette Ø${hc.diameter} mm, prof. ${hc.cupDepth} mm</text>`;
|
||
s += `<text x="${x3}" y="${ly + 22}" font-size="9" fill="#475569">Axes depuis haut : ${pos} mm — vis Ø${sd}×${sz} mm (${ssp} mm c/c, ${sof} mm du centre cuvette)</text>`;
|
||
ly += 36;
|
||
}
|
||
if (fs) {
|
||
s += `<text x="${x3}" y="${ly + 8}" font-size="9.5" fill="#1f2937"><tspan font-weight="600">${fs.model || 'SC 8/25'}</tspan> — face Ø${fs.faceHole?.diameter || 8} × prof. ${fs.faceHole?.depth || 12.5} mm</text>`;
|
||
s += `<text x="${x3}" y="${ly + 22}" font-size="9" fill="#475569">Axes depuis face intérieure : ${fs.axes?.back ?? 30} mm (arrière) / ${fs.axes?.front ?? 43} mm (avant)</text>`;
|
||
}
|
||
|
||
return s;
|
||
}
|
||
|
||
function generatePiecePlanSvg(piece) {
|
||
const widthMm = Math.max(1, Number(piece.widthMm) || 1);
|
||
const heightMm = Math.max(1, Number(piece.heightMm) || 1);
|
||
const thicknessMm = Math.max(1, Number(piece.thicknessMm) || 1);
|
||
const margin = 28;
|
||
const panelGap = 24;
|
||
const viewW = 330;
|
||
const viewH = 220;
|
||
const infoBlockH = 180;
|
||
const xA = margin;
|
||
const yA = 42;
|
||
const xB = xA + viewW + panelGap;
|
||
const yB = yA;
|
||
const xC = xB + viewW + panelGap;
|
||
const yC = yA;
|
||
const svgW = xC + viewW + margin;
|
||
const m = piece.machining || {};
|
||
const fmt = (v) => Number(v).toFixed(Number(v) % 1 === 0 ? 0 : 1);
|
||
|
||
// View A: front view with drilling positions
|
||
const scaleA = Math.min((viewW - 40) / widthMm, (viewH - 40) / heightMm);
|
||
const wA = widthMm * scaleA;
|
||
const hA = heightMm * scaleA;
|
||
const pAx = xA + (viewW - wA) / 2;
|
||
const pAy = yA + 24 + (viewH - 24 - hA) / 2;
|
||
let viewAOps = '';
|
||
const isHabFinishSide = piece.elementType === 'habillageFinitionGauche' || piece.elementType === 'habillageFinitionDroite';
|
||
const isHabFinishTop = piece.elementType === 'habillageFinitionDessus';
|
||
let viewAMiterMarks = '';
|
||
let miterInfoLine = '';
|
||
|
||
if (m.system32?.positions?.length) {
|
||
const frontX = pAx + Math.min(37 * scaleA, wA * 0.2);
|
||
const backX = pAx + wA - Math.min(37 * scaleA, wA * 0.2);
|
||
const r = Math.max(1.2, (m.system32.holeDiameter || 5) * scaleA * 0.5);
|
||
for (const pos of m.system32.positions) {
|
||
const y = pAy + Math.min(hA, Math.max(0, pos * scaleA));
|
||
viewAOps += `<circle cx="${frontX}" cy="${y}" r="${r}" class="hole"/><circle cx="${backX}" cy="${y}" r="${r}" class="hole"/>`;
|
||
}
|
||
viewAOps += `<text x="${pAx + wA / 2}" y="${pAy - 8}" class="txtCenter">System 32: Ø${m.system32.holeDiameter}${m.system32.throughHole ? ' traversant' : ` prof. ${m.system32.holeDepth || 12}`}</text>`;
|
||
}
|
||
|
||
// Rayons (SP 15/8/5): perçages face dessous Ø20×(15 ou 20 selon type)
|
||
if (
|
||
['etageres', 'rayonFixe'].includes(piece.elementType) &&
|
||
['sp_15_8_5', 'sp_15_8_5_face_only'].includes(m.mobileShelfSystem?.model)
|
||
) {
|
||
const specs = buildShelfMobileHoleSpecsMm(piece, widthMm, heightMm);
|
||
const face = specs.filter((h) => h.face === 'face');
|
||
if (face.length) {
|
||
const rFace = Math.max(2.0, (20 * 0.5) * scaleA);
|
||
for (const h of face) {
|
||
const x = pAx + Math.min(wA, Math.max(0, Number(h.xMm) * scaleA));
|
||
const y = pAy + Math.min(hA, Math.max(0, Number(h.yMm) * scaleA));
|
||
viewAOps += `<circle cx="${x}" cy="${y}" r="${rFace}" class="shelfFaceHole"/>`;
|
||
}
|
||
const z = Number(m.mobileShelfSystem?.faceHole?.depthMm ?? (piece.elementType === 'rayonFixe' ? 20 : 15));
|
||
viewAOps += `<text x="${pAx + wA / 2}" y="${pAy - 8}" class="txtCenter">${piece.elementType === 'rayonFixe' ? 'Rayon fixe' : 'Rayons mobiles'} SP 15/8/5: Face Ø20 · prof. ${Math.round(z)} (dessous)</text>`;
|
||
}
|
||
}
|
||
|
||
if (piece.elementType === 'renfortsTiroir' && m.drawerReinforcement?.ixconnectFixing?.holes?.length) {
|
||
const ix = m.drawerReinforcement.ixconnectFixing;
|
||
const d = Number(ix.holeDiameterMm) || 8;
|
||
const r = Math.max(1.5, (d * 0.5) * scaleA);
|
||
for (const h of ix.holes) {
|
||
const x = pAx + Math.min(wA, Math.max(0, Number(h.xMm) * scaleA));
|
||
const y = pAy + Math.min(hA, Math.max(0, Number(h.yMm) * scaleA));
|
||
viewAOps += `<circle cx="${x}" cy="${y}" r="${r}" class="connector"/>`;
|
||
}
|
||
viewAOps += `<text x="${pAx + wA / 2}" y="${pAy - 8}" class="txtCenter">${ix.model || 'SC 8/25'} — 4× face Ø${d} · prof. ${ix.depthInReinforcementMm} mm (montant/séparateur Ø${d} · prof. ${ix.depthInVerticalPanelMm} mm) · ${ix.cornerInsetMm} mm des coins · repère Y depuis haut</text>`;
|
||
}
|
||
|
||
// 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 };
|