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