Ship configurator updates with catalog DB included for server deploy. Co-authored-by: Cursor <[email protected]>
1510 lines
63 KiB
JavaScript
1510 lines
63 KiB
JavaScript
/**
|
||
* APEX 4.0 — Dossier Technique Maître
|
||
* Single source of truth for all manufacturing specifications.
|
||
* Every dimension in millimetres unless noted otherwise.
|
||
*/
|
||
|
||
import { LEGRABOX_SIDE_HEIGHTS_MM, legraboxNicheHeightMm } from './blumLegrabox.js';
|
||
|
||
// ── Panel thicknesses ────────────────────────────────────────────────
|
||
export const PANEL = {
|
||
structure: 19, // Côtés, dessus, fond, séparateurs, étagères
|
||
reinforcement: 16, // Renforts Forteresse
|
||
back: 8, // Dos (panneau arrière)
|
||
reinforcementWidth: 100, // Largeur standard d'un renfort
|
||
};
|
||
|
||
// ── Reinforcement layout rules (Renforts Forteresse) ────────────────
|
||
export const REINFORCEMENT = {
|
||
topBottomOffsetMm: 19, // Renfort haut/bas à 19 mm
|
||
thirdRequiredAboveHeightMm: 1500, // 3e renfort si armoire > 1.5 m
|
||
};
|
||
|
||
// ── Edge banding (Chants ABS) ────────────────────────────────────────
|
||
export const EDGE_BANDING = {
|
||
abs: 2, // Chant ABS visible (2 mm)
|
||
hidden: 0.5, // Chant fin caché (0.5 mm)
|
||
backPanelSides: 2, // Dos: ABS sur 2 chants longs (haut + bas)
|
||
topBottomSides: 2, // Dessus/Fond: ABS faces avant ET arrière
|
||
shelfSides: 1, // Étagères: ABS face avant uniquement
|
||
};
|
||
|
||
/**
|
||
* Surcote sur-mesure (mm) ajoutée à la coupe brute en longueur ET en largeur de chaque pièce.
|
||
* Marge de débit sur-mesure : la pièce est débitée plus grande puis ramenée à la cote finie.
|
||
*/
|
||
export const CUTTING_SURCOTE_MM = 10;
|
||
|
||
// ── Groove for back panel (Rainure dos) ──────────────────────────────
|
||
export const GROOVE = {
|
||
back: {
|
||
width: 9, // mm — largeur rainure
|
||
depth: 10.5, // mm — profondeur rainure
|
||
axisFromPanelEdge: 16, // mm — rainure à 16 mm du bord des côtés/fond/dessus
|
||
},
|
||
};
|
||
|
||
/** Rainure dos — plans / CNC : même coupe que montant (vertical) vs dessus/fond/bandeau (horizontal). */
|
||
export const BACK_GROOVE_PLAN_VERTICAL_TYPES = ['cotes', 'fileur', 'cotesDoublage'];
|
||
export const BACK_GROOVE_PLAN_HORIZONTAL_TYPES = ['dessus', 'fond', 'bandeauHaut'];
|
||
|
||
/** @returns {'vertical' | 'horizontal' | null} */
|
||
export function getBackGroovePlanOrientation(elementType) {
|
||
if (BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(elementType)) return 'vertical';
|
||
if (BACK_GROOVE_PLAN_HORIZONTAL_TYPES.includes(elementType)) return 'horizontal';
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Séparateurs : plaqués contre le dos (sans rainure). Largeur = même entraxe profondeur que le montant,
|
||
* diminuée du décalage d’axe et de la demi-largeur de rainure (côtés).
|
||
* @param {number} carcassDepthMm — profondeur caisse / largeur en plan du panneau côté (mm)
|
||
*/
|
||
export function getSeparatorDepthMm(carcassDepthMm) {
|
||
const d = Number(carcassDepthMm) || 0;
|
||
return Math.round(d - GROOVE.back.axisFromPanelEdge - GROOVE.back.width / 2);
|
||
}
|
||
|
||
// ── Connectors (Häfele Ixconnect) ────────────────────────────────────
|
||
export const CONNECTORS = {
|
||
// SC 8/60 — structural assembly (caisse: côtés ↔ dessus/fond)
|
||
ixconnect: {
|
||
model: 'SC 8/60',
|
||
edgeHole: { diameter: 8, depth: 45 }, // mm — perçage en about
|
||
faceHole: { diameter: 8, depth: 12 }, // mm — perçage en face
|
||
/** Accès Inbus — sur le même panneau que le long perçage Ø8 en chant,
|
||
* à `offsetFromAssemblyEdgeMm` du chant d’assemblage. */
|
||
lockScrewInbus: {
|
||
diameter: 6,
|
||
depth: 9,
|
||
/** mm — depuis le chant d’assemblage (axe du perçage Ø45), vers l’intérieur du panneau */
|
||
offsetFromAssemblyEdgeMm: 25
|
||
}
|
||
},
|
||
// SC 8/25 — finish panels (portes, finitions)
|
||
ixconnectFinish: {
|
||
model: 'SC 8/25',
|
||
faceHole: { diameter: 8, depth: 12.5 }, // mm — face interne Ø8 × 12.5mm
|
||
axes: { back: 30, front: 43 }, // mm — axes: 30mm (arrière) / 43mm (avant)
|
||
},
|
||
// Positioning per structural joint (axes mm depuis le bord avant de l’arête, le long de la profondeur)
|
||
// — Arêtes d’extrémité (montants extérieurs): T → IX → T
|
||
// — Jonctions internes (séparateurs): Ixconnect à l’axe médian profondeur, tourillons ±30 mm de cet axe
|
||
jointPatternEdge: {
|
||
tourillon1Axis: 20,
|
||
ixconnectAxis: 50,
|
||
tourillon2Axis: 80,
|
||
},
|
||
/** @deprecated use jointPatternEdge — alias pour compat. */
|
||
jointPattern: {
|
||
tourillon1Axis: 20,
|
||
ixconnectAxis: 50,
|
||
tourillon2Axis: 80,
|
||
},
|
||
/** Tourillons par rapport à l’axe Ixconnect sur jonction milieu (mm). */
|
||
jointPatternMiddleTourillonOffsetFromIxMm: 30,
|
||
tourillon: {
|
||
diameter: 8, // mm — Ø tourillon
|
||
length: 30, // mm — tourillon Ø8×30
|
||
},
|
||
tourillonDiameter: 8, // mm — backward compat
|
||
};
|
||
|
||
/**
|
||
* mm — depuis le bord physique panneau côté dos : axe rainure + largeur rainure (= fin utile côté dos, repère Ixconnect).
|
||
* Évite chevauchement perçages / rainure dos sur dessus·fond.
|
||
*/
|
||
export function getBackGrooveInnerEndInsetFromBackEdgeMm() {
|
||
return GROOVE.back.axisFromPanelEdge + GROOVE.back.width;
|
||
}
|
||
|
||
/**
|
||
* Axes Ixconnect / tourillons le long de l’arête (profondeur caisse pour dessus·fond, largeur = profondeur pour côtés),
|
||
* mesurés depuis le bord avant (face avant).
|
||
* @param {'edge'|'middle'} variant — extrémité de caisse vs jonction entre modules (séparateur)
|
||
* @param {{ horizontalPanel?: boolean }} [options] — si `horizontalPanel`, la profondeur utile exclut la zone rainure dos (16+9 mm depuis le bord arrière).
|
||
*/
|
||
export function getIxconnectJointPatternAlongDepthMm(depthMm, variant, options = {}) {
|
||
const horizontalPanel = Boolean(options.horizontalPanel);
|
||
const inset = horizontalPanel ? getBackGrooveInnerEndInsetFromBackEdgeMm() : 0;
|
||
const d = Math.max(0, Number(depthMm) || 0);
|
||
const dEff = horizontalPanel ? Math.max(0, d - inset) : d;
|
||
const round1 = (v) => Math.round(v * 10) / 10;
|
||
if (variant === 'edge') {
|
||
const t1 = CONNECTORS.jointPatternEdge.tourillon1Axis;
|
||
const ix = CONNECTORS.jointPatternEdge.ixconnectAxis;
|
||
const t2 = CONNECTORS.jointPatternEdge.tourillon2Axis;
|
||
if (!horizontalPanel || dEff <= 0) {
|
||
return {
|
||
role: 'edge',
|
||
tourillon1Axis: t1,
|
||
ixconnectAxis: ix,
|
||
tourillon2Axis: t2
|
||
};
|
||
}
|
||
if (dEff >= t2) {
|
||
return {
|
||
role: 'edge',
|
||
tourillon1Axis: t1,
|
||
ixconnectAxis: ix,
|
||
tourillon2Axis: t2
|
||
};
|
||
}
|
||
const s = dEff / t2;
|
||
return {
|
||
role: 'edge',
|
||
tourillon1Axis: round1(t1 * s),
|
||
ixconnectAxis: round1(ix * s),
|
||
tourillon2Axis: round1(t2 * s)
|
||
};
|
||
}
|
||
if (dEff <= 0) {
|
||
return {
|
||
role: 'middle',
|
||
tourillon1Axis: 0,
|
||
ixconnectAxis: 0,
|
||
tourillon2Axis: 0
|
||
};
|
||
}
|
||
const ixMid = round1(dEff / 2);
|
||
const off = CONNECTORS.jointPatternMiddleTourillonOffsetFromIxMm;
|
||
return {
|
||
role: 'middle',
|
||
tourillon1Axis: round1(Math.max(0, ixMid - off)),
|
||
ixconnectAxis: ixMid,
|
||
tourillon2Axis: round1(Math.min(dEff, ixMid + off))
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Largeurs de colonnes (mm) pour placer les jonctions — ordre: modules API, puis blocks, puis config.modules.
|
||
* Évite le cas « modules = [] » alors que `config.modules` contient les largeurs (une seule jonction / mauvais comptage).
|
||
*/
|
||
function getModuleWidthsForJunctions(modules = [], config = {}) {
|
||
const fromArr = (arr) =>
|
||
(Array.isArray(arr) ? arr : [])
|
||
.map((m) => Number(m?.widthMm) || 0)
|
||
.filter((v) => v > 0);
|
||
let w = fromArr(modules);
|
||
if (w.length === 0) w = fromArr(config.blocks);
|
||
if (w.length === 0) w = fromArr(config.modules);
|
||
return w;
|
||
}
|
||
|
||
/**
|
||
* Positions X des jonctions verticales (côtés + séparateurs) sur un panneau horizontal — mm depuis le bord gauche.
|
||
*/
|
||
export function getJunctionAxesAcrossWidthMm(dimensionsWidthMm, modules = [], config = {}) {
|
||
const maxW = Math.max(0, Number(dimensionsWidthMm) || 0);
|
||
const mWidths = getModuleWidthsForJunctions(modules, config);
|
||
if (mWidths.length > 0) {
|
||
const totalOuter = mWidths.reduce((sum, w) => sum + w, 0);
|
||
// Column widths are wardrobe-outer; the horizontal panel is shorter by the
|
||
// exterior montant insets (typically 19 mm per side). Shift interior
|
||
// boundaries into the panel's left-origin so the right edge is not left
|
||
// with a 19 mm "phantom" margin.
|
||
const overhang = totalOuter - maxW;
|
||
const leftShift =
|
||
overhang > 0.5 && mWidths.length >= 2 ? Math.min(PANEL.structure, overhang) : 0;
|
||
let acc = 0;
|
||
const axes = [0];
|
||
for (let i = 0; i < mWidths.length - 1; i++) {
|
||
acc += mWidths[i];
|
||
axes.push(Math.max(0, Math.min(maxW, acc - leftShift)));
|
||
}
|
||
axes.push(maxW);
|
||
const sorted = [...axes].sort((a, b) => a - b);
|
||
const dedup = [];
|
||
const eps = 0.5;
|
||
for (const v of sorted) {
|
||
if (dedup.length === 0 || Math.abs(v - dedup[dedup.length - 1]) > eps) {
|
||
dedup.push(Math.round(v * 100) / 100);
|
||
}
|
||
}
|
||
return dedup;
|
||
}
|
||
return [0, maxW];
|
||
}
|
||
|
||
// ── CNC / panel envelope (Bearbeitungsdimensionen) ──────────────────
|
||
/** Longest manufacturable panel edge (CNC Länge), mm. */
|
||
export const MAX_PANEL_DIMENSION_MM = 3250;
|
||
/** Shortest manufacturable panel edge on the machine bed (CNC Breite), mm. */
|
||
export const MAX_PANEL_SHORT_SIDE_MM = 1500;
|
||
/** Maximum panel thickness the CNC can machine (CNC Dicke), mm. */
|
||
export const MAX_PANEL_THICKNESS_MM = 125;
|
||
|
||
// ── Multi-column horizontal spans ───────────────────────────────────
|
||
export const HORIZONTAL_SPAN_TYPE = 'open';
|
||
export const HORIZONTAL_SPAN_DOOR_MODES = Object.freeze({
|
||
none: 'none',
|
||
perColumn: 'perColumn',
|
||
single: 'single'
|
||
});
|
||
export const HORIZONTAL_SPAN_BLOCK_MM = Object.freeze({
|
||
min: 300,
|
||
max: MAX_PANEL_DIMENSION_MM,
|
||
step: 50,
|
||
initialUpper: 300
|
||
});
|
||
const SPAN_EPSILON_MM = 0.5;
|
||
|
||
/**
|
||
* Ordered horizontal module extents, measured from the wardrobe's left edge.
|
||
* Invalid/negative widths are represented as zero-width modules so ids and indexes remain stable.
|
||
*/
|
||
export function getModulePositionsMm(modules = []) {
|
||
let leftMm = 0;
|
||
return (Array.isArray(modules) ? modules : []).map((module, index) => {
|
||
const widthMm = Math.max(0, Number(module?.widthMm) || 0);
|
||
const position = {
|
||
id: module?.id,
|
||
index,
|
||
module,
|
||
leftMm,
|
||
rightMm: leftMm + widthMm,
|
||
widthMm
|
||
};
|
||
leftMm += widthMm;
|
||
return position;
|
||
});
|
||
}
|
||
|
||
/** Vertical block edges (including 0 and body top), measured from the carcass bottom. */
|
||
export function getModuleVerticalBoundariesMm(module, config = {}) {
|
||
const bodyMm = Math.max(
|
||
0,
|
||
(Number(config?.heightMm) || 0) - (Number(config?.plinthHeightMm) || 0)
|
||
);
|
||
const raw = Array.isArray(module?.blockHeightsMm)
|
||
? module.blockHeightsMm.map(Number).filter((value) => Number.isFinite(value) && value > 0)
|
||
: [];
|
||
if (raw.length === 0) {
|
||
const legacyBottom = Number(module?.stackBottomHeightMm ?? config?.stackBottomHeightMm);
|
||
const stacked = module?.stacked === true || (module?.stacked == null && config?.stackEnabled === true);
|
||
if (stacked && legacyBottom > 0 && legacyBottom < bodyMm) raw.push(legacyBottom, bodyMm - legacyBottom);
|
||
else raw.push(bodyMm);
|
||
}
|
||
|
||
const boundaries = [0];
|
||
let cursor = 0;
|
||
for (const height of raw) {
|
||
cursor += height;
|
||
if (cursor > SPAN_EPSILON_MM && cursor < bodyMm - SPAN_EPSILON_MM) boundaries.push(cursor);
|
||
}
|
||
boundaries.push(bodyMm);
|
||
return boundaries.map((value) => Math.round(value * 100) / 100);
|
||
}
|
||
|
||
function uniqueModuleIds(moduleIds) {
|
||
const seen = new Set();
|
||
return (Array.isArray(moduleIds) ? moduleIds : []).filter((id) => {
|
||
if ((typeof id !== 'string' && typeof id !== 'number') || seen.has(String(id))) return false;
|
||
seen.add(String(id));
|
||
return true;
|
||
});
|
||
}
|
||
|
||
/** Canonical state representation; validation should be run before accepting user input. */
|
||
export function normalizeHorizontalSpan(span = {}, modules = []) {
|
||
const order = new Map(getModulePositionsMm(modules).map((position) => [String(position.id), position.index]));
|
||
const moduleIds = uniqueModuleIds(span.moduleIds)
|
||
.filter((id) => order.has(String(id)))
|
||
.sort((a, b) => order.get(String(a)) - order.get(String(b)));
|
||
const requestedDoorMode = String(span.doorMode || HORIZONTAL_SPAN_DOOR_MODES.none);
|
||
const doorMode = Object.values(HORIZONTAL_SPAN_DOOR_MODES).includes(requestedDoorMode)
|
||
? requestedDoorMode
|
||
: HORIZONTAL_SPAN_DOOR_MODES.none;
|
||
const normalized = {
|
||
id: span.id,
|
||
moduleIds,
|
||
bottomMm: Number(span.bottomMm),
|
||
topMm: Number(span.topMm),
|
||
hasBack: Boolean(span.hasBack),
|
||
doorMode,
|
||
type: HORIZONTAL_SPAN_TYPE
|
||
};
|
||
const upperBlockTopMm = Number(span.upperBlockTopMm);
|
||
if (Number.isFinite(upperBlockTopMm)) normalized.upperBlockTopMm = upperBlockTopMm;
|
||
return normalized;
|
||
}
|
||
|
||
/**
|
||
* Resolve a span to current module indexes, extents and covered block indexes.
|
||
* Returns null when it references missing modules.
|
||
*/
|
||
export function resolveHorizontalSpan(span, config = {}) {
|
||
const modules = Array.isArray(config?.modules) ? config.modules : [];
|
||
const positions = getModulePositionsMm(modules);
|
||
const byId = new Map(positions.map((position) => [String(position.id), position]));
|
||
const requestedIds = uniqueModuleIds(span?.moduleIds);
|
||
const selected = requestedIds.map((id) => byId.get(String(id)));
|
||
if (selected.some((position) => !position)) return null;
|
||
selected.sort((a, b) => a.index - b.index);
|
||
const bottomMm = Number(span?.bottomMm);
|
||
const topMm = Number(span?.topMm);
|
||
const coveredBlocks = selected.map((position) => {
|
||
const boundariesMm = getModuleVerticalBoundariesMm(position.module, config);
|
||
const blockIndexes = [];
|
||
for (let index = 0; index < boundariesMm.length - 1; index++) {
|
||
if (
|
||
boundariesMm[index] >= bottomMm - SPAN_EPSILON_MM &&
|
||
boundariesMm[index + 1] <= topMm + SPAN_EPSILON_MM
|
||
) blockIndexes.push(index);
|
||
}
|
||
return { moduleId: position.id, moduleIndex: position.index, boundariesMm, blockIndexes };
|
||
});
|
||
return {
|
||
span,
|
||
moduleIds: selected.map((position) => position.id),
|
||
moduleIndexes: selected.map((position) => position.index),
|
||
modules: selected.map((position) => position.module),
|
||
leftMm: selected[0]?.leftMm ?? 0,
|
||
rightMm: selected[selected.length - 1]?.rightMm ?? 0,
|
||
widthMm: selected.length ? selected[selected.length - 1].rightMm - selected[0].leftMm : 0,
|
||
bottomMm,
|
||
topMm,
|
||
coveredBlocks
|
||
};
|
||
}
|
||
|
||
function hasBoundary(boundaries, value) {
|
||
return boundaries.some((boundary) => Math.abs(boundary - value) <= SPAN_EPSILON_MM);
|
||
}
|
||
|
||
/**
|
||
* Validate all horizontal spans. Returned issues have stable machine-readable `code` values.
|
||
* Drawers are treated as occupying the bottom block; doors use `blockDoors` (legacy default: true).
|
||
*/
|
||
export function validateHorizontalSpans(config = {}, options = {}) {
|
||
const spans = Array.isArray(config?.horizontalSpans) ? config.horizontalSpans : [];
|
||
const maxPanelWidthMm = Number(options.maxPanelWidthMm) || MAX_PANEL_DIMENSION_MM;
|
||
const bodyMm = Math.max(0, (Number(config?.heightMm) || 0) - (Number(config?.plinthHeightMm) || 0));
|
||
const issues = [];
|
||
const resolvedValidShape = [];
|
||
const ids = new Set();
|
||
|
||
spans.forEach((span, spanIndex) => {
|
||
const field = `horizontalSpans[${spanIndex}]`;
|
||
const spanId = span?.id;
|
||
const add = (code, message) => issues.push({ code, field, spanId, message });
|
||
if ((typeof spanId !== 'string' && typeof spanId !== 'number') || String(spanId).trim() === '') {
|
||
add('missing_id', 'La traverse horizontale doit avoir un identifiant stable.');
|
||
} else if (ids.has(String(spanId))) {
|
||
add('duplicate_id', 'Identifiant de traverse horizontale dupliqué.');
|
||
} else ids.add(String(spanId));
|
||
if (span?.type !== HORIZONTAL_SPAN_TYPE) add('bad_type', `Type de traverse invalide (attendu : ${HORIZONTAL_SPAN_TYPE}).`);
|
||
if (typeof span?.hasBack !== 'boolean') add('bad_has_back', 'Le dos de la traverse doit être un booléen.');
|
||
const doorMode = span?.doorMode ?? HORIZONTAL_SPAN_DOOR_MODES.none;
|
||
if (!Object.values(HORIZONTAL_SPAN_DOOR_MODES).includes(doorMode)) {
|
||
add('bad_door_mode', 'Le choix de portes du block horizontal est invalide.');
|
||
}
|
||
if (!Array.isArray(span?.moduleIds) || uniqueModuleIds(span.moduleIds).length !== span.moduleIds.length) {
|
||
add('bad_module_ids', 'La liste des colonnes est invalide ou contient des doublons.');
|
||
}
|
||
|
||
const resolved = resolveHorizontalSpan(span, config);
|
||
if (!resolved) {
|
||
add('stale_module', 'La traverse référence une colonne inexistante.');
|
||
return;
|
||
}
|
||
if (resolved.moduleIds.length < 2) add('too_few_modules', 'Une traverse doit couvrir au moins deux colonnes.');
|
||
const contiguous = resolved.moduleIndexes.every(
|
||
(index, offset) => offset === 0 || index === resolved.moduleIndexes[offset - 1] + 1
|
||
);
|
||
if (!contiguous) add('noncontiguous_modules', 'Les colonnes couvertes doivent être contiguës.');
|
||
if (
|
||
!Number.isFinite(resolved.bottomMm) ||
|
||
!Number.isFinite(resolved.topMm) ||
|
||
resolved.bottomMm < 0 ||
|
||
resolved.topMm <= resolved.bottomMm ||
|
||
resolved.topMm > bodyMm + SPAN_EPSILON_MM
|
||
) add('bad_range', 'La plage verticale de la traverse est invalide.');
|
||
if (resolved.widthMm > maxPanelWidthMm + SPAN_EPSILON_MM) {
|
||
add('panel_too_wide', `La traverse dépasse la largeur de panneau maximale (${maxPanelWidthMm} mm).`);
|
||
}
|
||
|
||
let aligned = true;
|
||
resolved.coveredBlocks.forEach(({ boundariesMm }) => {
|
||
if (!hasBoundary(boundariesMm, resolved.bottomMm) || !hasBoundary(boundariesMm, resolved.topMm)) aligned = false;
|
||
});
|
||
if (!aligned) add('misaligned_boundaries', 'Les limites doivent coïncider avec les jonctions de blocks de chaque colonne.');
|
||
|
||
resolved.coveredBlocks.forEach(({ moduleIndex, blockIndexes }) => {
|
||
const module = config.modules[moduleIndex];
|
||
const doors = Array.isArray(module?.blockDoors) ? module.blockDoors : null;
|
||
if (blockIndexes.some((blockIndex) => doors ? doors[blockIndex] !== false : true)) {
|
||
add('incompatible_door', `Une porte occupe la zone couverte dans la colonne ${moduleIndex + 1}.`);
|
||
}
|
||
if ((Number(module?.drawers) || 0) > 0 && blockIndexes.includes(0)) {
|
||
add('incompatible_drawers', `Des tiroirs occupent la zone couverte dans la colonne ${moduleIndex + 1}.`);
|
||
}
|
||
});
|
||
resolvedValidShape.push(resolved);
|
||
});
|
||
|
||
for (let leftIndex = 0; leftIndex < resolvedValidShape.length; leftIndex++) {
|
||
for (let rightIndex = leftIndex + 1; rightIndex < resolvedValidShape.length; rightIndex++) {
|
||
const left = resolvedValidShape[leftIndex];
|
||
const right = resolvedValidShape[rightIndex];
|
||
const sharesModule = left.moduleIds.some((id) => right.moduleIds.some((other) => String(other) === String(id)));
|
||
const overlapsVertically =
|
||
left.bottomMm < right.topMm - SPAN_EPSILON_MM &&
|
||
right.bottomMm < left.topMm - SPAN_EPSILON_MM;
|
||
if (sharesModule && overlapsVertically) {
|
||
issues.push({
|
||
code: 'overlap',
|
||
field: `horizontalSpans[${rightIndex}]`,
|
||
spanId: right.span?.id,
|
||
message: 'Deux traverses horizontales se chevauchent.'
|
||
});
|
||
}
|
||
}
|
||
}
|
||
return issues;
|
||
}
|
||
|
||
/** Keep only spans that remain structurally valid against the current module order/model. */
|
||
export function reconcileHorizontalSpans(spans = [], config = {}, options = {}) {
|
||
const candidateConfig = { ...config, horizontalSpans: Array.isArray(spans) ? spans : [] };
|
||
const invalidIds = new Set(validateHorizontalSpans(candidateConfig, options).map((issue) => String(issue.spanId)));
|
||
return candidateConfig.horizontalSpans
|
||
.filter((span) => !invalidIds.has(String(span?.id)))
|
||
.map((span) => normalizeHorizontalSpan(span, candidateConfig.modules));
|
||
}
|
||
|
||
/**
|
||
* Remaining physical segments for the separator between module N and N+1.
|
||
* Span-covered ranges are removed; touching ranges are merged.
|
||
*/
|
||
export function getSeparatorVerticalSegments(config = {}, separatorIndex) {
|
||
const modules = Array.isArray(config?.modules) ? config.modules : [];
|
||
const left = modules[separatorIndex];
|
||
const right = modules[separatorIndex + 1];
|
||
if (!left || !right) return [];
|
||
const bodyMm = Math.max(0, (Number(config.heightMm) || 0) - (Number(config.plinthHeightMm) || 0));
|
||
const cuts = (Array.isArray(config.horizontalSpans) ? config.horizontalSpans : [])
|
||
.map((span) => resolveHorizontalSpan(span, config))
|
||
.filter((resolved) =>
|
||
resolved &&
|
||
resolved.moduleIndexes.includes(separatorIndex) &&
|
||
resolved.moduleIndexes.includes(separatorIndex + 1)
|
||
)
|
||
.map(({ bottomMm, topMm }) => ({
|
||
bottomMm: Math.max(0, bottomMm),
|
||
topMm: Math.min(bodyMm, topMm)
|
||
}))
|
||
.filter((range) => range.topMm > range.bottomMm)
|
||
.sort((a, b) => a.bottomMm - b.bottomMm);
|
||
const merged = [];
|
||
cuts.forEach((range) => {
|
||
const previous = merged[merged.length - 1];
|
||
if (previous && range.bottomMm <= previous.topMm + SPAN_EPSILON_MM) {
|
||
previous.topMm = Math.max(previous.topMm, range.topMm);
|
||
} else merged.push({ ...range });
|
||
});
|
||
const segments = [];
|
||
let cursor = 0;
|
||
merged.forEach((range) => {
|
||
if (range.bottomMm > cursor + SPAN_EPSILON_MM) segments.push({ bottomMm: cursor, topMm: range.bottomMm });
|
||
cursor = Math.max(cursor, range.topMm);
|
||
});
|
||
if (cursor < bodyMm - SPAN_EPSILON_MM) segments.push({ bottomMm: cursor, topMm: bodyMm });
|
||
return segments;
|
||
}
|
||
|
||
/**
|
||
* Insert selected horizontal limits into module block models while preserving each band's back/door flags.
|
||
* This is intentionally a model operation; callers decide whether resulting short blocks are acceptable.
|
||
*/
|
||
export function synchronizeModuleSpanBoundaries(config = {}, moduleIds = [], bottomMm, topMm) {
|
||
const selectedIds = new Set(uniqueModuleIds(moduleIds).map(String));
|
||
const bodyMm = Math.max(0, (Number(config.heightMm) || 0) - (Number(config.plinthHeightMm) || 0));
|
||
const requested = [Number(bottomMm), Number(topMm)]
|
||
.filter((value) => Number.isFinite(value) && value > 0 && value < bodyMm)
|
||
.sort((a, b) => a - b);
|
||
const modules = (Array.isArray(config.modules) ? config.modules : []).map((module) => {
|
||
if (!selectedIds.has(String(module?.id))) return module;
|
||
const oldEdges = getModuleVerticalBoundariesMm(module, config);
|
||
const edges = [...oldEdges];
|
||
requested.forEach((value) => {
|
||
if (!hasBoundary(edges, value)) edges.push(value);
|
||
});
|
||
edges.sort((a, b) => a - b);
|
||
const sourceBacks = Array.isArray(module.blockBacks) ? module.blockBacks : [];
|
||
const sourceDoors = Array.isArray(module.blockDoors) ? module.blockDoors : [];
|
||
const sourceIndexAt = (midpoint) => Math.max(
|
||
0,
|
||
Math.min(oldEdges.length - 2, oldEdges.findIndex((edge, index) => index < oldEdges.length - 1 && midpoint >= edge && midpoint <= oldEdges[index + 1]))
|
||
);
|
||
const heights = edges.slice(1).map((edge, index) => Math.round((edge - edges[index]) * 100) / 100);
|
||
const backs = heights.map((height, index) => {
|
||
const sourceIndex = sourceIndexAt(edges[index] + height / 2);
|
||
return typeof sourceBacks[sourceIndex] === 'boolean' ? sourceBacks[sourceIndex] : Boolean(module.hasBack);
|
||
});
|
||
const doors = heights.map((height, index) => {
|
||
const sourceIndex = sourceIndexAt(edges[index] + height / 2);
|
||
return typeof sourceDoors[sourceIndex] === 'boolean' ? sourceDoors[sourceIndex] : true;
|
||
});
|
||
return {
|
||
...module,
|
||
blockHeightsMm: heights,
|
||
blockBacks: backs,
|
||
blockDoors: doors,
|
||
stacked: heights.length >= 2,
|
||
stackBottomHeightMm: heights[0],
|
||
hasBack: backs[0],
|
||
hasBackTop: backs[backs.length - 1]
|
||
};
|
||
});
|
||
return { ...config, modules };
|
||
}
|
||
|
||
function horizontalSpanFailure(code, message) {
|
||
return { ok: false, code, message };
|
||
}
|
||
|
||
function sourceBlockIndexAt(edges, midpoint) {
|
||
const index = edges.findIndex((edge, current) => (
|
||
current < edges.length - 1 &&
|
||
midpoint >= edge - SPAN_EPSILON_MM &&
|
||
midpoint <= edges[current + 1] + SPAN_EPSILON_MM
|
||
));
|
||
return Math.max(0, Math.min(edges.length - 2, index < 0 ? edges.length - 2 : index));
|
||
}
|
||
|
||
function rebuildModuleWithSpanBoundary(module, config, oldBoundaryMm, newBoundaryMm, spanRange, upperRange) {
|
||
const oldEdges = getModuleVerticalBoundariesMm(module, config);
|
||
const edges = oldEdges
|
||
.filter((edge) => (
|
||
(!Number.isFinite(oldBoundaryMm) || Math.abs(edge - oldBoundaryMm) > SPAN_EPSILON_MM) &&
|
||
!(edge > newBoundaryMm + SPAN_EPSILON_MM && edge < upperRange.topMm - SPAN_EPSILON_MM)
|
||
));
|
||
if (!hasBoundary(edges, newBoundaryMm)) edges.push(newBoundaryMm);
|
||
edges.sort((a, b) => a - b);
|
||
|
||
const sourceBacks = Array.isArray(module.blockBacks) ? module.blockBacks : [];
|
||
const sourceDoors = Array.isArray(module.blockDoors) ? module.blockDoors : [];
|
||
const heights = edges.slice(1).map((edge, index) => Math.round((edge - edges[index]) * 100) / 100);
|
||
const backs = heights.map((height, index) => {
|
||
const sourceIndex = sourceBlockIndexAt(oldEdges, edges[index] + height / 2);
|
||
return typeof sourceBacks[sourceIndex] === 'boolean' ? sourceBacks[sourceIndex] : Boolean(module.hasBack);
|
||
});
|
||
const doors = heights.map((height, index) => {
|
||
const blockBottomMm = edges[index];
|
||
const blockTopMm = edges[index + 1];
|
||
if (
|
||
blockBottomMm >= spanRange.bottomMm - SPAN_EPSILON_MM &&
|
||
blockTopMm <= spanRange.topMm + SPAN_EPSILON_MM
|
||
) return false;
|
||
if (
|
||
blockBottomMm >= upperRange.bottomMm - SPAN_EPSILON_MM &&
|
||
blockTopMm <= upperRange.topMm + SPAN_EPSILON_MM
|
||
) return true;
|
||
const sourceIndex = sourceBlockIndexAt(oldEdges, blockBottomMm + height / 2);
|
||
return typeof sourceDoors[sourceIndex] === 'boolean' ? sourceDoors[sourceIndex] : true;
|
||
});
|
||
|
||
return {
|
||
...module,
|
||
blockHeightsMm: heights,
|
||
blockBacks: backs,
|
||
blockDoors: doors,
|
||
stacked: heights.length >= 2,
|
||
stackBottomHeightMm: heights[0],
|
||
hasBack: backs[0],
|
||
hasBackTop: backs[backs.length - 1]
|
||
};
|
||
}
|
||
|
||
function findHorizontalSpan(config, spanId) {
|
||
return (Array.isArray(config?.horizontalSpans) ? config.horizontalSpans : [])
|
||
.find((span) => String(span?.id) === String(spanId));
|
||
}
|
||
|
||
/**
|
||
* Describe the regular block explicitly created above a horizontal span.
|
||
* The marker is stored on the span so an unrelated pre-existing upper block is never mistaken for it.
|
||
*/
|
||
export function getHorizontalSpanUpperBlockState(config = {}, spanId) {
|
||
const span = findHorizontalSpan(config, spanId);
|
||
if (!span) return horizontalSpanFailure('span_not_found', 'Le block horizontal sélectionné n’existe plus.');
|
||
const resolved = resolveHorizontalSpan(span, config);
|
||
if (!resolved) return horizontalSpanFailure('span_not_found', 'Les colonnes du block horizontal n’existent plus.');
|
||
|
||
const upperBlockTopMm = Number(span.upperBlockTopMm);
|
||
if (!Number.isFinite(upperBlockTopMm) || upperBlockTopMm <= resolved.topMm + SPAN_EPSILON_MM) {
|
||
return { ok: true, exists: false, canAdd: resolved.topMm - resolved.bottomMm >= HORIZONTAL_SPAN_BLOCK_MM.min * 2 };
|
||
}
|
||
const commonBoundaries = resolved.coveredBlocks.every(({ boundariesMm }) => (
|
||
hasBoundary(boundariesMm, resolved.topMm) && hasBoundary(boundariesMm, upperBlockTopMm)
|
||
));
|
||
if (!commonBoundaries) {
|
||
return horizontalSpanFailure(
|
||
'missing_common_boundary',
|
||
'Impossible : les colonnes couvertes n’ont plus de limite haute commune.'
|
||
);
|
||
}
|
||
|
||
const heightMm = Math.round((upperBlockTopMm - resolved.topMm) * 100) / 100;
|
||
const maxHeightMm = Math.floor(
|
||
Math.min(
|
||
HORIZONTAL_SPAN_BLOCK_MM.max,
|
||
upperBlockTopMm - resolved.bottomMm - HORIZONTAL_SPAN_BLOCK_MM.min
|
||
) / HORIZONTAL_SPAN_BLOCK_MM.step
|
||
) * HORIZONTAL_SPAN_BLOCK_MM.step;
|
||
return {
|
||
ok: true,
|
||
exists: true,
|
||
heightMm,
|
||
minHeightMm: HORIZONTAL_SPAN_BLOCK_MM.min,
|
||
maxHeightMm,
|
||
stepMm: HORIZONTAL_SPAN_BLOCK_MM.step,
|
||
upperBlockTopMm
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Atomically carve a 300 mm regular block from the top of a horizontal span.
|
||
* Returns a new config or an error without mutating the input.
|
||
*/
|
||
export function addUpperBlockAboveHorizontalSpan(config = {}, spanId) {
|
||
const span = findHorizontalSpan(config, spanId);
|
||
if (!span) return horizontalSpanFailure('span_not_found', 'Le block horizontal sélectionné n’existe plus.');
|
||
if (Number.isFinite(Number(span.upperBlockTopMm))) {
|
||
return horizontalSpanFailure('upper_block_exists', 'Un block supérieur synchronisé existe déjà.');
|
||
}
|
||
const resolved = resolveHorizontalSpan(span, config);
|
||
if (!resolved) return horizontalSpanFailure('span_not_found', 'Les colonnes du block horizontal n’existent plus.');
|
||
const spanHeightMm = resolved.topMm - resolved.bottomMm;
|
||
if (spanHeightMm < HORIZONTAL_SPAN_BLOCK_MM.min * 2 - SPAN_EPSILON_MM) {
|
||
return horizontalSpanFailure(
|
||
'span_too_short',
|
||
'Le block horizontal doit mesurer au moins 600 mm pour ajouter un block supérieur.'
|
||
);
|
||
}
|
||
if (!resolved.coveredBlocks.every(({ boundariesMm }) => hasBoundary(boundariesMm, resolved.topMm))) {
|
||
return horizontalSpanFailure(
|
||
'missing_common_boundary',
|
||
'Impossible : les colonnes couvertes n’ont pas de limite haute commune.'
|
||
);
|
||
}
|
||
|
||
const oldTopMm = resolved.topMm;
|
||
const newTopMm = oldTopMm - HORIZONTAL_SPAN_BLOCK_MM.initialUpper;
|
||
const selectedIds = new Set(resolved.moduleIds.map(String));
|
||
const modules = config.modules.map((module) => (
|
||
selectedIds.has(String(module?.id))
|
||
? rebuildModuleWithSpanBoundary(
|
||
module,
|
||
config,
|
||
Number.NaN,
|
||
newTopMm,
|
||
{ bottomMm: resolved.bottomMm, topMm: newTopMm },
|
||
{ bottomMm: newTopMm, topMm: oldTopMm }
|
||
)
|
||
: module
|
||
));
|
||
const horizontalSpans = config.horizontalSpans.map((candidate) => (
|
||
String(candidate?.id) === String(spanId)
|
||
? normalizeHorizontalSpan({ ...candidate, topMm: newTopMm, upperBlockTopMm: oldTopMm }, modules)
|
||
: candidate
|
||
));
|
||
const nextConfig = { ...config, modules, horizontalSpans };
|
||
const issue = validateHorizontalSpans(nextConfig)[0];
|
||
if (issue) {
|
||
return horizontalSpanFailure(
|
||
'span_conflict',
|
||
`Impossible : cette modification invaliderait un block horizontal. ${issue.message}`
|
||
);
|
||
}
|
||
return { ok: true, config: nextConfig, heightMm: HORIZONTAL_SPAN_BLOCK_MM.initialUpper };
|
||
}
|
||
|
||
/**
|
||
* Move the shared boundary below an explicitly-created upper block in every covered column.
|
||
*/
|
||
export function resizeHorizontalSpanUpperBlock(config = {}, spanId, requestedHeightMm) {
|
||
const state = getHorizontalSpanUpperBlockState(config, spanId);
|
||
if (!state.ok) return state;
|
||
if (!state.exists) {
|
||
return horizontalSpanFailure('upper_block_missing', 'Ajoutez d’abord un block au-dessus du block horizontal.');
|
||
}
|
||
const heightMm = Number(requestedHeightMm);
|
||
if (
|
||
!Number.isFinite(heightMm) ||
|
||
heightMm < state.minHeightMm - SPAN_EPSILON_MM ||
|
||
heightMm > state.maxHeightMm + SPAN_EPSILON_MM ||
|
||
Math.abs(heightMm / state.stepMm - Math.round(heightMm / state.stepMm)) > 0.001
|
||
) {
|
||
return horizontalSpanFailure(
|
||
'invalid_height',
|
||
`Choisissez une hauteur de ${state.minHeightMm} à ${state.maxHeightMm} mm, par pas de ${state.stepMm} mm.`
|
||
);
|
||
}
|
||
|
||
const span = findHorizontalSpan(config, spanId);
|
||
const resolved = resolveHorizontalSpan(span, config);
|
||
const newTopMm = state.upperBlockTopMm - heightMm;
|
||
if (Math.abs(newTopMm - resolved.topMm) <= SPAN_EPSILON_MM) {
|
||
return { ok: true, config, heightMm };
|
||
}
|
||
const selectedIds = new Set(resolved.moduleIds.map(String));
|
||
const modules = config.modules.map((module) => (
|
||
selectedIds.has(String(module?.id))
|
||
? rebuildModuleWithSpanBoundary(
|
||
module,
|
||
config,
|
||
resolved.topMm,
|
||
newTopMm,
|
||
{ bottomMm: resolved.bottomMm, topMm: newTopMm },
|
||
{ bottomMm: newTopMm, topMm: state.upperBlockTopMm }
|
||
)
|
||
: module
|
||
));
|
||
const horizontalSpans = config.horizontalSpans.map((candidate) => (
|
||
String(candidate?.id) === String(spanId)
|
||
? normalizeHorizontalSpan({ ...candidate, topMm: newTopMm }, modules)
|
||
: candidate
|
||
));
|
||
const nextConfig = { ...config, modules, horizontalSpans };
|
||
const issue = validateHorizontalSpans(nextConfig)[0];
|
||
if (issue) {
|
||
return horizontalSpanFailure(
|
||
'span_conflict',
|
||
`Impossible : cette hauteur invaliderait un autre block horizontal. ${issue.message}`
|
||
);
|
||
}
|
||
return { ok: true, config: nextConfig, heightMm };
|
||
}
|
||
|
||
// ── System 32 drilling ───────────────────────────────────────────────
|
||
export const SYSTEM32 = {
|
||
firstHoleOffset: 51, // mm — distance from panel top/bottom edge to first/last hole
|
||
pitch: 32, // mm — distance between consecutive holes
|
||
holeDiameter: 5, // mm — Ø 5 mm
|
||
holeDepth: 12, // mm — profondeur de perçage (borgne, côtés de caisse)
|
||
throughHole: false, // Côtés de caisse: perçages borgnes (non traversants)
|
||
};
|
||
|
||
// ── System 32 for vertical separators ────────────────────────────────
|
||
export const SYSTEM32_SEPARATORS = {
|
||
firstHoleOffset: 32, // mm — 51mm - 19mm (fond) = 32mm from bottom edge
|
||
pitch: 32, // mm — same pitch
|
||
holeDiameter: 5, // mm — Ø 5 mm
|
||
throughHole: true, // Séparations verticales: Ø5mm TRAVERSANTS
|
||
};
|
||
|
||
// ── Hinge positioning (Blum CLIP top) ────────────────────────────────
|
||
export const HINGE = {
|
||
axisOffset: 103, // mm — per APEX 4.0 reversibility rule
|
||
axisFrom: 'top_and_bottom',
|
||
perDoor: 3, // default, real count uses matrix below
|
||
reversible: true, // Panels are reversible (symmetric S32 drilling)
|
||
model: 'Blum CLIP top',
|
||
/** Per hinge cup: two Ø8 bores (Blum drilling pattern), depth mm, spacing mm (center–center), offset mm from cup center toward door interior (⟂ hinge edge). */
|
||
cupScrewDiameter: 8,
|
||
cupScrewDepth: 13,
|
||
cupScrewSpacing: 45,
|
||
cupScrewOffsetFromCupCenter: 9.5,
|
||
};
|
||
|
||
// Height/weight matrix from APEX 4.0 section 6.B
|
||
export const HINGE_MATRIX = [
|
||
{ maxHeightMm: 900, maxWeightKg: 6, hinges: 2 },
|
||
{ maxHeightMm: 1600, maxWeightKg: 17, hinges: 3 },
|
||
{ maxHeightMm: 2000, maxWeightKg: 20, hinges: 4 },
|
||
{ maxHeightMm: 2400, maxWeightKg: 22, hinges: 5 },
|
||
{ maxHeightMm: 2600, maxWeightKg: Infinity, hinges: 6 }
|
||
];
|
||
|
||
export function getHingeCountForDoor({ heightMm, weightKg = null }) {
|
||
const h = Number(heightMm) || 0;
|
||
const hasWeight = weightKg !== null && weightKg !== undefined && weightKg !== '';
|
||
const w = hasWeight && Number.isFinite(Number(weightKg)) ? Number(weightKg) : null;
|
||
for (const row of HINGE_MATRIX) {
|
||
const byHeight = h <= row.maxHeightMm;
|
||
const byWeight = w == null ? false : w <= row.maxWeightKg;
|
||
if (byHeight || byWeight) return row.hinges;
|
||
}
|
||
return HINGE_MATRIX[HINGE_MATRIX.length - 1].hinges;
|
||
}
|
||
|
||
export function getHingeCupPositions(doorHeightMm, hingeCount = null) {
|
||
const height = Number(doorHeightMm) || 0;
|
||
const count = Math.max(2, Number(hingeCount) || getHingeCountForDoor({ heightMm: height }));
|
||
const first = HINGE.axisOffset;
|
||
const last = Math.max(first, height - HINGE.axisOffset);
|
||
|
||
if (count <= 2) return [first, last];
|
||
|
||
const spaces = count - 1;
|
||
const rawStep = (last - first) / spaces;
|
||
const positions = [first];
|
||
|
||
for (let i = 1; i < spaces; i++) {
|
||
const rawPos = first + rawStep * i;
|
||
// Rule: align intermediary hinges on System 32 grid.
|
||
const snapped = Math.round(rawPos / 32) * 32;
|
||
const minPos = positions[positions.length - 1] + 32;
|
||
const maxPos = last - (spaces - i) * 32;
|
||
positions.push(Math.min(Math.max(snapped, minPos), maxPos));
|
||
}
|
||
|
||
positions.push(last);
|
||
return positions;
|
||
}
|
||
|
||
// ── Plinth system (AXILO™ 78) ───────────────────────────────────────
|
||
export const PLINTH = {
|
||
model: 'AXILO 78',
|
||
/** Hauteurs cibles proposées dans le configurateur. */
|
||
targetHeightsMm: [60, 80, 100, 125, 150, 180, 200],
|
||
footHeight: 78, // @deprecated — nom du système, pas une hauteur de coupe
|
||
panelThicknessMm: 19,
|
||
panelTopClearanceMm: 5,
|
||
/** Recul de la face visible de plinthe par rapport au chant de caisse (avant et côtés libres). */
|
||
frontSetbackMm: 20,
|
||
mountingPlate: {
|
||
articleNumber: '637.76.710',
|
||
fixingMethod: 'press_fit',
|
||
face: 'underside',
|
||
frontCenterFromFrontMm: 19 + 40,
|
||
rearCenterFromRearMm: 50,
|
||
/**
|
||
* Distance du trou AXILO le plus proche du chant latéral du fond.
|
||
* Habillage : 15.5 mm. Sans habillage : 20.5 + plinthe 19 + recul 20.
|
||
*/
|
||
lateralNearestHoleFromSideWithCladdingMm: 15.5,
|
||
lateralNearestHoleFromSideWithPlinthMm: 20.5 + 19 + 20,
|
||
holeDiameterMm: 10,
|
||
holeDepthMm: 12,
|
||
dowelDiameterMm: 10.4,
|
||
dowelDepthMm: 11,
|
||
holeOffsetsFromCenterMm: [
|
||
{ x: -32, y: 32 },
|
||
{ x: 32, y: 32 },
|
||
{ x: -32, y: -32 },
|
||
{ x: 32, y: -32 }
|
||
]
|
||
},
|
||
plinthSupport: {
|
||
articleNumber: '637.47.490',
|
||
quantityPerFrontFoot: 1,
|
||
cncMachining: false
|
||
},
|
||
clipsPerMeter: 3, // compatibilité des anciens devis
|
||
/** Entraxe max entre axes de pieds (avant+arrière) sur la largeur de l'armoire. */
|
||
maxFootSpacingMm: 600,
|
||
// Reserved packing space for hardware kit
|
||
packingReserved: true,
|
||
};
|
||
|
||
export function normalizeAxiloTargetHeightMm(value, fallbackMm = 100) {
|
||
const requested = Number(value);
|
||
const fallback = Number(fallbackMm);
|
||
const reference = Number.isFinite(requested) ? requested : (Number.isFinite(fallback) ? fallback : 100);
|
||
return PLINTH.targetHeightsMm.reduce(
|
||
(best, candidate) =>
|
||
Math.abs(candidate - reference) < Math.abs(best - reference) ? candidate : best,
|
||
PLINTH.targetHeightsMm[0]
|
||
);
|
||
}
|
||
|
||
/** Hauteur finie du panneau de plinthe (jeu supérieur fournisseur de 5 mm). */
|
||
export function getAxiloPlinthPanelHeightMm(targetHeightMm) {
|
||
return Math.max(0, normalizeAxiloTargetHeightMm(targetHeightMm) - PLINTH.panelTopClearanceMm);
|
||
}
|
||
|
||
/** Habillage latéral jusqu'au sol (installation libre uniquement). */
|
||
export function hasSideHabillage(config = {}, side = 'left') {
|
||
if (config?.installationType !== 'free') return false;
|
||
if (config?.exteriorFinishesEnabled === false) return false;
|
||
const sideDefaults = config?.sideFinishesEnabled !== false;
|
||
const enabled = side === 'right'
|
||
? (config?.sideFinishRightEnabled ?? sideDefaults)
|
||
: (config?.sideFinishLeftEnabled ?? sideDefaults);
|
||
return !!enabled;
|
||
}
|
||
|
||
/**
|
||
* Côté libre sans habillage : retour de plinthe AXILO à la place du panneau
|
||
* de finition jusqu'au sol. Niche : pas de retour (côté contre le mur).
|
||
*/
|
||
export function hasSidePlinthReturn(config = {}, side = 'left') {
|
||
return config?.installationType === 'free' && !hasSideHabillage(config, side);
|
||
}
|
||
|
||
/** Longueur d'un retour de plinthe le long de la profondeur (recul avant 20 mm). */
|
||
export function getAxiloPlinthReturnLengthMm(depthMm) {
|
||
return Math.max(0, Number(depthMm) - PLINTH.frontSetbackMm);
|
||
}
|
||
|
||
/**
|
||
* Déduction latérale de la plinthe avant : habillage 19 mm au nu extérieur,
|
||
* ou recul 20 mm + épaisseur du retour (le retour n'est pas au nu du côté).
|
||
*/
|
||
export function getAxiloPlinthFrontSideDeductionMm(config = {}, side = 'left') {
|
||
if (hasSideHabillage(config, side)) return PLINTH.panelThicknessMm;
|
||
if (hasSidePlinthReturn(config, side)) return PLINTH.frontSetbackMm + PLINTH.panelThicknessMm;
|
||
return 0;
|
||
}
|
||
|
||
/** Portée latérale des perçages AXILO depuis le centre de platine (32 mm). */
|
||
export function getAxiloHoleReachXMm() {
|
||
return Math.max(
|
||
0,
|
||
...PLINTH.mountingPlate.holeOffsetsFromCenterMm.map((p) => Math.abs(Number(p.x) || 0))
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Centre de platine depuis le chant latéral du fond, pour que le trou le plus
|
||
* proche respecte 15.5 mm (habillage) ou 20.5 + 19 + 20 (retour de plinthe).
|
||
*/
|
||
export function getAxiloLateralCenterInsetMm(hasSideCladding) {
|
||
const holeInset = hasSideCladding
|
||
? PLINTH.mountingPlate.lateralNearestHoleFromSideWithCladdingMm
|
||
: PLINTH.mountingPlate.lateralNearestHoleFromSideWithPlinthMm;
|
||
return holeInset + getAxiloHoleReachXMm();
|
||
}
|
||
|
||
/**
|
||
* Axes X des platines AXILO sur une largeur d'armoire : 2 axes d'extrémité
|
||
* (4 pieds) + 1 axe intermédiaire (2 pieds) dès que l'entraxe dépasserait 600 mm.
|
||
*/
|
||
export function getAxiloFootXAxesMm(spanWidthMm, leftInsetMm, rightInsetMm) {
|
||
const width = Math.max(0, Number(spanWidthMm) || 0);
|
||
const holeReach = getAxiloHoleReachXMm();
|
||
const leftInset = Math.max(holeReach, Number(leftInsetMm) || holeReach);
|
||
const rightInset = Math.max(holeReach, Number(rightInsetMm) || holeReach);
|
||
const minX = Math.min(leftInset, width / 2);
|
||
const maxX = Math.max(minX, width - Math.min(rightInset, width / 2));
|
||
const span = maxX - minX;
|
||
const round2 = (v) => Math.round(v * 100) / 100;
|
||
if (span < 0.5) return [round2(minX)];
|
||
const intervals = Math.max(1, Math.ceil(span / PLINTH.maxFootSpacingMm));
|
||
const axes = [];
|
||
for (let i = 0; i <= intervals; i++) {
|
||
const x = round2(minX + (span * i) / intervals);
|
||
if (!axes.some((existing) => Math.abs(existing - x) < 0.5)) axes.push(x);
|
||
}
|
||
return axes;
|
||
}
|
||
|
||
/**
|
||
* Centres des platines sous le fond.
|
||
* 4 pieds aux extrémités de l'armoire + 2 pieds (avant/arrière) tous les 600 mm.
|
||
* Si `spanWidthMm` / `panelOffsetMm` sont fournis, les axes sont calculés sur
|
||
* toute l'armoire puis projetés sur ce panneau de fond.
|
||
*/
|
||
export function getAxiloPlateCentersMm(panelWidthMm, panelDepthMm, options = {}) {
|
||
const width = Math.max(0, Number(panelWidthMm) || 0);
|
||
const depth = Math.max(0, Number(panelDepthMm) || 0);
|
||
const holeReach = getAxiloHoleReachXMm();
|
||
const defaultInset = getAxiloLateralCenterInsetMm(true);
|
||
const requestedLeftInset = Number(options.leftEdgeCenterInsetMm);
|
||
const requestedRightInset = Number(options.rightEdgeCenterInsetMm);
|
||
const leftInset = Math.max(
|
||
holeReach,
|
||
Number.isFinite(requestedLeftInset) ? requestedLeftInset : defaultInset
|
||
);
|
||
const rightInset = Math.max(
|
||
holeReach,
|
||
Number.isFinite(requestedRightInset) ? requestedRightInset : defaultInset
|
||
);
|
||
const spanWidth = Number(options.spanWidthMm);
|
||
const layoutWidth = Number.isFinite(spanWidth) && spanWidth > 0 ? spanWidth : width;
|
||
const offset = Number.isFinite(Number(options.panelOffsetMm))
|
||
? Number(options.panelOffsetMm)
|
||
: 0;
|
||
const assignMin = Number.isFinite(Number(options.assignMinMm))
|
||
? Number(options.assignMinMm)
|
||
: offset;
|
||
const assignMax = Number.isFinite(Number(options.assignMaxMm))
|
||
? Number(options.assignMaxMm)
|
||
: offset + width;
|
||
const assignMaxInclusive = options.assignMaxInclusive !== false;
|
||
const globalAxes = getAxiloFootXAxesMm(layoutWidth, leftInset, rightInset);
|
||
const xAxes = [];
|
||
for (const gx of globalAxes) {
|
||
const afterMin = gx >= assignMin - 0.05;
|
||
const beforeMax = assignMaxInclusive
|
||
? gx <= assignMax + 0.05
|
||
: gx < assignMax - 0.05;
|
||
if (!afterMin || !beforeMax) continue;
|
||
if (width < holeReach * 2) continue;
|
||
const localX = Math.max(holeReach, Math.min(width - holeReach, gx - offset));
|
||
const x = Math.round(localX * 100) / 100;
|
||
if (!xAxes.some((existing) => Math.abs(existing - x) < 0.5)) xAxes.push(x);
|
||
}
|
||
const frontY = PLINTH.mountingPlate.frontCenterFromFrontMm;
|
||
const requestedRearInset = Number(options.rearCenterFromRearMm);
|
||
const rearCenterFromRearMm = Math.max(
|
||
PLINTH.mountingPlate.rearCenterFromRearMm,
|
||
Number.isFinite(requestedRearInset)
|
||
? requestedRearInset
|
||
: PLINTH.mountingPlate.rearCenterFromRearMm
|
||
);
|
||
const rearY = depth - rearCenterFromRearMm;
|
||
if (depth < frontY + rearCenterFromRearMm) return [];
|
||
return xAxes.flatMap((xMm) => [
|
||
{ xMm, yMm: frontY, row: 'front' },
|
||
{ xMm, yMm: rearY, row: 'rear' }
|
||
]);
|
||
}
|
||
|
||
// ── Reinforcement machining (Renforts Forteresse) ────────────────────
|
||
export const REINFORCEMENT_MACHINING = {
|
||
// Sur chaque petit chant d'extrémité: 1× Ixconnect SC 8/60 au centre + 2× tourillons à 20 mm des bords
|
||
connectorModel: 'SC 8/60',
|
||
connector: {
|
||
model: 'SC 8/60',
|
||
edgeHole: { diameter: 8, depth: 45 },
|
||
faceHole: { diameter: 8, depth: 12 },
|
||
},
|
||
ixconnectCenter: true,
|
||
tourillonCount: 2,
|
||
tourillonEdgeOffset: 20, // mm — tourillons à 20 mm des bords (axe mesuré depuis le même repère que l’Ixconnect)
|
||
tourillonDiameter: 8,
|
||
};
|
||
|
||
/**
|
||
* Axes de perçage sur le petit chant d'extrémité (mm depuis le bord haut du renfort).
|
||
* @param {number} reinforcementFaceHeightMm — hauteur de la face de renfort (généralement 100 mm)
|
||
*/
|
||
export function getReinforcementEndFixingAxesFromTopMm(reinforcementFaceHeightMm) {
|
||
const h = Math.max(0, Number(reinforcementFaceHeightMm) || 0);
|
||
const off = REINFORCEMENT_MACHINING.tourillonEdgeOffset;
|
||
const round1 = (v) => Math.round(v * 10) / 10;
|
||
return {
|
||
// Deux tourillons proches des bords haut/bas de la face 100mm.
|
||
tourillonAxesFromTopMm: [round1(off), round1(Math.max(off, h - off))],
|
||
ixconnectAxisFromTopMm: round1(h / 2),
|
||
};
|
||
}
|
||
|
||
// ── Niche and sanitary gaps ─────────────────────────────────────────────
|
||
export const NICHE = {
|
||
sanitaryGapAround: 30, // mm — vide sanitaire de 3 cm tout autour
|
||
drawerBackGap: 10, // mm — vide sanitaire derrière tiroir
|
||
};
|
||
|
||
// ── Drawers (manufactured — Blum Legrabox pure) ─────────────────────────
|
||
export const DRAWERS = {
|
||
maxColumnWidth: 1000, // mm
|
||
minDepth: 400, // mm
|
||
maxDepth: 650, // mm
|
||
depthStep: 50, // mm
|
||
hiddenHandleDepthReduction: 40, // mm (assumption until final handle specs)
|
||
/** Hauteurs de côté Legrabox (N/M/K/C/F). L'ancien 60 mm est remplacé par 66 mm (système N). */
|
||
standardHeights: [...LEGRABOX_SIDE_HEIGHTS_MM],
|
||
};
|
||
|
||
/**
|
||
* Hauteur de façade (encombrement niche SMS) pour une hauteur de côté Legrabox.
|
||
* 66→80 · 90→106 · 128→144 · 177→193 · 240→257. Héritage : 60 mm ≡ système N (66).
|
||
*/
|
||
export function drawerFacadeHeightMm(sideHeightMm) {
|
||
const h = Number(sideHeightMm) === 60 ? 66 : Number(sideHeightMm);
|
||
return legraboxNicheHeightMm(h);
|
||
}
|
||
|
||
/**
|
||
* Renforts latéraux caisse (25 mm) — uniquement si tiroirs cachés derrière porte ;
|
||
* un montant gauche + un droit par colonne à tiroirs, même profondeur (min. des deux parois).
|
||
* Fixation sur la face intérieure des montants / séparateurs.
|
||
*/
|
||
export const DRAWER_REINFORCEMENT = {
|
||
thicknessMm: 25,
|
||
/** mm depuis le fond caisse (repère rainure côté ou fond séparateur) — début utile du renfort */
|
||
backInsetFromRearMm: 25,
|
||
/** mm réservés vers l’avant quand les tiroirs sont derrière une porte à charnières */
|
||
hingeFrontClearanceMm: 77,
|
||
/** mm — déduit de la hauteur pile : partie droite au-dessus du rayon façade tiroir du haut */
|
||
topFrontRadiusMm: 3,
|
||
/** Ixconnect « petit » — même famille que finitions SC 8/25 */
|
||
ixconnectModel: 'SC 8/25',
|
||
/** mm — retrait des axes depuis chaque bord (chaque coin : 50 mm des deux côtés) */
|
||
ixconnectCornerInsetMm: 50,
|
||
ixconnectHoleDiameterMm: 8,
|
||
/** Perçage face côté renfort (vers montant / séparateur) */
|
||
ixconnectDepthInReinforcementMm: 13,
|
||
/** Perçage face intérieure montant ou séparateur (vis-à-vis) */
|
||
ixconnectDepthInVerticalPanelMm: 12,
|
||
};
|
||
|
||
/**
|
||
* Ixconnect SC 8/25 sur montants / séparateurs — zone tiroirs cachés + renfort.
|
||
* Repère face intérieure : X depuis bord avant (profondeur), Y depuis bas du panneau (corps).
|
||
*/
|
||
export const DRAWER_RENFORT_PANEL_IXCONNECT = {
|
||
model: 'SC 8/25',
|
||
/** 77 + 50 mm depuis le devant (axe avant) */
|
||
frontAxesFromFrontMm: 77 + 50,
|
||
/** Côté caisse : 19 + 9 + 50 mm depuis le fond */
|
||
backAxesFromRearCoteMm: 19 + 9 + 50,
|
||
/** Séparateur : 50 mm depuis le fond */
|
||
backAxesFromRearSeparatorMm: 50,
|
||
/** 50 mm du haut / bas de la zone renfort */
|
||
verticalInsetMm: 50,
|
||
faceHoleDiameterMm: 8,
|
||
faceHoleDepthMm: 12
|
||
};
|
||
|
||
/**
|
||
* 4 trous (rectangle) sur une face intérieure montant/séparateur pour renfort tiroir caché.
|
||
* @param {number} panelDepthMm — largeur du panneau en plan (= profondeur caisse ou prof. séparateur)
|
||
* @param {boolean} isCote — true = montant extérieur, false = séparateur
|
||
* @param {number} zoneBottomFromPanelBottomMm — bas de la zone tiroirs depuis bas du panneau
|
||
* @param {number} renfortHeightMm — hauteur utile renfort (= pile tiroirs − R)
|
||
*/
|
||
export function buildDrawerRenfortPanelIxconnectCornersMm(
|
||
panelDepthMm,
|
||
isCote,
|
||
zoneBottomFromPanelBottomMm,
|
||
renfortHeightMm
|
||
) {
|
||
const P = DRAWER_RENFORT_PANEL_IXCONNECT;
|
||
const D = Math.max(0, Number(panelDepthMm) || 0);
|
||
const z0 = Math.max(0, Number(zoneBottomFromPanelBottomMm) || 0);
|
||
const h = Math.max(0, Number(renfortHeightMm) || 0);
|
||
const backInset = isCote ? P.backAxesFromRearCoteMm : P.backAxesFromRearSeparatorMm;
|
||
const xf = P.frontAxesFromFrontMm;
|
||
const xb = Math.max(xf + 1, D - backInset);
|
||
const y1 = z0 + P.verticalInsetMm;
|
||
const y2 = z0 + h - P.verticalInsetMm;
|
||
if (h < 2 * P.verticalInsetMm + 5 || D < xf + backInset + 5 || y2 <= y1) return [];
|
||
return [
|
||
{ xMm: xf, yMm: y1, role: 'avant_bas' },
|
||
{ xMm: xf, yMm: y2, role: 'avant_haut' },
|
||
{ xMm: xb, yMm: y1, role: 'fond_bas' },
|
||
{ xMm: xb, yMm: y2, role: 'fond_haut' }
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 4 trous d’angle pour Ixconnect SC 8/25 — repère plan face : X depuis bord gauche, Y depuis bord haut (mm).
|
||
* @returns {{ xMm: number, yMm: number, role: string }[]}
|
||
*/
|
||
export function getDrawerReinforcementIxconnectHolePositionsMm(widthMm, heightMm, insetMm = DRAWER_REINFORCEMENT.ixconnectCornerInsetMm) {
|
||
const w = Math.max(0, Number(widthMm) || 0);
|
||
const h = Math.max(0, Number(heightMm) || 0);
|
||
const ins = Math.max(8, Math.min(Number(insetMm) || 50, Math.min(w, h) / 2 - 0.5));
|
||
if (w < 2 * ins || h < 2 * ins) return [];
|
||
return [
|
||
{ xMm: ins, yMm: ins, role: 'nw' },
|
||
{ xMm: w - ins, yMm: ins, role: 'ne' },
|
||
{ xMm: ins, yMm: h - ins, role: 'sw' },
|
||
{ xMm: w - ins, yMm: h - ins, role: 'se' }
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Hauteur totale du bloc tiroirs, mm — norme SMS / Blum Legrabox :
|
||
* chaque tiroir occupe sa hauteur de NICHE (encombrement façade min : 80/106/144/193/257),
|
||
* le jeu entre façades est pris DANS la niche (façade = niche − jeu).
|
||
* @param {number} drawerCount
|
||
* @param {number[]} drawerHeightsMm — hauteurs de côté Legrabox (66/90/128/177/240)
|
||
* @param {number} [drawerGapMm] — conservé pour compat ; le jeu est inclus dans la niche
|
||
*/
|
||
export function drawerStackHeightMm(drawerCount, drawerHeightsMm, drawerGapMm = 8) {
|
||
const n = Math.max(0, Number(drawerCount) || 0);
|
||
if (n <= 0) return 0;
|
||
const heights = Array.isArray(drawerHeightsMm) && drawerHeightsMm.length ? drawerHeightsMm : DRAWERS.standardHeights;
|
||
const picked = [];
|
||
for (let i = 0; i < n; i++) {
|
||
const v = Number(heights[Math.min(i, heights.length - 1)]) || 0;
|
||
picked.push(drawerFacadeHeightMm(v));
|
||
}
|
||
const sum = picked.reduce((s, v) => s + v, 0);
|
||
return Math.max(0, Math.round(sum));
|
||
}
|
||
|
||
/** Hauteurs de niche par tiroir (mm), du bas vers le haut. */
|
||
export function drawerNicheHeightsMm(drawerCount, drawerHeightsMm) {
|
||
const n = Math.max(0, Number(drawerCount) || 0);
|
||
const heights = Array.isArray(drawerHeightsMm) && drawerHeightsMm.length ? drawerHeightsMm : DRAWERS.standardHeights;
|
||
const out = [];
|
||
for (let i = 0; i < n; i++) {
|
||
const v = Number(heights[Math.min(i, heights.length - 1)]) || 0;
|
||
out.push(drawerFacadeHeightMm(v));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ── Full-height handle assumptions ──────────────────────────────────────
|
||
export const HANDLE = {
|
||
/** Retrait mécanique rainure poignée (haut + bas) */
|
||
fullHeightTopBottomOffset: 2, // mm from top and bottom
|
||
/** Déduction largeur panneau porte (vs poignée standard) — même valeur liste de débitage / plans */
|
||
fullHeightWidthDeductionMm: 18,
|
||
/** Rainure usinée sur le montant côté poignée (profil type dos, à confirmer au profilé) */
|
||
fullHeightGroove: {
|
||
width: GROOVE.back.width,
|
||
depth: GROOVE.back.depth,
|
||
},
|
||
};
|
||
|
||
/**
|
||
* Côté charnières en élévation pour la porte d’index `doorIndex` :
|
||
* vantail extérieur gauche → charnières gauche ; droit → droite ; portes centrales → réglage utilisateur.
|
||
*/
|
||
export function getDoorHingeSide(doorIndex, doorCount, hardware = {}) {
|
||
const hw = hardware || {};
|
||
const n = Math.max(1, Number(doorCount) || 1);
|
||
const i = Math.max(0, Number(doorIndex) || 0);
|
||
if (n <= 1) return hw.doorHingeSide === 'right' ? 'right' : 'left';
|
||
if (i === 0) return 'left';
|
||
if (i === n - 1) return 'right';
|
||
return hw.centerDoorHingeSide === 'right' ? 'right' : 'left';
|
||
}
|
||
|
||
// ── Block assembly drilling ─────────────────────────────────────────────
|
||
export const BLOCK_ASSEMBLY = {
|
||
sideOffsetFromFace: 30, // mm
|
||
sideOffsetFromBack: 30, // mm
|
||
topBottomOffset: 100, // mm
|
||
middleCount: 3, // per joint
|
||
/** Jeu entre double montants (assemblage caisson↔caisson). */
|
||
jointGapMm: 3
|
||
};
|
||
|
||
/** Colonne rayonnage (étagères) — caisson block même sans dos. */
|
||
export function isRayonnageModule(mod) {
|
||
const t = String(mod?.type || mod?.moduleCode || '').toLowerCase();
|
||
if (t === 'rayonnage') return true;
|
||
const shelves = Number(mod?.shelves) || 0;
|
||
if (t === 'mixte') return shelves > 0;
|
||
// A penderie keeps a single top shelf; 2+ shelves is rayonnage construction.
|
||
return shelves >= 2;
|
||
}
|
||
|
||
/**
|
||
* Construction caisson (block) vs séparations, par zone.
|
||
* Règle: dos → block ; sinon côté (montant extérieur) ou rayonnage → block.
|
||
* Les côtés extérieurs sont toujours en construction block (jonction à l'angle).
|
||
*/
|
||
export function usesBlockConstruction(mod, hasDos) {
|
||
if (hasDos) return true;
|
||
return isRayonnageModule(mod);
|
||
}
|
||
|
||
/** Dos d'une zone (blockIndex: 0 = bas). Défaut aligné sur le configurateur (false si non renseigné). */
|
||
export function moduleZoneHasBack(mod, config = {}, blockIndex = 0) {
|
||
const heights = Array.isArray(mod?.blockHeightsMm)
|
||
? mod.blockHeightsMm.map(Number).filter((v) => Number.isFinite(v) && v > 0)
|
||
: [];
|
||
const n = Math.max(1, heights.length);
|
||
const i = Math.max(0, Math.min(n - 1, Number(blockIndex) || 0));
|
||
const arr = Array.isArray(mod?.blockBacks) ? mod.blockBacks : null;
|
||
if (arr && typeof arr[i] === 'boolean') return arr[i];
|
||
if (i === n - 1 && n >= 2 && typeof mod?.hasBackTop === 'boolean') return mod.hasBackTop;
|
||
if (typeof mod?.hasBack === 'boolean') return mod.hasBack;
|
||
return config?.hasBack === true;
|
||
}
|
||
|
||
/**
|
||
* Colonne en caisson block si une zone a un dos, ou si c'est un rayonnage.
|
||
* Les côtés extérieurs restent des montants block même si la colonne est en séparations.
|
||
*/
|
||
export function columnUsesBlockConstruction(mod, config = {}, blockCount = null) {
|
||
if (isRayonnageModule(mod)) return true;
|
||
const n = Math.max(
|
||
1,
|
||
Number.isFinite(Number(blockCount)) && Number(blockCount) > 0
|
||
? Number(blockCount)
|
||
: (Array.isArray(mod?.blockHeightsMm) ? mod.blockHeightsMm.filter((v) => Number(v) > 0).length : 1)
|
||
);
|
||
for (let k = 0; k < n; k++) {
|
||
if (moduleZoneHasBack(mod, config, k)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Segments dessus (`level: 'top'`) ou fond (`level: 'bottom'`):
|
||
* 1 segment par caisson block, colonnes en séparations contiguës fusionnées.
|
||
*/
|
||
export function horizontalConstructionRuns(modules = [], config = {}, level = 'top') {
|
||
const mods = Array.isArray(modules) ? modules.filter(Boolean) : [];
|
||
const n = mods.length;
|
||
const bodyMm = Math.max(
|
||
0,
|
||
(Number(config?.heightMm) || 0) - (Number(config?.plinthHeightMm) || 0)
|
||
);
|
||
const edgeSpansByStart = new Map(
|
||
(Array.isArray(config?.horizontalSpans) ? config.horizontalSpans : [])
|
||
.map((span) => resolveHorizontalSpan(span, { ...config, modules: mods }))
|
||
.filter((resolved) => {
|
||
if (!resolved || resolved.moduleIndexes.length < 2) return false;
|
||
return level === 'bottom'
|
||
? Math.abs(resolved.bottomMm) <= SPAN_EPSILON_MM
|
||
: Math.abs(resolved.topMm - bodyMm) <= SPAN_EPSILON_MM;
|
||
})
|
||
.map((resolved) => [resolved.moduleIndexes[0], resolved])
|
||
);
|
||
const blockIndexOf = (mod) => {
|
||
const heights = Array.isArray(mod?.blockHeightsMm)
|
||
? mod.blockHeightsMm.map(Number).filter((v) => Number.isFinite(v) && v > 0)
|
||
: [];
|
||
const count = Math.max(1, heights.length);
|
||
return level === 'bottom' ? 0 : count - 1;
|
||
};
|
||
const isBlockAt = (index) => columnUsesBlockConstruction(mods[index], config);
|
||
const runs = [];
|
||
let i = 0;
|
||
while (i < n) {
|
||
const edgeSpan = edgeSpansByStart.get(i);
|
||
if (edgeSpan) {
|
||
const end = edgeSpan.moduleIndexes[edgeSpan.moduleIndexes.length - 1];
|
||
runs.push({
|
||
start: i,
|
||
end,
|
||
isBlock: true,
|
||
hasBack: Boolean(edgeSpan.span.hasBack),
|
||
horizontalSpanId: edgeSpan.span.id
|
||
});
|
||
i = end + 1;
|
||
continue;
|
||
}
|
||
if (isBlockAt(i)) {
|
||
runs.push({
|
||
start: i,
|
||
end: i,
|
||
isBlock: true,
|
||
hasBack: moduleZoneHasBack(mods[i], config, blockIndexOf(mods[i]))
|
||
});
|
||
i += 1;
|
||
} else {
|
||
let j = i;
|
||
while (j + 1 < n && !isBlockAt(j + 1) && !edgeSpansByStart.has(j + 1)) j += 1;
|
||
runs.push({ start: i, end: j, isBlock: false, hasBack: false });
|
||
i = j + 1;
|
||
}
|
||
}
|
||
return runs;
|
||
}
|
||
|
||
// ── Carcass assembly ferrures ───────────────────────────────────────────
|
||
export const CARCASS_FASTENERS = {
|
||
/** Ferrure d'assemblage utilisée pour les jonctions de caisse (côtés/séparateurs ↔ dessus/fond). */
|
||
model: 'Häfele Rasant-Tab',
|
||
/** Référence produit (Ferrures d’assemblage Rasant-Tab, Ø trou 3 ou 5 mm). */
|
||
productUrl: 'https://www.haefele.ch/fr/produkt/ferrures-d-assemblage-rasant-tab-visser-dans-trou-de-per-age-3-ou-5-mm/26236520/?MasterSKU=P-00861456',
|
||
piecesPerJoint: 3,
|
||
/** Retrait des ferrures d’extrémité (mm) */
|
||
endInsetMm: 50,
|
||
edgeOffsets: [50, 'middle', 50],
|
||
/**
|
||
* Décalage vers l'avant de la rangée arrière T–IX–T sur un fond avec rainure
|
||
* et pieds AXILO. Un quart de l'entraxe 64 mm évite l'alignement des perçages.
|
||
*/
|
||
axiloGrooveRearRowShiftMm: 16,
|
||
/** Perçage ferrure: profondeur sur longueur de la pièce (mm) */
|
||
ferrureDepthAlongLengthMm: 46,
|
||
/** Perçage ferrure: profondeur sur l’épaisseur de la pièce (mm) */
|
||
ferrureDepthInThicknessMm: 12,
|
||
dowel: {
|
||
diameter: 8,
|
||
/** Longueur nominale tourillon (réf. atelier) */
|
||
depth: 40,
|
||
/** Perçage borgne en face (dans l’épaisseur du panneau), mm — toujours 12 pour dessus/fond */
|
||
faceDrillDepthMm: 12,
|
||
/** 2× tourillons par ferrure : axes à ±sideOffset du centre de chaque ferrure (mm) */
|
||
sideOffset: 30,
|
||
},
|
||
};
|
||
|
||
/** Profondeur effective perçage tourillon en face (ne dépasse pas l’épaisseur). */
|
||
export function tourillonFaceDrillDepthMm(thicknessMm) {
|
||
const cap = CARCASS_FASTENERS.dowel.faceDrillDepthMm;
|
||
const t = Math.max(1, Number(thicknessMm) || PANEL.structure);
|
||
return Math.min(cap, Math.max(6, t - 2));
|
||
}
|
||
|
||
/** Longueur mini. d’arête (mm) pour placer une ferrure au milieu (en plus des 50 mm d’extrémité). */
|
||
export const CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM = 500;
|
||
|
||
/**
|
||
* Centres des ferrures le long d’une arête de jonction.
|
||
* Règle: 50 mm des extrémités ; ferrure centrale seulement si l’arête dépasse {@link CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM}.
|
||
* @param {number} edgeLengthMm — longueur de l’arête (mm), ici hauteur utile de la jonction
|
||
* @returns {number[]} distances depuis le début de l’arête (mm)
|
||
*/
|
||
export function getCarcassJointPositions(edgeLengthMm) {
|
||
const L = Math.max(0, Number(edgeLengthMm) || 0);
|
||
if (L <= 0) return [];
|
||
const inset = Math.min(CARCASS_FASTENERS.endInsetMm, Math.max(20, L * 0.1));
|
||
const tail = L - inset;
|
||
const minMid = CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM;
|
||
|
||
if (tail <= inset + 20) {
|
||
if (L > minMid) {
|
||
const u = L / 4;
|
||
return [u, L / 2, L - u].map((v) => Math.round(Math.min(L, Math.max(0, v)) * 10) / 10);
|
||
}
|
||
const u = L / 4;
|
||
return [u, L - u].map((v) => Math.round(Math.min(L, Math.max(0, v)) * 10) / 10);
|
||
}
|
||
|
||
if (L > minMid) {
|
||
return [inset, L / 2, tail].map((v) => Math.round(v * 10) / 10);
|
||
}
|
||
return [inset, tail].map((v) => Math.round(v * 10) / 10);
|
||
}
|
||
|
||
/**
|
||
* Chaque ferrure: 3 perçages alignés sur la longueur:
|
||
* - 1 perçage ferrure au centre
|
||
* - 2 perçages tourillons à ±sideOffset du centre
|
||
* @returns {{ centerMm: number, holesAlongMm: [number, number, number] }[]}
|
||
*/
|
||
export function getCarcassFerrureDowelDrillings(centerPositionsMm, edgeLengthMm, sideOffsetMm = CARCASS_FASTENERS.dowel.sideOffset) {
|
||
const L = Math.max(0, Number(edgeLengthMm) || 0);
|
||
const off = Math.max(0, Number(sideOffsetMm) || 0);
|
||
const centers = centerPositionsMm || [];
|
||
const clamp = (v) => Math.round(Math.min(L, Math.max(0, v)) * 10) / 10;
|
||
return centers.map((c0) => {
|
||
const c = Number(c0);
|
||
return { centerMm: c, holesAlongMm: [clamp(c - off), clamp(c), clamp(c + off)] };
|
||
});
|
||
}
|
||
|
||
// ── Shelf machining ─────────────────────────────────────────────────
|
||
export const SHELF_MACHINING = {
|
||
// Tourillons Ø8 front + back for shelf pin alignment
|
||
tourillonDiameter: 8, // mm
|
||
tourillonCount: 4, // 2 front + 2 back per shelf
|
||
// "2 rayons sur une même strate de 19mm" — 2 shelves can share same S32 row
|
||
shelvesPerStratum: 2,
|
||
};
|
||
|
||
// ── Packing ──────────────────────────────────────────────────────────
|
||
export const PACKING = {
|
||
maxWeightKg: 28,
|
||
// Approximate density for weight estimation (kg per m³)
|
||
densityKgM3: {
|
||
melamine19: 680, // Panneau mélaminé 19 mm
|
||
melamine16: 660, // Panneau mélaminé 16 mm (renforts)
|
||
melamine25: 670, // Panneau mélaminé 25 mm (renforts tiroir)
|
||
melamine8: 640, // Panneau mélaminé 8 mm (dos)
|
||
},
|
||
// Layer-based packing strategy (per APEX 4.0 spec):
|
||
layers: {
|
||
level1: 'sides', // Niveau 1: Côtés empilés
|
||
level2: 'topAndBottom', // Niveau 2: Dessus + fond côte à côte
|
||
level3: 'shelvesAndRenforts', // Niveau 3: Étagères + renforts groupés par épaisseur
|
||
},
|
||
// Hardware kit reservation (AXILO™ pieds + clips de plinthe)
|
||
hardwareKitReserved: true, // Espace réservé dans le colisage
|
||
};
|
||
|
||
// ── Convenience: convert mm constants to metres (for Three.js) ──────
|
||
export const PANEL_M = {
|
||
structure: PANEL.structure / 1000,
|
||
reinforcement: PANEL.reinforcement / 1000,
|
||
back: PANEL.back / 1000,
|
||
reinforcementWidth: PANEL.reinforcementWidth / 1000,
|
||
};
|