Co-authored-by: Cursor <[email protected]>
This commit is contained in:
lejay
2026-05-28 07:20:38 +02:00
co-authored by Cursor
parent 6923c319a1
commit 6c0d7e47b8
81 changed files with 21780 additions and 2986 deletions
+591
View File
@@ -0,0 +1,591 @@
/**
* Perçages face panneaux dessus/fond — alignés sur la vue A des plans (cuttingEngine)
* et sur les commentaires HOLE du G-code (cncGenerator).
*/
import {
CONNECTORS,
PANEL,
CARCASS_FASTENERS,
getCarcassFerrureDowelDrillings,
getHingeCountForDoor,
getHingeCupPositions,
HINGE,
tourillonFaceDrillDepthMm,
DRAWER_REINFORCEMENT,
DRAWER_RENFORT_PANEL_IXCONNECT
} 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;
/** 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;
/** Séparateurs verticaux : second jeu T–IX–T symétrique depuis le bord arrière (profondeur), si largeur brut le permet. */
export const IXCONNECT_SEPARATOR_BACK_ROW_MIN_WIDTH_MM = 200;
export function mapJunctionXAcrossWidthMm(widthMm, xmm) {
const edgeInset = PANEL.structure * 0.5;
const clamped = Math.max(0, Math.min(widthMm, Number(xmm) || 0));
if (clamped <= 0) return edgeInset;
if (clamped >= widthMm) return Math.max(0, widthMm - edgeInset);
return clamped;
}
/** Même logique que `getCarcassDowelStationsForPlan` dans cuttingEngine.js */
export function getCarcassDowelStationsForPlan(cf, edgeLengthMm) {
const L = Math.max(1, Number(edgeLengthMm) || 0);
const off = cf.dowelSideOffset ?? CARCASS_FASTENERS.dowel.sideOffset;
let positions = cf.positionsAlongJointMm;
if (!positions?.length && cf.dowelStations?.length) {
positions = cf.dowelStations.map((s) => s.centerMm).filter((v) => Number.isFinite(Number(v)));
}
return getCarcassFerrureDowelDrillings(positions || [], L, off);
}
/**
* @returns {Array<{ xMm: number, yMm: number, diameterMm: number, depthMm: number, type: string, face: string }>}
*/
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 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 edgeTolMm = 0.5;
const cf = m.carcassFasteners;
if (cf && (cf.dowelStations?.length || cf.positionsAlongJointMm?.length)) {
const edgeL = Math.max(1, Number(cf.edgeLengthMm) || heightMm);
const stations = getCarcassDowelStationsForPlan(cf, edgeL);
const jax = cf.junctionAxesAcrossWidthMm?.length ? cf.junctionAxesAcrossWidthMm : [0, refW];
const dTour = Number(cf.dowelDiameter || CARCASS_FASTENERS.dowel.diameter);
const zTour = tourillonFaceDrillDepthMm(thicknessMm);
const zFerr = Number(cf.ferrureDepthInThicknessMm ?? CARCASS_FASTENERS.ferrureDepthInThicknessMm);
const zFerrEdge = Number(cf.ferrureDepthAlongLengthMm ?? 46);
for (const xmm of jax) {
const rawX = clampX(xmm);
const isEnd = rawX <= edgeTolMm || rawX >= refW - edgeTolMm;
const endSide = rawX <= refW / 2 ? 'left' : 'right';
const x = clampX(mapJunctionXAcrossWidthMm(widthMm, xmm));
for (const st of stations) {
const along = st.holesAlongMm || [];
for (let hi = 0; hi < along.length; hi++) {
const alongMm = along[hi];
const yMm = clampY(alongMm);
const isTourillon = hi !== 1;
if (isTourillon) {
out.push({
xMm: x,
yMm,
diameterMm: dTour,
depthMm: zTour,
type: 'tourillon',
face: isEnd ? 'end' : 'face',
...(isEnd ? { end: endSide } : {})
});
} else {
/* Même perçage ferrure (Ø/prof) ; type ixconnect pour G-code / 3D : violet + long sur chant montant, comme renfort. */
out.push({
xMm: x,
yMm,
diameterMm: CARCASS_FERRURE_FACE_DIAMETER_MM,
depthMm: isEnd ? zFerrEdge : zFerr,
type: 'ixconnect',
face: isEnd ? 'end' : 'face',
...(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. */
{
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,
yMm,
diameterMm: dLock,
depthMm: zLock,
type: 'ixconnect_inbus',
face: 'face'
});
}
}
}
}
}
}
const ch = m.connectorHoles;
const joints = ch?.ixconnectJointsAlongDepth;
if (joints?.length) {
const dTour = Number(ch.tourillonDiameter || CONNECTORS.tourillonDiameter || 8);
const zTour = tourillonFaceDrillDepthMm(thicknessMm);
const dFace = Number(ch.faceHole?.diameter || CONNECTORS.ixconnect.faceHole.diameter || 8);
const zFace = Number(ch.faceHole?.depth || CONNECTORS.ixconnect.faceHole.depth || 12);
const dEdge = Number(ch.edgeHole?.diameter || CONNECTORS.ixconnect.edgeHole.diameter || 8);
const zEdge = Number(ch.edgeHole?.depth || CONNECTORS.ixconnect.edgeHole.depth || 45);
const zTourEdge = Math.min(zTour, 16);
for (const j of joints) {
/* isEnd doit utiliser l’axe nominal (repère caisse), pas clampX avant : si brut CNC < largeur finie,
* 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 endSide = rawAcross <= refW / 2 ? 'left' : 'right';
const x = isEnd
? rawAcross <= edgeTolMm
? 0
: refW
: clampX(mapJunctionXAcrossWidthMm(widthMm, 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' },
{ axis: j.tourillon2Axis, d: dTour, z: isEnd ? zTourEdge : zTour, type: 'tourillon' }
];
for (const h of seq) {
const pos = Number(h.axis);
const yMm = clampY(pos);
out.push({
xMm: x,
yMm,
diameterMm: h.d,
depthMm: h.z,
type: h.type,
face: isEnd ? 'end' : 'face',
...(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. */
{
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,
diameterMm: dLock,
depthMm: zLock,
type: 'ixconnect_inbus',
face: 'face'
});
}
}
}
return out;
}
/**
* Rayons mobiles (Étagères amovibles) — système SP 15/8/5
* - Perçage face (dessous non visible): Ø20 × 15, axe à 9.5 mm du bord (gauche/droite)
* - Perçage sur chant (épaisseur, contre côtés): Ø8 × 37, positionné à 6 mm de la face inférieure
* - Positionnement en profondeur (Système 32):
* - Depuis bord avant: 32 mm (37 axe caisse - 5 mm retrait étagère)
* - Depuis bord arrière: 37 mm
*
* Note: le plan 2D (X,Y) ne porte pas l'offset en épaisseur (6 mm) pour le perçage chant ; on l’encode
* comme perçage "end" pour la visualisation et l’annotation G-code.
*
* @returns {Array<{ xMm: number, yMm: number, diameterMm: number, depthMm: number, type: string, face: string, end?: string }>}
*/
export function buildShelfMobileHoleSpecsMm(piece, widthMm, heightMm) {
const out = [];
const et = (piece?.elementType || '');
if (!['etageres', 'rayonFixe'].includes(et)) return out;
const m = piece?.machining || {};
const sp = m?.mobileShelfSystem;
const isFixed = et === 'rayonFixe';
if (!sp || (sp.model !== 'sp_15_8_5' && sp.model !== 'sp_15_8_5_face_only')) return out;
const W = Math.max(1, Number(widthMm) || 1);
const D = Math.max(1, Number(heightMm) || 1);
const clampX = (v) => Math.max(0, Math.min(W, Number(v) || 0));
const clampY = (v) => Math.max(0, Math.min(D, Number(v) || 0));
const axisFromSide = Number(sp.faceHole?.axisFromSideMm ?? 9.5);
const dFace = Number(sp.faceHole?.diameterMm ?? 20);
const zFace = Number(sp.faceHole?.depthMm ?? 15);
const dEdge = Number(sp.edgeHole?.diameterMm ?? 8);
const zEdge = Number(sp.edgeHole?.depthMm ?? 37);
const yFront = clampY(Number(sp.positions?.fromFrontMm ?? 32));
const yBack = clampY(D - Number(sp.positions?.fromBackMm ?? 37));
const yPos = [yFront, yBack].filter((v, idx, arr) => arr.findIndex((u) => Math.abs(u - v) < 1e-6) === idx);
const xLeft = clampX(axisFromSide);
const xRight = clampX(W - axisFromSide);
const xSides = [xLeft, xRight].filter((v, idx, arr) => arr.findIndex((u) => Math.abs(u - v) < 1e-6) === idx);
for (const yMm of yPos) {
// Perçage face dessous (non visible): 2 côtés (gauche/droite)
for (const xMm of xSides) {
out.push({
xMm,
yMm,
diameterMm: dFace,
depthMm: zFace,
type: 'shelf_mobile_face',
face: 'face'
});
}
// Perçage chant côté gauche/droit (rayons mobiles uniquement)
if (!isFixed) {
out.push({
xMm: 0,
yMm,
diameterMm: dEdge,
depthMm: zEdge,
type: 'shelf_mobile_edge',
face: 'end',
end: 'left'
});
out.push({
xMm: W,
yMm,
diameterMm: dEdge,
depthMm: zEdge,
type: 'shelf_mobile_edge',
face: 'end',
end: 'right'
});
}
}
return out;
}
/**
* Renforts tiroir 25 mm — 4× Ixconnect SC 8/25 aux coins (50 mm des bords), repère Y depuis bord haut.
* @returns {Array<{ xMm: number, yMm: number, diameterMm: number, depthMm: number, type: string, face: string }>}
*/
function coteInnerFaceHasRenfortHoles(faceBlock) {
return Array.isArray(faceBlock?.holes) && faceBlock.holes.length > 0;
}
/**
* Pièce « côté » seul (gauche ou droit) : une seule face intérieure est présente dans `machining`.
* Pièce qty 2 : gauche = partIndex 0, droit = 1 lorsque les deux faces existent.
*/
function resolveCoteDrawerRenfortInnerFace(cotes, partIndex) {
const L = cotes?.leftInnerFace;
const R = cotes?.rightInnerFace;
const hasL = coteInnerFaceHasRenfortHoles(L);
const hasR = coteInnerFaceHasRenfortHoles(R);
if (hasL && hasR) {
return partIndex === 0 ? L : R;
}
if (hasL) return L;
if (hasR) return R;
return null;
}
/**
* Montants / séparateurs : Ixconnect SC 8/25 zone renfort tiroirs cachés (repère X avant, Y bas).
* @param {number} [partIndex] — pour cotes: 0 = montant gauche, 1 = droit lorsque les deux sont sur la même ligne ; pour séparateurs: index du séparateur
*/
export function buildDrawerRenfortPanelIxconnectSpecsMm(piece, widthMm, heightMm, partIndex = 0) {
const et = piece?.elementType || '';
const m = piece?.machining?.drawerRenfortPanelIxconnect;
if (!m) return [];
const d = Number(m.faceHole?.diameter ?? DRAWER_RENFORT_PANEL_IXCONNECT.faceHoleDiameterMm) || 8;
const z = Number(m.faceHole?.depth ?? DRAWER_RENFORT_PANEL_IXCONNECT.faceHoleDepthMm) || 12;
const out = [];
if (et === 'cotes' && m.cotes) {
const face = resolveCoteDrawerRenfortInnerFace(m.cotes, partIndex);
const holes = face?.holes;
if (!holes?.length) return [];
for (const h of holes) {
out.push({
xMm: Number(h.xMm) || 0,
yMm: Number(h.yMm) || 0,
diameterMm: d,
depthMm: z,
type: 'ixconnect_drawer_renfort_panel',
face: 'face'
});
}
return out;
}
if (et === 'panneauxInterieurs' && Array.isArray(m.separators)) {
const sep = m.separators[partIndex];
if (!sep) return [];
/** Vers module d’indice bas : une grande face du panneau ; vers module haut : l’autre (épaisseur). */
const pushFace = (block, tag, faceKey) => {
if (!block?.holes?.length) return;
for (const h of block.holes) {
out.push({
xMm: Number(h.xMm) || 0,
yMm: Number(h.yMm) || 0,
diameterMm: d,
depthMm: z,
type: `ixconnect_drawer_renfort_${tag}`,
face: faceKey
});
}
};
pushFace(sep.faceTowardLowerModule, 'sep_lower', 'face');
pushFace(sep.faceTowardHigherModule, 'sep_higher', 'back');
return out;
}
return [];
}
export function buildDrawerReinforcementHoleSpecsMm(piece, widthMm, heightMm) {
if ((piece?.elementType || '') !== 'renfortsTiroir') return [];
const ix = piece?.machining?.drawerReinforcement?.ixconnectFixing;
if (!ix?.holes?.length) return [];
const d = Number(ix.holeDiameterMm ?? DRAWER_REINFORCEMENT.ixconnectHoleDiameterMm) || 8;
const z = Number(ix.depthInReinforcementMm ?? DRAWER_REINFORCEMENT.ixconnectDepthInReinforcementMm) || 13;
return ix.holes.map((h) => ({
xMm: Number(h.xMm) || 0,
yMm: Number(h.yMm) || 0,
diameterMm: d,
depthMm: z,
type: 'ixconnect_drawer_reinforcement',
face: 'face'
}));
}
/**
* Montants : perçages face (axe épaisseur) Ixconnect + tourillons, 2 rangées haut/bas.
* Séparateurs verticaux : perçages en about sur les petits côtés d’extrémité (faces w×ép. aux deux bouts de la longueur),
* axe le long de Z (hauteur brut), pas depuis les grands chants profondeur (X).
* X = centres depuis bord avant profondeur ; Y G-code = 0 (bord bas) ou H (bord haut) — repère contour brut dans le plan pièce.
* @returns {Array<{ xMm: number, yMm: number, diameterMm: number, depthMm: number, type: string, face: string, end?: string }>}
*/
export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heightMm) {
const out = [];
const et = piece?.elementType || '';
if (!['cotes', 'panneauxInterieurs'].includes(et)) return out;
const ch = piece?.machining?.connectorHoles;
const p = ch?.jointPattern;
if (!p) return out;
const H = Math.max(1, Number(heightMm) || 1);
const W = Math.max(1, Number(widthMm) || 1);
const inset = IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM;
if (H < 2 * inset + 5) return out;
const thicknessMm = Math.max(1, Number(piece?.thicknessMm) || 19);
const clampY = (v) => Math.max(0, Math.min(H, Number(v) || 0));
const dTour = Number(ch.tourillonDiameter || CONNECTORS.tourillonDiameter || 8);
const zTour = tourillonFaceDrillDepthMm(thicknessMm);
const dFace = Number(ch.faceHole?.diameter || CONNECTORS.ixconnect.faceHole.diameter || 8);
const zFace = Number(ch.faceHole?.depth || CONNECTORS.ixconnect.faceHole.depth || 12);
const dEdge = Number(ch.edgeHole?.diameter || CONNECTORS.ixconnect.edgeHole.diameter || 8);
const zIxEdge = Number(ch.edgeHole?.depth || CONNECTORS.ixconnect.edgeHole.depth || 45);
const zTourEdge = Math.min(tourillonFaceDrillDepthMm(thicknessMm), 15);
const yRows = [inset, H - inset];
const t1 = Number(p.tourillon1Axis);
const t2 = Number(p.tourillon2Axis);
const ix = Number(p.ixconnectAxis);
const mirrorBack =
W >= IXCONNECT_SEPARATOR_BACK_ROW_MIN_WIDTH_MM && t2 > t1;
if (et === 'panneauxInterieurs') {
const seqFront = [
{ axis: t1, d: dTour, z: zTourEdge, type: 'tourillon' },
{ axis: ix, d: dEdge, z: zIxEdge, type: 'ixconnect' },
{ axis: t2, d: dTour, z: zTourEdge, type: 'tourillon' }
];
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' }
]
: null;
const pushEndRow = (yMm, seq, endSide) => {
const cy = clampY(yMm);
for (const h of seq) {
const xMm = mapJunctionXAcrossWidthMm(W, Number(h.axis));
out.push({
xMm,
yMm: cy,
diameterMm: h.d,
depthMm: h.z,
type: h.type,
face: 'end',
end: endSide
});
}
};
/* Y = 0 / H : extrémités longueur du brut (petits côtés w×ép.), pas un inset sur la grande face. */
pushEndRow(0, seqFront, 'bottom');
if (seqBack) pushEndRow(0, seqBack, 'bottom');
pushEndRow(H, seqFront, 'top');
if (seqBack) pushEndRow(H, seqBack, 'top');
return out;
}
/* cotes : face */
const seqFront = [
{ axis: t1, d: dTour, z: zTour, type: 'tourillon' },
{ axis: ix, d: dFace, z: zFace, type: 'ixconnect' },
{ axis: t2, d: dTour, z: zTour, type: 'tourillon' }
];
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' }
]
: null;
const pushFaceRow = (yMm, seq) => {
const cy = clampY(yMm);
for (const h of seq) {
const xMm = mapJunctionXAcrossWidthMm(W, Number(h.axis));
out.push({
xMm,
yMm: cy,
diameterMm: h.d,
depthMm: h.z,
type: h.type,
face: 'face'
});
}
};
for (const yMm of yRows) {
pushFaceRow(yMm, seqFront);
if (seqBack) pushFaceRow(yMm, seqBack);
}
return out;
}
/**
* Habillages de finition Premium — perçages SC 8/25 (Ixconnect) stockés dans `piece.machining.finishHabillage.holes`.
* @returns {Array<{ xMm: number, yMm: number, diameterMm: number, depthMm: number, type: string, face: string, end?: string }>}
*/
export function buildFinishHabillageHoleSpecsMm(piece, widthMm, heightMm) {
const out = [];
const fh = piece?.machining?.finishHabillage;
const holes = fh?.holes;
if (!Array.isArray(holes) || holes.length === 0) return out;
const W = Math.max(1, Number(widthMm) || 1);
const H = Math.max(1, Number(heightMm) || 1);
const clampX = (v) => Math.max(0, Math.min(W, Number(v) || 0));
const clampY = (v) => Math.max(0, Math.min(H, Number(v) || 0));
for (const h of holes) {
if (!h) continue;
out.push({
xMm: clampX(h.xMm),
yMm: clampY(h.yMm),
diameterMm: Number(h.diameterMm) || Number(fh?.faceHole?.diameter) || 8,
depthMm: Number(h.depthMm) || Number(fh?.faceHole?.depth) || 12.5,
type: h.type || 'ixconnect',
face: h.face || 'face',
...(h.end ? { end: h.end } : {})
});
}
return out;
}
/**
* Portes : cuvettes charnières (Blum) + 2 perçages Ø8 par cuvette (45 mm c/c, centres à 9,5 mm du centre cuvette vers l’intérieur).
* Positions (depuis haut) : axe 103 mm et grille System 32 (voir getHingeCupPositions).
* Repère plan: x = largeur gauche→droite ; y = hauteur depuis bas.
*
* @returns {Array<{ xMm: number, yMm: number, diameterMm: number, depthMm: number, type: string, face: string }>}
*/
export function buildDoorHingeCupHoleSpecsMm(piece, widthMm, heightMm) {
const out = [];
if ((piece?.elementType || '') !== 'portes') return out;
const W = Math.max(1, Number(widthMm) || 1);
const H = Math.max(1, Number(heightMm) || 1);
const clampX = (v) => Math.max(0, Math.min(W, Number(v) || 0));
const clampY = (v) => Math.max(0, Math.min(H, Number(v) || 0));
const hc = piece?.machining?.hingeCups;
if (!hc) return out;
const diameterMm = Number(hc.diameter) || 35;
const depthMm = Number(hc.cupDepth) || 12;
const screwDiameterMm = Number(hc.screwDiameterMm ?? HINGE.cupScrewDiameter) || 8;
const screwDepthMm = Number(hc.screwDepthMm ?? HINGE.cupScrewDepth) || 13;
const screwSpacingMm = Number(hc.screwSpacingMm ?? HINGE.cupScrewSpacing) || 45;
const screwOffsetMm = Number(hc.screwOffsetFromCupCenterMm ?? HINGE.cupScrewOffsetFromCupCenter) || 9.5;
const halfScrewSpacing = screwSpacingMm / 2;
// Default Blum inset for cup center (~22mm from edge) unless explicitly set upstream.
const axisFromEdgeMm = Number(hc.axisFromEdgeMm ?? hc.axisFromSideMm ?? hc.axisFromEdge ?? 22);
// Hinge side hint (viewer-only). If absent, keep a consistent default.
const hingeSide = piece?.doorPlanHints?.hingeSide === 'right' ? 'right' : 'left';
const xMm = hingeSide === 'right' ? clampX(W - axisFromEdgeMm) : clampX(axisFromEdgeMm);
const dxInterior = hingeSide === 'right' ? -screwOffsetMm : screwOffsetMm;
const count = Math.max(2, Number(hc.count) || getHingeCountForDoor({ heightMm: H }));
const positionsFromTop =
Array.isArray(hc.positionsFromTop) && hc.positionsFromTop.length
? hc.positionsFromTop.map((v) => Number(v)).filter((v) => Number.isFinite(v))
: getHingeCupPositions(H, count);
for (const fromTop of positionsFromTop) {
const yMm = clampY(H - fromTop); // convert "from top" to plan Y from bottom
out.push({
xMm,
yMm,
diameterMm,
depthMm,
type: 'hinge_cup',
face: 'face'
});
const xScrew = clampX(xMm + dxInterior);
for (const dy of [-halfScrewSpacing, halfScrewSpacing]) {
out.push({
xMm: xScrew,
yMm: clampY(yMm + dy),
diameterMm: screwDiameterMm,
depthMm: screwDepthMm,
type: 'hinge_screw',
face: 'face'
});
}
}
return out;
}
+601
View File
@@ -0,0 +1,601 @@
/**
* APEX 4.0 — Dossier Technique Maître
* Single source of truth for all manufacturing specifications.
* Every dimension in millimetres unless noted otherwise.
*/
// ── Panel thicknesses ────────────────────────────────────────────────
export const PANEL = {
structure: 19, // Côtés, dessus, fond, séparateurs, étagères
reinforcement: 16, // Renforts Forteresse
back: 8, // Dos (panneau arrière)
reinforcementWidth: 100, // Largeur standard d'un renfort
};
// ── Reinforcement layout rules (Renforts Forteresse) ────────────────
export const REINFORCEMENT = {
topBottomOffsetMm: 19, // Renfort haut/bas à 19 mm
thirdRequiredAboveHeightMm: 1500, // 3e renfort si armoire > 1.5 m
};
// ── Edge banding (Chants ABS) ────────────────────────────────────────
export const EDGE_BANDING = {
abs: 2, // Chant ABS visible (2 mm)
hidden: 0.5, // Chant fin caché (0.5 mm)
backPanelSides: 2, // Dos: ABS sur 2 chants longs (haut + bas)
topBottomSides: 2, // Dessus/Fond: ABS faces avant ET arrière
shelfSides: 1, // Étagères: ABS face avant uniquement
};
// ── Groove for back panel (Rainure dos) ──────────────────────────────
export const GROOVE = {
back: {
width: 9, // mm — largeur rainure
depth: 10.5, // mm — profondeur rainure
axisFromPanelEdge: 16, // mm — rainure à 16 mm du bord des côtés/fond/dessus
},
};
/** Rainure dos — plans / CNC : même coupe que montant (vertical) vs dessus/fond/bandeau (horizontal). */
export const BACK_GROOVE_PLAN_VERTICAL_TYPES = ['cotes', 'fileur', 'cotesDoublage'];
export const BACK_GROOVE_PLAN_HORIZONTAL_TYPES = ['dessus', 'fond', 'bandeauHaut'];
/** @returns {'vertical' | 'horizontal' | null} */
export function getBackGroovePlanOrientation(elementType) {
if (BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(elementType)) return 'vertical';
if (BACK_GROOVE_PLAN_HORIZONTAL_TYPES.includes(elementType)) return 'horizontal';
return null;
}
/**
* Séparateurs : plaqués contre le dos (sans rainure). Largeur = même entraxe profondeur que le montant,
* diminuée du décalage d’axe et de la demi-largeur de rainure (côtés).
* @param {number} carcassDepthMm — profondeur caisse / largeur en plan du panneau côté (mm)
*/
export function getSeparatorDepthMm(carcassDepthMm) {
const d = Number(carcassDepthMm) || 0;
return Math.round(d - GROOVE.back.axisFromPanelEdge - GROOVE.back.width / 2);
}
// ── Connectors (Häfele Ixconnect) ────────────────────────────────────
export const CONNECTORS = {
// SC 8/60 — structural assembly (caisse: côtés ↔ dessus/fond)
ixconnect: {
model: 'SC 8/60',
edgeHole: { diameter: 8, depth: 45 }, // mm — perçage en about
faceHole: { diameter: 8, depth: 12 }, // mm — perçage en face
/** Trou de serrage Inbus — face intérieure panneaux horizontaux (dessus/fond) uniquement */
lockScrewInbus: {
diameter: 6,
depth: 9,
/** mm — depuis le chant d’assemblage (axe du perçage Ø45), vers l’intérieur du panneau */
offsetFromAssemblyEdgeMm: 25
}
},
// SC 8/25 — finish panels (portes, finitions)
ixconnectFinish: {
model: 'SC 8/25',
faceHole: { diameter: 8, depth: 12.5 }, // mm — face interne Ø8 × 12.5mm
axes: { back: 30, front: 43 }, // mm — axes: 30mm (arrière) / 43mm (avant)
},
// Positioning per structural joint (axes mm depuis le bord avant de l’arête, le long de la profondeur)
// — Arêtes d’extrémité (montants extérieurs): T → IX → T
// — Jonctions internes (séparateurs): Ixconnect à l’axe médian profondeur, tourillons ±30 mm de cet axe
jointPatternEdge: {
tourillon1Axis: 20,
ixconnectAxis: 50,
tourillon2Axis: 80,
},
/** @deprecated use jointPatternEdge — alias pour compat. */
jointPattern: {
tourillon1Axis: 20,
ixconnectAxis: 50,
tourillon2Axis: 80,
},
/** Tourillons par rapport à l’axe Ixconnect sur jonction milieu (mm). */
jointPatternMiddleTourillonOffsetFromIxMm: 30,
tourillon: {
diameter: 8, // mm — Ø tourillon
length: 30, // mm — tourillon Ø8×30
},
tourillonDiameter: 8, // mm — backward compat
};
/**
* mm — depuis le bord physique panneau côté dos : axe rainure + largeur rainure (= fin utile côté dos, repère Ixconnect).
* Évite chevauchement perçages / rainure dos sur dessus·fond.
*/
export function getBackGrooveInnerEndInsetFromBackEdgeMm() {
return GROOVE.back.axisFromPanelEdge + GROOVE.back.width;
}
/**
* Axes Ixconnect / tourillons le long de l’arête (profondeur caisse pour dessus·fond, largeur = profondeur pour côtés),
* mesurés depuis le bord avant (face avant).
* @param {'edge'|'middle'} variant — extrémité de caisse vs jonction entre modules (séparateur)
* @param {{ horizontalPanel?: boolean }} [options] — si `horizontalPanel`, la profondeur utile exclut la zone rainure dos (16+9 mm depuis le bord arrière).
*/
export function getIxconnectJointPatternAlongDepthMm(depthMm, variant, options = {}) {
const horizontalPanel = Boolean(options.horizontalPanel);
const inset = horizontalPanel ? getBackGrooveInnerEndInsetFromBackEdgeMm() : 0;
const d = Math.max(0, Number(depthMm) || 0);
const dEff = horizontalPanel ? Math.max(0, d - inset) : d;
const round1 = (v) => Math.round(v * 10) / 10;
if (variant === 'edge') {
const t1 = CONNECTORS.jointPatternEdge.tourillon1Axis;
const ix = CONNECTORS.jointPatternEdge.ixconnectAxis;
const t2 = CONNECTORS.jointPatternEdge.tourillon2Axis;
if (!horizontalPanel || dEff <= 0) {
return {
role: 'edge',
tourillon1Axis: t1,
ixconnectAxis: ix,
tourillon2Axis: t2
};
}
if (dEff >= t2) {
return {
role: 'edge',
tourillon1Axis: t1,
ixconnectAxis: ix,
tourillon2Axis: t2
};
}
const s = dEff / t2;
return {
role: 'edge',
tourillon1Axis: round1(t1 * s),
ixconnectAxis: round1(ix * s),
tourillon2Axis: round1(t2 * s)
};
}
if (dEff <= 0) {
return {
role: 'middle',
tourillon1Axis: 0,
ixconnectAxis: 0,
tourillon2Axis: 0
};
}
const ixMid = round1(dEff / 2);
const off = CONNECTORS.jointPatternMiddleTourillonOffsetFromIxMm;
return {
role: 'middle',
tourillon1Axis: round1(Math.max(0, ixMid - off)),
ixconnectAxis: ixMid,
tourillon2Axis: round1(Math.min(dEff, ixMid + off))
};
}
/**
* Largeurs de colonnes (mm) pour placer les jonctions — ordre: modules API, puis blocks, puis config.modules.
* Évite le cas « modules = [] » alors que `config.modules` contient les largeurs (une seule jonction / mauvais comptage).
*/
function getModuleWidthsForJunctions(modules = [], config = {}) {
const fromArr = (arr) =>
(Array.isArray(arr) ? arr : [])
.map((m) => Number(m?.widthMm) || 0)
.filter((v) => v > 0);
let w = fromArr(modules);
if (w.length === 0) w = fromArr(config.blocks);
if (w.length === 0) w = fromArr(config.modules);
return w;
}
/**
* Positions X des jonctions verticales (côtés + séparateurs) sur un panneau horizontal — mm depuis le bord gauche.
*/
export function getJunctionAxesAcrossWidthMm(dimensionsWidthMm, modules = [], config = {}) {
const maxW = Math.max(0, Number(dimensionsWidthMm) || 0);
const mWidths = getModuleWidthsForJunctions(modules, config);
if (mWidths.length > 0) {
let acc = 0;
const axes = [0];
for (let i = 0; i < mWidths.length - 1; i++) {
acc += mWidths[i];
axes.push(Math.max(0, Math.min(maxW, acc)));
}
axes.push(maxW);
const sorted = [...axes].sort((a, b) => a - b);
const dedup = [];
const eps = 0.5;
for (const v of sorted) {
if (dedup.length === 0 || Math.abs(v - dedup[dedup.length - 1]) > eps) {
dedup.push(Math.round(v * 100) / 100);
}
}
return dedup;
}
return [0, maxW];
}
// ── System 32 drilling ───────────────────────────────────────────────
export const SYSTEM32 = {
firstHoleOffset: 51, // mm — distance from panel top/bottom edge to first/last hole
pitch: 32, // mm — distance between consecutive holes
holeDiameter: 5, // mm — Ø 5 mm
holeDepth: 12, // mm — profondeur de perçage (borgne, côtés de caisse)
throughHole: false, // Côtés de caisse: perçages borgnes (non traversants)
};
// ── System 32 for vertical separators ────────────────────────────────
export const SYSTEM32_SEPARATORS = {
firstHoleOffset: 32, // mm — 51mm - 19mm (fond) = 32mm from bottom edge
pitch: 32, // mm — same pitch
holeDiameter: 5, // mm — Ø 5 mm
throughHole: true, // Séparations verticales: Ø5mm TRAVERSANTS
};
// ── Hinge positioning (Blum CLIP top) ────────────────────────────────
export const HINGE = {
axisOffset: 103, // mm — per APEX 4.0 reversibility rule
axisFrom: 'top_and_bottom',
perDoor: 3, // default, real count uses matrix below
reversible: true, // Panels are reversible (symmetric S32 drilling)
model: 'Blum CLIP top',
/** Per hinge cup: two Ø8 bores (Blum drilling pattern), depth mm, spacing mm (center–center), offset mm from cup center toward door interior (⟂ hinge edge). */
cupScrewDiameter: 8,
cupScrewDepth: 13,
cupScrewSpacing: 45,
cupScrewOffsetFromCupCenter: 9.5,
};
// Height/weight matrix from APEX 4.0 section 6.B
export const HINGE_MATRIX = [
{ maxHeightMm: 900, maxWeightKg: 6, hinges: 2 },
{ maxHeightMm: 1600, maxWeightKg: 17, hinges: 3 },
{ maxHeightMm: 2000, maxWeightKg: 20, hinges: 4 },
{ maxHeightMm: 2400, maxWeightKg: 22, hinges: 5 },
{ maxHeightMm: 2600, maxWeightKg: Infinity, hinges: 6 }
];
export function getHingeCountForDoor({ heightMm, weightKg = null }) {
const h = Number(heightMm) || 0;
const hasWeight = weightKg !== null && weightKg !== undefined && weightKg !== '';
const w = hasWeight && Number.isFinite(Number(weightKg)) ? Number(weightKg) : null;
for (const row of HINGE_MATRIX) {
const byHeight = h <= row.maxHeightMm;
const byWeight = w == null ? false : w <= row.maxWeightKg;
if (byHeight || byWeight) return row.hinges;
}
return HINGE_MATRIX[HINGE_MATRIX.length - 1].hinges;
}
export function getHingeCupPositions(doorHeightMm, hingeCount = null) {
const height = Number(doorHeightMm) || 0;
const count = Math.max(2, Number(hingeCount) || getHingeCountForDoor({ heightMm: height }));
const first = HINGE.axisOffset;
const last = Math.max(first, height - HINGE.axisOffset);
if (count <= 2) return [first, last];
const spaces = count - 1;
const rawStep = (last - first) / spaces;
const positions = [first];
for (let i = 1; i < spaces; i++) {
const rawPos = first + rawStep * i;
// Rule: align intermediary hinges on System 32 grid.
const snapped = Math.round(rawPos / 32) * 32;
const minPos = positions[positions.length - 1] + 32;
const maxPos = last - (spaces - i) * 32;
positions.push(Math.min(Math.max(snapped, minPos), maxPos));
}
positions.push(last);
return positions;
}
// ── Plinth system (AXILO™ 78) ───────────────────────────────────────
export const PLINTH = {
model: 'AXILO 78',
footHeight: 78, // mm — hauteur pied réglable
clipsPerMeter: 3, // clips de plinthe par mètre linéaire
// Reserved packing space for hardware kit
packingReserved: true,
};
// ── Reinforcement machining (Renforts Forteresse) ────────────────────
export const REINFORCEMENT_MACHINING = {
// Sur chaque petit chant d'extrémité: 1× Ixconnect SC 8/60 au centre + 2× tourillons à 20 mm des bords
connectorModel: 'SC 8/60',
connector: {
model: 'SC 8/60',
edgeHole: { diameter: 8, depth: 45 },
faceHole: { diameter: 8, depth: 12 },
},
ixconnectCenter: true,
tourillonCount: 2,
tourillonEdgeOffset: 20, // mm — tourillons à 20 mm des bords (axe mesuré depuis le même repère que l’Ixconnect)
tourillonDiameter: 8,
};
/**
* Axes de perçage sur le petit chant d'extrémité (mm depuis le bord haut du renfort).
* @param {number} reinforcementFaceHeightMm — hauteur de la face de renfort (généralement 100 mm)
*/
export function getReinforcementEndFixingAxesFromTopMm(reinforcementFaceHeightMm) {
const h = Math.max(0, Number(reinforcementFaceHeightMm) || 0);
const off = REINFORCEMENT_MACHINING.tourillonEdgeOffset;
const round1 = (v) => Math.round(v * 10) / 10;
return {
// Deux tourillons proches des bords haut/bas de la face 100mm.
tourillonAxesFromTopMm: [round1(off), round1(Math.max(off, h - off))],
ixconnectAxisFromTopMm: round1(h / 2),
};
}
// ── Niche and sanitary gaps ─────────────────────────────────────────────
export const NICHE = {
sanitaryGapAround: 30, // mm — vide sanitaire de 3 cm tout autour
drawerBackGap: 10, // mm — vide sanitaire derrière tiroir
};
// ── Drawers (manufactured) ──────────────────────────────────────────────
export const DRAWERS = {
maxColumnWidth: 1000, // mm
minDepth: 400, // mm
maxDepth: 650, // mm
depthStep: 50, // mm
hiddenHandleDepthReduction: 40, // mm (assumption until final handle specs)
standardHeights: [60, 90, 128, 177, 240],
};
/**
* Renforts latéraux caisse (25 mm) — uniquement si tiroirs cachés derrière porte ;
* un montant gauche + un droit par colonne à tiroirs, même profondeur (min. des deux parois).
* Fixation sur la face intérieure des montants / séparateurs.
*/
export const DRAWER_REINFORCEMENT = {
thicknessMm: 25,
/** mm depuis le fond caisse (repère rainure côté ou fond séparateur) — début utile du renfort */
backInsetFromRearMm: 25,
/** mm réservés vers l’avant quand les tiroirs sont derrière une porte à charnières */
hingeFrontClearanceMm: 77,
/** mm — déduit de la hauteur pile : partie droite au-dessus du rayon façade tiroir du haut */
topFrontRadiusMm: 3,
/** Ixconnect « petit » — même famille que finitions SC 8/25 */
ixconnectModel: 'SC 8/25',
/** mm — retrait des axes depuis chaque bord (chaque coin : 50 mm des deux côtés) */
ixconnectCornerInsetMm: 50,
ixconnectHoleDiameterMm: 8,
/** Perçage face côté renfort (vers montant / séparateur) */
ixconnectDepthInReinforcementMm: 13,
/** Perçage face intérieure montant ou séparateur (vis-à-vis) */
ixconnectDepthInVerticalPanelMm: 12,
};
/**
* Ixconnect SC 8/25 sur montants / séparateurs — zone tiroirs cachés + renfort.
* Repère face intérieure : X depuis bord avant (profondeur), Y depuis bas du panneau (corps).
*/
export const DRAWER_RENFORT_PANEL_IXCONNECT = {
model: 'SC 8/25',
/** 77 + 50 mm depuis le devant (axe avant) */
frontAxesFromFrontMm: 77 + 50,
/** Côté caisse : 19 + 9 + 50 mm depuis le fond */
backAxesFromRearCoteMm: 19 + 9 + 50,
/** Séparateur : 50 mm depuis le fond */
backAxesFromRearSeparatorMm: 50,
/** 50 mm du haut / bas de la zone renfort */
verticalInsetMm: 50,
faceHoleDiameterMm: 8,
faceHoleDepthMm: 12
};
/**
* 4 trous (rectangle) sur une face intérieure montant/séparateur pour renfort tiroir caché.
* @param {number} panelDepthMm — largeur du panneau en plan (= profondeur caisse ou prof. séparateur)
* @param {boolean} isCote — true = montant extérieur, false = séparateur
* @param {number} zoneBottomFromPanelBottomMm — bas de la zone tiroirs depuis bas du panneau
* @param {number} renfortHeightMm — hauteur utile renfort (= pile tiroirs − R)
*/
export function buildDrawerRenfortPanelIxconnectCornersMm(
panelDepthMm,
isCote,
zoneBottomFromPanelBottomMm,
renfortHeightMm
) {
const P = DRAWER_RENFORT_PANEL_IXCONNECT;
const D = Math.max(0, Number(panelDepthMm) || 0);
const z0 = Math.max(0, Number(zoneBottomFromPanelBottomMm) || 0);
const h = Math.max(0, Number(renfortHeightMm) || 0);
const backInset = isCote ? P.backAxesFromRearCoteMm : P.backAxesFromRearSeparatorMm;
const xf = P.frontAxesFromFrontMm;
const xb = Math.max(xf + 1, D - backInset);
const y1 = z0 + P.verticalInsetMm;
const y2 = z0 + h - P.verticalInsetMm;
if (h < 2 * P.verticalInsetMm + 5 || D < xf + backInset + 5 || y2 <= y1) return [];
return [
{ xMm: xf, yMm: y1, role: 'avant_bas' },
{ xMm: xf, yMm: y2, role: 'avant_haut' },
{ xMm: xb, yMm: y1, role: 'fond_bas' },
{ xMm: xb, yMm: y2, role: 'fond_haut' }
];
}
/**
* 4 trous d’angle pour Ixconnect SC 8/25 — repère plan face : X depuis bord gauche, Y depuis bord haut (mm).
* @returns {{ xMm: number, yMm: number, role: string }[]}
*/
export function getDrawerReinforcementIxconnectHolePositionsMm(widthMm, heightMm, insetMm = DRAWER_REINFORCEMENT.ixconnectCornerInsetMm) {
const w = Math.max(0, Number(widthMm) || 0);
const h = Math.max(0, Number(heightMm) || 0);
const ins = Math.max(8, Math.min(Number(insetMm) || 50, Math.min(w, h) / 2 - 0.5));
if (w < 2 * ins || h < 2 * ins) return [];
return [
{ xMm: ins, yMm: ins, role: 'nw' },
{ xMm: w - ins, yMm: ins, role: 'ne' },
{ xMm: ins, yMm: h - ins, role: 'sw' },
{ xMm: w - ins, yMm: h - ins, role: 'se' }
];
}
/**
* Hauteur totale du bloc tiroirs (façades + jeux), mm.
* @param {number} drawerCount
* @param {number[]} drawerHeightsMm
* @param {number} [drawerGapMm]
*/
export function drawerStackHeightMm(drawerCount, drawerHeightsMm, drawerGapMm = 8) {
const n = Math.max(0, Number(drawerCount) || 0);
if (n <= 0) return 0;
const heights = Array.isArray(drawerHeightsMm) && drawerHeightsMm.length ? drawerHeightsMm : DRAWERS.standardHeights;
const gap = Number.isFinite(Number(drawerGapMm)) ? Number(drawerGapMm) : 8;
const sum = heights
.slice(0, Math.min(n, heights.length))
.reduce((s, v) => s + (Number(v) || 0), 0);
return Math.max(0, Math.round(sum + Math.max(0, n - 1) * gap));
}
// ── Full-height handle assumptions ──────────────────────────────────────
export const HANDLE = {
/** Retrait mécanique rainure poignée (haut + bas) */
fullHeightTopBottomOffset: 2, // mm from top and bottom
/** Déduction largeur panneau porte (vs poignée standard) — même valeur liste de débitage / plans */
fullHeightWidthDeductionMm: 18,
/** Rainure usinée sur le montant côté poignée (profil type dos, à confirmer au profilé) */
fullHeightGroove: {
width: GROOVE.back.width,
depth: GROOVE.back.depth,
},
};
/**
* Côté charnières en élévation pour la porte d’index `doorIndex` :
* vantail extérieur gauche → charnières gauche ; droit → droite ; portes centrales → réglage utilisateur.
*/
export function getDoorHingeSide(doorIndex, doorCount, hardware = {}) {
const hw = hardware || {};
const n = Math.max(1, Number(doorCount) || 1);
const i = Math.max(0, Number(doorIndex) || 0);
if (n <= 1) return hw.doorHingeSide === 'right' ? 'right' : 'left';
if (i === 0) return 'left';
if (i === n - 1) return 'right';
return hw.centerDoorHingeSide === 'right' ? 'right' : 'left';
}
// ── Block assembly drilling ─────────────────────────────────────────────
export const BLOCK_ASSEMBLY = {
sideOffsetFromFace: 30, // mm
sideOffsetFromBack: 30, // mm
topBottomOffset: 100, // mm
middleCount: 3, // per joint
};
// ── Carcass assembly ferrures ───────────────────────────────────────────
export const CARCASS_FASTENERS = {
/** Ferrure d'assemblage utilisée pour les jonctions de caisse (côtés/séparateurs ↔ dessus/fond). */
model: 'Häfele Rasant-Tab',
/** Référence produit (Ferrures d’assemblage Rasant-Tab, Ø trou 3 ou 5 mm). */
productUrl: 'https://www.haefele.ch/fr/produkt/ferrures-d-assemblage-rasant-tab-visser-dans-trou-de-per-age-3-ou-5-mm/26236520/?MasterSKU=P-00861456',
piecesPerJoint: 3,
/** Retrait des ferrures d’extrémité (mm) */
endInsetMm: 50,
edgeOffsets: [50, 'middle', 50],
/** Perçage ferrure: profondeur sur longueur de la pièce (mm) */
ferrureDepthAlongLengthMm: 46,
/** Perçage ferrure: profondeur sur l’épaisseur de la pièce (mm) */
ferrureDepthInThicknessMm: 12,
dowel: {
diameter: 8,
/** Longueur nominale tourillon (réf. atelier) */
depth: 40,
/** Perçage borgne en face (dans l’épaisseur du panneau), mm — toujours 12 pour dessus/fond */
faceDrillDepthMm: 12,
/** 2× tourillons par ferrure : axes à ±sideOffset du centre de chaque ferrure (mm) */
sideOffset: 30,
},
};
/** Profondeur effective perçage tourillon en face (ne dépasse pas l’épaisseur). */
export function tourillonFaceDrillDepthMm(thicknessMm) {
const cap = CARCASS_FASTENERS.dowel.faceDrillDepthMm;
const t = Math.max(1, Number(thicknessMm) || PANEL.structure);
return Math.min(cap, Math.max(6, t - 2));
}
/** Longueur mini. d’arête (mm) pour placer une ferrure au milieu (en plus des 50 mm d’extrémité). */
export const CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM = 500;
/**
* Centres des ferrures le long d’une arête de jonction.
* Règle: 50 mm des extrémités ; ferrure centrale seulement si l’arête dépasse {@link CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM}.
* @param {number} edgeLengthMm — longueur de l’arête (mm), ici hauteur utile de la jonction
* @returns {number[]} distances depuis le début de l’arête (mm)
*/
export function getCarcassJointPositions(edgeLengthMm) {
const L = Math.max(0, Number(edgeLengthMm) || 0);
if (L <= 0) return [];
const inset = Math.min(CARCASS_FASTENERS.endInsetMm, Math.max(20, L * 0.1));
const tail = L - inset;
const minMid = CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM;
if (tail <= inset + 20) {
if (L > minMid) {
const u = L / 4;
return [u, L / 2, L - u].map((v) => Math.round(Math.min(L, Math.max(0, v)) * 10) / 10);
}
const u = L / 4;
return [u, L - u].map((v) => Math.round(Math.min(L, Math.max(0, v)) * 10) / 10);
}
if (L > minMid) {
return [inset, L / 2, tail].map((v) => Math.round(v * 10) / 10);
}
return [inset, tail].map((v) => Math.round(v * 10) / 10);
}
/**
* Chaque ferrure: 3 perçages alignés sur la longueur:
* - 1 perçage ferrure au centre
* - 2 perçages tourillons à ±sideOffset du centre
* @returns {{ centerMm: number, holesAlongMm: [number, number, number] }[]}
*/
export function getCarcassFerrureDowelDrillings(centerPositionsMm, edgeLengthMm, sideOffsetMm = CARCASS_FASTENERS.dowel.sideOffset) {
const L = Math.max(0, Number(edgeLengthMm) || 0);
const off = Math.max(0, Number(sideOffsetMm) || 0);
const centers = centerPositionsMm || [];
const clamp = (v) => Math.round(Math.min(L, Math.max(0, v)) * 10) / 10;
return centers.map((c0) => {
const c = Number(c0);
return { centerMm: c, holesAlongMm: [clamp(c - off), clamp(c), clamp(c + off)] };
});
}
// ── Shelf machining ─────────────────────────────────────────────────
export const SHELF_MACHINING = {
// Tourillons Ø8 front + back for shelf pin alignment
tourillonDiameter: 8, // mm
tourillonCount: 4, // 2 front + 2 back per shelf
// "2 rayons sur une même strate de 19mm" — 2 shelves can share same S32 row
shelvesPerStratum: 2,
};
// ── Packing ──────────────────────────────────────────────────────────
export const PACKING = {
maxWeightKg: 28,
// Approximate density for weight estimation (kg per m³)
densityKgM3: {
melamine19: 680, // Panneau mélaminé 19 mm
melamine16: 660, // Panneau mélaminé 16 mm (renforts)
melamine25: 670, // Panneau mélaminé 25 mm (renforts tiroir)
melamine8: 640, // Panneau mélaminé 8 mm (dos)
},
// Layer-based packing strategy (per APEX 4.0 spec):
layers: {
level1: 'sides', // Niveau 1: Côtés empilés
level2: 'topAndBottom', // Niveau 2: Dessus + fond côte à côte
level3: 'shelvesAndRenforts', // Niveau 3: Étagères + renforts groupés par épaisseur
},
// Hardware kit reservation (AXILO™ pieds + clips de plinthe)
hardwareKitReserved: true, // Espace réservé dans le colisage
};
// ── Convenience: convert mm constants to metres (for Three.js) ──────
export const PANEL_M = {
structure: PANEL.structure / 1000,
reinforcement: PANEL.reinforcement / 1000,
back: PANEL.back / 1000,
reinforcementWidth: PANEL.reinforcementWidth / 1000,
};