Ship configurator updates with catalog DB included for server deploy.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
lejay
2026-10-07 17:13:03 +02:00
co-authored by Cursor
parent 6c0d7e47b8
commit 30921aade6
75 changed files with 15618 additions and 1300 deletions
+368
View File
@@ -0,0 +1,368 @@
/**
* APEX 4.0 — Blum LEGRABOX pure
* Données d'intégration : débit panneaux 16 mm, perçage CNC caisson (coulisses),
* perçage façades (INSERTA/EXPANDO), norme suisse SMS, génération de commande (BOM)
* et accessoires intérieurs.
*
* Source : dossier « INTÉGRATION DATA - APEX 4.0 » (modules usinage CNC / SMS / BOM /
* accessoires) + catalogue technique Blum. Toutes les cotes en mm.
*/
/** Épaisseur de référence stricte des fonds et parois arrière de tiroir. */
export const LEGRABOX_PANEL_THICKNESS_MM = 16;
/**
* Systèmes de hauteur Legrabox (N/M/K/C/F).
* - sideHeightMm : hauteur de côté (profil métallique) — « hauteur_cote_mm »
* - nicheHeightMm : hauteur de niche / encombrement façade min — « hauteur_des_niches_mm »
* - backHeightMm : hauteur C de la paroi arrière 16 mm — « hauteur_C_mm_selon_systeme »
* - hch : code hauteur commercial (S/M/L/XL/XXL)
* - smsFromSixths : norme suisse SMS — disponible à partir de n/6 (1/6 = 127 mm)
*/
export const LEGRABOX_SYSTEMS = Object.freeze({
N: Object.freeze({ key: 'N', hch: 'S', sideHeightMm: 66, nicheHeightMm: 80, backHeightMm: 39, smsFromSixths: 1 }),
M: Object.freeze({ key: 'M', hch: 'M', sideHeightMm: 90, nicheHeightMm: 106, backHeightMm: 63, smsFromSixths: 1 }),
K: Object.freeze({ key: 'K', hch: 'L', sideHeightMm: 128, nicheHeightMm: 144, backHeightMm: 101, smsFromSixths: 2 }),
C: Object.freeze({ key: 'C', hch: 'XL', sideHeightMm: 177, nicheHeightMm: 193, backHeightMm: 148, smsFromSixths: 2 }),
F: Object.freeze({ key: 'F', hch: 'XXL', sideHeightMm: 240, nicheHeightMm: 257, backHeightMm: 212, smsFromSixths: 3 })
});
/** Hauteurs de côté sélectionnables (remplace l'ancien 60 mm par 66 mm — système N). */
export const LEGRABOX_SIDE_HEIGHTS_MM = Object.freeze([66, 90, 128, 177, 240]);
/** Norme suisse SMS — division en sixièmes. */
export const SMS = Object.freeze({
sixthMm: 127,
description: 'Système de mesure suisse (Schweizer Mass-System) — division en sixièmes (1/6 = 127 mm)'
});
/** @returns {object|null} système Legrabox pour une hauteur de côté (66/90/128/177/240). */
export function getLegraboxSystemForSideHeight(sideHeightMm) {
const h = Number(sideHeightMm);
for (const sys of Object.values(LEGRABOX_SYSTEMS)) {
if (sys.sideHeightMm === h) return sys;
}
return null;
}
/**
* Hauteur de niche (encombrement façade min) pour une hauteur de côté.
* Hauteur inconnue → valeur inchangée (compat anciennes configs).
*/
export function legraboxNicheHeightMm(sideHeightMm) {
const sys = getLegraboxSystemForSideHeight(sideHeightMm);
return sys ? sys.nicheHeightMm : Math.max(0, Number(sideHeightMm) || 0);
}
// ── 1. Débit panneaux — épaisseur 16 mm ─────────────────────────────────
/**
* Fond de tiroir 16 mm.
* longueur A = NL − 10 · largeur B = X − 35 (X = largeur libre du corps).
* Usinage spécial : feuillure/renfort 38 × 8 mm à gauche et à droite.
* @param {number} nominalLengthMm — longueur nominale NL de la coulisse
* @param {number} internalWidthMm — largeur libre du corps (X)
*/
export function legraboxBottomPanelMm(nominalLengthMm, internalWidthMm) {
const nl = Math.max(0, Number(nominalLengthMm) || 0);
const x = Math.max(0, Number(internalWidthMm) || 0);
if (nl <= 10 || x <= 35) return null;
return {
lengthMm: nl - 10,
widthMm: x - 35,
thicknessMm: LEGRABOX_PANEL_THICKNESS_MM,
rebate: {
widthMm: 38,
depthMm: 8,
sides: ['left', 'right'],
note: 'Fond feuilluré/renforcé à gauche et à droite : 38 × 8 mm'
}
};
}
/**
* Paroi arrière (dos de tiroir) 16 mm.
* largeur B = X − 38 · hauteur C selon système (N 39 / M 63 / K 101 / C 148 / F 212).
* @param {number} internalWidthMm — largeur libre du corps (X)
* @param {string} systemKey — 'N' | 'M' | 'K' | 'C' | 'F'
*/
export function legraboxBackPanelMm(internalWidthMm, systemKey) {
const x = Math.max(0, Number(internalWidthMm) || 0);
const sys = LEGRABOX_SYSTEMS[systemKey];
if (!sys || x <= 38) return null;
return {
widthMm: x - 38,
heightMm: sys.backHeightMm,
thicknessMm: LEGRABOX_PANEL_THICKNESS_MM
};
}
// ── 2. Perçage CNC — coulisses sur caisson (Système 32) ────────────────
/**
* Positions de perçage cumulées (axe X) en mm depuis le bord AVANT du montant,
* pour le vissage des coulisses, selon capacité de charge.
* Perçage pilote Système 32 : Ø5 × 13 mm (vis à tête plate recommandées par Blum).
*/
export const LEGRABOX_RAIL_DRILLING = Object.freeze({
compatibleSideHeightsMm: LEGRABOX_SIDE_HEIGHTS_MM,
holeDiameterMm: 5,
holeDepthMm: 13,
/**
* Axe vertical de la ligne de vissage du profil de corps, au-dessus du bas
* de la niche tiroir. Hypothèse atelier (catalogue Blum) — à confirmer au gabarit ZML.3710.
*/
screwLineAboveNicheBottomMm: 9.5,
byLoadKg: Object.freeze({
40: Object.freeze([37, 69, 197, 229, 261, 293, 357]),
70: Object.freeze([37, 69, 261, 293, 357])
})
});
/**
* Positions X (depuis le chant avant du panneau vertical) pour une capacité donnée,
* bornées à la profondeur utile du panneau.
* @param {40|70} loadKg
* @param {number} [maxXMm] — profondeur maximum perçable (ex. profondeur panneau − retrait)
*/
export function getLegraboxRailHolePositionsMm(loadKg = 40, maxXMm = Infinity) {
const base = LEGRABOX_RAIL_DRILLING.byLoadKg[Number(loadKg) === 70 ? 70 : 40];
const max = Number.isFinite(Number(maxXMm)) ? Number(maxXMm) : Infinity;
return base.filter((x) => x <= max);
}
// ── 3. Perçage CNC — façades (INSERTA / cheville expansible) ────────────
/**
* Matrice de perçage façade — fixation INSERTA (cheville expansible Ø10, prof. > 12 mm).
* Cotes catalogue conservées telles quelles (validation programmeur CNC) :
* - minFrontHeightMm (A) : encombrement min de la façade
* - coteC / coteD : positions verticales des 2 perçages du support, depuis le bas façade
* - coteB : rangées supplémentaires (façades hautes C / F), depuis le bas façade
* - coteE : retrait horizontal de l'axe depuis la référence côté tiroir
* - coteG : cote catalogue complémentaire (référence gabarit)
*/
export const LEGRABOX_FACADE_INSERTA = Object.freeze({
toolNote: 'Cheville expansible Ø 10 mm, profondeur de perçage > 12 mm',
holeDiameterMm: 10,
holeDepthMm: 13,
bySystem: Object.freeze({
N: Object.freeze({ minFrontHeightMm: 80, coteC: 45.5, coteD: 16, coteE: 23.4, coteG: 14 }),
M: Object.freeze({ minFrontHeightMm: 106, coteC: 51, coteD: 32, coteE: 23.4, coteG: 14 }),
K: Object.freeze({ minFrontHeightMm: 144, coteC: 51, coteD: 32, coteE: 23.4, coteG: 14 }),
C: Object.freeze({ minFrontHeightMm: 193, coteB: Object.freeze([147, 179]), coteC: 51, coteD: 32, coteE: 24.4, coteG: 14 }),
F: Object.freeze({ minFrontHeightMm: 257, coteB: Object.freeze([179, 211, 243]), coteC: 51, coteD: 32, coteE: 24.4, coteG: 14 })
})
});
/**
* Positions XY des perçages INSERTA sur une façade (repère : origine bas-gauche façade).
* Interprétation atelier : 2 perçages verticaux par côté (y = coteD bas, y = coteC haut,
* depuis le bas façade) + rangées coteB pour C/F. Axe horizontal à coteE de la
* référence côté de tiroir (bord de la largeur libre X), soit
* x = (largeur façade − X)/2 + coteE depuis chaque bord de façade.
* @param {string} systemKey — 'N'…'F'
* @param {number} facadeWidthMm
* @param {number} facadeHeightMm
* @param {number} internalWidthMm — largeur libre du corps X
* @returns {{ xMm:number, yMm:number, role:string }[]}
*/
export function buildLegraboxFacadeInsertaHolePositionsMm(systemKey, facadeWidthMm, facadeHeightMm, internalWidthMm) {
const spec = LEGRABOX_FACADE_INSERTA.bySystem[systemKey];
const W = Math.max(0, Number(facadeWidthMm) || 0);
const H = Math.max(0, Number(facadeHeightMm) || 0);
const X = Math.max(0, Number(internalWidthMm) || 0);
if (!spec || W <= 0 || H <= 0 || X <= 0 || W <= X - 2 * spec.coteE) return [];
const inset = Math.max(spec.coteG, (W - X) / 2 + spec.coteE);
const xs = [
{ xMm: Math.round(inset * 10) / 10, side: 'gauche' },
{ xMm: Math.round((W - inset) * 10) / 10, side: 'droite' }
];
const ys = [
{ yMm: spec.coteD, role: 'bas' },
{ yMm: spec.coteC, role: 'haut' },
...(Array.isArray(spec.coteB) ? spec.coteB.map((b, i) => ({ yMm: b, role: `rangee_B${i + 1}` })) : [])
].filter((p) => p.yMm > 0 && p.yMm < H);
const out = [];
for (const { xMm, side } of xs) {
for (const { yMm, role } of ys) {
out.push({ xMm, yMm, role: `${side}_${role}` });
}
}
return out;
}
// ── 4. Montage / ajustement ──────────────────────────────────────────────
export const LEGRABOX_ASSEMBLY = Object.freeze({
montageFacade: 'Sans outils',
demontageFacade: 'Tournevis',
reglageHauteur: "Depuis l'intérieur (devant)",
reglageInclinaison: 'Depuis le bas (devant)',
technologiesOuvertureFermeture: Object.freeze([
'Blumotion (Fermeture automatique avec amortissement)',
'Tip-on Blumotion (Ouverture automatique avec amortissement intégré)',
'Tip-on (Système d\u2019ouverture mécanique)'
])
});
// ── 5. Génération de commande (BOM) ─────────────────────────────────────
export const LEGRABOX_ORDER_OPTIONS = Object.freeze({
couleursChassis: Object.freeze([
'blanc soie',
'argent polaire',
'gris orion',
'noir terra',
'acier inoxydable (anti-empreintes digitales)'
]),
typesDeTiroir: Object.freeze([
'Type de façade du tiroir (Standard)',
'Type de tiroir intérieur (Anglais)'
]),
variantesOuvertureFermeture: Object.freeze(['Blumotion', 'Tip-On Blumotion', 'Tip-On']),
fixationsFacadeDisponibles: Object.freeze(['Vis (Douille d\u2019écartement)', 'Expan-do T']),
etatDeLivraison: Object.freeze(['F', 'C', 'S'])
});
/** Règles métier strictes (regles_automatiques_requises). */
export const LEGRABOX_BOM_RULES = Object.freeze({
/** Ajouter « Stabilisation de façade » si largeur_corps_mm >= 600. */
frontStabiliserMinBodyWidthMm: 600,
/** Ajouter « Unité de synchronisation » si variante Tip-On Blumotion. */
syncUnitVariant: 'tip_on_blumotion'
});
/**
* Longueurs nominales disponibles (mm) par système et capacité de charge.
* N absent du dossier BOM — longueurs issues du catalogue Blum (40 kg uniquement).
* Exception : K (hauteur L) indisponible en acier inoxydable.
*/
export const LEGRABOX_NOMINAL_LENGTHS_MM = Object.freeze({
N: Object.freeze({ 40: Object.freeze([400, 450, 500, 550]), 70: Object.freeze([]) }),
M: Object.freeze({ 40: Object.freeze([270, 300, 350, 400, 450, 500, 550, 600]), 70: Object.freeze([450, 500, 550, 600, 650]) }),
K: Object.freeze({ 40: Object.freeze([300, 350, 400, 450, 500, 550, 600]), 70: Object.freeze([450, 500, 550, 600]) }),
C: Object.freeze({ 40: Object.freeze([270, 300, 350, 400, 450, 500, 550, 600]), 70: Object.freeze([450, 500, 550, 600, 650]) }),
F: Object.freeze({ 40: Object.freeze([400, 450, 500, 550, 600]), 70: Object.freeze([450, 500, 550, 600, 650]) })
});
/** Couleur châssis indisponible : hauteur K (L) en acier inoxydable. */
export function isLegraboxChassisColorAvailable(systemKey, chassisColor) {
const isInox = String(chassisColor || '').toLowerCase().includes('inox');
return !(systemKey === 'K' && isInox);
}
/**
* Plus grande longueur nominale disponible pour un système / capacité, bornée par la
* profondeur utile (profondeur intérieure min = NL + 3 mm gérée par l'appelant).
* @returns {number|null}
*/
export function getLegraboxNominalLengthMm(systemKey, availableDepthMm, loadKg = 40) {
const table = LEGRABOX_NOMINAL_LENGTHS_MM[systemKey];
if (!table) return null;
const list = table[Number(loadKg) === 70 ? 70 : 40] || [];
const depth = Math.max(0, Number(availableDepthMm) || 0);
let best = null;
for (const nl of list) {
if (nl <= depth && (best == null || nl > best)) best = nl;
}
return best;
}
// ── 6. Accessoires et subdivisions intérieures (up-sell) ────────────────
export const LEGRABOX_INTERIOR_ACCESSORIES = Object.freeze({
garnituresUniversellesPlastique: Object.freeze({
label: 'Garniture universelle plastique',
nominalLengthCompatibleMm: 500,
couleurs: Object.freeze(['Anthracite pearl', 'Blanc grainé']),
modelesParHauteur: Object.freeze({
h40: Object.freeze({
hauteurMm: 40,
largeursCorpsCompatiblesMm: Object.freeze([600, 800, 900]),
note: 'Particulièrement adapté sous les champs d\u2019induction'
}),
h52: Object.freeze({
hauteurMm: 52,
largeursCorpsCompatiblesMm: Object.freeze([500, 600, 800, 900])
})
})
}),
assortimentsPorteCouverts: Object.freeze({
label: 'Assortiment porte-couverts modulaire',
typeFixation: 'Autocollant avec insert en feutre',
couleurs: Object.freeze(['Lie de vin', 'Anthracite']),
configurations: Object.freeze([
Object.freeze({ pieces: 50, composition: '12 couteaux, 12 fourchettes, 12 cuillères, 12 cuillères à café, 2 grandes pièces' }),
Object.freeze({ pieces: 70, composition: '12 couteaux, 12 fourchettes, 12 cuillères, 12 cuillères à café, 12 fourchettes de cuisine, 6 petites pièces, 4 grandes pièces' }),
Object.freeze({ pieces: 90, composition: '24 couteaux, 24 fourchettes, 12 cuillères, 12 cuillères à café, 12 fourchettes de cuisine, 6 grandes pièces' })
])
}),
garnituresReglablesBoisInox: Object.freeze({
label: 'Garniture réglable hêtre / acier inox',
finition: 'Hêtre / acier inox',
nominalLengthCompatibleMm: 500,
profondeurReelleMm: 478,
modeles: Object.freeze({
bacEtroit: Object.freeze({ largeurCorpsCibleMm: [300, 400], largeurReelleAjustableMm: [200, 300] }),
bacLarge: Object.freeze({ largeurCorpsCibleMm: [450, 700], largeurReelleAjustableMm: [350, 600] })
})
}),
rangeCouvertsBoisHetre: Object.freeze({
label: 'Range-couverts réglable hêtre (laqué)',
finition: 'Hêtre (laqué)',
nominalLengthsCompatiblesMm: Object.freeze([500, 550, 600, 650]),
/** Profondeur réelle = longueur nominale − 28 mm */
profondeurReelleDeductionMm: 28,
modeles: Object.freeze({
bacEtroit: Object.freeze({ largeurCorpsCibleMm: [300, 400], largeurReelleAjustableMm: [200, 300] }),
bacLarge: Object.freeze({ largeurCorpsCibleMm: [450, 700], largeurReelleAjustableMm: [350, 600] })
})
})
});
/**
* Suggestions d'accessoires intérieurs compatibles avec la géométrie du tiroir.
* @param {{ bodyWidthMm:number, nominalLengthMm:number }} params — largeur de corps (KB) et NL
* @returns {{ key:string, label:string, detail:string }[]}
*/
export function getLegraboxAccessorySuggestions({ bodyWidthMm, nominalLengthMm }) {
const kb = Math.max(0, Number(bodyWidthMm) || 0);
const nl = Math.max(0, Number(nominalLengthMm) || 0);
const out = [];
const A = LEGRABOX_INTERIOR_ACCESSORIES;
if (nl === A.garnituresUniversellesPlastique.nominalLengthCompatibleMm) {
for (const [k, m] of Object.entries(A.garnituresUniversellesPlastique.modelesParHauteur)) {
if (m.largeursCorpsCompatiblesMm.includes(kb)) {
out.push({
key: `garniture_plastique_${k}`,
label: `${A.garnituresUniversellesPlastique.label} h${m.hauteurMm} mm`,
detail: m.note || `Largeur corps ${kb} mm, NL ${nl} mm`
});
}
}
}
const inRange = ([min, max]) => kb >= min && kb <= max;
if (nl === A.garnituresReglablesBoisInox.nominalLengthCompatibleMm) {
if (inRange(A.garnituresReglablesBoisInox.modeles.bacEtroit.largeurCorpsCibleMm)) {
out.push({ key: 'garniture_bois_inox_etroit', label: `${A.garnituresReglablesBoisInox.label} — bac étroit`, detail: `Largeur réelle ajustable 200–300 mm` });
}
if (inRange(A.garnituresReglablesBoisInox.modeles.bacLarge.largeurCorpsCibleMm)) {
out.push({ key: 'garniture_bois_inox_large', label: `${A.garnituresReglablesBoisInox.label} — bac large`, detail: `Largeur réelle ajustable 350–600 mm` });
}
}
if (A.rangeCouvertsBoisHetre.nominalLengthsCompatiblesMm.includes(nl)) {
const prof = nl - A.rangeCouvertsBoisHetre.profondeurReelleDeductionMm;
if (inRange(A.rangeCouvertsBoisHetre.modeles.bacEtroit.largeurCorpsCibleMm)) {
out.push({ key: 'range_couverts_hetre_etroit', label: `${A.rangeCouvertsBoisHetre.label} — bac étroit`, detail: `Profondeur réelle ${prof} mm` });
}
if (inRange(A.rangeCouvertsBoisHetre.modeles.bacLarge.largeurCorpsCibleMm)) {
out.push({ key: 'range_couverts_hetre_large', label: `${A.rangeCouvertsBoisHetre.label} — bac large`, detail: `Profondeur réelle ${prof} mm` });
}
}
return out;
}
+268 -62
View File
@@ -12,12 +12,19 @@ import {
HINGE,
tourillonFaceDrillDepthMm,
DRAWER_REINFORCEMENT,
DRAWER_RENFORT_PANEL_IXCONNECT
DRAWER_RENFORT_PANEL_IXCONNECT,
getBackGrooveInnerEndInsetFromBackEdgeMm
} from './manufacturing.js';
/** Perçage ferrure en face — Ø8 mm (même diamètre que tourillon / Ixconnect face) */
export const CARCASS_FERRURE_FACE_DIAMETER_MM = 8;
/** Axe X du trou Inbus sur le panneau qui porte le long perçage Ixconnect en chant. */
function ixconnectInbusXFromEndMm({ endSide, widthMm, clampX }) {
const offset = Number(CONNECTORS.ixconnect.lockScrewInbus?.offsetFromAssemblyEdgeMm) || 25;
return clampX(endSide === 'left' ? offset : Number(widthMm) - offset);
}
/** Montants / séparateurs : rangées Ixconnect jonction dessus·fond — retrait depuis bords haut/bas (mm, origine Y = bas pièce en G-code). */
export const IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM = 23;
@@ -32,6 +39,98 @@ export function mapJunctionXAcrossWidthMm(widthMm, xmm) {
return clamped;
}
/**
* Machining X is stored in finished-panel millimetres (origin = left). Saw blanks
* (`rawCutWidthMm`) are longer; that extra sits on +X, so right-edge features
* (montants, AXILO) look too far from the right until they are re-anchored.
* Features within `edgeBandMm` of the finished right edge keep the same inset
* on the workpiece; everything else stays left-origin.
*/
export function mapFinishedXOntoWorkpieceMm(xMm, finishedWidthMm, workpieceWidthMm, edgeBandMm = 120) {
const x = Number(xMm) || 0;
const fw = Math.max(0, Number(finishedWidthMm) || 0);
const ww = Math.max(0, Number(workpieceWidthMm) || 0);
if (ww < 0.5) return x;
if (fw < 0.5 || Math.abs(ww - fw) < 0.05) return Math.max(0, Math.min(ww, x));
const distRight = fw - x;
const distLeft = x;
if (distRight <= edgeBandMm && distRight <= distLeft + 0.05) {
return Math.max(0, Math.min(ww, ww - distRight));
}
return Math.max(0, Math.min(ww, x));
}
/** AXILO 78 Press-fit holes on the underside of a bottom panel. */
export function buildAxiloFootHoleSpecsMm(piece, widthMm, heightMm) {
const axilo = piece?.machining?.axiloFootPlates;
if ((piece?.elementType || '') !== 'fond' || !axilo?.centers?.length) return [];
const finishedW = Number(piece?.widthMm) > 0 ? Number(piece.widthMm) : widthMm;
const diameterMm = Number(axilo.holeDiameterMm) || 10;
const depthMm = Number(axilo.holeDepthMm) || 12;
const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0));
const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0));
const out = [];
for (const center of axilo.centers) {
for (const offset of axilo.holeOffsetsFromCenterMm || []) {
out.push({
xMm: clampX(
mapFinishedXOntoWorkpieceMm(
Number(center.xMm) + Number(offset.x || 0),
finishedW,
widthMm
)
),
yMm: clampY(Number(center.yMm) + Number(offset.y || 0)),
diameterMm,
depthMm,
type: 'axilo_press_fit',
face: 'back',
row: center.row
});
}
}
return out;
}
/** System 32 rows; the rear row clears the back-groove zone when present. */
export function buildSystem32HoleSpecsMm(piece, widthMm, heightMm) {
const system32 = piece?.machining?.system32;
if (!system32?.positions?.length) return [];
const finishedW = Number(piece?.widthMm) > 0 ? Number(piece.widthMm) : widthMm;
const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0));
const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0));
const mapX = (v) => clampX(mapFinishedXOntoWorkpieceMm(v, finishedW, widthMm));
const backClearanceMm = piece?.machining?.groove
? getBackGrooveInnerEndInsetFromBackEdgeMm()
: 0;
const xRows = [
mapX(37),
mapX(Math.max(0, finishedW - 37 - backClearanceMm))
];
const diameterMm = Number(system32.holeDiameter) || 5;
const depthMm = Number(system32.holeDepth || piece?.thicknessMm) || 12;
const toolMode = system32.throughHole ? 'through' : 'blind';
const out = [];
for (const pos of system32.positions) {
for (const xMm of xRows) {
out.push({
xMm,
yMm: clampY(pos),
diameterMm,
depthMm,
type: 'system32',
face: 'face',
toolMode
});
}
}
return out;
}
/** Même logique que `getCarcassDowelStationsForPlan` dans cuttingEngine.js */
export function getCarcassDowelStationsForPlan(cf, edgeLengthMm) {
const L = Math.max(1, Number(edgeLengthMm) || 0);
@@ -50,14 +149,22 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) {
const out = [];
const m = piece?.machining || {};
const thicknessMm = Math.max(1, Number(piece?.thicknessMm) || 19);
if (!['dessus', 'fond'].includes(piece.elementType || '')) return out;
const et = piece.elementType || '';
if (!['dessus', 'fond'].includes(et)) return out;
const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0));
const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0));
/** Largeur « caisse » (finie) pour repères jonctions — peut être < widthMm (brut CNC). */
const refW =
Number(piece?.widthMm) > 0 ? Math.min(widthMm, Number(piece.widthMm)) : widthMm;
const mapX = (xmm) => clampX(mapFinishedXOntoWorkpieceMm(xmm, refW, widthMm));
const edgeTolMm = 0.5;
/**
* Face d’entrée en épaisseur pour jonctions caisse (côtés / séparateurs) :
* — dessus : sous-face (intérieur caisse) → FACE=back (MPR von unten / 3D fromBottom)
* — fond : face supérieure (intérieur caisse) → FACE=face
*/
const carcassFace = et === 'dessus' ? 'back' : 'face';
const cf = m.carcassFasteners;
if (cf && (cf.dowelStations?.length || cf.positionsAlongJointMm?.length)) {
@@ -69,11 +176,15 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) {
const zFerr = Number(cf.ferrureDepthInThicknessMm ?? CARCASS_FASTENERS.ferrureDepthInThicknessMm);
const zFerrEdge = Number(cf.ferrureDepthAlongLengthMm ?? 46);
for (const xmm of jax) {
for (let junctionIndex = 0; junctionIndex < jax.length; junctionIndex++) {
const xmm = jax[junctionIndex];
const rawX = clampX(xmm);
const isEnd = rawX <= edgeTolMm || rawX >= refW - edgeTolMm;
const explicitFaceMode = cf.junctionFaceModes?.[junctionIndex];
const isEnd = explicitFaceMode
? explicitFaceMode === 'end'
: rawX <= edgeTolMm || rawX >= refW - edgeTolMm;
const endSide = rawX <= refW / 2 ? 'left' : 'right';
const x = clampX(mapJunctionXAcrossWidthMm(widthMm, xmm));
const x = mapX(mapJunctionXAcrossWidthMm(refW, xmm));
for (const st of stations) {
const along = st.holesAlongMm || [];
for (let hi = 0; hi < along.length; hi++) {
@@ -87,7 +198,7 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) {
diameterMm: dTour,
depthMm: zTour,
type: 'tourillon',
face: isEnd ? 'end' : 'face',
face: isEnd ? 'end' : carcassFace,
...(isEnd ? { end: endSide } : {})
});
} else {
@@ -98,32 +209,25 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) {
diameterMm: CARCASS_FERRURE_FACE_DIAMETER_MM,
depthMm: isEnd ? zFerrEdge : zFerr,
type: 'ixconnect',
face: isEnd ? 'end' : 'face',
face: isEnd ? 'end' : carcassFace,
...(isEnd ? { end: endSide } : {})
});
/* Trou Inbus : même règle que connecteur T–IX–T (un par Ixconnect ferrure). Sans ceci, les jeux côté rainure
* (souvent issus de ferrures caisse seules) n’ont pas de trou violet Inbus en plan / G-code / 3D. */
{
/* Le Ø6 d'accès appartient au même panneau que le long Ø8 percé en chant. */
if (isEnd) {
const lock = CONNECTORS.ixconnect.lockScrewInbus;
const dLock = Number(lock?.diameter) || 6;
const zLock = Number(lock?.depth) || 9;
const epsMm = 0.1;
let xInbus = clampX(x);
if (isEnd) {
const depthAlongMm = Math.min(zFerrEdge, widthMm * 0.48, 80);
xInbus =
endSide === 'left'
? depthAlongMm / 2 + epsMm
: widthMm - depthAlongMm / 2 - epsMm;
xInbus = clampX(xInbus);
}
out.push({
xMm: xInbus,
xMm: ixconnectInbusXFromEndMm({
endSide,
widthMm,
clampX
}),
yMm,
diameterMm: dLock,
depthMm: zLock,
type: 'ixconnect_inbus',
face: 'face'
face: carcassFace
});
}
}
@@ -134,7 +238,14 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) {
const ch = m.connectorHoles;
const joints = ch?.ixconnectJointsAlongDepth;
if (joints?.length) {
/* Quand les ferrures caisse (carcassFasteners) sont déjà posées, elles portent le trio
* T–IX–T + Inbus le long de chaque jonction. Réémettre ixconnectJointsAlongDepth
* (motif bord avant 20/50/80) doublonne le jeu avant — d’où deux trous au centre Ixconnect. */
const carcassOwnsHorizontalJoints =
Array.isArray(cf?.dowelStations) && cf.dowelStations.length > 0
? true
: Array.isArray(cf?.positionsAlongJointMm) && cf.positionsAlongJointMm.length > 0;
if (joints?.length && !carcassOwnsHorizontalJoints) {
const dTour = Number(ch.tourillonDiameter || CONNECTORS.tourillonDiameter || 8);
const zTour = tourillonFaceDrillDepthMm(thicknessMm);
const dFace = Number(ch.faceHole?.diameter || CONNECTORS.ixconnect.faceHole.diameter || 8);
@@ -148,14 +259,15 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) {
* clampX(axe droit) recule sous refW−ε et la jonction droite n’est plus « extrémité » → un seul Inbus. */
const axisNominal = Number(j.axisAcrossWidthMm) || 0;
const rawAcross = clampX(axisNominal);
const isEnd =
axisNominal <= edgeTolMm || axisNominal >= refW - edgeTolMm;
const isEnd = j.junctionFace
? j.junctionFace === 'end'
: axisNominal <= edgeTolMm || axisNominal >= refW - edgeTolMm;
const endSide = rawAcross <= refW / 2 ? 'left' : 'right';
const x = isEnd
? rawAcross <= edgeTolMm
? 0
: refW
: clampX(mapJunctionXAcrossWidthMm(widthMm, j.axisAcrossWidthMm));
: widthMm
: mapX(mapJunctionXAcrossWidthMm(refW, j.axisAcrossWidthMm));
const seq = [
{ axis: j.tourillon1Axis, d: dTour, z: isEnd ? zTourEdge : zTour, type: 'tourillon' },
{ axis: j.ixconnectAxis, d: isEnd ? dEdge : dFace, z: isEnd ? zEdge : zFace, type: 'ixconnect' },
@@ -170,37 +282,27 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) {
diameterMm: h.d,
depthMm: h.z,
type: h.type,
face: isEnd ? 'end' : 'face',
face: isEnd ? 'end' : carcassFace,
...(isEnd ? { end: endSide } : {})
});
}
/* Trou de serrage Inbus : un par Ixconnect — même (Y) que l’axe Ixconnect ; X aligné sur le centre du
* perçage tel qu’affiché en 3D (même formule que Cnc3DViewer pour FACE=end : min(prof chant, 48 % largeur, 80 mm) / 2).
* Sur jonction face : X = x du trio. Sur extrémité (montant) : entrée toujours face grande (perpendiculaire),
* mais X ne peut pas rester 0/refW sinon le cylindre est sur le bord au lieu du centre du violet sur chant. */
{
/* Le Ø6 d'accès appartient au même panneau que le long Ø8 percé en chant. */
if (isEnd) {
const lock = CONNECTORS.ixconnect.lockScrewInbus;
const dLock = Number(lock?.diameter) || 6;
const zLock = Number(lock?.depth) || 9;
const yIx = clampY(Number(j.ixconnectAxis));
const epsMm = 0.1;
let xInbus = clampX(x);
if (isEnd) {
const depthAlongMm = Math.min(zEdge, widthMm * 0.48, 80);
xInbus =
endSide === 'left'
? depthAlongMm / 2 + epsMm
: widthMm - depthAlongMm / 2 - epsMm;
xInbus = clampX(xInbus);
}
out.push({
xMm: xInbus,
yMm: yIx,
xMm: ixconnectInbusXFromEndMm({
endSide,
widthMm,
clampX
}),
yMm: clampY(Number(j.ixconnectAxis)),
diameterMm: dLock,
depthMm: zLock,
type: 'ixconnect_inbus',
face: 'face'
face: carcassFace
});
}
}
@@ -369,6 +471,81 @@ export function buildDrawerRenfortPanelIxconnectSpecsMm(piece, widthMm, heightMm
return [];
}
/**
* Coulisses Legrabox — perçages de vissage sur montants / séparateurs.
* Repère plan pièce : X depuis chant AVANT (origine bas-gauche), Y depuis bas panneau.
* `machining.drawerSlideDrilling` : { holeDiameterMm, holeDepthMm, positionsXFromFrontMm,
* cotes: { leftInnerFace: { railAxesYMm }, rightInnerFace: { railAxesYMm } },
* separators: [{ faceTowardLowerModule: { railAxesYMm }, faceTowardHigherModule: { railAxesYMm } }] }
* @param {number} [partIndex] — cotes: 0 = montant gauche, 1 = droit ; séparateurs: index
*/
export function buildLegraboxRailHoleSpecsMm(piece, widthMm, heightMm, partIndex = 0) {
const et = piece?.elementType || '';
const m = piece?.machining?.drawerSlideDrilling;
if (!m?.positionsXFromFrontMm?.length) return [];
const d = Number(m.holeDiameterMm) || 5;
const z = Number(m.holeDepthMm) || 13;
const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0));
const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0));
const out = [];
const pushFace = (axesYMm, faceKey, tag) => {
if (!Array.isArray(axesYMm) || !axesYMm.length) return;
for (const y of axesYMm) {
for (const x of m.positionsXFromFrontMm) {
out.push({
xMm: clampX(x),
yMm: clampY(y),
diameterMm: d,
depthMm: z,
type: `legrabox_rail${tag ? `_${tag}` : ''}`,
face: faceKey
});
}
}
};
if (et === 'cotes' && m.cotes) {
const L = m.cotes.leftInnerFace;
const R = m.cotes.rightInnerFace;
const hasL = Array.isArray(L?.railAxesYMm) && L.railAxesYMm.length > 0;
const hasR = Array.isArray(R?.railAxesYMm) && R.railAxesYMm.length > 0;
const face = hasL && hasR ? (partIndex === 0 ? L : R) : hasL ? L : hasR ? R : null;
pushFace(face?.railAxesYMm, 'face');
return out;
}
if (et === 'panneauxInterieurs' && Array.isArray(m.separators)) {
const sep = m.separators[partIndex];
if (!sep) return [];
pushFace(sep.faceTowardLowerModule?.railAxesYMm, 'face', 'sep_lower');
pushFace(sep.faceTowardHigherModule?.railAxesYMm, 'back', 'sep_higher');
return out;
}
return [];
}
/**
* Façades tiroir Legrabox — fixation INSERTA (cheville expansible Ø10, prof. 13 mm).
* Repère plan façade : origine bas-gauche, X/Y en mm (face arrière de la façade).
*/
export function buildLegraboxFacadeInsertaSpecsMm(piece, widthMm, heightMm) {
if ((piece?.elementType || '') !== 'tiroirFacade') return [];
const ins = piece?.machining?.legraboxInserta;
if (!ins?.holes?.length) return [];
const d = Number(ins.holeDiameterMm) || 10;
const z = Number(ins.holeDepthMm) || 13;
return ins.holes.map((h) => ({
xMm: Math.max(0, Math.min(widthMm, Number(h.xMm) || 0)),
yMm: Math.max(0, Math.min(heightMm, Number(h.yMm) || 0)),
diameterMm: d,
depthMm: z,
type: 'legrabox_inserta',
face: 'face'
}));
}
export function buildDrawerReinforcementHoleSpecsMm(piece, widthMm, heightMm) {
if ((piece?.elementType || '') !== 'renfortsTiroir') return [];
const ix = piece?.machining?.drawerReinforcement?.ixconnectFixing;
@@ -402,6 +579,19 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh
const H = Math.max(1, Number(heightMm) || 1);
const W = Math.max(1, Number(widthMm) || 1);
const finishedW =
Number(piece?.widthMm) > 0 ? Math.min(W, Number(piece.widthMm)) : W;
const backClearanceMm =
et === 'cotes' && piece?.machining?.groove
? getBackGrooveInnerEndInsetFromBackEdgeMm()
: 0;
const backReferenceMm = Math.max(0, finishedW - backClearanceMm);
const mapAxisX = (axisMm) =>
mapFinishedXOntoWorkpieceMm(
mapJunctionXAcrossWidthMm(finishedW, axisMm),
finishedW,
W
);
const inset = IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM;
if (H < 2 * inset + 5) return out;
@@ -420,9 +610,9 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh
const t2 = Number(p.tourillon2Axis);
const ix = Number(p.ixconnectAxis);
const mirrorBack =
W >= IXCONNECT_SEPARATOR_BACK_ROW_MIN_WIDTH_MM && t2 > t1;
finishedW >= IXCONNECT_SEPARATOR_BACK_ROW_MIN_WIDTH_MM && t2 > t1;
if (et === 'panneauxInterieurs') {
if (et === 'panneauxInterieurs' || (et === 'cotes' && ch.verticalJointMode === 'edge')) {
const seqFront = [
{ axis: t1, d: dTour, z: zTourEdge, type: 'tourillon' },
{ axis: ix, d: dEdge, z: zIxEdge, type: 'ixconnect' },
@@ -430,16 +620,21 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh
];
const seqBack = mirrorBack
? [
{ axis: W - t2, d: dTour, z: zTourEdge, type: 'tourillon' },
{ axis: W - ix, d: dEdge, z: zIxEdge, type: 'ixconnect' },
{ axis: W - t1, d: dTour, z: zTourEdge, type: 'tourillon' }
{ axis: backReferenceMm - t2, d: dTour, z: zTourEdge, type: 'tourillon' },
{ axis: backReferenceMm - ix, d: dEdge, z: zIxEdge, type: 'ixconnect' },
{ axis: backReferenceMm - t1, d: dTour, z: zTourEdge, type: 'tourillon' }
]
: null;
const lock = CONNECTORS.ixconnect.lockScrewInbus;
const dLock = Number(lock?.diameter) || 6;
const zLock = Number(lock?.depth) || 9;
const inbusOffset = Number(lock?.offsetFromAssemblyEdgeMm) || 25;
const pushEndRow = (yMm, seq, endSide) => {
const cy = clampY(yMm);
const inbusY = clampY(endSide === 'bottom' ? inbusOffset : H - inbusOffset);
for (const h of seq) {
const xMm = mapJunctionXAcrossWidthMm(W, Number(h.axis));
const xMm = mapAxisX(Number(h.axis));
out.push({
xMm,
yMm: cy,
@@ -449,6 +644,16 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh
face: 'end',
end: endSide
});
if (h.type === 'ixconnect') {
out.push({
xMm,
yMm: inbusY,
diameterMm: dLock,
depthMm: zLock,
type: 'ixconnect_inbus',
face: 'face'
});
}
}
};
@@ -460,7 +665,8 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh
return out;
}
/* cotes : face */
/* cotes sans habillage latéral : logement face uniquement.
* L'accès Inbus est sur la pièce opposée qui porte le long perçage en chant. */
const seqFront = [
{ axis: t1, d: dTour, z: zTour, type: 'tourillon' },
{ axis: ix, d: dFace, z: zFace, type: 'ixconnect' },
@@ -468,15 +674,15 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh
];
const seqBack = mirrorBack
? [
{ axis: W - t2, d: dTour, z: zTour, type: 'tourillon' },
{ axis: W - ix, d: dFace, z: zFace, type: 'ixconnect' },
{ axis: W - t1, d: dTour, z: zTour, type: 'tourillon' }
{ axis: backReferenceMm - t2, d: dTour, z: zTour, type: 'tourillon' },
{ axis: backReferenceMm - ix, d: dFace, z: zFace, type: 'ixconnect' },
{ axis: backReferenceMm - t1, d: dTour, z: zTour, type: 'tourillon' }
]
: null;
const pushFaceRow = (yMm, seq) => {
const cy = clampY(yMm);
for (const h of seq) {
const xMm = mapJunctionXAcrossWidthMm(W, Number(h.axis));
const xMm = mapAxisX(Number(h.axis));
out.push({
xMm,
yMm: cy,
@@ -487,10 +693,10 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh
});
}
};
for (const yMm of yRows) {
pushFaceRow(yMm, seqFront);
if (seqBack) pushFaceRow(yMm, seqBack);
}
pushFaceRow(yRows[0], seqFront);
if (seqBack) pushFaceRow(yRows[0], seqBack);
pushFaceRow(yRows[1], seqFront);
if (seqBack) pushFaceRow(yRows[1], seqBack);
return out;
}
+922 -14
View File
File diff suppressed because it is too large Load Diff
+350
View File
@@ -0,0 +1,350 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
addUpperBlockAboveHorizontalSpan,
getModuleVerticalBoundariesMm,
resizeHorizontalSpanUpperBlock,
resolveHorizontalSpan,
validateHorizontalSpans,
usesBlockConstruction,
columnUsesBlockConstruction,
horizontalConstructionRuns,
getAxiloPlateCentersMm,
getAxiloLateralCenterInsetMm,
getAxiloPlinthFrontSideDeductionMm,
PLINTH
} from './manufacturing.js';
import { validateConfig } from '../server/services/configValidation.js';
import { generateCuttingList } from '../server/services/cuttingEngine.js';
function makeConfig(columnCount, spanHeightMm = 900) {
const bottomMm = 300;
const topMm = bottomMm + spanHeightMm;
const bodyMm = 1800;
const modules = Array.from({ length: columnCount }, (_, index) => ({
id: `col-${index + 1}`,
type: 'penderie',
moduleCode: 'penderie',
widthMm: 600,
minWidth: 300,
maxWidth: 1200,
shelves: 0,
drawers: 0,
hasRod: false,
blockHeightsMm: [bottomMm, spanHeightMm, bodyMm - topMm],
blockBacks: [true, index % 2 === 0, false],
blockDoors: [true, false, true],
hasBack: true,
hasBackTop: false
}));
return {
installationType: 'free',
doorType: 'hinged',
widthMm: columnCount * 600,
heightMm: 1900,
depthMm: 600,
plinthHeightMm: 100,
effectiveDrawerDepthMm: 600,
hardware: { drawerType: 'visible', handleBoreHeightMm: 1000 },
modules,
horizontalSpans: [{
id: 'span-main',
moduleIds: modules.map((module) => module.id),
bottomMm,
topMm,
hasBack: true,
doorMode: 'none',
type: 'open'
}]
};
}
function assertSynchronizedUpperBlock(config, expectedSpanTopMm, expectedUpperHeightMm) {
const resolved = resolveHorizontalSpan(config.horizontalSpans[0], config);
assert.equal(resolved.topMm, expectedSpanTopMm);
for (const module of config.modules) {
const boundaries = getModuleVerticalBoundariesMm(module, config);
assert.ok(boundaries.includes(expectedSpanTopMm));
assert.ok(boundaries.includes(1200));
const upperIndex = boundaries.indexOf(expectedSpanTopMm);
assert.equal(boundaries[upperIndex + 1] - boundaries[upperIndex], expectedUpperHeightMm);
assert.equal(module.blockDoors[upperIndex], true);
resolved.coveredBlocks
.find((covered) => covered.moduleId === module.id)
.blockIndexes
.forEach((blockIndex) => assert.equal(module.blockDoors[blockIndex], false));
assert.equal(module.blockHeightsMm.reduce((sum, height) => sum + height, 0), 1800);
}
}
for (const columnCount of [2, 3]) {
test(`${columnCount}-column span adds and resizes a synchronized upper block`, () => {
const original = makeConfig(columnCount);
const added = addUpperBlockAboveHorizontalSpan(original, 'span-main');
assert.equal(added.ok, true);
assert.notEqual(added.config, original);
assert.deepEqual(original.modules[0].blockHeightsMm, [300, 900, 600]);
assertSynchronizedUpperBlock(added.config, 900, 300);
const resized = resizeHorizontalSpanUpperBlock(added.config, 'span-main', 450);
assert.equal(resized.ok, true);
assertSynchronizedUpperBlock(resized.config, 750, 450);
assert.deepEqual(validateHorizontalSpans(resized.config), []);
assert.deepEqual(validateConfig(resized.config).errors, []);
const cuttingList = generateCuttingList(resized.config, {}, resized.config.modules);
assert.ok(Array.isArray(cuttingList.pieces));
assert.ok(cuttingList.pieces.length > 0);
});
}
test('too-short span rejects upper block without mutation', () => {
const config = makeConfig(2, 500);
const snapshot = structuredClone(config);
const result = addUpperBlockAboveHorizontalSpan(config, 'span-main');
assert.equal(result.ok, false);
assert.equal(result.code, 'span_too_short');
assert.deepEqual(config, snapshot);
});
test('resize rejects non-step heights without mutation', () => {
const added = addUpperBlockAboveHorizontalSpan(makeConfig(2), 'span-main');
assert.equal(added.ok, true);
const snapshot = structuredClone(added.config);
const result = resizeHorizontalSpanUpperBlock(added.config, 'span-main', 425);
assert.equal(result.ok, false);
assert.equal(result.code, 'invalid_height');
assert.deepEqual(added.config, snapshot);
});
test('construction: dos → block ; sinon côté ou rayonnage → block', () => {
assert.equal(usesBlockConstruction({ type: 'penderie' }, true), true);
assert.equal(usesBlockConstruction({ type: 'penderie' }, false), false);
assert.equal(usesBlockConstruction({ type: 'tiroirs' }, false), false);
assert.equal(usesBlockConstruction({ type: 'rayonnage' }, false), true);
assert.equal(usesBlockConstruction({ moduleCode: 'rayonnage' }, false), true);
assert.equal(usesBlockConstruction({ type: 'penderie', shelves: 1 }, false), false);
assert.equal(usesBlockConstruction({ type: 'penderie', shelves: 4 }, false), true);
assert.equal(usesBlockConstruction({ type: 'mixte', shelves: 2 }, false), true);
const penderie = { type: 'penderie', shelves: 1, hasBack: false, blockBacks: [false] };
const rayonnage = { type: 'rayonnage', hasBack: false, blockBacks: [false] };
const shelvedPenderie = { type: 'penderie', shelves: 4, hasBack: false, blockBacks: [false] };
const withDos = { type: 'penderie', hasBack: true, blockBacks: [true] };
const config = { hasBack: false };
assert.equal(columnUsesBlockConstruction(penderie, config), false);
assert.equal(columnUsesBlockConstruction(rayonnage, config), true);
assert.equal(columnUsesBlockConstruction(shelvedPenderie, config), true);
assert.equal(columnUsesBlockConstruction(withDos, config), true);
const modules = [
{ type: 'penderie', widthMm: 600, hasBack: false, blockBacks: [false] },
{ type: 'penderie', widthMm: 600, hasBack: false, blockBacks: [false] },
{ type: 'rayonnage', widthMm: 500, hasBack: false, blockBacks: [false] },
{ type: 'penderie', widthMm: 600, hasBack: false, blockBacks: [false] }
];
assert.deepEqual(
horizontalConstructionRuns(modules, config, 'top').map(({ start, end, isBlock }) => ({ start, end, isBlock })),
[
{ start: 0, end: 1, isBlock: false },
{ start: 2, end: 2, isBlock: true },
{ start: 3, end: 3, isBlock: false }
]
);
const allPenderie = modules.map((m, i) => (
i === 2 ? { ...m, type: 'penderie', moduleCode: 'penderie', shelves: 0 } : m
));
assert.deepEqual(
horizontalConstructionRuns(allPenderie, config, 'top').map(({ start, end, isBlock }) => ({ start, end, isBlock })),
[{ start: 0, end: 3, isBlock: false }]
);
});
test('horizontal block at shell top replaces per-column roofs with one panel', () => {
const modules = [1, 2, 3].map((index) => ({
id: `col-${index}`,
type: 'rayonnage',
widthMm: 600,
blockHeightsMm: [1800],
blockBacks: [false]
}));
const config = {
widthMm: 1800,
heightMm: 1900,
plinthHeightMm: 100,
modules,
horizontalSpans: [{
id: 'span-top',
moduleIds: ['col-1', 'col-2'],
bottomMm: 0,
topMm: 1800,
hasBack: false,
doorMode: 'none',
type: 'open'
}]
};
assert.deepEqual(
horizontalConstructionRuns(modules, config, 'top')
.map(({ start, end, horizontalSpanId }) => ({ start, end, horizontalSpanId })),
[
{ start: 0, end: 1, horizontalSpanId: 'span-top' },
{ start: 2, end: 2, horizontalSpanId: undefined }
]
);
});
test('cutting list: merged dessus for separations, split for rayonnage', () => {
const baseModule = (index, type) => ({
id: `col-${index + 1}`,
type,
moduleCode: type,
widthMm: 600,
minWidth: 300,
maxWidth: 1200,
shelves: type === 'rayonnage' ? 4 : 0,
drawers: 0,
hasRod: type === 'penderie',
blockHeightsMm: [1800],
blockBacks: [false],
blockDoors: [true],
hasBack: false,
hasBackTop: false
});
const configOf = (types) => ({
installationType: 'free',
doorType: 'hinged',
widthMm: types.length * 600,
heightMm: 1900,
depthMm: 600,
plinthHeightMm: 100,
hasBack: false,
columnMode: 'separations',
hardware: { drawerType: 'visible', handleBoreHeightMm: 1000 },
modules: types.map((type, index) => baseModule(index, type)),
horizontalSpans: []
});
const dessusPieces = (config) =>
generateCuttingList(config, {}, config.modules).pieces.filter((piece) => piece.elementType === 'dessus');
const merged = dessusPieces(configOf(['penderie', 'penderie', 'penderie']));
assert.equal(merged.length, 1);
assert.match(merged[0].id, /col1-3/);
const split = dessusPieces(configOf(['penderie', 'rayonnage', 'penderie']));
assert.equal(split.length, 3);
});
test('AXILO feet: 4 corners plus a pair every 600mm on the armoire', () => {
const inset = getAxiloLateralCenterInsetMm(true);
const narrow = getAxiloPlateCentersMm(500, 610, {
leftEdgeCenterInsetMm: inset,
rightEdgeCenterInsetMm: inset
});
assert.equal(narrow.length, 4, 'A bay shorter than 600mm keeps only the 4 corner feet');
const wide = getAxiloPlateCentersMm(2362, 610, {
leftEdgeCenterInsetMm: inset,
rightEdgeCenterInsetMm: inset
});
assert.equal(wide.length, 10, '2400mm armoire: 4 corners + 3 intermediate pairs');
const xs = [...new Set(wide.map((c) => c.xMm))].sort((a, b) => a - b);
assert.equal(xs.length, 5);
for (let i = 1; i < xs.length; i++) {
assert.ok(xs[i] - xs[i - 1] <= PLINTH.maxFootSpacingMm + 0.01);
}
const left = getAxiloPlateCentersMm(761, 610, {
leftEdgeCenterInsetMm: inset,
rightEdgeCenterInsetMm: inset,
spanWidthMm: 2362,
panelOffsetMm: 0,
assignMinMm: 0,
assignMaxMm: 781,
assignMaxInclusive: false
});
const mid = getAxiloPlateCentersMm(759, 610, {
leftEdgeCenterInsetMm: inset,
rightEdgeCenterInsetMm: inset,
spanWidthMm: 2362,
panelOffsetMm: 801.5,
assignMinMm: 781,
assignMaxMm: 1581,
assignMaxInclusive: false
});
const right = getAxiloPlateCentersMm(761, 610, {
leftEdgeCenterInsetMm: inset,
rightEdgeCenterInsetMm: inset,
spanWidthMm: 2362,
panelOffsetMm: 1601.5,
assignMinMm: 1581,
assignMaxMm: 2362,
assignMaxInclusive: true
});
assert.equal(left.length + mid.length + right.length, 10, 'Split fonds share the armoire foot count');
});
test('grooved bottom staggers the rear Ixconnect row away from AXILO holes', () => {
const config = makeConfig(1);
const bottom = generateCuttingList(config, {}, config.modules).pieces.find(
(piece) =>
piece.elementType === 'fond' &&
piece.machining?.groove &&
piece.machining?.axiloFootPlates &&
piece.machining?.carcassFasteners
);
assert.ok(bottom, 'Expected a grooved bottom with AXILO and carcass machining');
const rearFootYs = bottom.machining.axiloFootPlates.centers
.filter((center) => center.row === 'rear')
.flatMap((center) =>
bottom.machining.axiloFootPlates.holeOffsetsFromCenterMm.map(
(offset) => center.yMm + offset.y
)
);
const rearJoint = bottom.machining.carcassFasteners.dowelStations.at(-1);
const minimumAxisClearanceMm = Math.min(
...rearJoint.holesAlongMm.flatMap((jointY) =>
rearFootYs.map((footY) => Math.abs(jointY - footY))
)
);
assert.ok(
minimumAxisClearanceMm >= 14,
`Rear T–IX–T and AXILO axes need clearance; received ${minimumAxisClearanceMm} mm`
);
});
test('niche front plinth spans the full wardrobe width', () => {
const config = {
...makeConfig(3),
installationType: 'niche',
exteriorFinishesEnabled: true,
sideFinishLeftEnabled: true,
sideFinishRightEnabled: true
};
assert.equal(getAxiloPlinthFrontSideDeductionMm(config, 'left'), 0);
assert.equal(getAxiloPlinthFrontSideDeductionMm(config, 'right'), 0);
const plinth = generateCuttingList(config, {}, config.modules).pieces
.find((piece) => piece.elementType === 'plintheBas');
assert.ok(plinth);
assert.equal(plinth.widthMm, config.widthMm);
const cuttingList = generateCuttingList(config, {}, config.modules);
const sideFillers = cuttingList.pieces.find((piece) => piece.elementType === 'fileurCotes');
const topFiller = cuttingList.pieces.find((piece) => piece.elementType === 'fileurHaut');
assert.ok(sideFillers);
assert.equal(sideFillers.widthMm, 50);
assert.equal(sideFillers.heightMm, config.heightMm + 30);
assert.equal(sideFillers.quantity, 2);
assert.ok(topFiller);
assert.equal(topFiller.widthMm, config.widthMm + 60);
assert.equal(topFiller.heightMm, 50);
assert.equal(topFiller.quantity, 1);
});