v2.1
Ship configurator updates with catalog DB included for server deploy. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user