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

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

1510 lines
63 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
};