diff --git a/.gitignore b/.gitignore index 12b1435..a0cbf0e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ node_modules/ -server/data/ +server/data/* .env .claude/ *.docx @@ -7,6 +7,7 @@ server/data/ ~WRL*.tmp *.db *.sqlite +!server/data/configurateur.db .DS_Store # Build output diff --git a/client/src/App.jsx b/client/src/App.jsx index 669ee31..1fb10ef 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -8,6 +8,7 @@ import Login from './pages/Login'; import Admin from './pages/Admin'; import Materials from './pages/Materials'; import Settings from './pages/Settings'; +import Legal from './pages/Legal'; export default function App() { return ( @@ -16,6 +17,10 @@ export default function App() { } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index e548767..6b2b238 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -117,6 +117,58 @@ export async function calculatePrice(config) { return res.json(); } +export async function fetchSavedDesigns() { + const res = await fetch(`${API_BASE}/saved-designs`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function saveDesign(name, config) { + const res = await fetch(`${API_BASE}/saved-designs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, config }) + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function updateSavedDesign(id, name, config) { + const res = await fetch(`${API_BASE}/saved-designs/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, config }) + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function deleteSavedDesign(id) { + const res = await fetch(`${API_BASE}/saved-designs/${id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error(await res.text()); +} + +export async function fetchLayoutPresets() { + const res = await fetch(`${API_BASE}/layout-presets`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function saveLayoutPreset(name, config) { + const res = await fetch(`${API_BASE}/layout-presets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, config }) + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function deleteLayoutPreset(id) { + const res = await fetch(`${API_BASE}/layout-presets/${id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error(await res.text()); +} + export async function fetchOrders() { const res = await fetch(`${API_BASE}/orders`); if (!res.ok) throw new Error(await res.text()); @@ -245,6 +297,46 @@ export async function fetchHaefeleMaterials({ search = '', active = null, page = return res.json(); } +// Leuba Hiag catalog sync API + +export async function fetchLeubaHiagTargets() { + const res = await fetch(`${API_BASE}/sync/leubahiag/targets`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export function getLeubaHiagSyncStreamUrl({ targets = [], activate = false, fetchPrices = true } = {}) { + const params = new URLSearchParams(); + const cleaned = (targets || []).filter(Boolean); + if (cleaned.length > 0) params.set('targets', cleaned.join(',')); + if (activate) params.set('activate', '1'); + if (!fetchPrices) params.set('prices', '0'); + const query = params.toString(); + return query ? `${API_BASE}/sync/leubahiag/stream?${query}` : `${API_BASE}/sync/leubahiag/stream`; +} + +export async function triggerLeubaHiagSync(payload) { + const res = await fetch(`${API_BASE}/sync/leubahiag`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload || {}) + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function fetchLeubaHiagMaterials({ search = '', active = null, page = 1, limit = 25 } = {}) { + const params = new URLSearchParams(); + if (search) params.set('search', search); + if (active !== null) params.set('active', active ? '1' : '0'); + params.set('page', String(page)); + params.set('limit', String(limit)); + + const res = await fetch(`${API_BASE}/leubahiag/materials?${params.toString()}`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + // Formex Materials CRUD export async function fetchFormexMaterials({ search = '', collection = '', category = '', supplier = '', active = null, page = 1, limit = 25 } = {}) { @@ -294,6 +386,16 @@ export async function updateFormexMaterial(id, data) { return res.json(); } +export async function setFormexMaterialActive(id, active) { + const res = await fetch(`${API_BASE}/formex/materials/${id}/active`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ active: !!active }) + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + export async function deleteFormexMaterial(id) { const res = await fetch(`${API_BASE}/formex/materials/${id}`, { method: 'DELETE' diff --git a/client/src/components/Cnc3DViewer.jsx b/client/src/components/Cnc3DViewer.jsx index f5e0227..5440ae9 100644 --- a/client/src/components/Cnc3DViewer.jsx +++ b/client/src/components/Cnc3DViewer.jsx @@ -13,7 +13,7 @@ import { Shape, Vector3 } from 'three'; -import { OrbitControls } from '@react-three/drei'; +import { Html, OrbitControls } from '@react-three/drei'; import { buildTopBottomPlanHoleSpecsMm, buildVerticalPanelIxconnectFaceHoleSpecsMm, @@ -21,9 +21,13 @@ import { buildFinishHabillageHoleSpecsMm, buildDoorHingeCupHoleSpecsMm, buildDrawerReinforcementHoleSpecsMm, - buildDrawerRenfortPanelIxconnectSpecsMm + buildDrawerRenfortPanelIxconnectSpecsMm, + buildLegraboxRailHoleSpecsMm, + buildLegraboxFacadeInsertaSpecsMm, + buildAxiloFootHoleSpecsMm, + buildSystem32HoleSpecsMm } from '@shared/cncPanelHoles.js'; -import { GROOVE, getBackGroovePlanOrientation, getBackGrooveInnerEndInsetFromBackEdgeMm } from '@shared/manufacturing.js'; +import { GROOVE, getBackGroovePlanOrientation } from '@shared/manufacturing.js'; import styles from './Cnc3DViewer.module.css'; /** Matches Canvas camera defaults so switching items without a saved view resets correctly. */ @@ -118,7 +122,7 @@ function PersistedOrbitControls({ storageKey }) { makeDefault enablePan enableZoom - zoomSpeed={-0.6} + zoomSpeed={0.9} enableRotate rotateSpeed={0.6} screenSpacePanning @@ -254,9 +258,12 @@ function AxisAlignAnimator({ alignId, alignAxis, fallbackDistance }) { function parseRawSize(gcode) { const lines = (gcode || '').split(/\r?\n/); + let fromRaw = null; for (const raw of lines) { const line = String(raw || ''); - if (!line.startsWith('; SIZE_RAW:')) continue; + const isFinished = line.startsWith('; SIZE:'); + const isRaw = line.startsWith('; SIZE_RAW:'); + if (!isFinished && !isRaw) continue; const mx = line.match(/X=([0-9]+(\.[0-9]+)?)/i); const my = line.match(/Y=([0-9]+(\.[0-9]+)?)/i); const mt = line.match(/T=([0-9]+(\.[0-9]+)?)/i); @@ -264,10 +271,33 @@ function parseRawSize(gcode) { const heightM = (my ? Number(my[1]) : 0) / 1000; const thickM = (mt ? Number(mt[1]) : 16) / 1000; if (widthM > 0 && heightM > 0) { - return { widthM, heightM, thickM }; + const parsed = { widthM, heightM, thickM }; + if (isFinished) return parsed; + fromRaw = parsed; } } - return null; + return fromRaw; +} + +/** + * Scene size must be the finished panel. SIZE_RAW includes saw surcote (+ banding + * compensation) piled on +X/+Y, which makes right-edge holes (montants, AXILO) look + * too far from the edge even though their coordinates are already in finished mm. + */ +function panelSceneSize(piece, gcode) { + const fromGcode = parseRawSize(gcode); + const widthMm = Number(piece?.widthMm); + const heightMm = Number(piece?.heightMm); + const thicknessMm = Number(piece?.thicknessMm); + if (widthMm > 0 && heightMm > 0) { + const thickMm = thicknessMm > 0 ? thicknessMm : ((fromGcode?.thickM || 0) * 1000) || 19; + return { + widthM: widthMm / 1000, + heightM: heightMm / 1000, + thickM: thickMm / 1000 + }; + } + return fromGcode; } function parseHoles(gcode) { @@ -301,9 +331,10 @@ function planHoleSpecsToSceneHoles(piece, rawSize, partIndex = 0) { if (!piece?.machining || !rawSize) return []; const wMm = rawSize.widthM * 1000; const hMm = rawSize.heightM * 1000; + const mirrorDepthAxis = ['dessus', 'fond'].includes(piece?.elementType || ''); const mapSpec = (spec) => ({ xM: spec.xMm / 1000, - yM: spec.yMm / 1000, + yM: (mirrorDepthAxis ? hMm - spec.yMm : spec.yMm) / 1000, dM: spec.diameterMm / 1000, zM: spec.depthMm / 1000, type: spec.type, @@ -312,12 +343,16 @@ function planHoleSpecsToSceneHoles(piece, rawSize, partIndex = 0) { }); return [ ...buildTopBottomPlanHoleSpecsMm(piece, wMm, hMm).map(mapSpec), + ...buildAxiloFootHoleSpecsMm(piece, wMm, hMm).map(mapSpec), + ...buildSystem32HoleSpecsMm(piece, wMm, hMm).map(mapSpec), ...buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, wMm, hMm).map(mapSpec), ...buildShelfMobileHoleSpecsMm(piece, wMm, hMm).map(mapSpec), ...buildFinishHabillageHoleSpecsMm(piece, wMm, hMm).map(mapSpec), ...buildDoorHingeCupHoleSpecsMm(piece, wMm, hMm).map(mapSpec), ...buildDrawerReinforcementHoleSpecsMm(piece, wMm, hMm).map(mapSpec), - ...buildDrawerRenfortPanelIxconnectSpecsMm(piece, wMm, hMm, partIndex).map(mapSpec) + ...buildDrawerRenfortPanelIxconnectSpecsMm(piece, wMm, hMm, partIndex).map(mapSpec), + ...buildLegraboxRailHoleSpecsMm(piece, wMm, hMm, partIndex).map(mapSpec), + ...buildLegraboxFacadeInsertaSpecsMm(piece, wMm, hMm).map(mapSpec) ]; } @@ -420,10 +455,38 @@ function ixconnectInbusFaceFromBottomThick(etPiece, h) { return ( h.type === 'ixconnect_inbus' && (etPiece === 'dessus' || etPiece === 'fond') && - h.face === 'face' + (h.face === 'face' || h.face === 'back') ); } +/** + * Dessus — entrée en épaisseur : + * - SC habillage (accouplement bandeau) → face extérieure (haut, y=t) + * - jonctions caisse (côtés / séparateurs / inbus) → face caisse (bas, y=0, même côté que la rainure) + */ +function dessusFaceHoleFromBottom(h) { + if (!h || h.face === 'end') return null; + if (h.type === 'ixconnect_finish') return false; + // SC 8/25 finish depth is 12.5 mm — keep on top even if type stayed `ixconnect`. + if ( + h.type === 'ixconnect' && + h.face === 'face' && + Math.abs((Number(h.zM) || 0) * 1000 - 12.5) < 0.35 + ) { + return false; + } + if ( + h.type === 'tourillon' || + h.type === 'ixconnect' || + h.type === 'ixconnect_inbus' || + h.type === 'ferrure' || + h.face === 'back' + ) { + return true; + } + return null; +} + /** Couleur des faces usinées (rainure) — pas le bois apparent extérieur. */ const GROOVE_CUT_FACE_COLOR = '#0d9488'; @@ -443,7 +506,6 @@ function getBackGrooveSceneParams(rawSize, piece) { const gw = (m.width ?? GROOVE.back.width) / 1000; const gd = (m.depth ?? GROOVE.back.depth) / 1000; const axis = (m.axisFromPanelEdge ?? m.axisFromBackEdge ?? GROOVE.back.axisFromPanelEdge) / 1000; - const endInsetM = getBackGrooveInnerEndInsetFromBackEdgeMm() / 1000; const eps = 0.00025; /* @@ -454,9 +516,8 @@ function getBackGrooveSceneParams(rawSize, piece) { */ if (orient === 'vertical') { const xC = w - axis; - // Côtés extérieurs: la rainure ne doit pas aller sous/au-dessus du dessus+fond (19mm chacun) → -2×19mm. - const extraEndInsetM = piece?.elementType === 'cotes' ? (Number(piece?.thicknessMm) || 19) / 1000 : 0; - const zLen = Math.max(0.05, h - 2 * (endInsetM + extraEndInsetM)); + // Side-panel back grooves run through the complete panel length. + const zLen = h; const yTop = t; const yBot = t - gd; const min = new Vector3(xC - gw / 2, yBot - eps, h / 2 - zLen / 2); @@ -472,7 +533,9 @@ function getBackGrooveSceneParams(rawSize, piece) { return { min, max, innerFaces }; } - const zC = h - axis; + // Les panneaux horizontaux sont affichés avec l'avant côté +Z (repères Gauche/Droite). + // La rainure est cotée depuis le bord arrière : elle se place donc à +axis, pas à h-axis. + const zC = axis; const et = piece.elementType || ''; const opensFromBottom = et === 'dessus' || et === 'bandeauHaut'; const y0 = 0.0002; @@ -663,21 +726,26 @@ function buildPanelHoleDiscardUniforms(holes, rawSize, piece) { const rawDepth = Math.max(0, Number(h.zM) || 0); let depth = Math.min(t * 0.999, Math.max(0.0008, rawDepth)); if (h.type === 'tourillon') depth = Math.min(depth, 0.013); - const sepFromBottom = ixconnectInbusFaceFromBottomThick(etPiece, h) - ? horizontalSeparatorFaceHoleFromBottomThick(etPiece) - : (etPiece === 'dessus' || etPiece === 'fond' || etPiece === 'bandeauHaut') && - h.face === 'face' && - !isHorizontalPanelExteriorCoteJunction(h.xM, w) && - horizontalSeparatorFaceHoleFromBottomThick(etPiece); + const dessusDir = etPiece === 'dessus' ? dessusFaceHoleFromBottom(h) : null; + const sepFromBottom = dessusDir != null + ? dessusDir + : ixconnectInbusFaceFromBottomThick(etPiece, h) + ? horizontalSeparatorFaceHoleFromBottomThick(etPiece) + : etPiece === 'bandeauHaut' && + h.face === 'face' && + !isHorizontalPanelExteriorCoteJunction(h.xM, w) && + horizontalSeparatorFaceHoleFromBottomThick(etPiece); // Rayons: perçage face toujours depuis le dessous (face non visible). const shelfFromBottom = (etPiece === 'etageres' || etPiece === 'rayonFixe') && h.face === 'face' && h.type === 'shelf_mobile_face'; let fromBottom = sepFromBottom || shelfFromBottom; - // Habillage finition sur montant : lignes haut/bas 50 mm = entrée face extérieure (FACE=back), opposée à la face caisse. - // Séparateurs : renfort tiroir vers module supérieur (colonne droite) = FACE=back — grande face opposée à celle du module inférieur. - if ((etPiece === 'cotes' || etPiece === 'panneauxInterieurs') && h.face === 'back') fromBottom = true; + // FACE=back désigne toujours la grande face opposée : sous-face pour les panneaux horizontaux, + // face extérieure/opposée pour les montants et séparateurs. + if (h.face === 'back' && dessusDir == null) fromBottom = true; + // Habillage dessus : face intérieure (contre le caisson) = y=0 dans la géométrie onglet (y=t = face extérieure pleine largeur). + if (etPiece === 'habillageFinitionDessus' && h.face === 'face') fromBottom = true; const y = fromBottom ? depth / 2 + 0.0002 : t - depth / 2 + 0.0002; cx = h.xM; cy = fromBottom ? depth / 2 + 0.0002 : t - depth / 2 + 0.0002; @@ -710,7 +778,7 @@ function patchMaterialPanelDiscard(material, grooveParams, uMin, uMax, holeDisca const grooveActive = grooveParams ? 1 : 0; const hc = holeDiscard.count; const mc = holeDiscard.miterCount || 0; - material.customProgramCacheKey = () => `apex-panel-cut-v12-g${grooveActive}-h${hc}-m${mc}`; + material.customProgramCacheKey = () => `apex-panel-cut-v14-g${grooveActive}-h${hc}-m${mc}`; material.onBeforeCompile = (shader) => { shader.uniforms.uGrooveActive = { value: grooveActive }; shader.uniforms.uGrooveMin = { value: uMin }; @@ -899,8 +967,10 @@ function GroovedPanelMesh({ rawSize, piece, materialColor, holes }) { roughness: 0.72, metalness: 0.05, transparent: true, - opacity: 0.72, - depthWrite: false, + opacity: 0.82, + // Occlude underside hole cylinders when viewing the opposite face; openings + // still come from the discard shader on the real entry face. + depthWrite: true, side: DoubleSide }); if (!grooveParams && holeDiscard.count === 0) return m; @@ -1060,6 +1130,40 @@ function GroovedPanelMesh({ rawSize, piece, materialColor, holes }) { ); } +const FRONT_BACK_LABEL_TYPES = new Set([ + 'cotes', + 'cotesDoublage', + 'fileur', + 'fileurCotes', + 'panneauxInterieurs', + 'plintheGauche', + 'plintheDroite', + 'habillageFinitionGauche', + 'habillageFinitionDroite' +]); + +function PieceSideLabels({ rawSize, piece }) { + if (!rawSize) return null; + + const labelY = rawSize.thickM + Math.max(0.012, rawSize.thickM * 0.75); + const labelZ = rawSize.heightM / 2; + const sideOffset = Math.max(0.07, rawSize.widthM * 0.08); + const labels = FRONT_BACK_LABEL_TYPES.has(piece?.elementType) + ? ['Devant', 'Derrière'] + : ['Gauche', 'Droite']; + + return ( + <> + + {labels[0]} + + + {labels[1]} + + + ); +} + function CncScene({ rawSize, materialColor, holes, piece, orbitStorageKey, alignId, alignAxis, fallbackDistance }) { /** * The panel mesh sits in +X/+Z (center at w/2, t/2, h/2). GridHelper is a square in XZ centered on its @@ -1088,16 +1192,25 @@ function CncScene({ rawSize, materialColor, holes, piece, orbitStorageKey, align {rawSize && ( )} - {rawSize && holes.map((h, i) => { + + {rawSize && [...holes] + // Draw Inbus after its Ixconnect so the Ø6 core stays visible when coaxial. + .map((h, i) => ({ h, i })) + .sort((a, b) => { + const rank = (t) => (t === 'ixconnect_inbus' ? 1 : 0); + return rank(a.h.type) - rank(b.h.type) || a.i - b.i; + }) + .map(({ h, i }) => { const radius = Math.max(0.0001, Number(h.dM) / 2); const etPiece = piece?.elementType || ''; const wPanel = rawSize.widthM; + const isInbus = h.type === 'ixconnect_inbus'; const color = - h.type === 'ixconnect' + h.type === 'ixconnect' || h.type === 'ixconnect_finish' ? '#7c3aed' : String(h.type || '').startsWith('ixconnect_drawer') ? '#ea580c' - : h.type === 'ixconnect_inbus' + : isInbus ? '#a855f7' : h.type === 'ferrure' ? '#0f766e' @@ -1106,16 +1219,20 @@ function CncScene({ rawSize, materialColor, holes, piece, orbitStorageKey, align : h.type === 'tourillon' ? '#64748b' : '#ea580c'; - /* Dessus/fond : sur face (séparateurs), trio T–IX–T tout gris. Sur chant (montants), comme renfort : violet = Ixconnect seul au milieu, tourillons gris. */ + /* Dessus/fond : jonctions caisse → trio gris ; SC habillage (finish) reste violet. */ let holeColor = color; - if ((etPiece === 'dessus' || etPiece === 'fond') && h.face === 'face') { - if (!isHorizontalPanelExteriorCoteJunction(h.xM, wPanel) && h.type === 'ixconnect') { - holeColor = '#64748b'; - } + if ((etPiece === 'dessus' || etPiece === 'fond') && h.type === 'ixconnect' && h.face !== 'end') { + const isFinishSc = + etPiece === 'dessus' && + h.face === 'face' && + Math.abs((Number(h.zM) || 0) * 1000 - 12.5) < 0.35; + if (!isFinishSc) holeColor = '#64748b'; } const lip = Math.min(0.00012, Math.max(0.00003, radius * 0.06)); const innerR = Math.max(0.00005, radius - lip); const outerR = radius + lip; + const faceHoleRenderOrder = isInbus ? 16 : 12; + const ringRenderOrder = isInbus ? 18 : 11; if (h.face === 'end') { const w = rawSize.widthM; @@ -1289,65 +1406,82 @@ function CncScene({ rawSize, materialColor, holes, piece, orbitStorageKey, align if (h.type === 'tourillon') depth = Math.min(depth, 0.013); const forceThroughSystem32 = h.type === 'system32' && etPiece !== 'cotes'; const throughFace = rawDepth >= rawSize.thickM - 1e-5 || forceThroughSystem32; - const sepFromBottom = ixconnectInbusFaceFromBottomThick(etPiece, h) - ? horizontalSeparatorFaceHoleFromBottomThick(etPiece) - : (etPiece === 'dessus' || etPiece === 'fond' || etPiece === 'bandeauHaut') && - h.face === 'face' && - !isHorizontalPanelExteriorCoteJunction(h.xM, wPanel) && - horizontalSeparatorFaceHoleFromBottomThick(etPiece); + const dessusDir = etPiece === 'dessus' ? dessusFaceHoleFromBottom(h) : null; + const sepFromBottom = dessusDir != null + ? dessusDir + : ixconnectInbusFaceFromBottomThick(etPiece, h) + ? horizontalSeparatorFaceHoleFromBottomThick(etPiece) + : etPiece === 'bandeauHaut' && + h.face === 'face' && + !isHorizontalPanelExteriorCoteJunction(h.xM, wPanel) && + horizontalSeparatorFaceHoleFromBottomThick(etPiece); const shelfFromBottom = (etPiece === 'etageres' || etPiece === 'rayonFixe') && h.face === 'face' && h.type === 'shelf_mobile_face'; let fromBottom = sepFromBottom || shelfFromBottom; - if ((etPiece === 'cotes' || etPiece === 'panneauxInterieurs') && h.face === 'back') fromBottom = true; + // FACE=back = entrée par la face opposée (sous-face du fond pour les pieds AXILO ; + // sous-face du dessus pour jonctions caisse côtés/séparateurs). + if (h.face === 'back' && dessusDir == null) fromBottom = true; + // Habillage dessus : Ixconnect sur la face intérieure (y=0), pas la face extérieure visible. + if (etPiece === 'habillageFinitionDessus' && h.face === 'face') fromBottom = true; const y = throughFace ? rawSize.thickM / 2 : fromBottom ? depth / 2 + 0.0002 : rawSize.thickM - depth / 2 + 0.0002; - const ringY = fromBottom ? -0.00008 : rawSize.thickM + 0.00008; + // Keep entry rings on the real entry face (depth-tested). depthTest:false was + // painting underside openings onto the visible top face through the transparent panel. + const ringY = fromBottom ? -0.00035 : rawSize.thickM + 0.00035; const ringRot = fromBottom ? [Math.PI / 2, 0, 0] : [-Math.PI / 2, 0, 0]; - const ringOppY = fromBottom ? rawSize.thickM + 0.00008 : -0.00008; + const ringOppY = fromBottom ? rawSize.thickM + 0.00035 : -0.00035; const ringOppRot = fromBottom ? [-Math.PI / 2, 0, 0] : [Math.PI / 2, 0, 0]; return ( - + - + {throughFace && ( - + )} @@ -1366,10 +1500,20 @@ export default function Cnc3DViewer({ materialColor = '#c4a574', orbitStorageKey = 'default' }) { - const rawSize = useMemo(() => parseRawSize(gcode), [gcode]); + const rawSize = useMemo(() => panelSceneSize(piece, gcode), [piece, gcode]); const holes = useMemo(() => { - const fromG = parseHoles(gcode); + const mirrorDepthAxis = ['dessus', 'fond'].includes(piece?.elementType || ''); + const fromG = parseHoles(gcode).map((hole) => ( + mirrorDepthAxis && rawSize + ? { ...hole, yM: rawSize.heightM - hole.yM } + : hole + )); const fromPlan = planHoleSpecsToSceneHoles(piece, rawSize); + const planIsComplete = ['dessus', 'fond', 'cotes', 'panneauxInterieurs'] + .includes(piece?.elementType || ''); + // These panels are fully represented by shared machining specs. Combining + // them with raw G-code renders a second, offset copy of the same holes. + if (planIsComplete && fromPlan.length) return fromPlan; return mergeHoleListsByPosition(fromG, fromPlan); }, [gcode, piece, rawSize]); diff --git a/client/src/components/Cnc3DViewer.module.css b/client/src/components/Cnc3DViewer.module.css index 707b68f..92f98b1 100644 --- a/client/src/components/Cnc3DViewer.module.css +++ b/client/src/components/Cnc3DViewer.module.css @@ -8,6 +8,24 @@ touch-action: none; } +.sideLabel { + display: inline-flex; + align-items: center; + padding: 3px 7px; + border: 1px solid rgba(71, 85, 105, 0.35); + border-radius: 999px; + background: rgba(255, 255, 255, 0.92); + color: #334155; + font-size: 0.68rem; + font-weight: 700; + line-height: 1; + letter-spacing: 0.02em; + white-space: nowrap; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.16); + pointer-events: none; + user-select: none; +} + .axisBar { position: absolute; left: 8px; diff --git a/client/src/components/CuttingListTable.jsx b/client/src/components/CuttingListTable.jsx index 89d02f6..6650028 100644 --- a/client/src/components/CuttingListTable.jsx +++ b/client/src/components/CuttingListTable.jsx @@ -30,7 +30,7 @@ function getPieceDepthMm(piece, cuttingListConfigDepthMm) { // For readability in the cutting list, "Profondeur" is the dimension along wardrobe depth. // Depending on the panel type, this maps to either width or height. - const depthIsWidth = new Set(['cotes', 'cotesDoublage', 'fileur', 'habillageFinitionGauche', 'habillageFinitionDroite']); + const depthIsWidth = new Set(['cotes', 'cotesDoublage', 'fileur', 'habillageFinitionGauche', 'habillageFinitionDroite', 'plintheGauche', 'plintheDroite']); const depthIsHeight = new Set(['dessus', 'fond', 'habillageFinitionDessus']); if (depthIsWidth.has(t)) return width ?? null; @@ -186,10 +186,14 @@ export default function CuttingListTable({ cuttingList, onExportMprPiece, cncPro function buildManufacturingRows(piece) { const m = piece?.machining || {}; + const surcote = piece.surcoteMm ? ` · surcote sur-mesure ${piece.surcoteMm} mm L+l` : ''; + const bandingDir = piece.edgeBanding?.direction?.label + ? ` · sens de collage: ${piece.edgeBanding.direction.label}` + : ''; const rows = [ ['Dimensions finies', `${piece.widthMm} x ${piece.heightMm} x ${piece.thicknessMm} mm`], - ['Dimensions brutes CNC', `${piece.rawCutWidthMm} x ${piece.rawCutHeightMm} mm`], - ['Chants', `${piece.edgeBanding?.sides || 0} côté(s) · épaisseur ${piece.edgeBanding?.thickness || 0} mm`] + ['Dimensions brutes CNC', `${piece.rawCutWidthMm} x ${piece.rawCutHeightMm} mm${surcote}`], + ['Chants', `${piece.edgeBanding?.sides || 0} côté(s) · épaisseur ${piece.edgeBanding?.thickness || 0} mm${bandingDir}`] ]; if (m.system32) { @@ -417,8 +421,12 @@ export default function CuttingListTable({ cuttingList, onExportMprPiece, cncPro {piece.surfaceM2.toFixed(3)} {piece.edgeBanding.type !== 'none' && ( - + {piece.edgeBanding.sides} côtés + {piece.edgeBanding.direction?.label ? ` · ${piece.edgeBanding.direction.label}` : ''} )} diff --git a/client/src/components/Layout.jsx b/client/src/components/Layout.jsx index 3900367..07c2fdf 100644 --- a/client/src/components/Layout.jsx +++ b/client/src/components/Layout.jsx @@ -1,10 +1,25 @@ -import { Link, useNavigate } from 'react-router-dom'; +import { useEffect, useRef } from 'react'; +import { Link, NavLink, useNavigate } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import styles from './Layout.module.css'; +const navClass = ({ isActive }) => (isActive ? styles.navActive : undefined); + export default function Layout({ children }) { const { isAdmin, logout } = useAuth(); const navigate = useNavigate(); + const headerRef = useRef(null); + + useEffect(() => { + const header = headerRef.current; + if (!header) return undefined; + const root = document.documentElement; + const update = () => root.style.setProperty('--header-height', `${header.offsetHeight}px`); + update(); + const observer = new ResizeObserver(update); + observer.observe(header); + return () => observer.disconnect(); + }, []); const handleLogout = () => { logout(); @@ -13,16 +28,16 @@ export default function Layout({ children }) { return (
-
+
SIGNATURE
); } diff --git a/client/src/components/Layout.module.css b/client/src/components/Layout.module.css index ca06ee3..d68e45a 100644 --- a/client/src/components/Layout.module.css +++ b/client/src/components/Layout.module.css @@ -9,8 +9,15 @@ align-items: center; justify-content: space-between; padding: 1rem 1.5rem; - background: var(--bg-card); + background: rgba(255, 255, 255, 0.9); + -webkit-backdrop-filter: blur(4px); + backdrop-filter: blur(4px); border-bottom: 1px solid var(--border); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); + position: -webkit-sticky; + position: sticky; + top: 0; + z-index: 70; } .logo { @@ -40,6 +47,23 @@ color: var(--primary); } +.header nav a.navActive { + position: relative; + color: var(--primary); + font-weight: 600; +} + +.header nav a.navActive::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: -0.375rem; + height: 2px; + border-radius: 2px; + background: var(--accent); +} + .header nav .logoutBtn { background: none; border: none; @@ -61,3 +85,36 @@ margin: 0 auto; padding: 0 1.5rem; } + +.footer { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem 1.5rem; + padding: 1rem 1.5rem; + margin-top: 2rem; + background: var(--bg-card); + border-top: 1px solid var(--border); + font-size: 0.82rem; + color: var(--text-muted); +} + +.footerBrand { + font-weight: 600; +} + +.footerNav { + display: flex; + flex-wrap: wrap; + gap: 1.25rem; +} + +.footerNav a { + color: var(--text-muted); + font-weight: 500; +} + +.footerNav a:hover { + color: var(--primary); +} diff --git a/client/src/components/Plans2DView.jsx b/client/src/components/Plans2DView.jsx index e11c4b7..3933925 100644 --- a/client/src/components/Plans2DView.jsx +++ b/client/src/components/Plans2DView.jsx @@ -12,7 +12,7 @@ import Icon from './ui/Icon'; const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001'; const PLANS_TAB_KEY = 'apex.plans.tab'; -const VALID_PLAN_TABS = ['assembly', 'side', 'top', 'cutting', 'hardware']; +const VALID_PLAN_TABS = ['assembly', 'side', 'top', 'cutting', 'calepinage', 'hardware']; function readSavedPlanTab() { try { @@ -34,6 +34,8 @@ export default function Plans2DView({ config, modules = [], colors = {}, orderId const [activeTab, setActiveTab] = useState(readSavedPlanTab); const [plans, setPlans] = useState(null); const [cuttingList, setCuttingList] = useState(null); + const [cuttingPlan, setCuttingPlan] = useState(null); + const [cuttingPlanError, setCuttingPlanError] = useState(null); const [hardwareList, setHardwareList] = useState(null); const [hardwareError, setHardwareError] = useState(null); const [loading, setLoading] = useState(true); @@ -128,6 +130,26 @@ export default function Plans2DView({ config, modules = [], colors = {}, orderId setHardwareError('Erreur lors de la génération de la liste de quincaillerie'); } + // Fetch cutting plan (calepinage) + try { + const planRes = await fetch(`${API_BASE}/api/plans/cutting-plan`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config, modules, colors }) + }); + const planData = await planRes.json(); + if (planRes.ok && planData.success) { + setCuttingPlan(planData.cuttingPlan); + setCuttingPlanError(null); + } else { + setCuttingPlan(null); + setCuttingPlanError(planData.error || 'Plan de coupe indisponible.'); + } + } catch { + setCuttingPlan(null); + setCuttingPlanError('Plan de coupe indisponible.'); + } + const cncRes = await fetch(`${API_BASE}/api/plans/export-cnc`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -246,6 +268,21 @@ export default function Plans2DView({ config, modules = [], colors = {}, orderId a.click(); URL.revokeObjectURL(url); setExportStatus('Archive Homag MPR (.zip) téléchargée.'); + } else if (format === 'excel') { + const res = await fetch(`${API_BASE}/api/plans/export-excel`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config, modules, colors }) + }); + if (!res.ok) throw new Error(await res.text()); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'liste-debitage.xlsx'; + a.click(); + URL.revokeObjectURL(url); + setExportStatus('Liste de débitage exportée en Excel (.xlsx).'); } } catch (err) { console.error('Export error:', err); @@ -303,6 +340,7 @@ export default function Plans2DView({ config, modules = [], colors = {}, orderId { id: 'side', label: 'Vue de côté' }, { id: 'top', label: 'Vue de dessus' }, { id: 'cutting', label: 'Liste de débitage' }, + { id: 'calepinage', label: 'Calepinage' }, { id: 'hardware', label: 'Quincaillerie' } ]; @@ -384,6 +422,49 @@ export default function Plans2DView({ config, modules = [], colors = {}, orderId /> )} + {activeTab === 'calepinage' && ( + cuttingPlanError ? ( +
{cuttingPlanError}
+ ) : cuttingPlan ? ( +
+
+
+ Panneaux nécessaires + {cuttingPlan.totalBoards} +
+
+ Format panneau + {cuttingPlan.stock?.widthMm} × {cuttingPlan.stock?.heightMm} mm +
+
+ Surface utilisée + {cuttingPlan.totalUsedAreaM2} m² +
+
+ Chute totale + {cuttingPlan.totalWasteAreaM2} m² ({cuttingPlan.globalWastePercent}%) +
+
+
+ {cuttingPlan.boards.map((board, i) => ( +
+
+ Panneau {i + 1} — {board.finishLabel} · {board.thicknessMm} mm + Chute {board.wastePercent}% +
+
+
+ ))} +
+
+ ) : ( +
Plan de coupe en cours de génération…
+ ) + )} + {activeTab === 'hardware' && ( )} + {(activeTab === 'cutting' || activeTab === 'calepinage' || activeTab === 'hardware') && ( + + )} + + +
+ +
+ setName(event.target.value)} + placeholder="Ex. Armoire chambre principale" + autoFocus + /> + +
+
+ + {error &&

{error}

} + {updateSuccess && ( +

+ + « {updateSuccess.name} » a bien été mise à jour. +

+ )} + +
+ {loading ? ( +

Chargement…

+ ) : designs.length === 0 ? ( +

Aucune armoire sauvegardée.

+ ) : ( + designs.map((design) => { + const savedConfig = design.config || {}; + const date = new Date(`${design.updated_at?.replace(' ', 'T')}Z`); + return ( +
+
+ {design.name} + + {savedConfig.widthMm || '—'} × {savedConfig.heightMm || '—'} × {savedConfig.depthMm || '—'} mm + {' · '} + {(savedConfig.modules || []).length} colonne{(savedConfig.modules || []).length > 1 ? 's' : ''} + + + Sauvegardée {Number.isNaN(date.getTime()) ? '' : date.toLocaleString('fr-CH')} + +
+
+ + + +
+
+ ); + }) + )} +
+ +
+ ); +} diff --git a/client/src/components/SavedDesignsModal.module.css b/client/src/components/SavedDesignsModal.module.css new file mode 100644 index 0000000..279dfdc --- /dev/null +++ b/client/src/components/SavedDesignsModal.module.css @@ -0,0 +1,235 @@ +.overlay { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + background: rgba(15, 23, 42, 0.62); +} + +.modal { + width: min(680px, 100%); + max-height: min(760px, 90vh); + display: flex; + flex-direction: column; + overflow: hidden; + border-radius: 12px; + background: var(--bg-card); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.3); +} + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1.25rem 1.4rem 1rem; + border-bottom: 1px solid var(--border); +} + +.header h2 { + margin: 0; + color: var(--primary); + font-size: 1.15rem; +} + +.header p { + margin: 0.3rem 0 0; + color: var(--text-muted); + font-size: 0.8rem; +} + +.close { + display: inline-flex; + padding: 0.35rem; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--text-muted); + cursor: pointer; +} + +.close:hover { background: var(--bg); color: var(--text); } + +.saveForm { + padding: 1rem 1.4rem; + background: var(--bg); + border-bottom: 1px solid var(--border); +} + +.saveForm label { + display: block; + margin-bottom: 0.4rem; + color: var(--text); + font-size: 0.78rem; + font-weight: 600; +} + +.saveRow { + display: flex; + gap: 0.6rem; +} + +.saveRow input { + min-width: 0; + flex: 1; + padding: 0.6rem 0.7rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-card); + color: var(--text); + font: inherit; +} + +.saveRow button, +.restore, +.update { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 0.6rem 0.85rem; + border: 1px solid var(--action); + border-radius: 6px; + background: var(--action); + color: #fff; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; +} + +.saveRow button:disabled, +.restore:disabled, +.update:disabled { + opacity: 0.5; + cursor: default; +} + +.error { + margin: 0; + padding: 0.7rem 1.4rem; + background: #fef2f2; + color: #b91c1c; + font-size: 0.8rem; +} + +.success { + display: flex; + align-items: center; + gap: 0.45rem; + margin: 0; + padding: 0.7rem 1.4rem; + background: #ecfdf5; + color: #047857; + font-size: 0.8rem; + font-weight: 600; +} + +.success span { + font-size: 1rem; + line-height: 1; +} + +.list { + min-height: 130px; + overflow-y: auto; + padding: 0.6rem 1.4rem 1.2rem; +} + +.empty { + padding: 2rem 1rem; + text-align: center; + color: var(--text-muted); + font-size: 0.85rem; +} + +.design { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.85rem 0; + border-bottom: 1px solid var(--border); +} + +.designInfo { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.designInfo strong { + overflow: hidden; + color: var(--text); + font-size: 0.9rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.designInfo span, +.designInfo small { + color: var(--text-muted); + font-size: 0.75rem; +} + +.actions { + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +.restore, +.update { + padding: 0.5rem 0.7rem; + background: transparent; + color: var(--action); +} + +.restore:hover { background: var(--action); color: #fff; } + +.update { + border-color: var(--border); + color: var(--text); +} + +.update:hover { + border-color: var(--action); + color: var(--action); +} + +.updateSuccess, +.updateSuccess:hover { + border-color: #059669; + background: #ecfdf5; + color: #047857; +} + +.delete { + display: inline-flex; + padding: 0.5rem; + border: 1px solid var(--border); + border-radius: 6px; + background: transparent; + color: var(--text-muted); + cursor: pointer; +} + +.delete:hover { border-color: #dc2626; color: #dc2626; } +.delete:disabled { opacity: 0.5; cursor: default; } + +@media (max-width: 560px) { + .saveRow, + .design { + align-items: stretch; + flex-direction: column; + } + + .actions { justify-content: flex-end; } + .restore, + .update { flex: 1; } +} diff --git a/client/src/components/SectionNav.jsx b/client/src/components/SectionNav.jsx new file mode 100644 index 0000000..d1a7bbd --- /dev/null +++ b/client/src/components/SectionNav.jsx @@ -0,0 +1,75 @@ +import { useEffect, useRef, useState } from 'react'; +import styles from './SectionNav.module.css'; + +export default function SectionNav({ sections }) { + const [active, setActive] = useState(sections[0]?.id ?? ''); + const navRef = useRef(null); + const isClickScrolling = useRef(false); + const clickTimeout = useRef(null); + + useEffect(() => { + const els = sections + .map((s) => document.getElementById(s.id)) + .filter(Boolean); + if (els.length === 0) return undefined; + + const ratios = new Map(); + const observer = new IntersectionObserver( + (entries) => { + if (isClickScrolling.current) return; + for (const entry of entries) { + ratios.set(entry.target.id, entry.intersectionRatio); + } + let best = ''; + let bestRatio = -1; + for (const [id, ratio] of ratios) { + if (ratio > bestRatio) { + best = id; + bestRatio = ratio; + } + } + if (best && bestRatio > 0) setActive(best); + }, + { threshold: [0, 0.1, 0.25, 0.5, 0.75, 1] } + ); + + for (const el of els) observer.observe(el); + return () => observer.disconnect(); + }, [sections]); + + useEffect(() => () => clearTimeout(clickTimeout.current), []); + + const handleClick = (id) => { + const el = document.getElementById(id); + if (!el) return; + setActive(id); + + isClickScrolling.current = true; + const nav = navRef.current; + const stickyBottom = nav ? nav.getBoundingClientRect().bottom : 0; + const top = el.getBoundingClientRect().top + window.scrollY - stickyBottom - 8; + window.scrollTo({ top, behavior: 'smooth' }); + + clearTimeout(clickTimeout.current); + clickTimeout.current = setTimeout(() => { + isClickScrolling.current = false; + }, 800); + }; + + return ( + + ); +} diff --git a/client/src/components/SectionNav.module.css b/client/src/components/SectionNav.module.css new file mode 100644 index 0000000..b62f39b --- /dev/null +++ b/client/src/components/SectionNav.module.css @@ -0,0 +1,52 @@ +.sectionNav { + position: -webkit-sticky; + position: sticky; + top: var(--header-height); + z-index: 60; + margin: 0 -1.5rem 1.5rem; + background: rgba(255, 255, 255, 0.9); + -webkit-backdrop-filter: blur(4px); + backdrop-filter: blur(4px); + border-bottom: 1px solid var(--border); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); +} + +.inner { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem; + padding: 0.125rem 1.5rem; +} + +.item { + position: relative; + padding: 0.5rem 0.6rem; + border: none; + background: transparent; + font-size: 0.8125rem; + font-weight: 500; + line-height: 1.25; + white-space: nowrap; + color: var(--text-muted); + transition: color 0.15s; +} + +.item:hover { + color: var(--text); +} + +.itemActive { + color: var(--action); +} + +.itemActive::after { + content: ''; + position: absolute; + left: 0.4rem; + right: 0.4rem; + bottom: 0; + height: 2px; + border-radius: 999px; + background: var(--action); +} diff --git a/client/src/components/WardrobeElement.jsx b/client/src/components/WardrobeElement.jsx index 46f45da..cd664ae 100644 --- a/client/src/components/WardrobeElement.jsx +++ b/client/src/components/WardrobeElement.jsx @@ -10,6 +10,8 @@ import * as THREE from 'three'; // Default colors for different element types const DEFAULT_COLORS = { plintheBas: '#8b7355', + plintheGauche: '#8b7355', + plintheDroite: '#8b7355', fileur: '#8b7355', bandeauHaut: '#8b7355', champs: '#6b5344', @@ -32,6 +34,8 @@ const DEFAULT_COLORS = { // Element labels for display const ELEMENT_LABELS = { plintheBas: 'Plinthe bas', + plintheGauche: 'Plinthe retour gauche', + plintheDroite: 'Plinthe retour droit', fileur: 'Fileur', bandeauHaut: 'Bandeau haut', champs: 'Champs', @@ -63,12 +67,27 @@ function getElementColor(elementType, colors) { return DEFAULT_COLORS[elementType] || '#cccccc'; } +/** + * Cross-origin swatch images (e.g. supplier CDNs) taint the we use to + * build WebGL textures, so the texture upload and dominant-color sampling both + * fail and the element falls back to a flat default color. Route absolute URLs + * through our same-origin proxy so the canvas stays clean. Relative URLs + * (locally cached materials under /materials/...) are already same-origin. + */ +function toCanvasSafeUrl(url) { + if (!url) return null; + if (/^https?:\/\//i.test(url)) { + return `/api/material-image?url=${encodeURIComponent(url)}`; + } + return url; +} + /** * Get image URL for element texture (if available) */ function getElementImageUrl(elementType, colors) { if (colors && colors[elementType] && colors[elementType].imageUrl) { - return colors[elementType].imageUrl; + return toCanvasSafeUrl(colors[elementType].imageUrl); } return null; } @@ -197,8 +216,10 @@ function loadCroppedTexture(url) { ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh); const tex = new THREE.CanvasTexture(canvas); - tex.wrapS = THREE.RepeatWrapping; - tex.wrapT = THREE.RepeatWrapping; + // Clamp (don't repeat): a single swatch is stretched to fill each panel so + // the non-seamless thumbnail edges never tile into a visible grid of squares. + tex.wrapS = THREE.ClampToEdgeWrapping; + tex.wrapT = THREE.ClampToEdgeWrapping; tex.colorSpace = THREE.SRGBColorSpace; tex.minFilter = THREE.LinearMipmapLinearFilter; tex.magFilter = THREE.LinearFilter; @@ -281,37 +302,20 @@ function useCroppedTexture(imageUrl, geometryArgs, geometryType) { } /** - * Configure texture repeat based on geometry dimensions. - * The cropped swatch represents approximately 0.3m x 0.3m of real material. + * Stretch a single swatch across the whole face instead of tiling it. + * + * The supplier thumbnails are not seamless, so any repeat > 1 produces a visible + * grid of squares at every tile boundary. Filling each panel with exactly one + * swatch (repeat = 1, clamped) removes that grid entirely. The `geometryArgs` / + * `geometryType` arguments are kept for API compatibility (callers pass them and + * the repeat is re-applied when geometry changes). */ -function configureTextureRepeat(tex, geometryArgs, geometryType) { - if (!tex || !geometryArgs) return; - - const TEXTURE_PATCH_SIZE = 0.3; // meters - let widthM, heightM; - - switch (geometryType) { - case 'box': - widthM = geometryArgs[0] || 1; - heightM = geometryArgs[1] || 1; - break; - case 'plane': - widthM = geometryArgs[0] || 1; - heightM = geometryArgs[1] || 1; - break; - case 'cylinder': - widthM = 0.3; - heightM = 0.3; - break; - default: - widthM = 1; - heightM = 1; - } - - tex.repeat.set( - Math.max(1, widthM / TEXTURE_PATCH_SIZE), - Math.max(1, heightM / TEXTURE_PATCH_SIZE) - ); +function configureTextureRepeat(tex /* , geometryArgs, geometryType */) { + if (!tex) return; + tex.wrapS = THREE.ClampToEdgeWrapping; + tex.wrapT = THREE.ClampToEdgeWrapping; + tex.repeat.set(1, 1); + tex.offset.set(0, 0); tex.needsUpdate = true; } diff --git a/client/src/components/WardrobePreview.jsx b/client/src/components/WardrobePreview.jsx index b9e64f9..68465c0 100644 --- a/client/src/components/WardrobePreview.jsx +++ b/client/src/components/WardrobePreview.jsx @@ -1,29 +1,115 @@ import { useRef, Suspense, useMemo, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'; -import { Canvas, useThree } from '@react-three/fiber'; -import { OrbitControls, Html } from '@react-three/drei'; +import { Canvas, useFrame, useThree } from '@react-three/fiber'; +import { OrbitControls, Html, Edges } from '@react-three/drei'; import * as THREE from 'three'; import Room3D, { calculateWardrobeTransform } from './Room3D'; import WardrobeElement from './WardrobeElement'; +import Icon from './ui/Icon'; import styles from './WardrobePreview.module.css'; -import { GROOVE, PANEL_M, getSeparatorDepthMm, getDoorHingeSide } from '@shared/manufacturing.js'; -import { shelfCenterYsMeters, minShelvesForStructuralHeight, resolveShelfPositionsMm, system32ShelfPositionsMm } from '../constants/wardrobe'; +import { + GROOVE, + PANEL_M, + PLINTH, + getAxiloPlinthPanelHeightMm, + getAxiloPlinthReturnLengthMm, + getAxiloPlinthFrontSideDeductionMm, + hasSidePlinthReturn, + getDoorHingeSide, + DRAWER_REINFORCEMENT, + drawerStackHeightMm, + drawerNicheHeightsMm, + HORIZONTAL_SPAN_DOOR_MODES, + resolveHorizontalSpan, + getSeparatorVerticalSegments, + validateHorizontalSpans, + usesBlockConstruction, + columnUsesBlockConstruction, + horizontalConstructionRuns, + BLOCK_ASSEMBLY +} from '@shared/manufacturing.js'; +import { shelfCenterYsMeters, minShelvesForStructuralHeight, minShelvesForColumn, moduleStackInfo, moduleBlockHeightsMm, moduleBlockDoors, moduleHangingZoneMm, resolveShelfPositionsMm, system32ShelfPositionsMm, STRUCTURAL_WINDOW_HALF_MM, DIMENSION_RANGES } from '../constants/wardrobe'; const SCALE = 1 / 1000; +/** Three-quarter from the front: facade stays readable, side depth still shows. */ +const DEFAULT_VIEW_YAW = Math.PI / 8; +/** Camera rise above the look-at point, before the direction is normalized. */ +const DEFAULT_VIEW_ELEV = 0.22; + +function defaultViewDirection() { + return new THREE.Vector3( + Math.sin(DEFAULT_VIEW_YAW), + DEFAULT_VIEW_ELEV, + Math.cos(DEFAULT_VIEW_YAW), + ).normalize(); +} + +function carcassBox(totalWidthMm, heightMm, depthMm) { + return { + w: (totalWidthMm || 2400) * SCALE, + h: (heightMm || 2400) * SCALE, + d: (depthMm || 600) * SCALE, + }; +} + +/** Camera-to-target distance that keeps the carcass inside the frame. */ +function distanceToFitCarcass(box, targetY, dir, fovDeg, aspect) { + const back = dir.clone().normalize(); + const xAxis = new THREE.Vector3().crossVectors(new THREE.Vector3(0, 1, 0), back).normalize(); + const yAxis = new THREE.Vector3().crossVectors(back, xAxis).normalize(); + const tanV = Math.tan(THREE.MathUtils.degToRad(fovDeg) / 2) * 0.86; + const tanH = tanV * Math.max(0.2, aspect || 1); + let need = 0; + const p = new THREE.Vector3(); + for (const sx of [-1, 1]) { + for (const sy of [0, 1]) { + for (const sz of [-1, 1]) { + p.set(sx * box.w / 2, sy * box.h - targetY, sz * box.d / 2); + const cz = p.dot(back); + need = Math.max(need, cz + Math.abs(p.dot(xAxis)) / tanH, cz + Math.abs(p.dot(yAxis)) / tanV); + } + } + } + return Math.max(0.8, need); +} + +function defaultCameraPosition({ roomConfig, totalWidthMm, heightMm, depthMm, aspect = 1.6 }) { + if (roomConfig) { + const roomSize = Math.max(roomConfig.width, roomConfig.length); + return [roomSize * 0.8, roomSize * 0.5, roomSize * 0.8]; + } + const box = carcassBox(totalWidthMm, heightMm, depthMm); + const targetY = box.h * 0.5; + const dir = defaultViewDirection(); + const dist = distanceToFitCarcass(box, targetY, dir, 45, aspect); + return [dir.x * dist, targetY + dir.y * dist, dir.z * dist]; +} const PANEL_THICKNESS = PANEL_M.structure; // 0.019 m (19 mm) const BACK_PANEL_THICKNESS = PANEL_M.back; // 0.008 m (8 mm) const REINFORCEMENT_THICKNESS = PANEL_M.reinforcement; // 0.016 m (16 mm) const REINFORCEMENT_WIDTH = PANEL_M.reinforcementWidth; // 0.100 m (100 mm) +/** Centre du dos sur l'axe de la rainure, mesuré depuis le chant arrière de la caisse. */ +const BACK_PANEL_AXIS_FROM_REAR_M = GROOVE.back.axisFromPanelEdge * SCALE; +/** Partie cachée du dos engagée dans chaque rainure périphérique. */ +const BACK_PANEL_GROOVE_INSERT_M = GROOVE.back.depth * SCALE; const DIMENSION_RULES = { - widthMm: { min: 400, max: 5000, step: 50 }, - heightMm: { min: 1800, max: 2800, step: 50 }, - depthMm: { min: 400, max: 800, step: 10 }, + widthMm: DIMENSION_RANGES.widthMm, + heightMm: DIMENSION_RANGES.heightMm, + depthMm: DIMENSION_RANGES.depthMm, }; +/** + * Jeu de façade (reveal) par côté, en mètres. 1.5 mm de chaque côté en hauteur + * et en largeur → 3 mm de jeu total entre façades adjacentes (portes et faces de tiroirs). + */ +const FACADE_REVEAL_MM = 1.5; +const FACADE_REVEAL_M = FACADE_REVEAL_MM * SCALE; +/** Tiny embed so coplanar carcass faces don't leak the background at 90° junctions. */ +const JOIN_OVERLAP_M = 0.0008; + /** Edges shorter than this (m) get a grip that extends past the geometry for easier grabs. */ const THIN_EDGE_M = 0.45; const MIN_EDGE_GRIP_M = 0.2; const GRIP_OVERFLOW_WHEN_THIN_M = 0.08; -const STRUCTURAL_WINDOW_HALF_MM = 200; /** * Visible grip length along an edge (width, height, or depth of the carcass). @@ -43,6 +129,94 @@ function gripHitCrossAxis(mainSpanM) { return Math.max(0.1, 0.06 + mainSpanM * 0.12); } +/** Below this sin² between view ray and drag axis, the axis points at the camera and ray projection is unstable. */ +const AXIS_DRAG_MIN_DENOM = 0.04; +/** Magnetic snap radius in screen pixels (converted to mm at drag start). */ +const SNAP_RADIUS_PX = 10; + +function rayFromClient(clientX, clientY, camera, domElement) { + const rect = domElement.getBoundingClientRect(); + const ndc = new THREE.Vector2( + ((clientX - rect.left) / rect.width) * 2 - 1, + -((clientY - rect.top) / rect.height) * 2 + 1 + ); + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(ndc, camera); + return raycaster.ray; +} + +function worldToClient(point, camera, domElement) { + const rect = domElement.getBoundingClientRect(); + const p = point.clone().project(camera); + return new THREE.Vector2( + rect.left + ((p.x + 1) / 2) * rect.width, + rect.top + ((1 - p.y) / 2) * rect.height + ); +} + +/** + * Maps pointer motion to a signed displacement (m) along a world axis through `origin`, + * so the grabbed handle stays under the cursor whatever the zoom or orbit angle. + * The camera must stay still for the duration of the gesture. + */ +function createAxisDrag({ origin, axis, camera, domElement, clientX, clientY }) { + const o = origin.clone(); + const a = axis.clone().normalize(); + const screenStart = worldToClient(o, camera, domElement); + const screenAxis = worldToClient(o.clone().addScaledVector(a, 1), camera, domElement).sub(screenStart); + const pxPerMeter = Math.max(1, screenAxis.length()); + + const projectOnAxis = (cx, cy) => { + const ray = rayFromClient(cx, cy, camera, domElement); + const w0 = ray.origin.clone().sub(o); + const b = ray.direction.dot(a); + const denom = 1 - b * b; + if (denom < AXIS_DRAG_MIN_DENOM) return null; + return (a.dot(w0) - b * ray.direction.dot(w0)) / denom; + }; + + const startT = projectOnAxis(clientX, clientY); + const useRay = startT !== null && pxPerMeter > 40; + let last = 0; + + return { + mmPerPx: 1000 / pxPerMeter, + deltaMeters(cx, cy) { + if (useRay) { + const t = projectOnAxis(cx, cy); + if (t !== null) last = t - startT; + return last; + } + if (pxPerMeter > 40) { + const d = new THREE.Vector2(cx - clientX, cy - clientY); + return d.dot(screenAxis) / (pxPerMeter * pxPerMeter); + } + // Axis seen end-on: fall back to a fixed rate, up/right = positive. + return ((cx - clientX) - (cy - clientY)) * 0.002; + }, + }; +} + +function axisDragFromEvent(e, localAxis, camera, domElement) { + const object = e.object; + object.updateWorldMatrix(true, false); + const origin = new THREE.Vector3().setFromMatrixPosition(object.matrixWorld); + const axis = localAxis.clone().transformDirection(object.matrixWorld); + return { + origin, + axis, + drag: createAxisDrag({ origin, axis, camera, domElement, clientX: e.clientX, clientY: e.clientY }), + }; +} + +/** First candidate (in priority order) within `radius` of `value`, else null. */ +function magneticSnap(value, candidates, radius) { + for (const c of candidates) { + if (Number.isFinite(c.value) && Math.abs(c.value - value) <= radius) return c; + } + return null; +} + /* =================================================================== ResizableSeparator - a draggable handle between two modules =================================================================== */ @@ -51,6 +225,7 @@ function ResizableSeparator({ height, depth, zCenter, + visibleSegments, separatorIndex, colors, onElementClick, @@ -90,17 +265,19 @@ function ResizableSeparator({ return ( - {/* Séparateur : plaqué au dos, sans rainure ; repère Z aligné arrière avec dessus/fond */} - - - + {/* Séparateur : plaqué au dos, sans rainure ; même emprise Z que dessus/fond */} + {(visibleSegments || [{ centerY: 0, height }]).map((segment, index) => ( + + + + ))} {/* Invisible hit area: wider/taller/deeper than the panel when the carcass is shallow or short */}
{Math.round(widthMm)}mm + {hint && {hint}}
); @@ -308,14 +486,19 @@ function DrawerBox({ depth, behindDoor, drawerDepthMm, + drawerHeightsMm, colors, onElementClick }) { - const drawerHeight = 0.15 * SCALE * 1000; // ~150mm in scene units - const gap = 0.008; // gap between drawers - const totalDrawerZone = Math.min(bodyH * 0.6, drawersCount * (drawerHeight + gap)); - const actualDrawerH = (totalDrawerZone - gap * (drawersCount - 1)) / drawersCount; - const startY = -bodyH / 2 + PANEL_THICKNESS; + const reveal = FACADE_REVEAL_M; + const gapMm = 8; // jeu de fabrication entre faces de tiroirs (mm) — pris DANS la niche Legrabox + // Hauteurs de NICHE Legrabox par tiroir (SMS) — mêmes valeurs que porte / rayon fixe / renforts. + const heightsMm = drawerNicheHeightsMm(drawersCount, drawerHeightsMm); + const totalMm = heightsMm.reduce((s, v) => s + v, 0); + // Même capping que le rayon fixe au-dessus des tiroirs, pour rester aligné. + const totalM = Math.min(bodyH * 0.78, totalMm * SCALE); + const k = totalMm > 0 ? totalM / (totalMm * SCALE) : 1; + /** Profondeur utile tiroir (mm) — réduite en mode caché. */ const effMm = Math.max(350, Number(drawerDepthMm) || 550); const drawerBodyDepthM = Math.max( @@ -326,25 +509,87 @@ function DrawerBox({ const frontFaceZ = behindDoor ? depth / 2 - PANEL_THICKNESS - 0.15 : depth / 2 - PANEL_THICKNESS - 0.002; - const centerZ = frontFaceZ - drawerBodyDepthM / 2; + const bodyCenterZ = frontFaceZ - drawerBodyDepthM / 2 - PANEL_THICKNESS / 2; + + const faceW = Math.max(0.04, moduleWidth - 2 * reveal); + const startY = -bodyH / 2 + PANEL_THICKNESS; + + let cursor = startY; + const items = []; + for (let di = 0; di < drawersCount; di++) { + const hM = heightsMm[di] * SCALE * k; + const yc = cursor + hM / 2; + // Façade = niche − jeu (8 mm pris dans la niche) − jeux visuels haut/bas. + const faceH = Math.max(0.02, hM - gapMm * SCALE * k - 2 * reveal); + items.push({ di, yc, faceH }); + cursor += hM; + } + + // Cales de compensation (renforts) — uniquement pour tiroirs cachés derrière une porte + // à charnières : un montant gauche + un droit, en retrait avant pour ne pas toucher les charnières. + const caleThicknessM = DRAWER_REINFORCEMENT.thicknessMm * SCALE; + const caleDepthM = Math.max( + 0.08, + depth - + (DRAWER_REINFORCEMENT.backInsetFromRearMm + DRAWER_REINFORCEMENT.hingeFrontClearanceMm) * SCALE - + PANEL_THICKNESS + ); + const carcassFrontZ = depth / 2 - PANEL_THICKNESS; + const caleFrontZ = carcassFrontZ - DRAWER_REINFORCEMENT.hingeFrontClearanceMm * SCALE; + const caleCenterZ = caleFrontZ - caleDepthM / 2; + const caleHeightM = Math.max(0.02, totalM); + const caleCenterY = plinthH / 2 + startY + totalM / 2; + const caleLeftX = moduleCenter - moduleWidth / 2 + PANEL_THICKNESS + caleThicknessM / 2; + const caleRightX = moduleCenter + moduleWidth / 2 - PANEL_THICKNESS - caleThicknessM / 2; return ( <> - {Array.from({ length: drawersCount }).map((_, di) => { - const y = startY + actualDrawerH / 2 + di * (actualDrawerH + gap); - return ( + {behindDoor && moduleWidth > 0 && drawersCount > 0 && ( + <> 0 ? centerZ : 0]} + geometryArgs={[caleThicknessM, caleHeightM, caleDepthM]} + position={[caleLeftX, caleCenterY, caleCenterZ]} colors={colors} onElementClick={onElementClick} - materialProps={{ roughness: 0.5, metalness: 0.05 }} + materialProps={{ roughness: 0.7, metalness: 0.02 }} /> - ); - })} + + + )} + {items.map(({ di, yc, faceH }) => ( + + {/* Corps (volume du tiroir, en retrait derrière la face) */} + 0 ? bodyCenterZ : 0]} + colors={colors} + onElementClick={onElementClick} + materialProps={{ roughness: 0.6, metalness: 0.03 }} + /> + {/* Face avant alignée (jeu 1.5 mm de chaque côté) */} + 0 ? frontFaceZ : 0]} + colors={colors} + onElementClick={onElementClick} + materialProps={{ roughness: 0.45, metalness: 0.05 }} + /> + + ))} ); } @@ -352,12 +597,82 @@ function DrawerBox({ /* =================================================================== WardrobeMesh - Full wardrobe model with drag/resize/drawers =================================================================== */ +const GHOST_COLOR = '#6366f1'; +const ignoreRaycast = () => null; + +function GhostBox({ size, position }) { + return ( + + + + + + ); +} + +/** Étape Espace: aperçu de l'agencement intérieur en transparence (non interactif). */ +function LayoutGhost({ config, modules, modulePositions, height, depth, plinthH }) { + const T = PANEL_THICKNESS; + const baseY = -height / 2 + plinthH; + const innerDepth = Math.max(0.02, depth - T * 2); + return ( + + {modules.map((mod, i) => { + const pos = modulePositions[i]; + if (!pos) return null; + const innerW = Math.max(0.02, pos.width - T * 2); + const heightsMm = moduleBlockHeightsMm(mod, config); + const junctionsMm = heightsMm.slice(0, -1).reduce((acc, h) => [...acc, (acc[acc.length - 1] || 0) + h], []); + const shelvesMm = resolveShelfPositionsMm({ + shelfCount: Math.max(minShelvesForColumn(config.heightMm, mod, config), Number(mod.shelves) || 0), + heightMm: config.heightMm, + plinthHeightMm: config.plinthHeightMm ?? 100, + shelfPositionsMm: mod.shelfPositionsMm, + stack: moduleStackInfo(mod, config) + }); + const drawers = Math.max(0, Number(mod.drawers) || 0); + const drawerH = drawers > 0 ? (drawerStackHeightMm(drawers, config.hardware?.drawerHeightsMm) * SCALE) / drawers : 0; + const zone = moduleHangingZoneMm(mod, config); + return ( + + {i > 0 && ( + + )} + {junctionsMm.map((mm) => ( + + ))} + {shelvesMm.map((mm) => ( + + ))} + {Array.from({ length: drawers }, (_, d) => ( + + ))} + {zone && ( + + + + + )} + + ); + })} + + ); +} + function WardrobeMesh({ config, colors = {}, onElementClick, onModuleClick, selectedModuleIndex, + selectedBlockIndex = 0, + selectedHorizontalSpanId, + onHorizontalSpanClick, modules = [], showDoors = true, onResizeModules, @@ -366,8 +681,9 @@ function WardrobeMesh({ onBeginUndoableGesture, activeStep = 1, suppressHtmlOverlays = false, + onInteractionChange, }) { - const { gl, camera, raycaster } = useThree(); + const { gl, camera, controls } = useThree(); const useModules = modules && modules.length > 0; const shellOnlyMode = activeStep === 1; @@ -380,32 +696,154 @@ function WardrobeMesh({ const height = (config.heightMm || 2400) * SCALE; const depth = (config.depthMm || 600) * SCALE; const plinthH = (config.plinthHeightMm || 100) * SCALE; + const plinthPanelH = getAxiloPlinthPanelHeightMm(config.plinthHeightMm || 100) * SCALE; + const plinthThicknessM = PLINTH.panelThicknessMm * SCALE; + const plinthSetbackM = PLINTH.frontSetbackMm * SCALE; + const plinthZ = depth / 2 - plinthSetbackM - plinthThicknessM / 2; const bodyH = height - plinthH; + const isNiche = config.installationType === 'niche'; + const nicheMeasurements = config.nicheMeasurements || {}; + const smallestNicheMeasureM = (values, fallbackMm) => { + const valid = values.map(Number).filter((value) => Number.isFinite(value) && value > 0); + return (valid.length > 0 ? Math.min(...valid) : fallbackMm) * SCALE; + }; + const nicheOpeningWidth = isNiche + ? smallestNicheMeasureM( + [ + nicheMeasurements.widthTopMm, + nicheMeasurements.widthMidMm, + nicheMeasurements.widthBottomMm + ], + totalWidth + 2 * (Number(config.sanitaryGapMm) || 30) + ) + : width; + const nicheOpeningHeight = isNiche + ? smallestNicheMeasureM( + [nicheMeasurements.heightLeftMm, nicheMeasurements.heightRightMm], + (config.heightMm || 2400) + (Number(config.sanitaryGapMm) || 30) + ) + : height; const exteriorFinishesEnabled = !!config.exteriorFinishesEnabled; const sideDefaultsEnabled = config?.sideFinishesEnabled !== false; const sideFinishLeftEnabled = config?.sideFinishLeftEnabled ?? sideDefaultsEnabled; const sideFinishRightEnabled = config?.sideFinishRightEnabled ?? sideDefaultsEnabled; const topFinishEnabled = config?.topFinishEnabled !== false; - const isBlockMode = config.columnMode === 'block'; + const sideAssemblyLeft = + config.installationType === 'free' && exteriorFinishesEnabled && sideFinishLeftEnabled; + const sideAssemblyRight = + config.installationType === 'free' && exteriorFinishesEnabled && sideFinishRightEnabled; + const leftPlinthReturn = hasSidePlinthReturn(config, 'left'); + const rightPlinthReturn = hasSidePlinthReturn(config, 'right'); + const leftPlinthDeductM = getAxiloPlinthFrontSideDeductionMm(config, 'left') * SCALE; + const rightPlinthDeductM = getAxiloPlinthFrontSideDeductionMm(config, 'right') * SCALE; + const frontPlinthWidth = isNiche + ? width + : Math.max(0.02, width - leftPlinthDeductM - rightPlinthDeductM); + const frontPlinthCenterX = isNiche + ? 0 + : (leftPlinthDeductM - rightPlinthDeductM) / 2; + const horizontalPanelLeftInset = PANEL_THICKNESS; + const horizontalPanelRightInset = PANEL_THICKNESS; + const horizontalPanelWidth = + Math.max(0.02, width - horizontalPanelLeftInset - horizontalPanelRightInset); + const horizontalPanelCenterX = (horizontalPanelLeftInset - horizontalPanelRightInset) / 2; + /** + * Dos optionnel PAR ZONE (colonne). + * Construction: dos → caisson block ; sinon côté (montant extérieur) ou rayonnage → block ; + * sinon séparations. Legacy sans flags: dos présent partout (ancien comportement). + */ + const legacyBack = config?.hasBack !== false; + /** + * Empilage vertical PAR COLONNE — N blocks: `mod.blockHeightsMm` / `mod.blockBacks` + * (legacy: stacked + stackBottomHeightMm / hasBack + hasBackTop). Jonction = double panneau horizontal. + */ + const modHeightsMm = (i) => moduleBlockHeightsMm(modules?.[i] ?? null, config); + /** Dos par block (bas → haut); legacy sans flags → dos présent (ancien comportement). */ + const modBacks = (i) => { + const m = modules?.[i]; + const n = modHeightsMm(i).length; + const arr = Array.isArray(m?.blockBacks) ? m.blockBacks : null; + const base = m && typeof m.hasBack === 'boolean' ? m.hasBack : legacyBack; + return Array.from({ length: n }, (_, k) => { + if (arr && typeof arr[k] === 'boolean') return arr[k]; + if (k === n - 1 && n >= 2 && m && typeof m.hasBackTop === 'boolean') return m.hasBackTop; + return base; + }); + }; + /** Y (repère groupe) des jonctions entre blocks de la colonne i ([] si 1 block). */ + const modJointYs = (i) => { + const hs = modHeightsMm(i); + if (hs.length < 2) return []; + const ys = []; + let acc = 0; + for (let k = 0; k < hs.length - 1; k++) { + acc += hs[k]; + ys.push(-height / 2 + plinthH + acc * SCALE); + } + return ys; + }; + const modStacked = (i) => modHeightsMm(i).length >= 2; + const anyStacked = useModules ? modules.some((_, i) => modStacked(i)) : modStacked(0); + const modBack = (i) => modBacks(i)[0] ?? legacyBack; + /** Dos de la zone du dessus (dernier block, ou zone unique). */ + const modBackTop = (i) => { + const b = modBacks(i); + return b.length > 0 ? b[b.length - 1] : legacyBack; + }; + const modBackAny = (i) => modBacks(i).some(Boolean); + const anyBack = useModules ? modules.some((_, i) => modBackAny(i)) : legacyBack; + const backAtLeftSide = useModules ? modBackAny(0) : legacyBack; + const backAtRightSide = useModules ? modBackAny(modules.length - 1) : legacyBack; + /** Colonne en caisson block: dos, ou rayonnage (même sans dos). Les côtés extérieurs restent des montants block. */ + const modIsBlock = (i) => { + if (!useModules) return usesBlockConstruction(null, legacyBack); + return columnUsesBlockConstruction(modules[i], config); + }; + /** Jonction i (entre colonnes i et i+1): double montant si caisson des deux côtés. */ + const boundaryAnyBlock = (i) => modIsBlock(i) || modIsBlock(i + 1); /** Gap between double montants (assemblage block) */ - const BLOCK_JOINT_GAP_M = 0.003; - const separatorDepthM = getSeparatorDepthMm(config.depthMm || 600) * SCALE; - const separatorZ = PANEL_THICKNESS - depth / 2 + separatorDepthM / 2; - const isNiche = config.installationType === 'niche'; + const BLOCK_JOINT_GAP_M = (BLOCK_ASSEMBLY.jointGapMm || 3) * SCALE; + /** Same Z footprint as dessus/fond so front/back corners meet the horizontals. */ + const separatorDepthM = depth - PANEL_THICKNESS; + const separatorZ = PANEL_THICKNESS / 2; const isSliding = config.doorType === 'sliding'; const hw = config.hardware || {}; const doorOpenAngle = 0.32; // radians (~18°) - visible “ajar” preview - /** Séparateurs: en block, montants alignés sur la hauteur habillage (body + bandeau si habillage dessus). */ - const sepCenterY = isBlockMode - ? (topFinishEnabled ? plinthH / 2 + PANEL_THICKNESS / 2 : plinthH / 2) - : plinthH / 2; - const sepHeight = isBlockMode - ? (topFinishEnabled ? bodyH + PANEL_THICKNESS : bodyH) - : bodyH - PANEL_THICKNESS * 2; - /** Single full-width top on step 1 (dimensions); per-block split from step 2 when separators matter. */ - const splitTopPerBlock = - isBlockMode && useModules && modules.length > 1 && !shellOnlyMode; + /** Séparateurs: côté caisson (block), montants alignés sur la hauteur habillage (body + bandeau si habillage dessus). */ + const sepStyleBlock = { + y: topFinishEnabled ? plinthH / 2 + PANEL_THICKNESS / 2 : plinthH / 2, + h: topFinishEnabled ? bodyH + PANEL_THICKNESS : bodyH + }; + const sepStyleSeparations = { + y: plinthH / 2, + h: bodyH - PANEL_THICKNESS * 2 + JOIN_OVERLAP_M + }; + /** + * Inset of a column's horizontal board from a module edge. + * Verticals sit between dessus/fond, so the board must cover their thickness + * (outer face of a clad side, joint gap of a double montant, centerline of a shared separator). + */ + const horizontalInsetAt = (moduleIndex, side) => { + const neighbor = side === 'left' ? moduleIndex - 1 : moduleIndex + 1; + if (neighbor < 0 || neighbor >= modules.length) return PANEL_THICKNESS; + if (modIsBlock(moduleIndex)) { + return PANEL_THICKNESS + (modIsBlock(neighbor) ? BLOCK_JOINT_GAP_M / 2 : 0); + } + return 0; + }; + const montantXsAt = (boundaryX, boundaryIndex) => { + const jointHalf = (BLOCK_JOINT_GAP_M + PANEL_THICKNESS) / 2; + const leftBlock = modIsBlock(boundaryIndex); + const rightBlock = modIsBlock(boundaryIndex + 1); + if (leftBlock && rightBlock) return [boundaryX - jointHalf, boundaryX + jointHalf]; + if (leftBlock) return [boundaryX - PANEL_THICKNESS / 2]; + if (rightBlock) return [boundaryX + PANEL_THICKNESS / 2]; + return [boundaryX]; + }; + /** Single full-width shell on step 1; structural boards are split by block afterwards. */ + const splitTopPerZone = + anyBack && useModules && modules.length > 1 && !shellOnlyMode; const dimGrip = useMemo(() => { const w = gripSpanAlongEdge(width); @@ -454,15 +892,161 @@ function WardrobeMesh({ }); }, [modulePositions, width, useModules, modules]); + const spanConfig = useMemo(() => ({ ...config, modules }), [config, modules]); + const invalidHorizontalSpanIds = useMemo( + () => new Set(validateHorizontalSpans(spanConfig).map((issue) => String(issue.spanId))), + [spanConfig] + ); + const resolvedHorizontalSpans = useMemo( + () => (Array.isArray(config?.horizontalSpans) ? config.horizontalSpans : []) + .filter((span) => !invalidHorizontalSpanIds.has(String(span?.id))) + .map((span) => resolveHorizontalSpan(span, spanConfig)) + .filter((resolved) => ( + resolved && + resolved.moduleIndexes.length >= 2 && + resolved.widthMm > 0 && + Number.isFinite(resolved.bottomMm) && + Number.isFinite(resolved.topMm) && + resolved.topMm > resolved.bottomMm && + resolved.moduleIndexes.every( + (moduleIndex, index) => index === 0 || moduleIndex === resolved.moduleIndexes[index - 1] + 1 + ) + )), + [config?.horizontalSpans, invalidHorizontalSpanIds, spanConfig] + ); + const renderedSpanConfig = useMemo( + () => ({ ...spanConfig, horizontalSpans: resolvedHorizontalSpans.map((resolved) => resolved.span) }), + [spanConfig, resolvedHorizontalSpans] + ); + const spanCoversBlock = (moduleIndex, blockIndex) => + resolvedHorizontalSpans.some((resolved) => resolved.coveredBlocks.some( + (covered) => covered.moduleIndex === moduleIndex && covered.blockIndexes.includes(blockIndex) + )); + const spanCoversY = (moduleIndex, y) => { + const bodyYmm = (y - (-height / 2 + plinthH)) / SCALE; + return resolvedHorizontalSpans.some( + (resolved) => + resolved.moduleIndexes.includes(moduleIndex) && + bodyYmm >= resolved.bottomMm - 0.5 && + bodyYmm <= resolved.topMm + 0.5 + ); + }; + const stackPanelCovered = (moduleIndex, jointY, side) => { + const jointMm = (jointY - (-height / 2 + plinthH)) / SCALE; + return resolvedHorizontalSpans.some((resolved) => { + if (!resolved.moduleIndexes.includes(moduleIndex)) return false; + if (jointMm > resolved.bottomMm + 0.5 && jointMm < resolved.topMm - 0.5) return true; + if (side === 'upper' && Math.abs(jointMm - resolved.bottomMm) <= 0.5) return true; + if (side === 'lower' && Math.abs(jointMm - resolved.topMm) <= 0.5) return true; + return false; + }); + }; + const separatorSegmentsFor = (separatorIndex, style) => { + const styleBottom = style.y - style.h / 2; + const styleTop = style.y + style.h / 2; + return getSeparatorVerticalSegments(renderedSpanConfig, separatorIndex) + .map((segment) => { + const bottom = Math.max( + styleBottom, + -height / 2 + plinthH + segment.bottomMm * SCALE + ); + const top = Math.min( + styleTop, + -height / 2 + plinthH + segment.topMm * SCALE + ); + return { + centerY: (bottom + top) / 2 - style.y, + height: top - bottom + }; + }) + .filter((segment) => segment.height > 0.001); + }; + + /** + * Habillage dessus: 1 bandeau par caisson avec dos, colonnes sans dos contiguës fusionnées. + */ + const topSegments = useMemo(() => { + if (!useModules || modulePositions.length === 0) return null; + const n = modules.length; + const runs = []; + let i = 0; + while (i < n) { + if (modBackTop(i)) { + runs.push({ start: i, end: i, hasBack: true }); + i += 1; + } else { + let j = i; + while (j + 1 < n && !modBackTop(j + 1)) j += 1; + runs.push({ start: i, end: j, hasBack: false }); + i = j + 1; + } + } + return runs.map((seg) => { + const x0 = modulePositions[seg.start].x0; + const x1 = modulePositions[seg.end].x0 + modulePositions[seg.end].width; + const leftInset = horizontalInsetAt(seg.start, 'left'); + const rightInset = horizontalInsetAt(seg.end, 'right'); + const xa = x0 + leftInset; + const xb = x1 - rightInset; + return { + ...seg, + rawX0: x0, + rawX1: x1, + center: -width / 2 + (xa + xb) / 2, + w: Math.max(0.02, xb - xa) + }; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + modulePositions, + width, + useModules, + modules, + legacyBack, + config?.stackEnabled, + bodyH, + ]); + + const geometryFromRuns = (runs) => { + if (!runs || runs.length === 0) return null; + return runs.map((seg) => { + const x0 = modulePositions[seg.start].x0; + const x1 = modulePositions[seg.end].x0 + modulePositions[seg.end].width; + const leftInset = horizontalInsetAt(seg.start, 'left'); + const rightInset = horizontalInsetAt(seg.end, 'right'); + const xa = x0 + leftInset; + const xb = x1 - rightInset; + return { + ...seg, + center: -width / 2 + (xa + xb) / 2, + w: Math.max(0.02, xb - xa), + showsBack: Boolean(seg.hasBack) + }; + }); + }; + + /** Dessus structurel: 1 panneau par caisson block, séparations contiguës = 1 panneau continu. */ + const topConstructionSegments = useMemo(() => { + if (!useModules || modulePositions.length === 0) return null; + return geometryFromRuns(horizontalConstructionRuns(modules, config, 'top')); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [modulePositions, width, useModules, modules, legacyBack, config?.stackEnabled, config?.horizontalSpans, bodyH]); + + /** Fond structurel: même règle que le dessus, au niveau bas. */ + const bottomConstructionSegments = useMemo(() => { + if (!useModules || modulePositions.length === 0) return null; + return geometryFromRuns(horizontalConstructionRuns(modules, config, 'bottom')); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [modulePositions, width, useModules, modules, legacyBack, config?.stackEnabled, config?.horizontalSpans, bodyH]); + // --- Hover state for width labels --- const [hoveredModule, setHoveredModule] = useState(null); const [hoveredShelfKey, setHoveredShelfKey] = useState(null); - const [resizeState, setResizeState] = useState(null); // { separatorIndex, startX } - const [shelfDragState, setShelfDragState] = useState(null); // { moduleIndex, shelfIndex, startClientY, startPosMm, shelfPositionsMm, slotsMm } - const [dimensionResizeState, setDimensionResizeState] = useState(null); // { type, startClientX, startClientY, startValue } - const dimensionResizeStateRef = useRef(null); - const planeRef = useRef(new THREE.Plane(new THREE.Vector3(0, 1, 0), 0)); - const intersectPoint = useRef(new THREE.Vector3()); + const [resizeState, setResizeState] = useState(null); // { separatorIndex, hint, snapped } + const [shelfDragState, setShelfDragState] = useState(null); // { moduleIndex, shelfIndex } + const [dimensionResizeState, setDimensionResizeState] = useState(null); // { type, startValue } + /** Live gesture data (axis tracker, start values, limits); kept out of state so moves never read stale closures. */ + const gestureRef = useRef(null); const dimensionEditMode = activeStep === 1 && typeof onSetDimension === 'function'; /** Drei Html portals use a high z-index and can paint above fixed modals (e.g. plans). */ @@ -478,109 +1062,154 @@ function WardrobeMesh({ onSetDimension?.(type, stepped); }, [dimensionEditMode, onSetDimension]); - // --- Helpers to project pointer to world X --- - const getWorldX = useCallback((e) => { - const rect = gl.domElement.getBoundingClientRect(); - const mouse = new THREE.Vector2( - ((e.clientX - rect.left) / rect.width) * 2 - 1, - -((e.clientY - rect.top) / rect.height) * 2 + 1 - ); - raycaster.setFromCamera(mouse, camera); - planeRef.current.set(new THREE.Vector3(0, 1, 0), -height / 2); - if (raycaster.ray.intersectPlane(planeRef.current, intersectPoint.current)) { - return intersectPoint.current.x; - } - return null; - }, [gl, camera, raycaster, height]); - - // --- Resize pointer down --- + // --- Separator drag: the panel follows the cursor along the carcass X axis --- const handleResizePointerDown = useCallback((separatorIndex, e) => { e.stopPropagation(); + const left = modules[separatorIndex]; + const right = modules[separatorIndex + 1]; + if (!left || !right) return; e.target?.setPointerCapture?.(e.pointerId); - const worldX = getWorldX(e); - if (worldX === null) return; + const { drag } = axisDragFromEvent(e, new THREE.Vector3(1, 0, 0), camera, gl.domElement); + const startLeft = left.widthMm; + const startRight = right.widthMm; + const pair = startLeft + startRight; + const lo = Math.max(left.minWidth || 300, pair - (right.maxWidth || 1200)); + const hi = Math.min(left.maxWidth || 1200, pair - (right.minWidth || 300)); + const otherWidths = modules + .map((m, i) => ({ value: m.widthMm, index: i })) + .filter((m) => m.index !== separatorIndex && m.index !== separatorIndex + 1); + if (controls) controls.enabled = false; onBeginUndoableGesture?.(); - setResizeState({ separatorIndex, startX: worldX }); + gestureRef.current = { kind: 'separator', separatorIndex, drag, startLeft, startRight, pair, lo, hi, otherWidths }; + setResizeState({ separatorIndex, hint: null, snapped: false }); gl.domElement.style.cursor = 'col-resize'; - }, [getWorldX, onBeginUndoableGesture]); + }, [modules, camera, gl, controls, onBeginUndoableGesture]); const handleDimensionPointerDown = useCallback((type, e) => { if (!dimensionEditMode) return; + let localAxis; + let factor; + if (type === 'widthMm') { + // Carcass grows symmetrically: the grabbed half of the bar follows the cursor outward. + e.object.updateWorldMatrix(true, false); + const center = new THREE.Vector3().setFromMatrixPosition(e.object.matrixWorld); + const worldX = new THREE.Vector3(1, 0, 0).transformDirection(e.object.matrixWorld); + const side = e.point && e.point.clone().sub(center).dot(worldX) < 0 ? -1 : 1; + localAxis = new THREE.Vector3(side, 0, 0); + factor = 2; + } else if (type === 'heightMm') { + localAxis = new THREE.Vector3(0, 1, 0); + factor = 1; + } else { + localAxis = new THREE.Vector3(0, 0, 1); + factor = 2; + } + const { drag } = axisDragFromEvent(e, localAxis, camera, gl.domElement); + const startValue = Number(config[type]) || 0; + if (controls) controls.enabled = false; onBeginUndoableGesture?.(); - const nextState = { - type, - startClientX: e.clientX, - startClientY: e.clientY, - startValue: Number(config[type]) || 0, - }; - dimensionResizeStateRef.current = nextState; - setDimensionResizeState(nextState); - if (type === 'heightMm') gl.domElement.style.cursor = 'ns-resize'; - if (type === 'widthMm') gl.domElement.style.cursor = 'ew-resize'; - if (type === 'depthMm') gl.domElement.style.cursor = 'ew-resize'; - }, [dimensionEditMode, gl, config, onBeginUndoableGesture]); + gestureRef.current = { kind: 'dimension', type, drag, factor, startValue }; + setDimensionResizeState({ type, startValue }); + gl.domElement.style.cursor = type === 'heightMm' ? 'ns-resize' : 'ew-resize'; + }, [dimensionEditMode, camera, gl, controls, config, onBeginUndoableGesture]); const handleShelfPointerDown = useCallback((moduleIndex, shelfIndex, isStructural, e) => { if (activeStep !== 2 || typeof onUpdateModule !== 'function') return; const mod = modules[moduleIndex]; const shelfCount = Math.max(0, Number(mod?.shelves) || 0); if (shelfCount <= 0) return; - const slotsMm = system32ShelfPositionsMm(config.heightMm, config.plinthHeightMm ?? 100); + // Colonne empilée: rangées System 32 propres à chaque block (rien dans la jonction). + const dragStack = moduleStackInfo(mod, config); + const slotsMm = system32ShelfPositionsMm(config.heightMm, config.plinthHeightMm ?? 100, dragStack); if (!slotsMm.length) return; const shelfPositionsMm = resolveShelfPositionsMm({ shelfCount, heightMm: config.heightMm, plinthHeightMm: config.plinthHeightMm ?? 100, - shelfPositionsMm: mod?.shelfPositionsMm + shelfPositionsMm: mod?.shelfPositionsMm, + stack: dragStack }); const startPosMm = shelfPositionsMm[shelfIndex]; if (!Number.isFinite(startPosMm)) return; e.stopPropagation(); e.target?.setPointerCapture?.(e.pointerId); + const { drag } = axisDragFromEvent(e, new THREE.Vector3(0, 1, 0), camera, gl.domElement); + if (controls) controls.enabled = false; onBeginUndoableGesture?.(); - setShelfDragState({ + gestureRef.current = { + kind: 'shelf', moduleIndex, shelfIndex, - startClientY: e.clientY, + drag, startPosMm, shelfPositionsMm, + originalShelfPositionsMm: mod?.shelfPositionsMm, slotsMm - }); + }; + setShelfDragState({ moduleIndex, shelfIndex }); gl.domElement.style.cursor = 'ns-resize'; - }, [activeStep, onUpdateModule, modules, config.heightMm, config.plinthHeightMm, onBeginUndoableGesture, gl]); + }, [activeStep, onUpdateModule, modules, config.heightMm, config.plinthHeightMm, onBeginUndoableGesture, camera, gl, controls]); - // Attach window-level pointer events for resize + // Window-level listeners so drags stay live when the pointer leaves the canvas. const onPointerMoveRef = useRef(null); const onPointerUpRef = useRef(null); + const onKeyDownRef = useRef(null); useEffect(() => { + const endGesture = () => { + const g = gestureRef.current; + if (!g) return; + gestureRef.current = null; + if (g.kind === 'separator') setResizeState(null); + if (g.kind === 'dimension') setDimensionResizeState(null); + if (g.kind === 'shelf') { + setShelfDragState(null); + setHoveredShelfKey(null); + } + gl.domElement.style.cursor = 'auto'; + }; + const handleMove = (e) => { - if (resizeState) { - const worldX = getWorldX(e); - if (worldX !== null) { - const deltaX = worldX - resizeState.startX; - const deltaXmm = deltaX / SCALE; - if (onResizeModules && Math.abs(deltaXmm) > 1) { - onResizeModules(resizeState.separatorIndex, deltaXmm); - setResizeState(prev => prev ? { ...prev, startX: worldX } : null); - } + const g = gestureRef.current; + if (!g || g.cancelled) return; + const deltaMm = g.drag.deltaMeters(e.clientX, e.clientY) / SCALE; + + if (g.kind === 'separator') { + const raw = g.startLeft + deltaMm; + const fine = e.shiftKey; + let next = fine ? Math.round(raw) : Math.round(raw / 10) * 10; + let snap = null; + if (!fine && !e.altKey) { + const radius = Math.max(4, SNAP_RADIUS_PX * g.drag.mmPerPx); + const roundFifty = Math.round(raw / 50) * 50; + snap = magneticSnap(raw, [ + { value: g.pair / 2, hint: '= égales' }, + ...g.otherWidths.map((m) => ({ value: m.value, hint: `= col. ${m.index + 1}` })), + { value: g.startLeft, hint: 'initial' }, + { value: roundFifty, hint: null }, + ], radius); + if (snap) next = Math.round(snap.value); } + next = Math.min(g.hi, Math.max(g.lo, next)); + const clamped = next === g.lo || next === g.hi; + onResizeModules?.(g.separatorIndex, next - g.startLeft, { leftMm: g.startLeft, rightMm: g.startRight }); + const hint = snap?.hint ?? (clamped && Math.abs(raw - next) > 1 ? 'limite' : null); + setResizeState((prev) => ( + prev && (prev.hint !== hint || prev.snapped !== !!snap?.hint) + ? { ...prev, hint, snapped: !!snap?.hint } + : prev + )); + return; } - if (dimensionResizeStateRef.current) { - const { type, startClientX, startClientY, startValue } = dimensionResizeStateRef.current; - const mmPerPx = e.shiftKey ? 0.5 : 2; - const deltaPx = type === 'heightMm' - ? startClientY - e.clientY - : type === 'depthMm' - ? startClientX - e.clientX - : e.clientX - startClientX; - const nextValue = startValue + deltaPx * mmPerPx; - applyDimensionValue(type, nextValue); + + if (g.kind === 'dimension') { + applyDimensionValue(g.type, g.startValue + deltaMm * g.factor); + return; } - if (shelfDragState) { - const mmPerPx = e.shiftKey ? 0.6 : 1.5; - const rawMm = shelfDragState.startPosMm + (shelfDragState.startClientY - e.clientY) * mmPerPx; - const slots = shelfDragState.slotsMm; + + if (g.kind === 'shelf') { + const rawMm = g.startPosMm + deltaMm; + const slots = g.slotsMm; let nearestIdx = 0; let best = Infinity; for (let i = 0; i < slots.length; i++) { @@ -590,168 +1219,387 @@ function WardrobeMesh({ nearestIdx = i; } } - const n = shelfDragState.shelfPositionsMm.length; - const minIdx = shelfDragState.shelfIndex; - const maxIdx = slots.length - (n - shelfDragState.shelfIndex); + const n = g.shelfPositionsMm.length; + const minIdx = g.shelfIndex; + const maxIdx = slots.length - (n - g.shelfIndex); nearestIdx = Math.max(minIdx, Math.min(maxIdx, nearestIdx)); - const nextShelfPositions = [...shelfDragState.shelfPositionsMm]; - nextShelfPositions[shelfDragState.shelfIndex] = slots[nearestIdx]; - onUpdateModule?.(shelfDragState.moduleIndex, { shelfPositionsMm: nextShelfPositions }); + const nextShelfPositions = [...g.shelfPositionsMm]; + nextShelfPositions[g.shelfIndex] = slots[nearestIdx]; + onUpdateModule?.(g.moduleIndex, { shelfPositionsMm: nextShelfPositions }); } }; - const handleUp = () => { - if (resizeState) { - setResizeState(null); - gl.domElement.style.cursor = 'auto'; - } - if (dimensionResizeStateRef.current) { - dimensionResizeStateRef.current = null; - setDimensionResizeState(null); - gl.domElement.style.cursor = 'auto'; - } - if (shelfDragState) { - setShelfDragState(null); - setHoveredShelfKey(null); - gl.domElement.style.cursor = 'auto'; + const handleKeyDown = (e) => { + const g = gestureRef.current; + if (!g || g.cancelled || e.key !== 'Escape') return; + e.preventDefault(); + if (g.kind === 'separator') { + onResizeModules?.(g.separatorIndex, 0, { leftMm: g.startLeft, rightMm: g.startRight }); + } else if (g.kind === 'dimension') { + applyDimensionValue(g.type, g.startValue); + } else if (g.kind === 'shelf') { + onUpdateModule?.(g.moduleIndex, { shelfPositionsMm: g.originalShelfPositionsMm }); } + // Stay "interacting" until the button is released so the orbit controls don't pick up the held pointer. + g.cancelled = true; + if (g.kind === 'separator') setResizeState((prev) => (prev ? { ...prev, hint: 'annulé', snapped: false } : prev)); }; onPointerMoveRef.current = handleMove; - onPointerUpRef.current = handleUp; - }, [resizeState, dimensionResizeState, shelfDragState, getWorldX, onResizeModules, applyDimensionValue, onUpdateModule, gl]); + onPointerUpRef.current = endGesture; + onKeyDownRef.current = handleKeyDown; + }, [onResizeModules, applyDimensionValue, onUpdateModule, gl]); - // Attach/detach event listeners useEffect(() => { const move = (e) => onPointerMoveRef.current?.(e); const up = (e) => onPointerUpRef.current?.(e); - - // Use window-level listeners so resize remains live even if pointer leaves canvas. + const key = (e) => onKeyDownRef.current?.(e); window.addEventListener('pointermove', move); window.addEventListener('pointerup', up); - + window.addEventListener('pointercancel', up); + window.addEventListener('keydown', key); return () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); + window.removeEventListener('pointercancel', up); + window.removeEventListener('keydown', key); }; }, []); // Disable OrbitControls during resize const interactionActive = resizeState !== null || dimensionResizeState !== null || shelfDragState !== null; + const dimensionChipLabel = (letter, type) => { + const value = Math.round(Number(config[type]) || 0); + if (dimensionResizeState?.type !== type) return `${letter} ${value}`; + const delta = value - Math.round(dimensionResizeState.startValue); + return `${letter} ${value} mm${delta ? ` (${delta > 0 ? '+' : ''}${delta})` : ''}`; + }; + useEffect(() => { + onInteractionChange?.(interactionActive); + }, [interactionActive, onInteractionChange]); const elementClickHandler = dimensionEditMode ? undefined : onElementClick; const moduleClickHandler = dimensionEditMode ? null : onModuleClick; + const activeStepRef = useRef(activeStep); + activeStepRef.current = activeStep; + // Invisible column/span volumes must not steal clicks off the Colonnes step. + // A stable raycast reads the ref so a stale mesh still lets the piece behind receive the click. + const columnSelectRaycast = useCallback(function columnSelectRaycast(raycaster, intersects) { + if (activeStepRef.current !== 2) return; + THREE.Mesh.prototype.raycast.call(this, raycaster, intersects); + }, []); + /** + * Colonnes: clicking a block's panels selects that block. + * Matériaux and the other steps color the piece that was actually hit, including the + * interior (dos, étagère) when the click lands inside the block. + */ + const moduleSelectFor = (moduleIndex, blockIndex = 0) => (elementType, label) => { + if (activeStepRef.current === 2 && moduleClickHandler) { + moduleClickHandler(moduleIndex, blockIndex); + return; + } + elementClickHandler?.(elementType, label); + }; return ( - {/* Plinthe bas */} + {/* Plinthe bas — planche 19 mm, reculée 20 mm du chant avant (et 20 mm des côtés libres). */} + {leftPlinthReturn && ( + + )} + {rightPlinthReturn && ( + + )} - {/* Dessus (top panel) — 1 pièce (séparations) ou 1 par caisson (block) */} - {splitTopPerBlock ? ( - modulePositions.map((mp, i) => { - const segW = Math.max(0.02, mp.width - 2 * PANEL_THICKNESS - (isBlockMode ? BLOCK_JOINT_GAP_M : 0)); + {/* Dessus structurel — 1 panneau par caisson block, séparations contiguës fusionnées. + Pleine profondeur caisse : le dos 8 mm s'engage dans la rainure (axe 16 mm). */} + {!shellOnlyMode && topConstructionSegments ? ( + topConstructionSegments.map((sg) => { + const closed = sg.showsBack; return ( - - + + + {closed && ( - + )} + ); }) ) : ( - + - + {anyBack && ( + + )} )} - {/* Fond (bottom panel) */} - - - - + {/* Fond structurel — 1 panneau par caisson block, séparations contiguës fusionnées. + Pleine profondeur caisse : le dos 8 mm s'engage dans la rainure (axe 16 mm). */} + {!shellOnlyMode && bottomConstructionSegments ? ( + bottomConstructionSegments.map((sg) => { + const closed = sg.showsBack; + return ( + + + {closed && ( + + )} + + ); + }) + ) : ( + + + {anyBack && ( + + )} + + )} - {/* Côté gauche */} - - - - + {/* Empilage vertical PAR COLONNE: chaque jonction = dessus du block inférieur + fond du block supérieur. + Masqué à l'étape Espace (carcasse seule). */} + {!shellOnlyMode && (useModules ? ( + modules.flatMap((m, i) => { + const jointYs = modJointYs(i); + if (jointYs.length === 0) return []; + const backs = modBacks(i); + const leftInset = horizontalInsetAt(i, 'left'); + const rightInset = horizontalInsetAt(i, 'right'); + const panelW = Math.max(0.02, modulePositions[i].width - leftInset - rightInset); + const cx = -width / 2 + modulePositions[i].x0 + leftInset + panelW / 2; + const renderW = panelW + JOIN_OVERLAP_M * 2; + return jointYs.flatMap((jointY, k) => [ + !stackPanelCovered(i, jointY, 'lower') && + + {backs[k] && ( + + )} + , + !stackPanelCovered(i, jointY, 'upper') && + + {backs[k + 1] && ( + + )} + + ].filter(Boolean)); + }) + ) : ( + anyStacked && + modJointYs(0).flatMap((jointY, k) => [ + + + , + + + + ]) + ))} - {/* Côté droit */} - - - - + {/* Côtés extérieurs — chaque block passe entre ses panneaux horizontaux s'il est habillé. + Étape Espace: un côté continu (pas de joints de blocks). */} + {[ + { + side: 'left', + x: -width / 2 + PANEL_THICKNESS / 2 + (sideAssemblyLeft ? PANEL_THICKNESS : 0), + inset: sideAssemblyLeft, + heights: shellOnlyMode ? [bodyH / SCALE] : modHeightsMm(0), + backs: shellOnlyMode ? [backAtLeftSide] : modBacks(0), + grooveVariant: 'vertical-left', + fallbackBack: backAtLeftSide + }, + { + side: 'right', + x: width / 2 - PANEL_THICKNESS / 2 - (sideAssemblyRight ? PANEL_THICKNESS : 0), + inset: sideAssemblyRight, + heights: shellOnlyMode + ? [bodyH / SCALE] + : modHeightsMm(Math.max(0, modules.length - 1)), + backs: shellOnlyMode + ? [backAtRightSide] + : modBacks(Math.max(0, modules.length - 1)), + grooveVariant: 'vertical-right', + fallbackBack: backAtRightSide + } + ].flatMap((sideInfo) => { + let offsetMm = 0; + return sideInfo.heights.map((blockHeightMm, blockIndex) => { + const blockHeight = blockHeightMm * SCALE; + const panelHeight = Math.max( + 0.02, + blockHeight - (sideInfo.inset ? PANEL_THICKNESS * 2 : 0) + JOIN_OVERLAP_M + ); + const centerY = -height / 2 + plinthH + offsetMm * SCALE + blockHeight / 2; + offsetMm += blockHeightMm; + const hasBack = sideInfo.backs?.[blockIndex] ?? sideInfo.fallbackBack; + return ( + + + {hasBack && ( + + )} + + ); + }); + })} {/* Habillages de finition (Libre + Premium) */} {!isNiche && exteriorFinishesEnabled && ( @@ -763,12 +1611,12 @@ function WardrobeMesh({ geometryType="box" geometryArgs={[ PANEL_THICKNESS, - topFinishEnabled ? bodyH + PANEL_THICKNESS : bodyH, + (topFinishEnabled ? bodyH + PANEL_THICKNESS : bodyH) + plinthH, depth ]} position={[ - -width / 2 - PANEL_THICKNESS / 2, - topFinishEnabled ? plinthH / 2 + PANEL_THICKNESS / 2 : plinthH / 2, + -width / 2 + PANEL_THICKNESS / 2, + topFinishEnabled ? PANEL_THICKNESS / 2 : 0, 0 ]} colors={colors} @@ -782,30 +1630,33 @@ function WardrobeMesh({ geometryType="box" geometryArgs={[ PANEL_THICKNESS, - topFinishEnabled ? bodyH + PANEL_THICKNESS : bodyH, + (topFinishEnabled ? bodyH + PANEL_THICKNESS : bodyH) + plinthH, depth ]} position={[ - width / 2 + PANEL_THICKNESS / 2, - topFinishEnabled ? plinthH / 2 + PANEL_THICKNESS / 2 : plinthH / 2, + width / 2 - PANEL_THICKNESS / 2, + topFinishEnabled ? PANEL_THICKNESS / 2 : 0, 0 ]} colors={colors} onElementClick={elementClickHandler} /> )} - {/* Habillage dessus — 1 bandeau ou 1 par caisson (block) */} + {/* Habillage dessus — 1 bandeau continu ou 1 par zone (caissons avec dos) */} {topFinishEnabled && ( - splitTopPerBlock ? ( - modulePositions.map((mp, i) => { - const n = modules.length; - const tl = i === 0 ? (sideFinishLeftEnabled ? PANEL_THICKNESS : 0) : PANEL_THICKNESS; - const tr = i === n - 1 ? (sideFinishRightEnabled ? PANEL_THICKNESS : 0) : PANEL_THICKNESS; - const habitW = Math.max(0.001, mp.width + tl + tr); - const cx = mp.center + (tr - tl) / 2; + splitTopPerZone && topSegments ? ( + topSegments.map((sg, i) => { + const isFirst = sg.start === 0; + const isLast = sg.end === modules.length - 1; + const rawW = sg.rawX1 - sg.rawX0; + const rawC = -width / 2 + (sg.rawX0 + sg.rawX1) / 2; + const tl = isFirst ? 0 : PANEL_THICKNESS; + const tr = isLast ? 0 : PANEL_THICKNESS; + const habitW = Math.max(0.001, rawW + tl + tr); + const cx = rawC + (tr - tl) / 2; return ( { - const tl = sideFinishLeftEnabled ? PANEL_THICKNESS : 0; - const tr = sideFinishRightEnabled ? PANEL_THICKNESS : 0; - const habitW = Math.max(0.001, width + tl + tr); - const cx = (tr - tl) / 2; + const habitW = Math.max(0.001, width); + const cx = 0; return ( )} - {/* Dos (back panel) — 8 mm per APEX 4.0 spec */} - + {/* Dos (back panel) — 8 mm per APEX 4.0 spec — par zone (colonne × niveau si empilage) */} + {useModules && !shellOnlyMode ? ( + modules.flatMap((m, i) => { + const leftInset = horizontalInsetAt(i, 'left'); + const rightInset = horizontalInsetAt(i, 'right'); + const innerW = Math.max(0.02, modulePositions[i].width - leftInset - rightInset); + const panelW = innerW + BACK_PANEL_GROOVE_INSERT_M * 2; + const panelCenter = -width / 2 + modulePositions[i].x0 + leftInset + innerW / 2; + const heightsM = modHeightsMm(i).map((h) => h * SCALE); + const backs = modBacks(i); + let accY = -height / 2 + plinthH; + const zones = heightsM.map((hM, k) => { + const zone = { + key: `blk${k}`, + blockIndex: k, + on: backs[k], + h: hM - PANEL_THICKNESS * 2 + BACK_PANEL_GROOVE_INSERT_M * 2, + y: accY + hM / 2 + }; + accY += hM; + return zone; + }); + return zones + .filter((z) => z.on && z.h > 0.02 && !spanCoversBlock(i, z.blockIndex)) + .map((z) => ( + + )); + }) + ) : ( + anyBack && ( + + ) + )} {/* Outline to keep wardrobe edges readable through niche wall */} {config?.installationType === 'niche' && ( )} - {/* Fileurs (si encastré) */} + {/* Fileurs de niche surdimensionnés à 50 mm, recoupés sur place au profil des murs. */} {isNiche && ( <> + )} - {/* Separators between modules — en block: double montant + hauteur habillage */} + {/* Separators between modules — jonction caisson↔caisson: double montant; sinon panneau simple */} {!shellOnlyMode && useModules && onResizeModules ? separatorPositions.flatMap((pos, i) => { - const jointHalf = (BLOCK_JOINT_GAP_M + PANEL_THICKNESS) / 2; - const xs = isBlockMode - ? [pos.x - jointHalf, pos.x + jointHalf] - : [pos.x]; + const style = boundaryAnyBlock(pos.index) ? sepStyleBlock : sepStyleSeparations; + const xs = montantXsAt(pos.x, pos.index); return xs.map((xSep, j) => ( { - const jointHalf = (BLOCK_JOINT_GAP_M + PANEL_THICKNESS) / 2; - const xs = isBlockMode - ? [pos.x - jointHalf, pos.x + jointHalf] - : [pos.x]; - return xs.map((xSep, j) => ( - + const style = boundaryAnyBlock(pos.index) ? sepStyleBlock : sepStyleSeparations; + const xs = montantXsAt(pos.x, pos.index); + const visibleSegments = separatorSegmentsFor(pos.index, style); + return xs.flatMap((xSep, j) => visibleSegments.map((segment, segmentIndex) => ( + - )); + ))); }) } + {/* Multi-column horizontal spans: shared panels/back and one span-wide selection target. */} + {!shellOnlyMode && resolvedHorizontalSpans.map((resolved) => { + const firstIndex = resolved.moduleIndexes[0]; + const lastIndex = resolved.moduleIndexes[resolved.moduleIndexes.length - 1]; + const leftInset = horizontalInsetAt(firstIndex, 'left'); + const rightInset = horizontalInsetAt(lastIndex, 'right'); + const rawLeft = -width / 2 + resolved.leftMm * SCALE; + const rawRight = -width / 2 + resolved.rightMm * SCALE; + const panelLeft = rawLeft + leftInset; + const panelRight = rawRight - rightInset; + const panelWidth = Math.max(0.02, panelRight - panelLeft); + const renderPanelWidth = panelWidth + JOIN_OVERLAP_M * 2; + const centerX = (panelLeft + panelRight) / 2; + const backLeft = + rawLeft + (firstIndex === 0 ? PANEL_THICKNESS : PANEL_THICKNESS / 2); + const backRight = + rawRight - (lastIndex === modules.length - 1 ? PANEL_THICKNESS : PANEL_THICKNESS / 2); + const backWidth = Math.max(0.02, backRight - backLeft); + const backCenterX = (backLeft + backRight) / 2; + const bodyBottomY = -height / 2 + plinthH; + const bottomY = bodyBottomY + resolved.bottomMm * SCALE; + const topY = bodyBottomY + resolved.topMm * SCALE; + const spanHeight = Math.max(0.02, topY - bottomY); + const hasOwnBottom = resolved.bottomMm > 0.5; + const hasOwnTop = resolved.topMm < bodyH / SCALE - 0.5; + const selected = String(selectedHorizontalSpanId) === String(resolved.span.id); + const clickSpan = (e) => { + if (e.delta > 5) return; + e.stopPropagation(); + onHorizontalSpanClick?.(resolved.span.id); + }; + const doorMode = resolved.span.doorMode ?? HORIZONTAL_SPAN_DOOR_MODES.none; + const spanDoorClick = + activeStep === 2 && onHorizontalSpanClick + ? () => onHorizontalSpanClick(resolved.span.id) + : elementClickHandler; + const makeSpanDoor = ({ + key, + doorCenterX, + doorWidth, + hingeModuleIndex, + hingeSideOverride + }) => { + const doorHeight = Math.max(0.04, spanHeight - 2 * FACADE_REVEAL_M); + const facadeWidth = Math.max(0.04, doorWidth - 2 * FACADE_REVEAL_M); + const doorCount = Math.max(1, modules.length); + const overrideSides = + Array.isArray(hw.doorHingeSides) && hw.doorHingeSides.length === doorCount + ? hw.doorHingeSides + : null; + const hingeSide = + hingeSideOverride + ? hingeSideOverride + : overrideSides + ? (overrideSides[hingeModuleIndex] === 'right' ? 'right' : 'left') + : getDoorHingeSide(hingeModuleIndex, doorCount, hw); + const angle = hingeSide === 'left' ? -doorOpenAngle : doorOpenAngle; + const hingeX = hingeSide === 'left' + ? doorCenterX - facadeWidth / 2 + : doorCenterX + facadeWidth / 2; + const localX = hingeSide === 'left' ? facadeWidth / 2 : -facadeWidth / 2; + + return ( + + + + ); + }; + + return ( + + {hasOwnBottom && ( + + + {resolved.span.hasBack && ( + + )} + + )} + {hasOwnTop && ( + + + {resolved.span.hasBack && ( + + )} + + )} + {resolved.span.hasBack && spanHeight > PANEL_THICKNESS * 2 && ( + + )} + { + if (activeStepRef.current !== 2) return; + clickSpan(e); + }} + > + + + + {showDoors && doorMode === HORIZONTAL_SPAN_DOOR_MODES.perColumn && ( + <> + {makeSpanDoor({ + key: `horizontal-span-door-${resolved.span.id}-left`, + doorCenterX: rawLeft + (rawRight - rawLeft) / 4, + doorWidth: (rawRight - rawLeft) / 2, + hingeModuleIndex: firstIndex, + hingeSideOverride: 'left' + })} + {makeSpanDoor({ + key: `horizontal-span-door-${resolved.span.id}-right`, + doorCenterX: rawLeft + 3 * (rawRight - rawLeft) / 4, + doorWidth: (rawRight - rawLeft) / 2, + hingeModuleIndex: lastIndex, + hingeSideOverride: 'right' + })} + + )} + {showDoors && doorMode === HORIZONTAL_SPAN_DOOR_MODES.single && ( + makeSpanDoor({ + key: `horizontal-span-door-${resolved.span.id}-single`, + doorCenterX: (rawLeft + rawRight) / 2, + doorWidth: rawRight - rawLeft, + hingeModuleIndex: firstIndex + }) + )} + {activeStep === 2 && selected && ( + null} + > + + + + + )} + + ); + })} + {/* Width labels -- show during resize or hover */} {!shellOnlyMode && !suppressHtmlOverlays && modules.length > 0 && (resizeState !== null || hoveredModule !== null) && ( <> @@ -926,12 +2041,15 @@ function WardrobeMesh({ )} {modules[resizeState.separatorIndex + 1] && ( )} @@ -945,16 +2063,51 @@ function WardrobeMesh({ )} + {shellOnlyMode && useModules && ( + + )} + {/* Module contents: doors, shelves, rods, drawers + click-to-select */} {!shellOnlyMode && useModules && modules.map((module, moduleIndex) => { const pos = modulePositions[moduleIndex]; if (!pos) return null; const moduleCenter = pos.center; const moduleWidth = pos.width; + const innerLeftInset = horizontalInsetAt(moduleIndex, 'left'); + const innerRightInset = horizontalInsetAt(moduleIndex, 'right'); + const innerWidth = Math.max(0.02, moduleWidth - innerLeftInset - innerRightInset); + const innerCenter = moduleCenter + (innerLeftInset - innerRightInset) / 2; const shelfCount = module.shelves || 0; const drawersCount = module.drawers || 0; const isSelected = selectedModuleIndex === moduleIndex; - const needsMiddleFix = minShelvesForStructuralHeight(config.heightMm || 2400) > 0; + const blockHeightsM = modHeightsMm(moduleIndex).map((h) => h * SCALE); + const blockZones = []; + let blockOffsetY = -height / 2 + plinthH; + blockHeightsM.forEach((blockHeight, blockIndex) => { + blockZones.push({ + blockIndex, + height: blockHeight, + centerY: blockOffsetY + blockHeight / 2 + }); + blockOffsetY += blockHeight; + }); + const selectedBlock = blockZones[Math.min(Math.max(0, selectedBlockIndex || 0), blockZones.length - 1)]; + const blockIndexAtY = (y) => { + const match = blockZones.find((zone) => ( + y >= zone.centerY - zone.height / 2 && y <= zone.centerY + zone.height / 2 + )); + return match?.blockIndex ?? 0; + }; + // Colonne empilée: la jonction bas/haut est structurelle → pas de fenêtre structurelle à imposer. + const moduleStack = moduleStackInfo(module, config); + const needsMiddleFix = !moduleStack && minShelvesForStructuralHeight(config.heightMm || 2400) > 0; const bodyHmm = (Number(config.heightMm) || 0) - (Number(config.plinthHeightMm) || 0); const bodyMidMm = bodyHmm / 2; const structuralMinMm = bodyMidMm - STRUCTURAL_WINDOW_HALF_MM; @@ -963,7 +2116,8 @@ function WardrobeMesh({ shelfCount, heightMm: config.heightMm, plinthHeightMm: config.plinthHeightMm ?? 100, - shelfPositionsMm: module.shelfPositionsMm + shelfPositionsMm: module.shelfPositionsMm, + stack: moduleStack }); const hasShelfInStructuralWindow = !needsMiddleFix || @@ -971,39 +2125,63 @@ function WardrobeMesh({ const structuralZoneCenterY = -height / 2 + plinthH + (bodyMidMm / 1000); const structuralZoneHeight = (STRUCTURAL_WINDOW_HALF_MM * 2) / 1000; - const moduleSpanForZone = Math.max(0.02, moduleWidth - PANEL_THICKNESS * 2); + const moduleSpanForZone = innerWidth; const depthSpanForZone = Math.max(0.02, depth * 0.9); const shelfCenterYs = shelfCenterYsMeters({ shelfCount, heightM: height, plinthHM: plinthH, shelfGapMm: module.shelfGapMm, - shelfPositionsMm: module.shelfPositionsMm, + shelfPositionsMm: resolvedShelfPositionsMm, }); return ( - {/* Invisible click/hover area for module selection */} - {activeStep !== 2 && ( + {/* Invisible volume filling the block. It only receives clicks on Colonnes. + On Matériaux the raycast is skipped so the click hits the piece inside (dos, étagère…). */} + {blockZones.map((zone) => ( setHoveredModule(moduleIndex)} + key={`block-hit-${moduleIndex}-${zone.blockIndex}`} + position={[moduleCenter, zone.centerY, 0]} + raycast={columnSelectRaycast} + onPointerOver={() => { + if (activeStepRef.current !== 2) return; + setHoveredModule(moduleIndex); + }} onPointerOut={() => setHoveredModule(prev => prev === moduleIndex ? null : prev)} onClick={(e) => { + if (activeStepRef.current !== 2) return; + // Ignore clicks that end a drag gesture (shelf/separator resize) + if (e.delta > 5) return; e.stopPropagation(); - if (moduleClickHandler) moduleClickHandler(moduleIndex); + if (moduleClickHandler) moduleClickHandler(moduleIndex, zone.blockIndex); }} > - - + + - )} + ))} - {/* Selection highlight */} - {isSelected && ( - - - + {/* Highlight only on Colonnes. On Matériaux the block must not stay marked as selected. */} + {activeStep === 2 && isSelected && selectedBlock && ( + null} + > + + + )} @@ -1020,29 +2198,19 @@ function WardrobeMesh({ )} - {/* Portes battantes per module */} + {/* Portes battantes per module — 1 porte par block avec porte (block sans porte = niche ouverte) */} {showDoors && !isSliding && ( (() => { - const doorW = moduleWidth * 0.96; + const doorW = Math.max(0.04, moduleWidth - 2 * FACADE_REVEAL_M); const drawerCount = Math.max(0, Number(module?.drawers) || 0); const drawerHeightsMm = Array.isArray(hw.drawerHeightsMm) ? hw.drawerHeightsMm : [90, 128, 177, 240]; - const drawerGapMm = 8; - const drawerStackMmRaw = - drawerCount > 0 - ? drawerHeightsMm - .slice(0, Math.min(drawerCount, drawerHeightsMm.length)) - .reduce((s, v) => s + (Number(v) || 0), 0) + - Math.max(0, drawerCount - 1) * drawerGapMm - : 0; + const drawerStackMmRaw = drawerStackHeightMm(drawerCount, drawerHeightsMm); const drawerStackM = Math.min(bodyH * 0.78, Math.max(0, drawerStackMmRaw) * SCALE); /** Caché : porte pleine hauteur ; Visible : porte au-dessus du bandeau tiroirs. */ - const drawerHidden = ['hidden', 'hidden_with_handle'].includes(hw.drawerType || 'visible'); + const drawerHidden = ['hidden', 'hidden_with_handle'].includes( + module.drawerType || hw.drawerType || 'visible' + ); const splitDoorOverDrawers = drawerCount > 0 && !drawerHidden; - const drawerStackForDoor = splitDoorOverDrawers ? drawerStackM : 0; - const doorH = Math.max(0.12, bodyH * 0.96 - drawerStackForDoor); - const doorCenterY = splitDoorOverDrawers - ? plinthH / 2 + drawerStackM / 2 - : plinthH / 2; const doorCount = Math.max(1, modules.length); const overrideSides = Array.isArray(hw.doorHingeSides) && hw.doorHingeSides.length === doorCount @@ -1058,21 +2226,43 @@ function WardrobeMesh({ ? moduleCenter - doorW / 2 : moduleCenter + doorW / 2; const localX = hingeSide === 'left' ? doorW / 2 : -doorW / 2; - return ( - - - - ); + + const blockDoors = moduleBlockDoors(module, config, blockHeightsM.length); + const out = []; + let startY = 0; // depuis le bas de caisse (m) + for (let k = 0; k < blockHeightsM.length; k++) { + const hk = blockHeightsM[k]; + const blockStart = startY; + startY += hk; + if (!blockDoors[k] || spanCoversBlock(moduleIndex, k)) continue; + // Tiroirs (partent du bas): la porte du block bas commence au-dessus du bandeau tiroirs. + const drawerStackForDoor = k === 0 && splitDoorOverDrawers ? drawerStackM : 0; + const doorH = Math.max( + 0.08, + hk - 2 * FACADE_REVEAL_M - drawerStackForDoor - (drawerStackForDoor > 0 ? 2 * FACADE_REVEAL_M : 0) + ); + const doorCenterY = -height / 2 + plinthH + blockStart + hk / 2 + drawerStackForDoor / 2; + out.push( + + + + ); + } + return out; })() )} @@ -1098,6 +2288,7 @@ function WardrobeMesh({ <> {Array.from({ length: shelfCount }).map((_, shelfIndex) => { const shelfY = shelfCenterYs[shelfIndex]; + if (spanCoversY(moduleIndex, shelfY)) return null; const isStructural = needsMiddleFix && shelfIndex === structuralIdx; const shelfKey = `${moduleIndex}-${shelfIndex}`; const isShelfHovered = hoveredShelfKey === shelfKey; @@ -1108,15 +2299,15 @@ function WardrobeMesh({ {activeStep === 2 && ( handleShelfPointerDown(moduleIndex, shelfIndex, isStructural, e)} + onClick={(e) => e.stopPropagation()} > 0 && ( (() => { const drawerHeightsMm = Array.isArray(hw.drawerHeightsMm) ? hw.drawerHeightsMm : [90, 128, 177, 240]; - const drawerGapMm = 8; - const drawerStackMmRaw = - drawersCount > 0 - ? drawerHeightsMm - .slice(0, Math.min(drawersCount, drawerHeightsMm.length)) - .reduce((s, v) => s + (Number(v) || 0), 0) + - Math.max(0, drawersCount - 1) * drawerGapMm - : 0; + const drawerStackMmRaw = drawerStackHeightMm(drawersCount, drawerHeightsMm); const drawerStackM = Math.min(bodyH * 0.78, Math.max(0, drawerStackMmRaw) * SCALE); const yFixed = (-height / 2 + plinthH + PANEL_THICKNESS) + drawerStackM + PANEL_THICKNESS / 2; + if (spanCoversY(moduleIndex, yFixed)) return null; return ( ); })() )} {/* Drawers */} - {drawersCount > 0 && ( + {drawersCount > 0 && !spanCoversBlock(moduleIndex, 0) && ( )} {/* Rod (penderie) */} - {module.hasRod && ( - - )} + {module.hasRod && (() => { + const zone = moduleHangingZoneMm(module, config); + const rodY = -height / 2 + plinthH + zone.rodMm * SCALE; + if (spanCoversY(moduleIndex, rodY)) return null; + return ( + + ); + })()} ); })} {/* Portes coulissantes (sliding) */} {showDoors && isSliding && ( - + resolvedHorizontalSpans.length === 0 ? ( + + ) : (() => { + const bodyBottomY = -height / 2 + plinthH; + const xEdges = [ + -width / 2, + width / 2, + ...resolvedHorizontalSpans.flatMap((resolved) => [ + -width / 2 + resolved.leftMm * SCALE, + -width / 2 + resolved.rightMm * SCALE + ]) + ].sort((a, b) => a - b); + const yEdges = [ + bodyBottomY, + bodyBottomY + bodyH, + ...resolvedHorizontalSpans.flatMap((resolved) => [ + bodyBottomY + resolved.bottomMm * SCALE, + bodyBottomY + resolved.topMm * SCALE + ]) + ].sort((a, b) => a - b); + const cells = []; + for (let xi = 0; xi < xEdges.length - 1; xi++) { + const x0 = xEdges[xi]; + const x1 = xEdges[xi + 1]; + if (x1 - x0 < 0.001) continue; + for (let yi = 0; yi < yEdges.length - 1; yi++) { + const y0 = yEdges[yi]; + const y1 = yEdges[yi + 1]; + if (y1 - y0 < 0.001) continue; + const midXmm = ((x0 + x1) / 2 + width / 2) / SCALE; + const midYmm = ((y0 + y1) / 2 - bodyBottomY) / SCALE; + const covered = resolvedHorizontalSpans.some((resolved) => ( + midXmm >= resolved.leftMm - 0.5 && + midXmm <= resolved.rightMm + 0.5 && + midYmm >= resolved.bottomMm - 0.5 && + midYmm <= resolved.topMm + 0.5 + )); + if (!covered) cells.push({ x0, x1, y0, y1 }); + } + } + return cells.map((cell, index) => ( + + )); + })() )} {/* Module content is rendered above in the module loop */} @@ -1246,7 +2502,7 @@ function WardrobeMesh({ dimGrip.crossW, dimGrip.crossW, ]} - label="L" + label={dimensionChipLabel('L', 'widthMm')} cursor="ew-resize" tintColor="#2563eb" chipOffset={[0, 0.07, 0]} @@ -1261,7 +2517,7 @@ function WardrobeMesh({ Math.max(dimGrip.h + 0.05, MIN_EDGE_GRIP_M + 0.04), dimGrip.crossH, ]} - label="H" + label={dimensionChipLabel('H', 'heightMm')} cursor="ns-resize" tintColor="#9333ea" chipOffset={[0.09, 0, 0]} @@ -1276,7 +2532,7 @@ function WardrobeMesh({ dimGrip.crossD, Math.max(dimGrip.d + 0.05, MIN_EDGE_GRIP_M + 0.06), ]} - label="P" + label={dimensionChipLabel('P', 'depthMm')} cursor="ew-resize" tintColor="#0891b2" chipOffset={[0.09, 0, 0.09]} @@ -1338,26 +2594,16 @@ function InteractionBlocker({ active }) { /* =================================================================== CameraController - exposes zoom/reset via ref =================================================================== */ -const CameraController = forwardRef(({ roomConfig, config, modules, activeStep }, ref) => { +const CameraController = forwardRef(({ roomConfig, config, modules, activeStep, interactionActive = false }, ref) => { const { camera, gl } = useThree(); const controlsRef = useRef(); const isDimensionStep = activeStep === 1; const snapAnimRef = useRef(null); - const cameraTarget = useMemo(() => { - if (roomConfig) { - return [0, roomConfig.height / 2, 0]; - } - return [0, (config.heightMm || 2400) * SCALE * 0.5, 0]; - }, [roomConfig, config]); - - // Keep OrbitControls target in sync as dimensions/modules change. - useEffect(() => { - if (!controlsRef.current) return; - const [x, y, z] = cameraTarget; - controlsRef.current.target.set(x, y, z); - controlsRef.current.update(); - }, [cameraTarget]); + const targetY = roomConfig ? roomConfig.height / 2 : (config.heightMm || 2400) * SCALE * 0.5; + const cameraTarget = useMemo(() => [0, targetY, 0], [targetY]); + /** Initial target only: later changes are animated, never snapped through the prop. */ + const [initialTarget] = useState(cameraTarget); const totalWidthMm = useMemo(() => { if (Array.isArray(modules) && modules.length > 0) { @@ -1380,11 +2626,141 @@ const CameraController = forwardRef(({ roomConfig, config, modules, activeStep } return Math.max(1.2, dist * 1.25); }, [roomConfig, totalWidthMm, config.heightMm, config.depthMm]); + // Zoom limits stay frozen while a handle is dragged so the view never moves under the cursor. + const frozenFitRef = useRef(null); + if (interactionActive && frozenFitRef.current === null) frozenFitRef.current = fitDistance; + if (!interactionActive) frozenFitRef.current = null; + const effectiveFitDistance = frozenFitRef.current ?? fitDistance; + const getDefaultDistance = useCallback( - () => fitDistance, - [fitDistance] + () => effectiveFitDistance, + [effectiveFitDistance] ); + const box = useMemo(() => ({ + w: totalWidthMm * SCALE, + h: (config.heightMm || 2400) * SCALE, + d: (config.depthMm || 600) * SCALE, + }), [totalWidthMm, config.heightMm, config.depthMm]); + + /** Distance along the current view direction at which the whole carcass fits the viewport. */ + const distanceToFit = useCallback((target) => { + camera.updateMatrixWorld(); + const right = new THREE.Vector3(); + const up = new THREE.Vector3(); + const back = new THREE.Vector3(); + camera.matrixWorld.extractBasis(right, up, back); + const tanV = Math.tan(THREE.MathUtils.degToRad(camera.fov || 45) / 2) * 0.86; + const tanH = tanV * (camera.aspect || 1); + let need = 0; + const p = new THREE.Vector3(); + for (const sx of [-1, 1]) for (const sy of [0, 1]) for (const sz of [-1, 1]) { + p.set(sx * box.w / 2, sy * box.h, sz * box.d / 2).sub(target); + const cz = p.dot(back); + need = Math.max(need, cz + Math.abs(p.dot(right)) / tanH, cz + Math.abs(p.dot(up)) / tanV); + } + return need; + }, [camera, box]); + + const distanceLimits = useCallback(() => { + const fit = getDefaultDistance(); + return isDimensionStep + ? [Math.max(0.1, fit * 0.8), 30] + : [fit * 0.35, fit * 2.6]; + }, [getDefaultDistance, isDimensionStep]); + + /** Smoothly moves target/distance while keeping the user's orbit angle. */ + const animateFraming = useCallback(({ toTarget, toDistance, duration = 0.45 }) => { + const controls = controlsRef.current; + if (!controls) return; + const fromTarget = controls.target.clone(); + const offset = camera.position.clone().sub(fromTarget); + const fromDistance = offset.length(); + const [minD, maxD] = distanceLimits(); + snapAnimRef.current = { + t: 0, + duration, + dir: offset.normalize(), + fromTarget, + toTarget: toTarget.clone(), + fromDistance, + toDistance: Math.min(maxD, Math.max(minD, toDistance ?? fromDistance)), + }; + }, [camera, distanceLimits]); + + useFrame((_, dt) => { + const anim = snapAnimRef.current; + const controls = controlsRef.current; + if (!anim || !controls) return; + anim.t = Math.min(1, anim.t + dt / anim.duration); + const k = 1 - Math.pow(1 - anim.t, 3); + controls.target.lerpVectors(anim.fromTarget, anim.toTarget, k); + const dist = anim.fromDistance + (anim.toDistance - anim.fromDistance) * k; + camera.position.copy(controls.target).addScaledVector(anim.dir, dist); + const [minD, maxD] = distanceLimits(); + controls.minDistance = Math.min(minD, dist); + controls.maxDistance = Math.max(maxD, dist); + controls.update(); + if (anim.t >= 1) snapAnimRef.current = null; + }); + + // Limits are applied imperatively and loosened to the current distance so tightening them never snaps the camera. + useEffect(() => { + const controls = controlsRef.current; + if (!controls || snapAnimRef.current) return; + const [minD, maxD] = distanceLimits(); + const current = camera.position.distanceTo(controls.target); + controls.minDistance = Math.min(minD, current); + controls.maxDistance = Math.max(maxD, current); + }, [distanceLimits, camera]); + + const cancelSnap = useCallback(() => { + snapAnimRef.current = null; + }, []); + + const placeDefaultView = useCallback(() => { + const controls = controlsRef.current; + if (!controls) return; + cancelSnap(); + const target = new THREE.Vector3(...cameraTarget); + controls.target.copy(target); + camera.up.set(0, 1, 0); + if (roomConfig) { + const roomSize = Math.max(roomConfig.width, roomConfig.length); + camera.position.set(roomSize * 0.8, roomSize * 0.5, roomSize * 0.8); + } else { + const dir = defaultViewDirection(); + camera.position.copy(target).addScaledVector(dir, 2); + camera.lookAt(target); + camera.updateMatrixWorld(); + camera.position.copy(target).addScaledVector(dir, distanceToFit(target)); + } + const [minD, maxD] = distanceLimits(); + const current = camera.position.distanceTo(controls.target); + controls.minDistance = Math.min(minD, current); + controls.maxDistance = Math.max(maxD, current); + controls.update(); + }, [camera, cameraTarget, roomConfig, cancelSnap, distanceToFit, distanceLimits]); + + const fitView = useCallback(() => { + const target = new THREE.Vector3(...cameraTarget); + animateFraming({ toTarget: target, toDistance: roomConfig ? undefined : distanceToFit(target) }); + }, [animateFraming, cameraTarget, distanceToFit, roomConfig]); + + // After a size change (deferred until the drag ends), re-centre and back off only if the carcass no longer fits. + const framingKey = `${targetY}|${box.w}|${box.h}|${box.d}`; + const lastFramingKeyRef = useRef(framingKey); + useEffect(() => { + if (interactionActive || lastFramingKeyRef.current === framingKey) return; + lastFramingKeyRef.current = framingKey; + const controls = controlsRef.current; + if (!controls) return; + const target = new THREE.Vector3(...cameraTarget); + const current = camera.position.distanceTo(controls.target); + const toDistance = roomConfig ? current : Math.max(current, distanceToFit(target)); + animateFraming({ toTarget: target, toDistance }); + }, [framingKey, interactionActive, cameraTarget, camera, roomConfig, distanceToFit, animateFraming]); + const setView = useCallback((view) => { if (!controlsRef.current) return; const target = controlsRef.current.target; @@ -1402,34 +2778,21 @@ const CameraController = forwardRef(({ roomConfig, config, modules, activeStep } controlsRef.current.update(); }, [camera, getDefaultDistance, config]); - const cancelSnap = useCallback(() => { - if (snapAnimRef.current) { - cancelAnimationFrame(snapAnimRef.current.raf); - snapAnimRef.current = null; - } - }, []); - - // Intentionally no snapping rules: keep mouse movement direct. + const size = useThree((s) => s.size); + const didPlaceDefaultRef = useRef(false); + useFrame(() => { + if (didPlaceDefaultRef.current || !controlsRef.current) return; + if (size.width < 2 || size.height < 2) return; + didPlaceDefaultRef.current = true; + placeDefaultView(); + }); useImperativeHandle(ref, () => ({ + fitView, resetView: () => { - if (controlsRef.current) { - cancelSnap(); - const defaultDist = getDefaultDistance(); - if (isDimensionStep) { - camera.position.set(defaultDist * 0.75, defaultDist * 0.7, defaultDist * 0.9); - if (camera.isOrthographicCamera) { - camera.zoom = 85; - camera.updateProjectionMatrix(); - } - } else { - // Default to a consistent 3/4 view - camera.position.set(defaultDist * 0.75, defaultDist * 0.55, defaultDist * 0.75); - } - controlsRef.current.update(); - } + placeDefaultView(); }, - }), [camera, cancelSnap, getDefaultDistance, isDimensionStep]); + }), [fitView, placeDefaultView]); useEffect(() => { if (controlsRef.current) { @@ -1458,9 +2821,7 @@ const CameraController = forwardRef(({ roomConfig, config, modules, activeStep } rotateSpeed={0.55} zoomSpeed={0.9} panSpeed={0.85} - minDistance={isDimensionStep ? Math.max(0.1, getDefaultDistance() * 0.8) : getDefaultDistance() * 0.9} - maxDistance={isDimensionStep ? 30 : getDefaultDistance() * 2.6} - target={cameraTarget} + target={initialTarget} onStart={handleControlStart} onEnd={handleControlEnd} makeDefault @@ -1480,6 +2841,9 @@ function Scene({ onElementClick, onModuleClick, selectedModuleIndex, + selectedBlockIndex, + selectedHorizontalSpanId, + onHorizontalSpanClick, showDoors, onResizeModules, onUpdateModule, @@ -1488,6 +2852,7 @@ function Scene({ activeStep, suppressHtmlOverlays, }) { + const [interactionActive, setInteractionActive] = useState(false); const wardrobeTransform = useMemo(() => { if (roomConfig) { return calculateWardrobeTransform(roomConfig, config); @@ -1499,16 +2864,36 @@ function Scene({ if (roomConfig) return null; if (config?.installationType !== 'niche') return null; const OUTER_WALL_EXTENT_M = 200; // effectively infinite wall planes - const gapSideM = 0.05; // 5cm clearance on sides - const gapTopM = 0; // no clearance on top - const gapBottomM = 0; // no clearance under (sit on floor) - const gapDepthM = 0.05; // 5cm clearance front/back const widthMmRaw = Array.isArray(modules) && modules.length > 0 ? modules.reduce((s, m) => s + (Number(m?.widthMm) || 0), 0) : (config.widthMm || 2400); const widthM = Math.max(0.4, (widthMmRaw || 2400) * SCALE); const heightM = Math.max(1.8, (config.heightMm || 2400) * SCALE); const depthM = Math.max(0.4, (config.depthMm || 600) * SCALE); + const sanitaryGapMm = Math.max(0, Number(config.sanitaryGapMm) || 0); + const niche = config.nicheMeasurements || {}; + const smallestPositive = (values, fallback) => { + const valid = values.map(Number).filter((value) => Number.isFinite(value) && value > 0); + return valid.length > 0 ? Math.min(...valid) : fallback; + }; + const nicheWidthM = smallestPositive( + [niche.widthTopMm, niche.widthMidMm, niche.widthBottomMm], + widthMmRaw + sanitaryGapMm * 2 + ) * SCALE; + const nicheHeightM = smallestPositive( + [niche.heightLeftMm, niche.heightRightMm], + (config.heightMm || 2400) + sanitaryGapMm * 2 + ) * SCALE; + const nicheDepthM = smallestPositive( + [niche.depthTopMm, niche.depthMidMm, niche.depthBottomMm], + (config.depthMm || 600) + sanitaryGapMm * 2 + ) * SCALE; + + const gapSideM = Math.max(0, (nicheWidthM - widthM) / 2); + // The wardrobe sits on the floor, so all remaining vertical clearance is visible above it. + const gapTopM = Math.max(0, nicheHeightM - heightM); + const gapBottomM = 0; + const gapDepthM = Math.max(0, (nicheDepthM - depthM) / 2); const nicheW = widthM + gapSideM * 2; const nicheH = heightM + gapTopM + gapBottomM; @@ -1536,7 +2921,16 @@ function Scene({ leftX: -widthM / 2 - gapSideM, rightX: widthM / 2 + gapSideM, }; - }, [roomConfig, config?.installationType, config?.widthMm, config?.heightMm, config?.depthMm, modules]); + }, [ + roomConfig, + config?.installationType, + config?.widthMm, + config?.heightMm, + config?.depthMm, + config?.sanitaryGapMm, + config?.nicheMeasurements, + modules + ]); const freeBackdrop = useMemo(() => { if (roomConfig) return null; @@ -1546,7 +2940,10 @@ function Scene({ // Place the wall right behind the wardrobe back face. const wallZ = -depthM / 2 - 0.01; const wallY = heightM * 0.6; - return { wallZ, wallY }; + return { + wallZ, + wallY, + }; }, [roomConfig, config?.installationType, config?.depthMm, config?.heightMm]); return ( @@ -1565,7 +2962,7 @@ function Scene({ /> - {/* Niche "alcove" backdrop with 5cm clearance */} + {/* Niche alcove backdrop using the measured installation clearances. */} {/* All backdrop meshes opt out of raycasting: they are decorative only and must never intercept pointer events meant for wardrobe handles. */} {nicheBackdrop && ( @@ -1577,19 +2974,19 @@ function Scene({
{/* back wall (niche sized) */} - null}> + null}> {/* left wall (niche sized) */} - null}> + null}> {/* right wall (niche sized) */} - null}> + null}> @@ -1636,6 +3033,9 @@ function Scene({ onElementClick={onElementClick} onModuleClick={onModuleClick} selectedModuleIndex={selectedModuleIndex} + selectedBlockIndex={selectedBlockIndex} + selectedHorizontalSpanId={selectedHorizontalSpanId} + onHorizontalSpanClick={onHorizontalSpanClick} showDoors={showDoors} onResizeModules={onResizeModules} onUpdateModule={onUpdateModule} @@ -1643,10 +3043,18 @@ function Scene({ onBeginUndoableGesture={onBeginUndoableGesture} activeStep={activeStep} suppressHtmlOverlays={suppressHtmlOverlays} + onInteractionChange={setInteractionActive} />
- + ); } @@ -1720,30 +3128,55 @@ export default function WardrobePreview({ onUndo3d, onRedo3d, selectedModuleIndex, + selectedBlockIndex = 0, + selectedHorizontalSpanId, + onHorizontalSpanClick, suppressHtmlOverlays = false, }) { const containerRef = useRef(null); const controllerRef = useRef(null); const [selectedElement, setSelectedElement] = useState(null); - const [showDoors, setShowDoors] = useState(true); + const [showDoors, setShowDoors] = useState(activeStep !== 2); const isDimensionStep = activeStep === 1; const perspectiveCameraPosition = useMemo(() => { - if (roomConfig) { - const roomSize = Math.max(roomConfig.width, roomConfig.length); - return [roomSize * 0.8, roomSize * 0.5, roomSize * 0.8]; - } - return [2.5, 1.5, 2.5]; - }, [roomConfig]); + const totalWidthMm = Array.isArray(modules) && modules.length > 0 + ? modules.reduce((sum, mod) => sum + (Number(mod?.widthMm) || 0), 0) || (config.widthMm || 2400) + : (config.widthMm || 2400); + return defaultCameraPosition({ + roomConfig, + totalWidthMm, + heightMm: config.heightMm, + depthMm: config.depthMm, + }); + }, [roomConfig, modules, config.widthMm, config.heightMm, config.depthMm]); const handleResetView = useCallback(() => { controllerRef.current?.resetView(); }, []); + const handleFitView = useCallback(() => { + controllerRef.current?.fitView(); + }, []); + useEffect(() => { - // Re-anchor camera when entering/leaving the dedicated dimension mode. - controllerRef.current?.resetView(); - }, [isDimensionStep]); + const onKeyDown = (e) => { + if (e.key !== 'f' && e.key !== 'F') return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + const el = e.target; + if (el && (el.closest?.('input, textarea, select') || el.isContentEditable)) return; + e.preventDefault(); + controllerRef.current?.fitView(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, []); + + useEffect(() => { + // Columns: hide doors so interior layout is visible; Portes: show them. + if (activeStep === 2) setShowDoors(false); + else if (activeStep === 3) setShowDoors(true); + }, [activeStep]); const handleToggleDoors = useCallback(() => { setShowDoors(prev => !prev); @@ -1756,13 +3189,16 @@ export default function WardrobePreview({ } }, [onElementClick]); - // All element types that can be color-picked (including back panel) + // All element types that can be color-picked (back panel only when enabled) + const hasBackOption = (modules && modules.length > 0) + ? modules.some((m) => (typeof m?.hasBack === 'boolean' ? m.hasBack : config?.hasBack !== false)) + : config?.hasBack !== false; const PICKABLE_ELEMENTS = useMemo(() => [ { type: 'portes', label: 'Portes' }, { type: 'cotes', label: 'Côtés' }, { type: 'dessus', label: 'Dessus' }, { type: 'fond', label: 'Fond (bas)' }, - { type: 'dos', label: 'Dos (arrière)' }, + ...(hasBackOption ? [{ type: 'dos', label: 'Dos (arrière)' }] : []), { type: 'plintheBas', label: 'Plinthe' }, { type: 'bandeauHaut', label: 'Bandeau haut' }, { type: 'panneauxInterieurs', label: 'Panneaux int.' }, @@ -1772,7 +3208,7 @@ export default function WardrobePreview({ { type: 'renfortsTiroir', label: 'Renforts tiroir' }, { type: 'tiroirs', label: 'Tiroirs' }, { type: 'penderie', label: 'Tringle' }, - ], []); + ], [hasBackOption]); const handleElementSelect = useCallback((e) => { const val = e.target.value; @@ -1785,7 +3221,7 @@ export default function WardrobePreview({ e.target.value = ''; }, [PICKABLE_ELEMENTS, handleElementClick]); - const showDoorToggle = activeStep === 2 || activeStep === 3 || activeStep === 6; + const showDoorToggle = activeStep === 2 || activeStep === 3 || activeStep === 4 || activeStep === 6; const showColorSelector = activeStep === 4 || activeStep === 6; const showReset = isDimensionStep || activeStep === 2 || activeStep === 6; const show3dUndo = typeof onUndo3d === 'function'; @@ -1806,6 +3242,9 @@ export default function WardrobePreview({ )} {/* Zoom Controls */}
+ {showReset && ( )} @@ -1843,7 +3282,7 @@ export default function WardrobePreview({ onClick={handleToggleDoors} title={showDoors ? 'Masquer les portes' : 'Afficher les portes'} > - {showDoors ? '\u{1F6AA}' : '\u{1F441}\uFE0F'} + )} @@ -1868,7 +3307,6 @@ export default function WardrobePreview({ Chargement 3D…
}> bodyBottom - mm; + const modules = cfg.modules || []; + let x = 0; + + return ( + + ); +} + +export default function LayoutPresets({ wc }) { + const { config } = wc; + const [presets, setPresets] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [naming, setNaming] = useState(false); + const [name, setName] = useState(''); + const [saving, setSaving] = useState(false); + + useEffect(() => { + fetchLayoutPresets() + .then(setPresets) + .catch((err) => setError(errorMessage(err))) + .finally(() => setLoading(false)); + }, []); + + const autoProposal = useMemo(() => proposeLayout(config), [config]); + const isNiche = config.installationType === 'niche'; + const follow = wc.layoutFollow; + const autoActive = !!follow && (follow.mode !== 'preset' || follow.fellBack); + const followedPreset = follow?.mode === 'preset' ? presets.find((p) => p.id === follow.presetId) : null; + const hint = !follow + ? 'Colonnes personnalisées: choisissez une disposition pour suivre à nouveau les dimensions.' + : follow.mode === 'preset' && follow.fellBack + ? `« ${followedPreset?.name || 'Disposition'} » ne convient pas à ces cotes: proposition auto en attendant.` + : follow.mode === 'preset' + ? `« ${followedPreset?.name || 'Disposition'} » s\u2019adapte à vos dimensions (aperçu en transparence).` + : isNiche + ? 'La proposition auto s\u2019adapte aux cotes de la niche (aperçu en transparence).' + : 'La proposition auto suit vos dimensions (aperçu en transparence).'; + + const handleSave = async (event) => { + event.preventDefault(); + const trimmed = name.trim(); + if (!trimmed) return; + setSaving(true); + setError(''); + try { + const preset = await saveLayoutPreset(trimmed, pickLayoutPreset(config)); + setPresets((current) => [...current, preset]); + setName(''); + setNaming(false); + } catch (err) { + setError(errorMessage(err)); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (preset) => { + if (!window.confirm(`Retirer la disposition « ${preset.name} » ?`)) return; + setError(''); + try { + await deleteLayoutPreset(preset.id); + setPresets((current) => current.filter((p) => p.id !== preset.id)); + } catch (err) { + setError(errorMessage(err)); + } + }; + + return ( +
+
+

Dispositions types

+ {hint} +
+ + {error &&

{error}

} + +
+
+ +
+ {loading &&

Chargement…

} + {presets.filter((p) => p.config).map((preset) => { + const cols = (preset.config.modules || []).length; + const active = follow?.mode === 'preset' && follow.presetId === preset.id && !follow.fellBack; + return ( +
+ + +
+ ); + })} + + {naming ? ( +
+ setName(e.target.value)} + placeholder="Nom de la disposition" + className={styles.saveInput} + autoFocus + onKeyDown={(e) => { if (e.key === 'Escape') setNaming(false); }} + /> +
+ + +
+
+ ) : ( + + )} +
+
+ ); +} diff --git a/client/src/components/configurator/LayoutPresets.module.css b/client/src/components/configurator/LayoutPresets.module.css new file mode 100644 index 0000000..827aeda --- /dev/null +++ b/client/src/components/configurator/LayoutPresets.module.css @@ -0,0 +1,258 @@ +.presets { + margin-top: 0.5rem; + border: 1px solid var(--border); + background: var(--bg-card); + border-radius: 8px; + padding: 0.55rem 0.6rem 0.6rem; +} + +.header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 0.45rem; +} + +.title { + margin: 0; + font-size: 0.8125rem; + font-weight: 600; + color: var(--text); +} + +.hint { + font-size: 0.72rem; + color: var(--text-muted); +} + +.error { + margin: 0 0 0.4rem; + font-size: 0.75rem; + color: #b91c1c; +} + +.empty { + margin: 0; + font-size: 0.75rem; + color: var(--text-muted); +} + +.strip { + display: flex; + gap: 0.5rem; + overflow-x: auto; + padding-bottom: 0.2rem; + scroll-snap-type: x proximity; +} + +.card { + position: relative; + flex: 0 0 132px; + scroll-snap-align: start; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--bg); + transition: border-color 0.15s, background 0.15s; +} + +.card:hover { + border-color: color-mix(in srgb, var(--action) 50%, var(--border)); +} + +.cardActive { + border-color: var(--action); + background: color-mix(in srgb, var(--action) 8%, var(--bg)); +} + +.cardMain { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0.2rem; + width: 100%; + padding: 0.45rem 0.45rem 0.4rem; + border: none; + background: none; + cursor: pointer; + text-align: left; + color: inherit; +} + +.cardName { + font-size: 0.75rem; + font-weight: 600; + line-height: 1.2; +} + +.autoBadge { + display: inline-block; + margin-left: 0.2rem; + padding: 0.05rem 0.3rem; + border-radius: 3px; + background: var(--action); + color: #fff; + font-size: 0.6rem; + font-weight: 700; + vertical-align: middle; +} + +.cardMeta { + font-size: 0.68rem; + color: var(--text-muted); +} + +.cardDelete { + position: absolute; + top: 4px; + right: 4px; + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + border: 1px solid var(--border); + border-radius: 50%; + background: var(--bg-card); + color: var(--text-muted); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s; +} + +.card:hover .cardDelete, +.cardDelete:focus-visible { + opacity: 1; +} + +.cardDelete:hover { + color: #b91c1c; + border-color: #fecaca; +} + +.thumb { + width: 100%; + height: 78px; + display: block; +} + +.thumbBody { + fill: color-mix(in srgb, var(--bg-card) 80%, #c4a574); + stroke: var(--text-muted); + stroke-width: 1.4; + vector-effect: non-scaling-stroke; +} + +.thumbPlinth { + fill: var(--text-muted); + opacity: 0.45; +} + +.thumbOpen { + fill: var(--bg); + opacity: 0.7; +} + +.thumbPanel { + stroke: var(--text-muted); + stroke-width: 1.4; + vector-effect: non-scaling-stroke; +} + +.thumbShelf { + stroke: var(--text-muted); + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.thumbDrawer { + fill: var(--bg-card); + stroke: var(--text-muted); + stroke-width: 0.9; + vector-effect: non-scaling-stroke; +} + +.thumbHandle { + stroke: var(--text-muted); + stroke-width: 1.2; + vector-effect: non-scaling-stroke; +} + +.thumbRod { + stroke: var(--action); + stroke-width: 1.6; + stroke-linecap: round; + vector-effect: non-scaling-stroke; +} + +.addCard { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.35rem; + min-height: 128px; + padding: 0.5rem; + border-style: dashed; + color: var(--text-muted); + font-size: 0.72rem; + text-align: center; + cursor: pointer; +} + +.addCard:hover { + color: var(--action); +} + +.saveCard { + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.4rem; + flex-basis: 170px; + padding: 0.5rem; +} + +.saveInput { + width: 100%; + padding: 0.3rem 0.4rem; + border: 1px solid var(--border); + border-radius: 5px; + font-size: 0.75rem; + background: var(--bg-card); + color: var(--text); +} + +.saveActions { + display: flex; + gap: 0.3rem; +} + +.saveBtn, +.cancelBtn { + flex: 1; + padding: 0.3rem 0.4rem; + border-radius: 5px; + font-size: 0.7rem; + font-weight: 500; + cursor: pointer; + border: 1px solid var(--border); +} + +.saveBtn { + background: var(--action); + border-color: var(--action); + color: #fff; +} + +.saveBtn:disabled { + opacity: 0.55; + cursor: default; +} + +.cancelBtn { + background: var(--bg-card); + color: var(--text); +} diff --git a/client/src/components/configurator/PreviewArea.jsx b/client/src/components/configurator/PreviewArea.jsx index 12bce6b..4df36c7 100644 --- a/client/src/components/configurator/PreviewArea.jsx +++ b/client/src/components/configurator/PreviewArea.jsx @@ -1,14 +1,15 @@ import { useCallback, useEffect } from 'react'; import WardrobePreview from '../WardrobePreview'; import DesignTip from '../DesignTip'; -import { resolveShelfPositionsMm, minShelvesForStructuralHeight } from '../../constants/wardrobe'; +import LayoutPresets from './LayoutPresets'; +import { resolveShelfPositionsMm, minShelvesForStructuralHeight, moduleStackInfo, moduleHangingZoneMm, ROD_CLEAR_HEIGHT_MM, STRUCTURAL_WINDOW_HALF_MM } from '../../constants/wardrobe'; import styles from './PreviewArea.module.css'; export default function PreviewArea({ wc, ui }) { const colorPickEnabled = ui.activeStep === 4 || ui.activeStep === 6; const infoByStep = { - 1: 'Survolez les zones colorees du cadre puis glissez pour regler L/H/P.', - 2: 'Tirez les separateurs pour redimensionner et glissez les rayonnages par la tranche avant.', + 1: 'Glissez les poignees L/H/P : elles suivent le curseur. Echap annule, F recadre la vue.', + 2: 'Tirez les separateurs (aimantes sur largeurs egales, Maj = au mm, Alt = sans aimant, Echap = annuler) et glissez les rayonnages par la tranche avant.', 3: 'Activez ou masquez les portes pour verifier l ouverture.', 4: 'Choisissez un element puis appliquez sa couleur.', 5: 'Verifiez les volumes interieurs pour valider la quincaillerie.', @@ -24,6 +25,9 @@ export default function PreviewArea({ wc, ui }) { const out = []; for (let i = 0; i < mods.length; i++) { const mod = mods[i]; + // Colonne empilée: la jonction bas/haut joue le rôle de rayon structurel. + const stack = moduleStackInfo(mod, wc.config); + if (stack) continue; const shelfCount = Math.max(0, Number(mod?.shelves) || 0); const positions = resolveShelfPositionsMm({ shelfCount, @@ -31,14 +35,19 @@ export default function PreviewArea({ wc, ui }) { plinthHeightMm: plinthMm, shelfPositionsMm: mod?.shelfPositionsMm }); - const hasStructural = positions.some((p) => p >= midMm - 200 && p <= midMm + 200); + const hasStructural = positions.some((p) => p >= midMm - STRUCTURAL_WINDOW_HALF_MM && p <= midMm + STRUCTURAL_WINDOW_HALF_MM); if (!hasStructural) out.push(`Colonne ${i + 1}: placer un rayon dans la zone structurelle (±20 cm).`); } return out; })(); + const hangingWarnings = ui.activeStep !== 2 ? [] : (wc.config.modules || []).flatMap((mod, i) => { + const zone = moduleHangingZoneMm(mod, wc.config); + if (!zone || zone.clearMm >= ROD_CLEAR_HEIGHT_MM.short) return []; + return [`Colonne ${i + 1}: penderie de ${zone.clearMm} mm sous tringle (min. ${ROD_CLEAR_HEIGHT_MM.short} mm vestes, ${ROD_CLEAR_HEIGHT_MM.long} mm manteaux). Empilez la colonne ou retirez un rayon.`]; + }); const handleSetDimension = useCallback((key, valueMm) => { - wc.setDimension(key, valueMm); - }, [wc.setDimension]); + wc.setDimensionWithoutUndo(key, valueMm); + }, [wc.setDimensionWithoutUndo]); useEffect(() => { const onKeyDown = (e) => { @@ -115,10 +124,13 @@ export default function PreviewArea({ wc, ui }) { colors={wc.config.colors} modules={wc.config.modules} onElementClick={colorPickEnabled ? (elementType, label) => ui.openColorPicker(elementType, label) : undefined} - onModuleClick={ui.handleModuleClick} - onUpdateModule={wc.handleColumnUpdate} + onModuleClick={ui.activeStep === 2 ? ui.handleModuleClick : undefined} + onUpdateModule={wc.handleColumnUpdateWithoutUndo} onResizeModules={wc.handleResizeModules} - selectedModuleIndex={ui.expandedColumn} + selectedModuleIndex={ui.activeStep === 2 ? ui.expandedColumn : null} + selectedBlockIndex={ui.expandedBlock} + selectedHorizontalSpanId={ui.activeStep === 2 ? ui.selectedHorizontalSpanId : null} + onHorizontalSpanClick={ui.activeStep === 2 ? ui.handleHorizontalSpanSelect : undefined} onSetDimension={handleSetDimension} onBeginUndoableGesture={wc.pushUndoSnapshot} canUndo3d={wc.canUndo} @@ -130,10 +142,11 @@ export default function PreviewArea({ wc, ui }) {
{infoByStep[ui.activeStep] || 'Manipulez la vue pour controler la configuration.'}
- {structuralWarnings.map((msg) => ( + {[...structuralWarnings, ...hangingWarnings].map((msg) => (
{msg}
))}
+ {ui.activeStep === 1 && } ); diff --git a/client/src/components/configurator/StepColumns.jsx b/client/src/components/configurator/StepColumns.jsx index 6f9997c..be453b3 100644 --- a/client/src/components/configurator/StepColumns.jsx +++ b/client/src/components/configurator/StepColumns.jsx @@ -1,15 +1,47 @@ -import { useState, useRef, useCallback } from 'react'; +import { useState, useRef, useCallback, useEffect } from 'react'; import { COLUMN_TYPES, maxShelfGapMm, SHELF_GAP_MM, minShelvesForStructuralHeight, + minShelvesForColumn, + moduleStackInfo, + moduleBlockHeightsMm, + moduleBlockBacks, + moduleBlockDoors, + moduleBottomBlockMinMm, + minBlocksForBody, + maxBlocksForBody, resolveShelfPositionsMm, system32ShelfPositionsMm, - SYSTEM32_PITCH_MM + SYSTEM32_PITCH_MM, + STRUCTURAL_WINDOW_HALF_MM, + STACK_BLOCK_MM } from '../../constants/wardrobe'; +import { + getModuleVerticalBoundariesMm, + HORIZONTAL_SPAN_DOOR_MODES, + resolveHorizontalSpan, + synchronizeModuleSpanBoundaries, + validateHorizontalSpans +} from '@shared/manufacturing.js'; import s from './steps.module.css'; +const HORIZONTAL_SPAN_DOOR_OPTIONS = [ + { value: HORIZONTAL_SPAN_DOOR_MODES.none, label: 'Sans porte' }, + { value: HORIZONTAL_SPAN_DOOR_MODES.perColumn, label: 'Deux portes, charnières extérieures' }, + { value: HORIZONTAL_SPAN_DOOR_MODES.single, label: 'Une porte pleine largeur' } +]; + +function horizontalSpanDoorLabel(doorMode) { + return HORIZONTAL_SPAN_DOOR_OPTIONS.find((option) => option.value === doorMode)?.label + || HORIZONTAL_SPAN_DOOR_OPTIONS[0].label; +} + +function horizontalSpanBackLabel(hasBack) { + return hasBack ? 'Avec dos' : 'Sans dos'; +} + /** After moving array item from -> to, map a selected index to the new position. */ function mapExpandedIndexAfterReorder(expanded, from, to) { if (expanded === null || expanded === undefined) return expanded; @@ -26,11 +58,19 @@ export default function StepColumns({ wc, ui }) { const { config, numColumns, handleNumColumnsChange, handleAIOptimize, handleColumnTypeChange, - handleColumnLock, handleColumnUpdate, + handleColumnLock, handleColumnUpdate, setAllBacks, setAllBlockCounts, aiOptimizing, aiOptimizeMsg, setAiOptimizeMsg, totalModuleWidth, reorderModules, pushUndoSnapshot, + createHorizontalSpan, updateHorizontalSpan, deleteHorizontalSpan, + addHorizontalSpanUpperBlock, resizeHorizontalSpanUpperBlockHeight, + getHorizontalSpanUpperBlock, } = wc; - const { expandedColumn, setExpandedColumn } = ui; + const { + expandedColumn, setExpandedColumn, expandedBlock, setExpandedBlock, + selectedHorizontalSpanId, setSelectedHorizontalSpanId, + horizontalSpanDraft, setHorizontalSpanDraft, + horizontalSpanMessage, setHorizontalSpanMessage, + } = ui; const [draggingIndex, setDraggingIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(null); @@ -81,60 +121,210 @@ export default function StepColumns({ wc, ui }) { suppressCardClickRef.current = false; return; } + setSelectedHorizontalSpanId(null); + setHorizontalSpanDraft(null); + setHorizontalSpanMessage(''); + setExpandedBlock(0); setExpandedColumn(isActive ? null : i); - }, [setExpandedColumn]); + }, [ + setExpandedBlock, + setExpandedColumn, + setHorizontalSpanDraft, + setHorizontalSpanMessage, + setSelectedHorizontalSpanId, + ]); + + // When a block is selected (3D click or card), bring its options into view. + const detailRef = useRef(null); + useEffect(() => { + if (expandedColumn === null) return; + detailRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, [expandedColumn]); const minShelvesStructural = minShelvesForStructuralHeight(config.heightMm); const hasTallCabinet = minShelvesStructural > 0; const hw = config.hardware || {}; - const drawerDepth = config.effectiveDrawerDepthMm || Math.max(400, Math.min(650, config.depthMm - 10)); + const drawerDepthBase = Math.max( + 350, + Math.round(Math.max(400, Math.min(650, config.depthMm - 10)) / 50) * 50 + ); + + /** Empilage PAR COLONNE — modèle N blocks: mod.blockHeightsMm / mod.blockBacks. */ + const modHeights = (m) => moduleBlockHeightsMm(m, config); + const modBacks = (m) => moduleBlockBacks(m, config); + const backStates = config.modules.map((m) => modBacks(m).every(Boolean)); + const noBackStates = config.modules.map((m) => modBacks(m).every((b) => !b)); + const allWithBack = backStates.length > 0 && backStates.every(Boolean); + const noneWithBack = noBackStates.every(Boolean); + const mixedBack = !allWithBack && !noneWithBack; + + /** Nombre de colonnes non limité: seule la largeur borne (min. 300 mm par colonne). */ + const maxColumns = Math.max(1, Math.floor((config.widthMm || 2400) / 300)); + + /** Empilage vertical: bornes (caisse hors plinthe). Hauteur > panneau max → blocks imposés. */ + const bodyHmm = (Number(config.heightMm) || 0) - (Number(config.plinthHeightMm) || 0); + const stackMinMm = STACK_BLOCK_MM.min; + const minBlocks = minBlocksForBody(bodyHmm); + const maxBlocks = maxBlocksForBody(bodyHmm); + const blockCounts = config.modules.map((m) => modHeights(m).length); + const anyStacked = blockCounts.some((n) => n >= 2); + const allSameCount = blockCounts.length > 0 && blockCounts.every((n) => n === blockCounts[0]); + const mixedStacked = anyStacked && !(allSameCount && blockCounts[0] >= 2); + const horizontalSpans = Array.isArray(config.horizontalSpans) ? config.horizontalSpans : []; + const resolvedSpans = horizontalSpans + .map((span) => resolveHorizontalSpan(span, config)) + .filter(Boolean); + const selectedSpan = horizontalSpans.find( + (span) => String(span.id) === String(selectedHorizontalSpanId) + ) || null; + const selectedResolvedSpan = selectedSpan ? resolveHorizontalSpan(selectedSpan, config) : null; + const selectedUpperBlock = selectedSpan + ? getHorizontalSpanUpperBlock(selectedSpan.id) + : null; + + const beginHorizontalSpan = (moduleIndex, blockIndex) => { + if (config.modules.length < 2) { + setHorizontalSpanMessage('Ajoutez au moins deux colonnes pour créer un block horizontal.'); + return; + } + setSelectedHorizontalSpanId(null); + setHorizontalSpanMessage(''); + setHorizontalSpanDraft({ startModuleIndex: moduleIndex, startBlockIndex: blockIndex, endModuleIndex: null }); + }; + + const cancelHorizontalSpan = () => { + setHorizontalSpanDraft(null); + setHorizontalSpanMessage(''); + }; + + const confirmHorizontalSpan = () => { + const startIndex = Number(horizontalSpanDraft?.startModuleIndex); + const blockIndex = Number(horizontalSpanDraft?.startBlockIndex); + const endIndex = Number(horizontalSpanDraft?.endModuleIndex); + const startModule = config.modules[startIndex]; + if (!startModule || !Number.isInteger(endIndex) || endIndex === startIndex || !config.modules[endIndex]) { + setHorizontalSpanMessage('Choisissez une colonne de fin adjacente (au moins deux colonnes au total).'); + return; + } + + const boundaries = getModuleVerticalBoundariesMm(startModule, config); + const bottomMm = boundaries[blockIndex]; + const topMm = boundaries[blockIndex + 1]; + if (!Number.isFinite(bottomMm) || !Number.isFinite(topMm)) { + setHorizontalSpanMessage('Le block de départ sélectionné n’existe plus.'); + return; + } + + const firstIndex = Math.min(startIndex, endIndex); + const lastIndex = Math.max(startIndex, endIndex); + const includedModules = config.modules.slice(firstIndex, lastIndex + 1); + const moduleIds = includedModules.map((module) => module.id); + if (bottomMm === 0) { + const drawerIndex = includedModules.findIndex((module) => (Number(module.drawers) || 0) > 0); + if (drawerIndex >= 0) { + setHorizontalSpanMessage( + `Impossible : retirez d’abord les tiroirs de la colonne ${firstIndex + drawerIndex + 1}, car ils occupent le block bas.` + ); + return; + } + } + + // Preview the synchronized model, then turn off every door band covered + // by the horizontal opening before validating the candidate. + const synchronized = synchronizeModuleSpanBoundaries(config, moduleIds, bottomMm, topMm); + const moduleIdSet = new Set(moduleIds.map(String)); + const modulesWithOpenBands = synchronized.modules.map((module) => { + if (!moduleIdSet.has(String(module.id))) return module; + const edges = getModuleVerticalBoundariesMm(module, synchronized); + const doors = moduleBlockDoors(module, synchronized, edges.length - 1); + return { + ...module, + blockDoors: doors.map((hasDoor, index) => ( + edges[index] >= bottomMm - 0.01 && edges[index + 1] <= topMm + 0.01 ? false : hasDoor + )) + }; + }); + const draftSpan = { + id: '__horizontal-span-preview__', + moduleIds, + bottomMm, + topMm, + hasBack: Boolean(moduleBlockBacks(startModule, config)[blockIndex]), + doorMode: HORIZONTAL_SPAN_DOOR_MODES.none, + type: 'open' + }; + const candidate = { + ...synchronized, + modules: modulesWithOpenBands, + horizontalSpans: [...horizontalSpans, draftSpan] + }; + const issue = validateHorizontalSpans(candidate).find((item) => item.spanId === draftSpan.id); + if (issue) { + setHorizontalSpanMessage(issue.message); + return; + } + + const spanId = createHorizontalSpan({ ...draftSpan, id: undefined }); + setSelectedHorizontalSpanId(spanId); + setHorizontalSpanDraft(null); + setHorizontalSpanMessage('Block horizontal créé sans porte. Configurez ses portes dans les paramètres du block horizontal.'); + }; + + const selectHorizontalSpan = (spanId) => { + ui.handleHorizontalSpanSelect?.(spanId); + }; + + const removeHorizontalSpan = (spanId) => { + if (!deleteHorizontalSpan(spanId)) return; + setSelectedHorizontalSpanId(null); + setHorizontalSpanMessage('Block horizontal supprimé. Les découpes de colonnes sont conservées.'); + }; + + const addUpperBlock = () => { + const result = addHorizontalSpanUpperBlock(selectedSpan?.id); + if (!result?.ok) { + setHorizontalSpanMessage(result?.message || 'Impossible d’ajouter le block supérieur.'); + return; + } + setHorizontalSpanMessage('Block supérieur de 300 mm ajouté et synchronisé sur toutes les colonnes couvertes.'); + }; + + const resizeUpperBlock = (heightMm) => { + const result = resizeHorizontalSpanUpperBlockHeight(selectedSpan?.id, heightMm); + if (!result?.ok) { + setHorizontalSpanMessage(result?.message || 'Impossible de redimensionner le block supérieur.'); + return; + } + setHorizontalSpanMessage(''); + }; return (

Configuration intérieure

- Choisissez le nombre de colonnes et personnalisez chacune. Cliquez sur un module dans la vue 3D pour le sélectionner. + Cliquez sur un block dans la vue 3D (façade, intérieur ou dos) pour ouvrir ses options ici. {canReorderColumns && ( - Glissez-déposez les cartes ci-dessous pour changer l'ordre des colonnes (gauche → droite = avant → arrière dans la vue 3D). + Glissez-déposez les cartes ci-dessous pour changer l'ordre des colonnes (gauche → droite = identique à la vue 3D). )}

-
- -
- {[ - { key: 'separations', label: 'Séparations' }, - { key: 'block', label: 'Block (x caisses)' } - ].map((m) => ( - - ))} -
- {config.columnMode === 'block' && ( -

Assemblage block: vis de jonction + perçages 3 milieux et +100/-100 mm (30 mm face/dos).

- )} -
- - {/* Column count */} + {/* Column count — non limité, borné par la largeur (min 300 mm par colonne) */}
Nombre de colonnes -
- {[1, 2, 3, 4, 5, 6].map(n => ( - - ))} +
+ + {numColumns} +
+ {numColumns >= maxColumns && ( +

Maximum {maxColumns} colonnes pour {config.widthMm} mm de largeur (min. 300 mm par colonne).

+ )} {aiOptimizeMsg && (
@@ -187,27 +380,288 @@ export default function StepColumns({ wc, ui }) { {typeDef.icon} {typeDef.label} {mod.widthMm} mm + + {(() => { + const backs = modBacks(mod); + const n = backs.length; + const withBack = backs.filter(Boolean).length; + const dosTxt = withBack === n ? 'avec dos' : withBack === 0 ? 'sans dos' : `dos ${withBack}/${n}`; + return n >= 2 ? `${n} blocks \u00b7 ${dosTxt}` : dosTxt; + })()} + + {resolvedSpans + .filter((resolved) => resolved.moduleIndexes.includes(i)) + .map((resolved) => ( + { + event.stopPropagation(); + selectHorizontalSpan(resolved.span.id); + }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + event.stopPropagation(); + selectHorizontalSpan(resolved.span.id); + } + }} + > + Horizontal {resolved.bottomMm}–{resolved.topMm} · {horizontalSpanBackLabel(resolved.span.hasBack)} · {horizontalSpanDoorLabel(resolved.span.doorMode)} + + ))} ); })}
+ {horizontalSpans.length > 0 && ( +
+
Blocks horizontaux
+
+ {resolvedSpans.map((resolved, index) => ( + + ))} +
+ {selectedSpan && selectedResolvedSpan && ( +
+ + Colonnes {selectedResolvedSpan.moduleIndexes[0] + 1} à {selectedResolvedSpan.moduleIndexes[selectedResolvedSpan.moduleIndexes.length - 1] + 1} + +
+ Dos du block horizontal + {[false, true].map((hasBack) => ( + + ))} +
+
+ Portes du block horizontal + {(() => { + const activeDoorMode = selectedSpan.doorMode ?? HORIZONTAL_SPAN_DOOR_MODES.none; + const hasDoor = activeDoorMode !== HORIZONTAL_SPAN_DOOR_MODES.none; + return ( + <> + + + {hasDoor && ( + <> + Configuration des portes + {HORIZONTAL_SPAN_DOOR_OPTIONS.slice(1).map((option) => ( + + ))} + + )} + + Ces portes appartiennent au block horizontal. Les portes individuelles des blocks couverts restent désactivées. + + + ); + })()} +
+ {selectedUpperBlock?.ok && selectedUpperBlock.exists ? ( +
+ Block supérieur synchronisé +
+ resizeUpperBlock(Number(event.target.value))} + /> + {selectedUpperBlock.heightMm} mm +
+
+ ) : ( + <> + + {!selectedUpperBlock?.ok && ( + {selectedUpperBlock?.message} + )} + {selectedUpperBlock?.ok && !selectedUpperBlock.canAdd && ( + + Hauteur insuffisante : conservez au moins 300 mm pour le block horizontal et 300 mm pour le nouveau block. + + )} + + )} + + Cette limite est commune à toutes les colonnes couvertes. Sa hauteur se règle ici par pas de 50 mm. + + +
+ )} +
+ )} + + {horizontalSpanMessage && ( +
+ {horizontalSpanMessage} + +
+ )} + {/* Detail tray for expanded column */} {expandedColumn !== null && config.modules[expandedColumn] && (() => { const i = expandedColumn; const mod = config.modules[i]; const typeDef = COLUMN_TYPES.find(t => t.key === mod.type) || COLUMN_TYPES[0]; + const colHeights = modHeights(mod); + const colBacks = modBacks(mod); + const colDoors = moduleBlockDoors(mod, config, colHeights.length); + const colCount = colHeights.length; + const drawerType = ['hidden', 'hidden_with_handle'].includes(mod.drawerType) + ? 'hidden' + : mod.drawerType === 'visible' + ? 'visible' + : ['hidden', 'hidden_with_handle'].includes(hw.drawerType) + ? 'hidden' + : 'visible'; + const drawerDepth = Math.max(350, drawerDepthBase - (drawerType === 'hidden' ? 40 : 0)); + const bottomBlockMinMm = moduleBottomBlockMinMm(mod, config); + const colMaxBlocks = Math.max( + minBlocks, + Math.min(maxBlocks, 1 + Math.floor((bodyHmm - bottomBlockMinMm) / stackMinMm)) + ); + const modStackedHere = colCount >= 2; + const activeBlockIndex = Math.min(Math.max(0, expandedBlock || 0), colCount - 1); + const columnResolvedSpans = resolvedSpans.filter((resolved) => + resolved.moduleIndexes.includes(i) + ); + const activeResolvedSpan = columnResolvedSpans.find((resolved) => + resolved.coveredBlocks.some( + (covered) => + covered.moduleIndex === i && + covered.blockIndexes.includes(activeBlockIndex) + ) + ); + const columnHasBottomSpan = columnResolvedSpans.some( + (resolved) => resolved.bottomMm <= 0.5 + ); + const modStackInfo = moduleStackInfo(mod, config); + const blockLabel = (k) => + colCount <= 1 + ? '' + : k === 0 + ? `Block 1 (bas)` + : k === colCount - 1 + ? `Block ${colCount} (haut)` + : `Block ${k + 1}`; + const setBlockCount = (n) => { + const count = Math.min(colMaxBlocks, Math.max(minBlocks, n)); + const even = bodyHmm / count; + setExpandedBlock((current) => Math.min(current, count - 1)); + handleColumnUpdate(i, { + blockHeightsMm: count <= 1 ? [bodyHmm] : Array.from({ length: count }, () => even) + }); + }; + const setBlockHeight = (k, valueMm) => { + const next = colHeights.slice(); + next[k] = Number(valueMm); + handleColumnUpdate(i, { blockHeightsMm: next }); + }; + const setBlockBack = (k, value) => { + const next = colBacks.slice(); + next[k] = value; + handleColumnUpdate(i, { + blockBacks: next, + hasBack: next[0], + hasBackTop: next[next.length - 1] + }); + }; + const setBlockDoor = (k, value) => { + const next = colDoors.slice(); + next[k] = value; + handleColumnUpdate(i, { blockDoors: next }); + }; const shelfN = mod.shelves || 0; const shelfPositions = resolveShelfPositionsMm({ shelfCount: shelfN, heightMm: config.heightMm, plinthHeightMm: config.plinthHeightMm ?? 100, - shelfPositionsMm: mod.shelfPositionsMm + shelfPositionsMm: mod.shelfPositionsMm, + stack: modStackInfo }); - const minShelves = minShelvesStructural; - const bodyHmm = (Number(config.heightMm) || 0) - (Number(config.plinthHeightMm) || 0); + // Colonne empilée: la jonction est structurelle → pas de rayon minimum imposé. + const minShelves = minShelvesForColumn(config.heightMm, mod, config); const bodyMidMm = bodyHmm / 2; - const shelfSlots = system32ShelfPositionsMm(config.heightMm, config.plinthHeightMm ?? 100); + const shelfSlots = system32ShelfPositionsMm(config.heightMm, config.plinthHeightMm ?? 100, modStackInfo); const structuralIdx = minShelves > 0 && shelfPositions.length > 0 ? shelfPositions.reduce((bestIdx, posMm, idx, arr) => { @@ -218,7 +672,7 @@ export default function StepColumns({ wc, ui }) { : null; const hasShelfInStructuralWindow = minShelves <= 0 || - shelfPositions.some((p) => p >= bodyMidMm - 200 && p <= bodyMidMm + 200); + shelfPositions.some((p) => p >= bodyMidMm - STRUCTURAL_WINDOW_HALF_MM && p <= bodyMidMm + STRUCTURAL_WINDOW_HALF_MM); const gapMax = Math.min( SHELF_GAP_MM.max, maxShelfGapMm(config.heightMm, config.plinthHeightMm ?? 100, shelfN) @@ -232,16 +686,29 @@ export default function StepColumns({ wc, ui }) { handleColumnUpdate(i, { shelfPositionsMm: next }); }; return ( -
+
- Colonne {i + 1} — {typeDef.icon} {typeDef.label} - + + Colonne {i + 1} — {typeDef.icon} {typeDef.label} + {modStackedHere && ` · ${blockLabel(activeBlockIndex)}`} + +
+ + +
{/* Type tiles */} @@ -277,6 +744,11 @@ export default function StepColumns({ wc, ui }) { >+
+ {columnResolvedSpans.length > 0 && ( +

+ La colonne reste modifiable. Les limites couvertes communes se règlent dans l’éditeur du block horizontal. +

+ )} {minShelves >= 1 && (mod.shelves || 0) <= minShelves && (

Hauteur > 2 m : au moins 1 étagère par colonne (rigidité, assemblage RAZENTZAB). Le compteur ne peut pas descendre en dessous. @@ -380,7 +852,8 @@ export default function StepColumns({ wc, ui }) { type="button" className={s.counterBtn} onClick={() => handleColumnUpdate(i, { drawers: Math.min(6, (mod.drawers || 0) + 1) })} - disabled={(mod.drawers || 0) >= 6} + disabled={(mod.drawers || 0) >= 6 || columnHasBottomSpan} + title={columnHasBottomSpan ? 'Un block horizontal occupe le block bas de cette colonne.' : undefined} >+

@@ -396,8 +869,8 @@ export default function StepColumns({ wc, ui }) { @@ -405,7 +878,7 @@ export default function StepColumns({ wc, ui }) {

- Manufacture depuis le bas. Profondeur utile: {drawerDepth} mm (réglage global). + Manufacture depuis le bas. Profondeur utile: {drawerDepth} mm pour ce block.

)} @@ -421,14 +894,287 @@ export default function StepColumns({ wc, ui }) { {mod.hasRod ? 'Oui' : 'Non'} + + {/* Empilage vertical (par colonne): nombre de blocks en hauteur */} +
+ Blocks en hauteur +
+ + {colCount} + +
+
+ + {modStackedHere && ( +
+ {colHeights.map((h, k) => ( + + ))} +
+ )} + +
+ {horizontalSpanDraft?.startModuleIndex === i && + horizontalSpanDraft?.startBlockIndex === activeBlockIndex ? ( + <> +
Créer un block horizontal
+

+ Block de départ : colonne {i + 1}, {Math.round(getModuleVerticalBoundariesMm(mod, config)[activeBlockIndex])}– + {Math.round(getModuleVerticalBoundariesMm(mod, config)[activeBlockIndex + 1])} mm. + Choisissez la colonne de fin ; toutes les colonnes intermédiaires seront incluses. +

+
+ {config.modules.map((candidate, candidateIndex) => candidateIndex === i ? null : ( + + ))} +
+
+ + +
+ + ) : ( + + )} +
+ + {modStackedHere && ( + <> +
Paramètres de {blockLabel(activeBlockIndex).toLowerCase()}
+ {activeBlockIndex < colCount - 1 ? ( +
+ Hauteur +
+ setBlockHeight(activeBlockIndex, Number(e.target.value))} + disabled={columnResolvedSpans.length > 0} + /> + {colHeights[activeBlockIndex]} mm +
+
+ ) : ( +
+ Hauteur + {colHeights[activeBlockIndex]} mm (reste) +
+ )} +

+ {colCount} blocks empilés ({colHeights.join(' + ')} mm, caisse hors plinthe). + Les jonctions (doubles panneaux) jouent le rôle de rayon structurel pour cette colonne. + {(mod.drawers || 0) > 0 && ` Le block bas reste à ${bottomBlockMinMm} mm minimum pour contenir les tiroirs.`} +

+ + )} + + {/* Dos par block */} + {modStackedHere ? ( + <> +
+ Dos + +
+

+ Block avec dos = caisson block (dos 8 mm rainuré). Sans dos: block si côté ou rayonnage, sinon séparations. Block haut sans dos → dessus de doublage obligatoire. +

+ + ) : ( + <> +
+ Dos (panneau arrière) + +
+

+ {colBacks[0] + ? 'Avec dos: cette zone est un caisson block (dos 8 mm rainuré, montants propres).' + : 'Sans dos: caisson block si cette colonne est un côté ou un rayonnage, sinon séparations (pas de rainure dos).'} +

+ + )} + + {/* Porte par block (bloc ou partie de bloc sans porte) */} + {config.doorType !== 'sliding' && ( + modStackedHere ? ( + <> +
+ Porte + +
+ {!colDoors[activeBlockIndex] && ( +

+ Block sans porte = niche ouverte (pas de charnières ni poignée sur ce block). +

+ )} + + ) : ( +
+ Porte + +
+ ) + )} ); })()} + {expandedColumn === null && ( +
+ Sélectionnez un block (vue 3D ou cartes ci-dessus) pour afficher ses options : type, étagères, tiroirs, tringle, dos… +
+ )} + + {/* Global settings, collapsed by default to keep block options in view */} +
+ Réglages généraux (dos, empilage) + +
+ +
+ {[ + { hasBack: true, label: 'Avec dos (toutes)' }, + { hasBack: false, label: 'Sans dos (toutes)' } + ].map((m) => ( + + ))} +
+ {mixedBack ? ( +

Configuration mixte: dos ou rayonnage = caisson block (dos 8 mm rainuré si présent, vis de jonction), le reste en séparations — les côtés restent des montants block. Réglez le dos de chaque zone dans les options de son block.

+ ) : allWithBack ? ( +

Avec dos (8 mm rainuré) → assemblage block (x caisses): vis de jonction + perçages 3 milieux et +100/-100 mm (30 mm face/dos). Modifiable par zone dans les options du block.

+ ) : ( +

Sans dos → block seulement pour les côtés et le rayonnage, sinon séparations (pas de rainure dos). Modifiable par zone dans les options du block.

+ )} +
+ + {/* Empilage vertical: blocks en hauteur */} +
+ +
+ Nombre de blocks (toutes) +
+ + {allSameCount ? blockCounts[0] : 'mixte'} + +
+
+

+ {minBlocks >= 2 + ? `Hauteur > ${STACK_BLOCK_MM.max} mm (panneau max) : au moins ${minBlocks} blocks empilés par colonne. ` + : ''} + {mixedStacked + ? 'Configuration mixte : réglez le nombre de blocks et leurs hauteurs par colonne dans ses options. ' + : ''} + {anyStacked + ? 'Habillage latéral obligatoire seulement si la colonne d’extrémité est empilée. Chaque block a son option dos (options du block).' + : `Min. ${stackMinMm} mm par block, max. ${maxBlocks} blocks pour cette hauteur.`} + {horizontalSpans.length > 0 + ? ' Les limites couvertes restent synchronisées et se règlent depuis l’éditeur du block horizontal.' + : ''} +

+
+
+

{config.modules.length} colonne{config.modules.length > 1 ? 's' : ''} — Largeur totale : {totalModuleWidth} mm

- {hasTallCabinet &&

Armoire > 2m: rayon au milieu obligatoire avec assemblage RAZENTZAB.

} + {hasTallCabinet && config.modules.some((m) => modHeights(m).length < 2) && ( +

Armoire > 2m: rayon au milieu obligatoire avec assemblage RAZENTZAB (colonnes non empilées).

+ )} {config.modules.some((m) => (m.drawers || 0) > 0 && (m.widthMm || 0) > 1000) && (

Une colonne avec tiroirs dépasse 1000 mm. Limite max appliquée.

)} diff --git a/client/src/components/configurator/StepDimensions.jsx b/client/src/components/configurator/StepDimensions.jsx index 1e7e04f..9cbfeca 100644 --- a/client/src/components/configurator/StepDimensions.jsx +++ b/client/src/components/configurator/StepDimensions.jsx @@ -1,10 +1,16 @@ import s from './steps.module.css'; +import { + AXILO_TARGET_HEIGHTS_MM, + DIMENSION_RANGES, + outerColumnStacked, + moduleBlockBacks +} from '../../constants/wardrobe.js'; const DIMS = [ - { key: 'widthMm', label: 'Largeur totale', min: 400, max: 5000, step: 50, unit: 'mm' }, - { key: 'heightMm', label: 'Hauteur', min: 1800, max: 2800, step: 50, unit: 'mm' }, - { key: 'depthMm', label: 'Profondeur', min: 400, max: 800, step: 10, unit: 'mm' }, - { key: 'plinthHeightMm', label: 'Plinthe', min: 60, max: 160, step: 10, unit: 'mm' }, + { key: 'widthMm', label: 'Largeur totale', ...DIMENSION_RANGES.widthMm, unit: 'mm' }, + { key: 'heightMm', label: 'Hauteur', ...DIMENSION_RANGES.heightMm, unit: 'mm' }, + { key: 'depthMm', label: 'Profondeur', ...DIMENSION_RANGES.depthMm, unit: 'mm' }, + { key: 'plinthHeightMm', label: 'Plinthe', ...DIMENSION_RANGES.plinthHeightMm, unit: 'mm' }, ]; export default function StepDimensions({ wc }) { @@ -15,13 +21,26 @@ export default function StepDimensions({ wc }) { const sideRightEnabled = config.sideFinishRightEnabled ?? sideDefaultsEnabled; const topFinishEnabled = config.topFinishEnabled !== false; + // Habillages obligatoires: + // - colonne d'extrémité empilée → habillage de ce côté seulement + // - une zone du dessus (niveau haut si colonne empilée, sinon zone unique) sans dos → dessus de doublage + const moduleCount = (config.modules || []).length; + const sideLeftDoublageMandatory = outerColumnStacked(config.modules, config, 'left'); + const sideRightDoublageMandatory = outerColumnStacked(config.modules, config, 'right'); + const topDoublageMandatory = + moduleCount > 0 && + (config.modules || []).some((m) => { + const backs = moduleBlockBacks(m, config); + return !backs[backs.length - 1]; + }); + const minNicheWidth = Math.min(niche.widthTopMm || config.widthMm, niche.widthMidMm || config.widthMm, niche.widthBottomMm || config.widthMm); const minNicheDepth = Math.min(niche.depthTopMm || config.depthMm, niche.depthMidMm || config.depthMm, niche.depthBottomMm || config.depthMm); const minNicheHeight = Math.min(niche.heightLeftMm || config.heightMm, niche.heightRightMm || config.heightMm); const gap = Number(config.sanitaryGapMm || 30); - const usableWidth = Math.max(400, minNicheWidth - gap * 2); - const usableDepth = Math.max(400, minNicheDepth - gap * 2); - const usableHeight = Math.max(1800, minNicheHeight - gap * 2); + const usableWidth = Math.max(DIMENSION_RANGES.widthMm.min, minNicheWidth - gap * 2); + const usableDepth = Math.max(DIMENSION_RANGES.depthMm.min, minNicheDepth - gap * 2); + const usableHeight = Math.max(DIMENSION_RANGES.heightMm.min, minNicheHeight - gap); return (
@@ -84,7 +103,23 @@ export default function StepDimensions({ wc }) { {/* Dimension sliders */}
- {DIMS.map(({ key, label, min, max, step, unit }) => ( + {DIMS.map(({ key, label, min, max, step, unit }) => key === 'plinthHeightMm' ? ( +
+ Hauteur cible pieds AXILO 78 +
+ + Plinthe débitée à {Math.max(0, Number(config[key]) - 5)} mm +
+
+ ) : (
{label}
@@ -118,29 +153,47 @@ export default function StepDimensions({ wc }) {
+ {(sideLeftDoublageMandatory || sideRightDoublageMandatory || topDoublageMandatory) && ( +

+ {sideLeftDoublageMandatory && sideRightDoublageMandatory + ? 'Colonnes d\'extrémité empilées : habillages gauche et droit obligatoires. ' + : sideLeftDoublageMandatory + ? 'Colonne gauche empilée : habillage gauche obligatoire. ' + : sideRightDoublageMandatory + ? 'Colonne droite empilée : habillage droit obligatoire. ' + : ''} + {topDoublageMandatory && 'Une zone du dessus (niveau haut) sans dos : dessus de doublage obligatoire.'} +

+ )}
)} diff --git a/client/src/components/configurator/StepFinalize.jsx b/client/src/components/configurator/StepFinalize.jsx index 027aeae..09c7da9 100644 --- a/client/src/components/configurator/StepFinalize.jsx +++ b/client/src/components/configurator/StepFinalize.jsx @@ -1,8 +1,19 @@ -import { COLUMN_TYPES } from '../../constants/wardrobe'; +import { COLUMN_TYPES, moduleBlockHeightsMm, moduleBlockBacks, moduleBlockDoors } from '../../constants/wardrobe'; +import { HORIZONTAL_SPAN_DOOR_MODES, resolveHorizontalSpan } from '@shared/manufacturing.js'; import { submitOrder } from '../../api/client'; import Icon from '../ui/Icon'; import s from './steps.module.css'; +const HORIZONTAL_SPAN_DOOR_LABELS = { + [HORIZONTAL_SPAN_DOOR_MODES.none]: 'sans porte', + [HORIZONTAL_SPAN_DOOR_MODES.perColumn]: 'deux portes, charnières extérieures', + [HORIZONTAL_SPAN_DOOR_MODES.single]: 'une porte pleine largeur' +}; + +function horizontalSpanDoorLabel(doorMode) { + return HORIZONTAL_SPAN_DOOR_LABELS[doorMode] || HORIZONTAL_SPAN_DOOR_LABELS[HORIZONTAL_SPAN_DOOR_MODES.none]; +} + export default function StepFinalize({ wc, ui }) { const { config, priceResult, serviceOptions, selectedFinish, totalModuleWidth } = wc; const { orderForm, setOrderForm, orderSent, setOrderSent, setActiveStep } = ui; @@ -22,7 +33,32 @@ export default function StepFinalize({ wc, ui }) { // Build summary lines const installLabel = config.installationType === 'free' ? 'Libre' : 'Niche'; - const doorLabel = config.doorType === 'hinged' ? 'Battantes' : `Coulissantes (${config.slidingPanelsCount} vantaux) [legacy]`; + // Portes par block: blocks sans porte = niches ouvertes. + const doorTotals = config.modules.reduce( + (acc, m) => { + const doors = moduleBlockDoors(m, config); + acc.total += doors.length; + acc.withDoor += doors.filter(Boolean).length; + return acc; + }, + { total: 0, withDoor: 0 } + ); + const resolvedHorizontalSpans = (config.horizontalSpans || []) + .map((span) => resolveHorizontalSpan(span, config)) + .filter(Boolean); + const regularDoorLabel = + config.doorType !== 'hinged' + ? `Coulissantes (${config.slidingPanelsCount} vantaux) [legacy]` + : doorTotals.withDoor === doorTotals.total + ? `Battantes (${doorTotals.withDoor})` + : doorTotals.withDoor === 0 + ? 'Sans portes (niches ouvertes)' + : `Battantes — ${doorTotals.withDoor}/${doorTotals.total} blocks avec porte`; + const doorLabel = resolvedHorizontalSpans.length > 0 + ? `${regularDoorLabel} · Blocks horizontaux : ${resolvedHorizontalSpans + .map((resolved) => horizontalSpanDoorLabel(resolved.span.doorMode)) + .join(', ')}` + : regularDoorLabel; const roomLabel = { other: 'Autre', bathroom: 'Salle de bain', @@ -34,6 +70,34 @@ export default function StepFinalize({ wc, ui }) { return `${t.icon} ${t.label} (${m.widthMm}mm)`; }).join(', '); + // Empilage PAR COLONNE — N blocks: zones = somme des blocks de chaque colonne. + const modHeightsList = config.modules.map((m) => moduleBlockHeightsMm(m, config)); + const modBacksList = config.modules.map((m, i) => moduleBlockBacks(m, config, modHeightsList[i].length)); + const stackedCount = modHeightsList.filter((hs) => hs.length >= 2).length; + const zoneCount = modHeightsList.reduce((sum, hs) => sum + hs.length, 0); + const backCount = modBacksList.reduce((sum, backs) => sum + backs.filter(Boolean).length, 0); + const backSummary = + backCount === 0 + ? 'Sans dos (côtés et rayonnage en block, reste en séparations)' + : backCount === zoneCount + ? 'Avec dos partout (caissons block)' + : `Mixte — ${backCount}/${zoneCount} zone${backCount > 1 ? 's' : ''} avec dos (caissons block) ; sans dos: block si côté ou rayonnage, sinon séparations`; + const stackSignatures = [...new Set(modHeightsList.filter((hs) => hs.length >= 2).map((hs) => hs.join(' + ')))]; + const stackSummary = + stackedCount === 0 + ? '1 (pleine hauteur)' + : stackedCount === config.modules.length && stackSignatures.length === 1 + ? `${modHeightsList[0].length} empilés — ${stackSignatures[0]} mm (toutes les colonnes)` + : stackedCount === config.modules.length + ? 'Empilage sur toutes les colonnes (blocks et hauteurs par colonne)' + : `Mixte — ${stackedCount}/${config.modules.length} colonne${stackedCount > 1 ? 's' : ''} empilée${stackedCount > 1 ? 's' : ''}, reste pleine hauteur`; + const horizontalSpanSummaries = resolvedHorizontalSpans + .map((resolved) => ( + `colonnes ${resolved.moduleIndexes[0] + 1}–${resolved.moduleIndexes[resolved.moduleIndexes.length - 1] + 1}, ` + + `${resolved.bottomMm}–${resolved.topMm} mm, ${resolved.span.hasBack ? 'avec dos' : 'sans dos'}, ` + + horizontalSpanDoorLabel(resolved.span.doorMode) + )); + return (

Récapitulatif & Commande

@@ -67,6 +131,15 @@ export default function StepFinalize({ wc, ui }) {
+ {horizontalSpanSummaries.length > 0 && ( +
+ Blocks horizontaux ({horizontalSpanSummaries.length}) + + {horizontalSpanSummaries.join(' · ')} + + +
+ )}
Portes @@ -113,17 +186,27 @@ export default function StepFinalize({ wc, ui }) {
Fabrication APEX 4.0
- Structure / Renforts / Dos : - 19 mm / 16 mm / 8 mm + Structure / Renforts{backCount > 0 ? ' / Dos' : ''} : + 19 mm / 16 mm{backCount > 0 ? ' / 8 mm' : ''} +
+
+ Dos (par zone) : + {backSummary} +
+
+ Blocks en hauteur (par colonne) : + {stackSummary}
System 32 : Ø5 mm, prof. 12 mm, pas 32 mm
-
- Rainure dos : - 9×10.5 mm, à 16 mm du bord -
+ {backCount > 0 && ( +
+ Rainure dos : + 9×10.5 mm, à 16 mm du bord{backCount < zoneCount ? ' (zones avec dos uniquement)' : ''} +
+ )}
Connecteurs structure : SC 8/60 (extrémités T@20 → IX@50 → T@80 · milieu IX + T ±30 mm) @@ -134,7 +217,9 @@ export default function StepFinalize({ wc, ui }) {
Plinthe : - AXILO™ 78 + + AXILO™ 78 — pieds {config.plinthHeightMm} mm, panneau {Math.max(0, Number(config.plinthHeightMm) - 5)} mm +
{priceResult?.details?.reinforcementCount > 0 && (
diff --git a/client/src/components/configurator/StepHardware.jsx b/client/src/components/configurator/StepHardware.jsx index 622b38d..ca3dbbd 100644 --- a/client/src/components/configurator/StepHardware.jsx +++ b/client/src/components/configurator/StepHardware.jsx @@ -3,9 +3,14 @@ import s from './steps.module.css'; export default function StepHardware({ wc }) { const { config, handleVariants, hingeVariants, blumHardware, setHardware } = wc; - const hasDrawers = config.modules.some(m => m.drawers > 0); - const drawerType = config.hardware?.drawerType || 'visible'; - const standardHeights = [60, 90, 128, 177, 240]; + const drawerModules = config.modules.filter(m => m.drawers > 0); + const hasDrawers = drawerModules.length > 0; + const allDrawersVisible = hasDrawers && drawerModules.every( + m => (m.drawerType || config.hardware?.drawerType || 'visible') === 'visible' + ); + // Blum Legrabox : hauteurs de côté N/M/K/C/F (66/90/128/177/240 mm). + const standardHeights = [66, 90, 128, 177, 240]; + const legraboxSystemByHeight = { 66: 'N', 90: 'M', 128: 'K', 177: 'C', 240: 'F' }; const currentHeights = config.hardware?.drawerHeightsMm || [90, 128, 177, 240]; return ( @@ -24,7 +29,7 @@ export default function StepHardware({ wc }) { className={`${s.hardwareBtn} ${config.hardware.handleVariantId === h.id ? s.hardwareBtnSelected : ''}`} onClick={() => setHardware('handleVariantId', h.id)} title={`${h.name} - ${h.price_chf} CHF`} - disabled={drawerType === 'visible' && hasDrawers} + disabled={allDrawersVisible} > {h.image_url ? ( {h.name} @@ -36,7 +41,7 @@ export default function StepHardware({ wc }) { ))}
- {drawerType === 'visible' && hasDrawers && ( + {allDrawersVisible && (

Tiroir visible avec coulisse Blum Motion: poignée non requise.

)}
@@ -96,12 +101,12 @@ export default function StepHardware({ wc }) { setHardware('drawerHeightsMm', next.slice(0, 4)); }} > - {mm} mm + {legraboxSystemByHeight[mm]} — {mm} mm ); })}
-

4 hauteurs standard max (hypothèse codée en dur pour cette version).

+

Hauteurs de côté Blum Legrabox (N/M/K/C/F) — 4 hauteurs max par colonne.

)} diff --git a/client/src/components/configurator/StepperBar.jsx b/client/src/components/configurator/StepperBar.jsx index b634eb0..54cb9d1 100644 --- a/client/src/components/configurator/StepperBar.jsx +++ b/client/src/components/configurator/StepperBar.jsx @@ -1,7 +1,18 @@ import { STEPS } from '../../constants/wardrobe'; +import Icon from '../ui/Icon'; import styles from './StepperBar.module.css'; -export default function StepperBar({ activeStep, onStepChange, totalChf, touched }) { +export default function StepperBar({ + activeStep, + onStepChange, + onOpenSavedDesigns, + canUndo, + canRedo, + onUndo, + onRedo, + totalChf, + touched +}) { return (
{STEPS.map(({ id, label }) => ( @@ -20,6 +31,33 @@ export default function StepperBar({ activeStep, onStepChange, totalChf, touched ))} +
+ + +
+
CHF {(totalChf || 0).toLocaleString('fr-CH', { minimumFractionDigits: 0 })}.– diff --git a/client/src/components/configurator/StepperBar.module.css b/client/src/components/configurator/StepperBar.module.css index 98851b2..871e8ce 100644 --- a/client/src/components/configurator/StepperBar.module.css +++ b/client/src/components/configurator/StepperBar.module.css @@ -3,11 +3,14 @@ align-items: center; gap: 0.25rem; padding: 0.5rem 1rem; - background: var(--bg-card); + background: rgba(255, 255, 255, 0.9); + -webkit-backdrop-filter: blur(4px); + backdrop-filter: blur(4px); border-bottom: 1px solid var(--border); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); position: -webkit-sticky; position: sticky; - top: 0; + top: var(--header-height); z-index: 60; overflow-x: auto; -webkit-overflow-scrolling: touch; @@ -67,6 +70,52 @@ .spacer { flex: 1; min-width: 0.5rem; } +.historyActions { + display: inline-flex; + flex-shrink: 0; + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; +} + +.historyBtn { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.45rem 0.55rem; + border: 0; + background: var(--bg-card); + color: var(--text); + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; +} + +.historyBtn + .historyBtn { border-left: 1px solid var(--border); } +.historyBtn:hover:not(:disabled) { background: var(--bg); color: var(--action); } +.historyBtn:disabled { color: var(--text-muted); cursor: not-allowed; opacity: 0.45; } + +.savedDesignsBtn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + flex-shrink: 0; + padding: 0.45rem 0.7rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-card); + color: var(--text); + font-size: 0.78rem; + font-weight: 600; + white-space: nowrap; + cursor: pointer; +} + +.savedDesignsBtn:hover { + border-color: var(--action); + color: var(--action); +} + .priceBadge { display: flex; align-items: center; @@ -92,5 +141,8 @@ .stepperBar { padding: 0.4rem 0.5rem; gap: 0.15rem; } .step { padding: 0.4rem 0.6rem; font-size: 0.75rem; } .stepLabel { display: none; } + .historyBtn span { display: none; } + .savedDesignsBtn span { display: none; } + .savedDesignsBtn { padding: 0.4rem 0.5rem; } .priceBadge { font-size: 0.8125rem; padding: 0.35rem 0.65rem; } } diff --git a/client/src/components/configurator/steps.module.css b/client/src/components/configurator/steps.module.css index 25d81b1..4284fec 100644 --- a/client/src/components/configurator/steps.module.css +++ b/client/src/components/configurator/steps.module.css @@ -97,6 +97,9 @@ flex-direction: column; gap: 0.25rem; } +.dimFieldFull { + grid-column: 1 / -1; +} .dimLabel { font-size: 0.8125rem; font-weight: 500; @@ -298,6 +301,22 @@ .colCardIcon { font-size: 1.2rem; display: block; } .colCardType { font-size: 0.6875rem; font-weight: 600; color: var(--text); margin-top: 0.15rem; } .colCardWidth { font-size: 0.625rem; color: var(--text-muted); } +.spanMembership { + display: block; + margin-top: 0.3rem; + padding: 0.18rem 0.25rem; + border-radius: 4px; + background: color-mix(in srgb, #0ea5e9 12%, var(--bg)); + color: #0369a1; + font-size: 0.56rem; + font-weight: 700; + line-height: 1.2; + cursor: pointer; +} +.spanMembershipActive { + background: #0ea5e9; + color: #fff; +} .colLockIcon { position: absolute; top: 0.2rem; @@ -320,6 +339,61 @@ justify-content: space-between; margin-bottom: 0.75rem; } +.colDetailHeaderBtns { + display: flex; + align-items: center; + gap: 0.35rem; +} + +/* Placeholder shown while no block is selected */ +.detailPlaceholder { + padding: 0.85rem 1rem; + border: 1px dashed var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--text-muted); + font-size: 0.8rem; + line-height: 1.4; + margin-bottom: 0.75rem; +} + +/* Global settings accordion (dos par zone, empilage) */ +.globalSettings { + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-card); + margin: 0.25rem 0 0.75rem; +} +.globalSettingsSummary { + cursor: pointer; + padding: 0.6rem 0.85rem; + font-size: 0.85rem; + font-weight: 600; + color: var(--text); + user-select: none; + list-style: none; +} +.globalSettingsSummary::-webkit-details-marker { display: none; } +.globalSettingsSummary::before { + content: '\25B8'; + display: inline-block; + margin-right: 0.45rem; + color: var(--text-muted); + transition: transform 0.15s; +} +.globalSettings[open] > .globalSettingsSummary::before { + transform: rotate(90deg); +} +.globalSettings[open] > .globalSettingsSummary { + border-bottom: 1px solid var(--border); + margin-bottom: 0.6rem; +} +.globalSettings > .hardwareGroup { + padding: 0 0.85rem; +} +.globalSettings > .hardwareGroup:last-child { + padding-bottom: 0.75rem; +} .colDetailTitle { font-size: 0.9375rem; font-weight: 600; @@ -337,6 +411,180 @@ .lockBtn:hover { border-color: var(--text); } .isLocked { background: var(--bg); } +.blockPicker { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr)); + gap: 0.4rem; + margin: 0.35rem 0 0.75rem; +} +.blockPickerBtn { + display: grid; + gap: 0.15rem; + padding: 0.55rem 0.65rem; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--bg); + color: var(--text); + font-size: 0.75rem; + font-weight: 600; + cursor: pointer; + text-align: left; + transition: border-color 0.15s, background 0.15s; +} +.blockPickerBtn:hover { border-color: var(--action); } +.blockPickerBtn small { + color: var(--text-muted); + font-size: 0.65rem; + font-weight: 500; +} +.blockPickerBtnActive { + border-color: var(--action); + background: color-mix(in srgb, var(--action) 10%, var(--bg)); + color: var(--action); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--action) 30%, transparent); +} +.blockSettingsTitle { + margin: 0.2rem 0 0.25rem; + padding-top: 0.65rem; + border-top: 1px solid var(--border); + color: var(--text); + font-size: 0.8rem; + font-weight: 700; +} + +.spanPanel { + padding: 0.75rem; + border: 1px solid color-mix(in srgb, #0ea5e9 35%, var(--border)); + border-radius: 8px; + background: color-mix(in srgb, #0ea5e9 5%, var(--bg-card)); + margin-bottom: 0.75rem; +} +.spanPanelTitle { + margin-bottom: 0.45rem; + color: var(--text); + font-size: 0.82rem; + font-weight: 700; +} +.spanList { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; +} +.spanListItem { + display: grid; + gap: 0.1rem; + padding: 0.4rem 0.55rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-card); + color: var(--text); + font-size: 0.72rem; + text-align: left; + cursor: pointer; +} +.spanListItem small { color: var(--text-muted); } +.spanListItemActive { + border-color: #0ea5e9; + box-shadow: 0 0 0 1px color-mix(in srgb, #0ea5e9 30%, transparent); +} +.spanEditor { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin-top: 0.6rem; + padding-top: 0.6rem; + border-top: 1px solid var(--border); + font-size: 0.75rem; +} +.spanDoorChoices { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.35rem; + width: 100%; + min-width: 0; + margin: 0; + padding: 0.6rem; + border: 1px solid color-mix(in srgb, #0ea5e9 30%, var(--border)); + border-radius: 7px; +} +.spanDoorChoices legend { + padding: 0 0.25rem; + color: var(--text); + font-size: 0.75rem; + font-weight: 700; +} +.spanDoorChoices small { + grid-column: 1 / -1; + color: var(--text-muted); + font-size: 0.68rem; + line-height: 1.35; +} +.spanDoorChoice { + min-width: 0; + padding: 0.45rem 0.4rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-card); + color: var(--text); + font-size: 0.7rem; + font-weight: 600; + line-height: 1.25; + cursor: pointer; +} +.spanDoorChoice:hover { + border-color: #0ea5e9; +} +.spanDoorChoiceActive { + border-color: #0ea5e9; + background: color-mix(in srgb, #0ea5e9 12%, var(--bg-card)); + color: #0369a1; + box-shadow: 0 0 0 1px color-mix(in srgb, #0ea5e9 25%, transparent); +} +.spanDeleteBtn { + padding: 0.3rem 0.6rem; + border: 1px solid #ef4444; + border-radius: 5px; + background: transparent; + color: #dc2626; + font-size: 0.75rem; + cursor: pointer; +} +.spanSuccess { + display: flex; + gap: 0.5rem; + align-items: flex-start; + padding: 0.45rem 0.6rem; + border: 1px solid color-mix(in srgb, #22c55e 35%, var(--border)); + border-radius: 6px; + background: color-mix(in srgb, #22c55e 10%, var(--bg-card)); + color: var(--text); + font-size: 0.78rem; + margin: 0.4rem 0 0.75rem; +} +.horizontalSpanCreator { + margin: 0.55rem 0 0.75rem; +} +.horizontalSpanStartBtn { + width: 100%; + padding: 0.5rem 0.7rem; + border: 1px dashed #0ea5e9; + border-radius: 7px; + background: color-mix(in srgb, #0ea5e9 6%, var(--bg-card)); + color: #0369a1; + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; +} +.spanEndChoices, +.spanCreatorActions { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; + margin-top: 0.5rem; +} +.spanCreatorActions .optimizeBtn { margin-left: 0; } + /* Type selector tiles */ .typeTiles { display: flex; @@ -840,17 +1088,30 @@ transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease; } -.finishOption:hover { +.finishOption:hover:not(:disabled) { border-color: var(--action); transform: translateY(-1px); } +.finishOption:disabled { + cursor: not-allowed; + opacity: 1; + transform: none; +} + .finishOptionActive { border-color: var(--action); background: color-mix(in srgb, var(--action) 10%, var(--bg-card)); box-shadow: 0 8px 18px color-mix(in srgb, var(--action) 20%, transparent); } +.finishOptionMandatory, +.finishOptionMandatory:hover { + border-color: var(--warning); + background: color-mix(in srgb, var(--warning) 16%, var(--bg-card)); + box-shadow: 0 8px 18px color-mix(in srgb, var(--warning) 22%, transparent); +} + .finishOptionLabel { font-size: 0.75rem; font-weight: 600; @@ -865,6 +1126,26 @@ color: var(--text-muted); } +.finishOptionMandatory .finishOptionLabel, +.finishOptionMandatory .finishOptionState { + color: #92400e; +} + +.finishOptionMandatory .finishOptionState { + font-weight: 700; +} + +.finishMandatoryNotice { + font-size: 0.75rem; + line-height: 1.35; + color: #92400e; + background: color-mix(in srgb, var(--warning) 12%, var(--bg-card)); + border: 1px solid color-mix(in srgb, var(--warning) 35%, var(--border)); + border-radius: 8px; + padding: 0.45rem 0.6rem; + margin: 0.5rem 0 0; +} + .ruleGrid3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -916,6 +1197,7 @@ .cardToggle { flex-direction: column; } .columnStrip { gap: 0.25rem; } .ruleGrid3 { grid-template-columns: 1fr; } + .spanDoorChoices { grid-template-columns: 1fr; } } /* Compact mode for desktop split-panel (keeps everything visible) */ diff --git a/client/src/components/ui/Icon.jsx b/client/src/components/ui/Icon.jsx index 642fcc4..7ad44ac 100644 --- a/client/src/components/ui/Icon.jsx +++ b/client/src/components/ui/Icon.jsx @@ -30,6 +30,16 @@ export default function Icon({ name, size = 16, className = '' }) { ); + case 'fit': + return ( + + + + + + + + ); case 'eye': return ( @@ -37,6 +47,23 @@ export default function Icon({ name, size = 16, className = '' }) { ); + case 'door': + return ( + + + + + + ); + case 'door-off': + return ( + + + + + + + ); case 'download': return ( @@ -45,6 +72,34 @@ export default function Icon({ name, size = 16, className = '' }) { ); + case 'save': + return ( + + + + + ); + case 'restore': + return ( + + + + + ); + case 'undo': + return ( + + + + + ); + case 'redo': + return ( + + + + + ); case 'trash': return ( @@ -75,6 +130,63 @@ export default function Icon({ name, size = 16, className = '' }) { ); + case 'coins': + return ( + + + + + + + ); + case 'layers': + return ( + + + + + + ); + case 'sliding': + return ( + + + + + + + ); + case 'drawer': + return ( + + + + + + ); + case 'handle': + return ( + + + + + ); + case 'groove': + return ( + + + + + ); + case 'clearance': + return ( + + + + + + + ); case 'x': return ( diff --git a/client/src/constants/layoutProposal.js b/client/src/constants/layoutProposal.js new file mode 100644 index 0000000..fdceb4f --- /dev/null +++ b/client/src/constants/layoutProposal.js @@ -0,0 +1,140 @@ +import { PANEL } from '@shared/manufacturing.js'; +import { + carcassBodyMm, minBlocksForBody, minShelvesForStructuralHeight, + ROD_CLEAR_HEIGHT_MM, ROD_OFFSET_FROM_TOP_MM, STACK_BLOCK_MM +} from './wardrobe'; + +/** Largeur visée par colonne (mm); les colonnes tiroirs restent ≤ 800 mm. */ +const TARGET_COLUMN_MM = 750; +const MIN_COLUMN_MM = 400; +/** Block de penderie empilé: ≈ 1710 mm sous tringle (manteaux longs). */ +const HANGING_BLOCK_MM = 1800; +/** Profondeur en dessous de laquelle les cintres ne passent pas (règle 4.B). */ +const MIN_HANGING_DEPTH_MM = 400; +/** Hauteur minimale d'armoire pour une tringle (validation serveur). */ +const MIN_ROD_WARDROBE_HEIGHT_MM = 1200; +const SHELF_PITCH_MM = 400; + +const rodClearForBlockMm = (blockMm) => blockMm - PANEL.structure * 2 - ROD_OFFSET_FROM_TOP_MM; + +function columnCount(widthMm) { + if (widthMm < MIN_COLUMN_MM * 2) return 1; + let n = Math.max(1, Math.ceil(widthMm / TARGET_COLUMN_MM)); + while (n > 1 && widthMm / n < MIN_COLUMN_MM) n -= 1; + return n; +} + +/** Penderies aux extrémités, rangements au centre (tiroirs au milieu). */ +function columnTypes(n, hanging) { + if (!hanging) { + return Array.from({ length: n }, (_, i) => (i % 2 === 1 ? 'tiroirs' : 'rayonnage')); + } + if (n === 1) return ['penderie']; + if (n === 2) return ['penderie', 'rayonnage']; + if (n === 3) return ['penderie', 'rayonnage', 'tiroirs']; + const inner = Array.from({ length: n - 2 }, (_, i) => (i % 2 === 0 ? 'tiroirs' : 'rayonnage')); + return ['penderie', ...inner, 'penderie']; +} + +/** + * Découpe verticale: au-delà de 2000 mm une colonne mono-block exige un rayon structurel à mi-hauteur, + * ce qui coupe la penderie → penderie empilée (block bas de penderie + surmeuble). + */ +function hangingBlocksMm(bodyMm, heightMm) { + const needsSplit = minShelvesForStructuralHeight(heightMm) > 0 || minBlocksForBody(bodyMm) > 1; + if (!needsSplit) return [bodyMm]; + // Seul le dernier block absorbe le reste: les autres sont au pas de 50 mm (normalisation des blocks). + const step = STACK_BLOCK_MM.step; + const bottomMm = Math.min(HANGING_BLOCK_MM, Math.floor((bodyMm - STACK_BLOCK_MM.min) / step) * step); + const topMm = bodyMm - bottomMm; + if (topMm > STACK_BLOCK_MM.max) return null; + return [bottomMm, topMm]; +} + +/** Rangements: un seul block tant que le panneau le permet, sinon même jonction que les penderies. */ +function storageBlocksMm(bodyMm, hangingBlocks) { + if (minBlocksForBody(bodyMm) <= 1) return [bodyMm]; + if (hangingBlocks) return hangingBlocks; + const bottomMm = Math.round(bodyMm / 2 / STACK_BLOCK_MM.step) * STACK_BLOCK_MM.step; + return [bottomMm, bodyMm - bottomMm]; +} + +/** Rayons répartis tous les ~400 mm sur la zone de rangement de chaque block. */ +function shelvesFor(blocksMm, reservedBottomMm = 0) { + return blocksMm.reduce((sum, blockMm, i) => { + const usable = blockMm - (i === 0 ? reservedBottomMm : 0); + return sum + Math.max(0, Math.round(usable / SHELF_PITCH_MM) - 1); + }, 0); +} + +/** + * Disposition proposée automatiquement à partir des cotes (largeur, hauteur, profondeur). + * Retourne une structure compatible `applyLayoutPreset` (modules avec largeurs sommant à widthMm). + */ +export function proposeLayout(config) { + const widthMm = Number(config.widthMm) || 0; + const heightMm = Number(config.heightMm) || 0; + const bodyMm = carcassBodyMm(config); + const depthMm = Number(config.depthMm) || 0; + const hasBack = config.hasBack === true; + const drawerType = config.hardware?.drawerType ?? 'visible'; + + const hangingBlocks = hangingBlocksMm(bodyMm, heightMm); + const canHang = + depthMm >= MIN_HANGING_DEPTH_MM && + heightMm >= MIN_ROD_WARDROBE_HEIGHT_MM && + hangingBlocks != null && + rodClearForBlockMm(hangingBlocks[0]) >= ROD_CLEAR_HEIGHT_MM.short; + + const n = columnCount(widthMm); + const types = columnTypes(n, canHang && widthMm / n >= MIN_COLUMN_MM); + const storageBlocks = storageBlocksMm(bodyMm, hangingBlocks); + const structuralMin = (blocks) => (blocks.length < 2 ? minShelvesForStructuralHeight(heightMm) : 0); + + const baseWidth = Math.floor(widthMm / n); + return { + widthMm, + heightMm, + depthMm, + plinthHeightMm: config.plinthHeightMm, + hasBack, + stackEnabled: canHang && hangingBlocks.length > 1, + horizontalSpans: [], + modules: types.map((type, i) => { + const isHanging = type === 'penderie'; + const blockHeightsMm = isHanging ? hangingBlocks : storageBlocks; + const drawers = type === 'tiroirs' ? (bodyMm >= 900 ? 3 : 2) : 0; + const reservedMm = drawers > 0 ? 160 * drawers : 0; + const shelves = isHanging + ? 0 + : Math.max(structuralMin(blockHeightsMm), shelvesFor(blockHeightsMm, reservedMm)); + return { + id: `auto-c${i + 1}`, + type, + moduleCode: type, + widthMm: i === n - 1 ? widthMm - baseWidth * (n - 1) : baseWidth, + minWidth: 300, + maxWidth: type === 'tiroirs' ? 1000 : 1200, + position: i, + locked: false, + shelves, + drawers, + hasRod: isHanging, + drawerType, + hasBack, + hasBackTop: hasBack, + blockHeightsMm: blockHeightsMm.slice() + }; + }) + }; +} + +/** Empreinte des choix structurants d'une disposition (sert à détecter une personnalisation). */ +export function layoutModulesSignature(modules) { + return (modules || []) + .map((m) => [ + m.type, m.widthMm, m.shelves ?? 0, m.drawers ?? 0, m.hasRod ? 1 : 0, + (Array.isArray(m.blockHeightsMm) ? m.blockHeightsMm : []).map(Math.round).join('/') + ].join(':')) + .join('|'); +} diff --git a/client/src/constants/wardrobe.js b/client/src/constants/wardrobe.js index 1d537ad..005cd29 100644 --- a/client/src/constants/wardrobe.js +++ b/client/src/constants/wardrobe.js @@ -1,4 +1,10 @@ -import { PANEL } from '@shared/manufacturing.js'; +import { + PANEL, + PLINTH, + drawerStackHeightMm, + MAX_PANEL_DIMENSION_MM, + MAX_PANEL_SHORT_SIDE_MM +} from '@shared/manufacturing.js'; /** Inner carcass height (mm): floor to ceiling inside side panels. */ export function innerCarcassHeightMm(heightMm, plinthHeightMm) { @@ -15,18 +21,178 @@ export function maxShelfGapMm(heightMm, plinthHeightMm, shelfCount) { return Math.floor((H - n * t) / (n - 1)); } +/** + * Single source of truth for wardrobe envelope dimension ranges (mm). + * Width max = CNC length; depth max = CNC short side; beyond width max the system couples another armoire. + */ +export const DIMENSION_RANGES = { + widthMm: { min: 300, max: MAX_PANEL_DIMENSION_MM, step: 50 }, + // > MAX_PANEL_DIMENSION_MM possible uniquement via l'empilage vertical (chaque block ≤ panneau max). + heightMm: { min: 300, max: 4000, step: 50 }, + depthMm: { min: 300, max: MAX_PANEL_SHORT_SIDE_MM, step: 10 }, + plinthHeightMm: { min: 60, max: 200, step: 5 }, +}; + +export const AXILO_TARGET_HEIGHTS_MM = [...PLINTH.targetHeightsMm]; + export const SHELF_GAP_MM = { min: 80, max: 520, step: 10 }; +/** Empilage vertical: bornes de hauteur de chaque block (caisse, hors plinthe). max = panneau max. */ +export const STACK_BLOCK_MM = { min: 300, max: MAX_PANEL_DIMENSION_MM, step: 50 }; export const SYSTEM32_PITCH_MM = 32; export const SYSTEM32_FIRST_HOLE_MM = 51; /** Above this carcass height (mm), at least one shelf per column is required (rigidité / RAZENTZAB). */ export const STRUCTURAL_MIN_SHELF_HEIGHT_MM = 2000; +/** Half-width (mm) of the structural shelf window around carcass mid-height. */ +export const STRUCTURAL_WINDOW_HALF_MM = 200; + /** Minimum shelf count for a column at this height (0 or 1). */ export function minShelvesForStructuralHeight(heightMm) { return (Number(heightMm) || 0) > STRUCTURAL_MIN_SHELF_HEIGHT_MM ? 1 : 0; } +/** Hauteur de caisse (hors plinthe). */ +export function carcassBodyMm(config) { + return (Number(config?.heightMm) || 0) - (Number(config?.plinthHeightMm) || 0); +} + +/** Nombre minimum de blocks pour cette hauteur de caisse (panneau max par block). */ +export function minBlocksForBody(bodyMm) { + return Math.max(1, Math.ceil((Number(bodyMm) || 0) / STACK_BLOCK_MM.max)); +} + +/** Nombre maximum de blocks pour cette hauteur de caisse (min 300 mm par block). */ +export function maxBlocksForBody(bodyMm) { + return Math.max(1, Math.floor((Number(bodyMm) || 0) / STACK_BLOCK_MM.min)); +} + +/** Hauteur minimale du block bas pour contenir tous les tiroirs de la colonne. */ +export function moduleBottomBlockMinMm(mod, config) { + const drawerCount = Math.max(0, Number(mod?.drawers) || 0); + if (drawerCount <= 0) return STACK_BLOCK_MM.min; + const drawerHeightsMm = config?.hardware?.drawerHeightsMm; + const drawerStackMm = drawerStackHeightMm(drawerCount, drawerHeightsMm); + return Math.max( + STACK_BLOCK_MM.min, + Math.ceil(drawerStackMm / STACK_BLOCK_MM.step) * STACK_BLOCK_MM.step + ); +} + +/** + * Empilage vertical PAR COLONNE — modèle N blocks. + * Canonique: `mod.blockHeightsMm` (hauteurs de caisse des blocks, bas → haut). + * Legacy 2 blocks: `mod.stacked` + `mod.stackBottomHeightMm` (défauts config.stackEnabled / config.stackBottomHeightMm). + * Retourne des hauteurs normalisées: chaque block dans [min, max], somme = hauteur de caisse. + */ +export function moduleBlockHeightsMm(mod, config) { + const bodyH = carcassBodyMm(config); + const min = STACK_BLOCK_MM.min; + const bottomMin = moduleBottomBlockMinMm(mod, config); + if (!(bodyH >= min * 2)) return [Math.max(0, bodyH)]; + const maxBlocks = maxBlocksForBody(bodyH); + const minBlocks = minBlocksForBody(bodyH); + + let raw = Array.isArray(mod?.blockHeightsMm) + ? mod.blockHeightsMm.map(Number).filter((v) => Number.isFinite(v) && v > 0) + : null; + + if (!raw || raw.length === 0) { + // Legacy: flags 2 blocks (stacked + stackBottomHeightMm). + const stacked = mod && typeof mod.stacked === 'boolean' ? mod.stacked : config?.stackEnabled === true; + if (stacked && maxBlocks >= 2) { + const b = Number(mod?.stackBottomHeightMm ?? config?.stackBottomHeightMm); + const bottom = Math.min(Math.max(min, bodyH - min), Math.max(min, Number.isFinite(b) ? b : 1000)); + raw = [bottom, bodyH - bottom]; + } else { + raw = [bodyH]; + } + } + + // Empilage obligatoire au-delà du panneau max: forcer le nombre minimum de blocks. + let count = Math.min(Math.max(raw.length, minBlocks), maxBlocks); + // Si les tiroirs ne tiennent pas dans le block bas, réduire le nombre de + // blocks facultatifs afin de leur réserver la hauteur nécessaire. + while (count > minBlocks && bodyH - min * (count - 1) < bottomMin) count -= 1; + if (count <= 1) return [bodyH]; + if (raw.length !== count) { + // Nombre de blocks imposé/écrêté → répartition égale. + const even = bodyH / count; + raw = Array.from({ length: count }, () => even); + } + + const out = []; + let remaining = bodyH; + for (let i = 0; i < count - 1; i++) { + const blocksAfter = count - i - 1; + const maxH = Math.min(STACK_BLOCK_MM.max, remaining - min * blocksAfter); + const minH = i === 0 ? bottomMin : min; + const stepped = Math.round((Number(raw[i]) || minH) / STACK_BLOCK_MM.step) * STACK_BLOCK_MM.step; + const h = Math.min(maxH, Math.max(minH, stepped)); + out.push(h); + remaining -= h; + } + out.push(remaining); + return out; +} + +/** Porte par block (bas → haut). Défaut: porte sur chaque block (`bloc ou partie de bloc sans porte`). */ +export function moduleBlockDoors(mod, config, blockCount = null) { + const n = blockCount ?? moduleBlockHeightsMm(mod, config).length; + const arr = Array.isArray(mod?.blockDoors) ? mod.blockDoors : null; + return Array.from({ length: n }, (_, i) => (arr && typeof arr[i] === 'boolean' ? arr[i] : true)); +} + +/** Dos par block (bas → haut). Legacy: hasBack (block 1 / défaut), hasBackTop (dernier block). */ +export function moduleBlockBacks(mod, config, blockCount = null) { + const n = blockCount ?? moduleBlockHeightsMm(mod, config).length; + const arr = Array.isArray(mod?.blockBacks) ? mod.blockBacks : null; + const base = typeof mod?.hasBack === 'boolean' ? mod.hasBack : config?.hasBack === true; + return Array.from({ length: n }, (_, i) => { + if (arr && typeof arr[i] === 'boolean') return arr[i]; + if (i === n - 1 && n >= 2 && typeof mod?.hasBackTop === 'boolean') return mod.hasBackTop; + return base; + }); +} + +/** Colonne empilée = au moins 2 blocks en hauteur. */ +export function moduleStacked(mod, config) { + return moduleBlockHeightsMm(mod, config).length >= 2; +} + +/** + * Habillage latéral obligatoire seulement si la colonne d'extrémité est empilée + * (joints visibles sur cette face). Un côté en un seul block reste optionnel. + */ +export function outerColumnStacked(modules, config, side) { + const mods = Array.isArray(modules) ? modules.filter(Boolean) : []; + if (mods.length === 0) return config?.stackEnabled === true; + const mod = side === 'right' ? mods[mods.length - 1] : mods[0]; + return moduleStacked(mod, config); +} + +/** + * Infos empilage d'une colonne pour rayons / rendu: null (1 block) ou + * { heightsMm, boundariesMm } — boundariesMm = hauteurs cumulées des jonctions (depuis le bas de caisse). + */ +export function moduleStackInfo(mod, config) { + const heightsMm = moduleBlockHeightsMm(mod, config); + if (heightsMm.length < 2) return null; + const boundariesMm = []; + let acc = 0; + for (let i = 0; i < heightsMm.length - 1; i++) { + acc += heightsMm[i]; + boundariesMm.push(acc); + } + return { heightsMm, boundariesMm }; +} + +/** Colonne empilée → les jonctions (doubles panneaux) jouent le rôle de rayon structurel. */ +export function minShelvesForColumn(heightMm, mod, config) { + if (mod && moduleStacked(mod, config)) return 0; + return minShelvesForStructuralHeight(heightMm); +} + /** * Shelf center Y (meters), wardrobe group space — matches WardrobePreview formulas. * @param {number|null|undefined} shelfGapMm — clear opening between shelf faces; null = equal division (default). @@ -72,25 +238,190 @@ export function shelfCenterYsMeters({ ); } -export function system32ShelfPositionsMm(heightMm, plinthHeightMm) { +/** + * Rangées System 32 disponibles (mm depuis le bas de caisse). + * `stack` optionnel ({ boundariesMm }): colonne empilée → chaque block a ses propres rangées, + * aucune position ne traverse une jonction (double panneau horizontal). + */ +export function system32ShelfPositionsMm(heightMm, plinthHeightMm, stack = null) { const bodyH = (Number(heightMm) || 0) - (Number(plinthHeightMm) || 0); if (!(bodyH > 0)) return []; - const minPos = SYSTEM32_FIRST_HOLE_MM; - const maxPos = bodyH - SYSTEM32_FIRST_HOLE_MM; - if (maxPos < minPos) return []; - const count = Math.floor((maxPos - minPos) / SYSTEM32_PITCH_MM) + 1; - return Array.from({ length: Math.max(0, count) }, (_, i) => minPos + i * SYSTEM32_PITCH_MM); + const rangeSlots = (fromMm, toMm) => { + const minPos = fromMm + SYSTEM32_FIRST_HOLE_MM; + const maxPos = toMm - SYSTEM32_FIRST_HOLE_MM; + if (maxPos < minPos) return []; + const count = Math.floor((maxPos - minPos) / SYSTEM32_PITCH_MM) + 1; + return Array.from({ length: Math.max(0, count) }, (_, i) => minPos + i * SYSTEM32_PITCH_MM); + }; + const boundaries = (Array.isArray(stack?.boundariesMm) ? stack.boundariesMm : []) + .map(Number) + .filter((v) => Number.isFinite(v) && v > 0 && v < bodyH) + .sort((a, b) => a - b); + if (boundaries.length > 0) { + const edges = [0, ...boundaries, bodyH]; + const slots = []; + for (let i = 0; i + 1 < edges.length; i++) { + slots.push(...rangeSlots(edges[i], edges[i + 1])); + } + return slots; + } + return rangeSlots(0, bodyH); +} + +function shelfIndexValidRange(indexes, slots, shelfIdx, shelfCount) { + const low = Math.max(shelfIdx, shelfIdx > 0 ? indexes[shelfIdx - 1] + 1 : 0); + const high = Math.min( + slots.length - (shelfCount - shelfIdx), + shelfIdx < shelfCount - 1 ? indexes[shelfIdx + 1] - 1 : slots.length - 1 + ); + return [low, high]; +} + +function applyShelfIndexOrdering(indexes, slots, shelfCount) { + const next = [...indexes]; + for (let i = 0; i < shelfCount; i++) { + const minIdx = i; + next[i] = Math.max(minIdx, Math.min(slots.length - (shelfCount - i), next[i])); + } + for (let i = shelfCount - 2; i >= 0; i--) { + next[i] = Math.min(next[i], next[i + 1] - 1); + } + return next; +} + +/** + * Répartit les rayons dans les ouvertures de chaque block, puis les centre dans + * leur ouverture sur le pas System 32. Les jonctions sont ainsi traitées comme + * des séparations structurelles, et non comme des positions autour desquelles + * les rayons peuvent se regrouper. + */ +function distributedShelfIndexes(indexCount, slots, heightMm, plinthHeightMm, stack) { + const bodyH = (Number(heightMm) || 0) - (Number(plinthHeightMm) || 0); + const boundaries = (Array.isArray(stack?.boundariesMm) ? stack.boundariesMm : []) + .map(Number) + .filter((v) => Number.isFinite(v) && v > 0 && v < bodyH) + .sort((a, b) => a - b); + + if (boundaries.length === 0) { + return Array.from({ length: indexCount }, (_, i) => { + const t = ((i + 1) * (slots.length + 1)) / (indexCount + 1); + return Math.max(0, Math.min(slots.length - 1, Math.round(t) - 1)); + }); + } + + const edges = [0, ...boundaries, bodyH]; + const segmentCounts = Array(edges.length - 1).fill(0); + + // À chaque ajout, subdiviser l'ouverture actuellement la plus haute. + for (let shelfIdx = 0; shelfIdx < indexCount; shelfIdx++) { + let bestSegment = 0; + let largestOpening = -Infinity; + for (let segmentIdx = 0; segmentIdx < segmentCounts.length; segmentIdx++) { + const segmentHeight = edges[segmentIdx + 1] - edges[segmentIdx]; + const openingHeight = segmentHeight / (segmentCounts[segmentIdx] + 1); + if (openingHeight > largestOpening) { + largestOpening = openingHeight; + bestSegment = segmentIdx; + } + } + segmentCounts[bestSegment] += 1; + } + + const targets = []; + for (let segmentIdx = 0; segmentIdx < segmentCounts.length; segmentIdx++) { + const count = segmentCounts[segmentIdx]; + const fromMm = edges[segmentIdx]; + const segmentHeight = edges[segmentIdx + 1] - fromMm; + for (let i = 0; i < count; i++) { + targets.push(fromMm + ((i + 1) * segmentHeight) / (count + 1)); + } + } + + return targets.map((targetMm) => { + let bestIdx = 0; + let bestDist = Infinity; + for (let i = 0; i < slots.length; i++) { + const distance = Math.abs(slots[i] - targetMm); + if (distance < bestDist) { + bestDist = distance; + bestIdx = i; + } + } + return bestIdx; + }); +} + +/** Snap the shelf with the smallest move so one lands in the structural window (System 32). */ +function ensureStructuralShelfInIndexes(indexes, slots, heightMm, plinthHeightMm, shelfCount) { + if ((Number(heightMm) || 0) <= STRUCTURAL_MIN_SHELF_HEIGHT_MM || shelfCount <= 0) return indexes; + + const bodyMidMm = ((Number(heightMm) || 0) - (Number(plinthHeightMm) || 0)) / 2; + const winMin = bodyMidMm - STRUCTURAL_WINDOW_HALF_MM; + const winMax = bodyMidMm + STRUCTURAL_WINDOW_HALF_MM; + + if (indexes.some((idx) => slots[idx] >= winMin && slots[idx] <= winMax)) return indexes; + + const structuralSlotIndices = []; + for (let si = 0; si < slots.length; si++) { + if (slots[si] >= winMin && slots[si] <= winMax) structuralSlotIndices.push(si); + } + if (structuralSlotIndices.length === 0) return indexes; + + let bestMove = null; + let bestCost = Infinity; + for (let i = 0; i < shelfCount; i++) { + const [low, high] = shelfIndexValidRange(indexes, slots, i, shelfCount); + for (const si of structuralSlotIndices) { + if (si < low || si > high) continue; + const cost = Math.abs(slots[indexes[i]] - slots[si]); + if (cost < bestCost) { + bestCost = cost; + bestMove = { shelfIdx: i, slotIdx: si }; + } + } + } + + if (!bestMove) { + let closestShelf = 0; + let closestDist = Infinity; + for (let i = 0; i < shelfCount; i++) { + const d = Math.abs(slots[indexes[i]] - bodyMidMm); + if (d < closestDist) { + closestDist = d; + closestShelf = i; + } + } + const [low, high] = shelfIndexValidRange(indexes, slots, closestShelf, shelfCount); + let bestSlot = null; + let bestSlotDist = Infinity; + for (const si of structuralSlotIndices) { + if (si < low || si > high) continue; + const d = Math.abs(slots[si] - bodyMidMm); + if (d < bestSlotDist) { + bestSlotDist = d; + bestSlot = si; + } + } + if (bestSlot != null) bestMove = { shelfIdx: closestShelf, slotIdx: bestSlot }; + } + + if (!bestMove) return indexes; + + const next = [...indexes]; + next[bestMove.shelfIdx] = bestMove.slotIdx; + return applyShelfIndexOrdering(next, slots, shelfCount); } export function resolveShelfPositionsMm({ shelfCount, heightMm, plinthHeightMm, - shelfPositionsMm + shelfPositionsMm, + stack = null }) { const n = Math.max(0, Number(shelfCount) || 0); if (n <= 0) return []; - const slots = system32ShelfPositionsMm(heightMm, plinthHeightMm); + const slots = system32ShelfPositionsMm(heightMm, plinthHeightMm, stack); if (slots.length <= 0) return []; if (n >= slots.length) return slots.slice(0, n); @@ -113,10 +444,7 @@ export function resolveShelfPositionsMm({ if (Array.isArray(shelfPositionsMm) && shelfPositionsMm.length === n) { indexes = shelfPositionsMm.map(toNearestIndex); } else { - indexes = Array.from({ length: n }, (_, i) => { - const t = ((i + 1) * (slots.length + 1)) / (n + 1); - return Math.max(0, Math.min(slots.length - 1, Math.round(t) - 1)); - }); + indexes = distributedShelfIndexes(n, slots, heightMm, plinthHeightMm, stack); } for (let i = 0; i < n; i++) { @@ -128,9 +456,59 @@ export function resolveShelfPositionsMm({ indexes[i] = Math.min(indexes[i], indexes[i + 1] - 1); } + // Colonne empilée: la jonction bas/haut est déjà structurelle → pas de rayon structurel imposé. + if (!stack) { + indexes = ensureStructuralShelfInIndexes(indexes, slots, heightMm, plinthHeightMm, n); + } + return indexes.map((idx) => slots[idx]); } +/** Penderie — hauteurs libres sous tringle (règle 4.B): vestes courtes / manteaux longs. */ +export const ROD_CLEAR_HEIGHT_MM = { short: 1100, long: 1600 }; +/** Axe de tringle sous le dessus du compartiment de penderie. */ +export const ROD_OFFSET_FROM_TOP_MM = 50; + +/** + * Compartiment de penderie d'une colonne avec tringle: le block le plus bas offrant la hauteur + * vestes courtes (accessible), à défaut le plus haut; entre le dernier obstacle (fond, tiroirs, rayon) + * et le dessus du block. Hauteurs en mm depuis le bas de caisse; `clearMm` = hauteur libre sous la tringle. + */ +export function moduleHangingZoneMm(mod, config) { + if (!mod?.hasRod) return null; + const T = PANEL.structure; + const heights = moduleBlockHeightsMm(mod, config); + const rawClear = (h) => h - 2 * T - ROD_OFFSET_FROM_TOP_MM; + let blockIndex = heights.findIndex((h) => rawClear(h) >= ROD_CLEAR_HEIGHT_MM.short); + if (blockIndex < 0) { + blockIndex = 0; + heights.forEach((h, i) => { if (h > heights[blockIndex]) blockIndex = i; }); + } + const blockBottomMm = heights.slice(0, blockIndex).reduce((sum, h) => sum + h, 0); + const topMm = blockBottomMm + heights[blockIndex] - T; + let floorMm = blockBottomMm + T; + + const drawers = Math.max(0, Number(mod.drawers) || 0); + if (blockIndex === 0 && drawers > 0) { + floorMm += drawerStackHeightMm(drawers, config?.hardware?.drawerHeightsMm); + } + + const shelfCount = Math.max(minShelvesForColumn(config?.heightMm, mod, config), Number(mod.shelves) || 0); + const shelves = resolveShelfPositionsMm({ + shelfCount, + heightMm: config?.heightMm, + plinthHeightMm: config?.plinthHeightMm ?? 100, + shelfPositionsMm: mod.shelfPositionsMm, + stack: moduleStackInfo(mod, config) + }); + for (const shelfMm of shelves) { + if (shelfMm > floorMm && shelfMm < topMm) floorMm = shelfMm + T / 2; + } + + const rodMm = topMm - ROD_OFFSET_FROM_TOP_MM; + return { blockIndex, floorMm, topMm, rodMm, clearMm: Math.max(0, Math.round(rodMm - floorMm)) }; +} + // Column element types -- single source of truth export const COLUMN_TYPES = [ { key: 'penderie', label: 'Penderie', icon: '\u{1F454}', shelves: 1, drawers: 0, hasRod: true, defaultWidth: 600, minWidth: 400, maxWidth: 1200 }, @@ -193,7 +571,19 @@ export const DEFAULT_CONFIG = { }, sanitaryGapMm: 30, modules: [], - columnMode: 'separations', // separations | block + // Ouvertures horizontales couvrant plusieurs colonnes contiguës. + horizontalSpans: [], + // Dos (panneau arrière) optionnel, réglable PAR ZONE (mod.hasBack = zone basse, mod.hasBackTop = zone haute si empilage). + // Construction: dos → caisson block ; sinon côté (montant extérieur) ou rayonnage → block ; sinon séparations. + // `hasBack` global = défaut pour les nouvelles colonnes / sessions legacy. + hasBack: false, + columnMode: 'separations', // separations | block — block si TOUTES les zones ont un dos + // Empilage vertical PAR COLONNE — N blocks: mod.blockHeightsMm (hauteurs bas → haut) + mod.blockBacks. + // Legacy 2 blocks: mod.stacked + mod.stackBottomHeightMm; les valeurs globales ci-dessous = DÉFAUTS. + // Règles: colonne d'extrémité empilée → habillage de ce côté obligatoire; dernier block sans dos → dessus de doublage; + // hauteur de caisse > panneau max (MAX_PANEL_DIMENSION_MM) → empilage obligatoire (nombre de blocks minimum imposé). + stackEnabled: false, + stackBottomHeightMm: 1000, // hauteur caisse du block bas (hors plinthe, legacy 2 blocks) colors: { ...DEFAULT_COLORS }, hardware: { handleVariantId: null, @@ -218,6 +608,19 @@ export const DEFAULT_CONFIG = { serviceOptionIds: [] }; +/** Champs de config qui constituent une disposition type (structure, sans matériaux ni quincaillerie). */ +export const LAYOUT_PRESET_KEYS = [ + 'widthMm', 'heightMm', 'depthMm', 'plinthHeightMm', + 'hasBack', 'stackEnabled', 'stackBottomHeightMm', + 'modules', 'horizontalSpans' +]; + +export function pickLayoutPreset(config) { + return Object.fromEntries( + LAYOUT_PRESET_KEYS.filter((key) => config?.[key] !== undefined).map((key) => [key, config[key]]) + ); +} + export const STEPS = [ { id: 1, key: 'espace', label: 'Espace' }, { id: 2, key: 'colonnes', label: 'Colonnes' }, diff --git a/client/src/hooks/useConfiguratorUI.js b/client/src/hooks/useConfiguratorUI.js index 22964b8..f8ed908 100644 --- a/client/src/hooks/useConfiguratorUI.js +++ b/client/src/hooks/useConfiguratorUI.js @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; const SESSION_STEP_KEY = 'apex_wardrobe_step'; const PLANS_OPEN_KEY = 'apex.plans.open'; @@ -22,6 +22,13 @@ export default function useConfiguratorUI() { try { return Number(sessionStorage.getItem(SESSION_STEP_KEY)) || 1; } catch { return 1; } }); const [expandedColumn, setExpandedColumn] = useState(null); + const [expandedBlock, setExpandedBlock] = useState(0); + const [selectedHorizontalSpanId, setSelectedHorizontalSpanId] = useState(null); + const [horizontalSpanDraft, setHorizontalSpanDraft] = useState(null); + const [horizontalSpanMessage, setHorizontalSpanMessage] = useState(''); + + const activeStepRef = useRef(activeStep); + activeStepRef.current = activeStep; useEffect(() => { try { sessionStorage.setItem(SESSION_STEP_KEY, String(activeStep)); } catch {} @@ -67,10 +74,30 @@ export default function useConfiguratorUI() { const [orderSent, setOrderSent] = useState(false); const [lastOrderId, setLastOrderId] = useState(null); + // Matériaux (4) and Finaliser (6) use the 3D click to color a piece. + const staysOnColorStep = () => activeStepRef.current === 4 || activeStepRef.current === 6; + // Module click from 3D view - const handleModuleClick = useCallback((moduleIndex) => { - setExpandedColumn(prev => prev === moduleIndex ? null : moduleIndex); + const handleModuleClick = useCallback((moduleIndex, blockIndex = 0) => { + if (staysOnColorStep()) return; + setSelectedHorizontalSpanId(null); + setHorizontalSpanDraft(null); + setHorizontalSpanMessage(''); + setExpandedBlock(blockIndex); + setExpandedColumn(prev => ( + prev === moduleIndex && expandedBlock === blockIndex ? null : moduleIndex + )); setActiveStep(2); // jump to columns step when clicking a module + }, [expandedBlock]); + + // Kept as a dedicated callback so the 3D preview can select spans later + // without changing the existing module-click contract. + const handleHorizontalSpanSelect = useCallback((spanId) => { + if (staysOnColorStep()) return; + setSelectedHorizontalSpanId(spanId ?? null); + setHorizontalSpanDraft(null); + setHorizontalSpanMessage(''); + setActiveStep(2); }, []); // Element click from 3D view @@ -101,6 +128,10 @@ export default function useConfiguratorUI() { return { activeStep, setActiveStep, expandedColumn, setExpandedColumn, + expandedBlock, setExpandedBlock, + selectedHorizontalSpanId, setSelectedHorizontalSpanId, + horizontalSpanDraft, setHorizontalSpanDraft, + horizontalSpanMessage, setHorizontalSpanMessage, showMaterialPicker, setShowMaterialPicker, showMaterialModal, setShowMaterialModal, showColorPicker, setShowColorPicker, @@ -118,6 +149,7 @@ export default function useConfiguratorUI() { orderSent, setOrderSent, lastOrderId, setLastOrderId, handleModuleClick, + handleHorizontalSpanSelect, openColorPicker, closeColorPicker, openMaterialDetail, diff --git a/client/src/hooks/useWardrobeConfig.js b/client/src/hooks/useWardrobeConfig.js index 44a5458..468457f 100644 --- a/client/src/hooks/useWardrobeConfig.js +++ b/client/src/hooks/useWardrobeConfig.js @@ -2,14 +2,107 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { fetchFinishes, fetchServiceOptions, calculatePrice } from '../api/client'; import { COLUMN_TYPES, DEFAULT_CYCLE, DEFAULT_COLORS, DEFAULT_CONFIG, maxShelfGapMm, SHELF_GAP_MM, - minShelvesForStructuralHeight, resolveShelfPositionsMm + minShelvesForStructuralHeight, minShelvesForColumn, moduleStackInfo, resolveShelfPositionsMm, + moduleBlockHeightsMm, moduleBlockBacks, moduleBlockDoors, carcassBodyMm, maxBlocksForBody, + outerColumnStacked, pickLayoutPreset, moduleHangingZoneMm, ROD_CLEAR_HEIGHT_MM, + DIMENSION_RANGES } from '../constants/wardrobe'; +import { + addUpperBlockAboveHorizontalSpan, + getModuleVerticalBoundariesMm, + getHorizontalSpanUpperBlockState, + normalizeAxiloTargetHeightMm, + normalizeHorizontalSpan, + reconcileHorizontalSpans, + resizeHorizontalSpanUpperBlock, + synchronizeModuleSpanBoundaries +} from '@shared/manufacturing.js'; +import { proposeLayout, layoutModulesSignature } from '../constants/layoutProposal'; const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001'; const SESSION_KEY = 'apex_wardrobe_config'; const SESSION_COLS_KEY = 'apex_wardrobe_numcols'; const MAX_CONFIG_UNDO = 50; +function createHorizontalSpanId() { + if (globalThis.crypto?.randomUUID) return `span-${globalThis.crypto.randomUUID()}`; + return `span-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +function sameNumberArray(left, right) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => Number(value) === Number(right[index])) + ); +} + +/* + * Disposition « suivie »: tant que les colonnes correspondent à l'empreinte `autoLayoutSignature` + * (aucune personnalisation), elles sont recalculées à chaque changement de cotes selon `layoutFollow`: + * { mode: 'auto' } → proposition automatique; { mode: 'preset', presetId, modules } → disposition type + * remise à l'échelle, ou proposition automatique (fellBack) si elle ne respecte plus les règles. + */ + +/** Pré-normalise blocks et rayons comme les effets de cohérence, pour une empreinte stable. */ +function fitLayoutModules(c, sourceModules) { + const sourceWidthMm = sourceModules.reduce((sum, m) => sum + (Number(m.widthMm) || 0), 0) || c.widthMm; + const scale = c.widthMm / sourceWidthMm; + let allocatedMm = 0; + return sourceModules.map((module, index) => { + const isLast = index === sourceModules.length - 1; + const widthMm = isLast ? c.widthMm - allocatedMm : Math.round((Number(module.widthMm) || 0) * scale); + allocatedMm += widthMm; + const base = { ...module, widthMm, position: index, shelfPositionsMm: undefined }; + const sized = { ...base, blockHeightsMm: moduleBlockHeightsMm(base, c) }; + return { ...sized, shelves: Math.max(minShelvesForColumn(c.heightMm, sized, c), Number(module.shelves) || 0) }; + }); +} + +function layoutFits(c, modules) { + return modules.every((m) => { + const typeDef = COLUMN_TYPES.find((t) => t.key === m.type); + if (typeDef && (m.widthMm < typeDef.minWidth || m.widthMm > typeDef.maxWidth)) return false; + if (!m.hasRod) return true; + const zone = moduleHangingZoneMm(m, c); + return c.depthMm >= 400 && zone.clearMm >= ROD_CLEAR_HEIGHT_MM.short; + }); +} + +function withLayout(c, rawModules, layoutFollow) { + const modules = rawModules.map((module) => { + const sized = { ...module, blockHeightsMm: moduleBlockHeightsMm(module, c) }; + return { ...sized, shelves: Math.max(minShelvesForColumn(c.heightMm, sized, c), Number(module.shelves) || 0) }; + }); + return { + ...c, + modules, + stackEnabled: modules.some((m) => (m.blockHeightsMm || []).length >= 2), + horizontalSpans: [], + layoutFollow, + autoLayoutSignature: layoutModulesSignature(modules) + }; +} + +function withAutoLayout(c) { + return withLayout(c, proposeLayout(c).modules, { mode: 'auto' }); +} + +function withFollowedLayout(c) { + const follow = c.layoutFollow; + if (follow?.mode === 'preset' && Array.isArray(follow.modules)) { + const modules = fitLayoutModules(c, follow.modules); + if (layoutFits(c, modules)) return withLayout(c, modules, { ...follow, fellBack: false }); + return withLayout(c, proposeLayout(c).modules, { ...follow, fellBack: true }); + } + return withAutoLayout(c); +} + +function isLayoutFollowing(c) { + return !!c.autoLayoutSignature && layoutModulesSignature(c.modules) === c.autoLayoutSignature; +} + function loadSavedConfig() { try { const raw = sessionStorage.getItem(SESSION_KEY); @@ -33,6 +126,8 @@ export default function useWardrobeConfig() { const pushUndoSnapshot = useCallback(() => { const snap = JSON.parse(JSON.stringify(configRef.current)); + const latest = undoPastRef.current[undoPastRef.current.length - 1]; + if (latest && JSON.stringify(latest) === JSON.stringify(snap)) return; undoPastRef.current.push(snap); if (undoPastRef.current.length > MAX_CONFIG_UNDO) undoPastRef.current.shift(); undoFutureRef.current = []; @@ -43,6 +138,7 @@ export default function useWardrobeConfig() { if (undoPastRef.current.length === 0) return; const prev = undoPastRef.current.pop(); undoFutureRef.current.push(JSON.parse(JSON.stringify(configRef.current))); + configRef.current = prev; setConfig(prev); setUndoRevision((n) => n + 1); }, []); @@ -51,9 +147,79 @@ export default function useWardrobeConfig() { if (undoFutureRef.current.length === 0) return; const next = undoFutureRef.current.pop(); undoPastRef.current.push(JSON.parse(JSON.stringify(configRef.current))); + configRef.current = next; setConfig(next); setUndoRevision((n) => n + 1); }, []); + + const setConfigUndoable = useCallback((updater) => { + pushUndoSnapshot(); + setConfig(updater); + }, [pushUndoSnapshot]); + + /** Une armoire restaurée est figée (pas de suivi des cotes), sauf `buildFollowed` (disposition type). */ + const restoreConfig = useCallback((savedConfig, buildFollowed = null) => { + if (!savedConfig || typeof savedConfig !== 'object' || Array.isArray(savedConfig)) return false; + + const legacyBack = savedConfig.hasBack ?? (savedConfig.columnMode === 'block'); + const restored = { + ...DEFAULT_CONFIG, + ...savedConfig, + horizontalSpans: Array.isArray(savedConfig.horizontalSpans) ? savedConfig.horizontalSpans : [], + modules: (savedConfig.modules || []).map((module) => { + const hasBack = typeof module.hasBack === 'boolean' ? module.hasBack : legacyBack; + return { + ...module, + hasBack, + hasBackTop: typeof module.hasBackTop === 'boolean' ? module.hasBackTop : hasBack + }; + }), + nicheMeasurements: { + ...DEFAULT_CONFIG.nicheMeasurements, + ...(savedConfig.nicheMeasurements || {}) + }, + hardware: { + ...DEFAULT_CONFIG.hardware, + ...(savedConfig.hardware || {}) + }, + colors: { + ...DEFAULT_CONFIG.colors, + ...(savedConfig.colors || {}) + } + }; + restored.horizontalSpans = reconcileHorizontalSpans(restored.horizontalSpans, restored); + const next = buildFollowed + ? buildFollowed(restored) + : { ...restored, layoutFollow: null, autoLayoutSignature: null }; + + pushUndoSnapshot(); + configRef.current = next; + setConfig(next); + setNumColumns(Math.max(1, next.modules.length || 1)); + return true; + }, [pushUndoSnapshot]); + + /** + * Applique une disposition type (structure seule) en conservant matériaux, quincaillerie et type de pose. + * En niche, les cotes sont imposées par la niche: les largeurs de colonnes sont remises à l'échelle. + * La disposition suit ensuite les changements de cotes tant que les colonnes ne sont pas personnalisées. + */ + const applyLayoutPreset = useCallback((layout, presetId = null) => { + if (!layout || !Array.isArray(layout.modules) || layout.modules.length === 0) return false; + const current = configRef.current; + const structure = pickLayoutPreset(layout); + if (current.installationType === 'niche') { + delete structure.widthMm; + delete structure.heightMm; + delete structure.depthMm; + } + const follow = { mode: 'preset', presetId, modules: layout.modules, fellBack: false }; + return restoreConfig( + { ...current, ...structure }, + (restored) => withLayout(restored, fitLayoutModules(restored, layout.modules), follow) + ); + }, [restoreConfig]); + const [finishes, setFinishes] = useState([]); const [serviceOptions, setServiceOptions] = useState([]); const [priceResult, setPriceResult] = useState(null); @@ -78,18 +244,35 @@ export default function useWardrobeConfig() { // Keep extended configuration schema compatible with older saved sessions. useEffect(() => { - setConfig((c) => ({ - ...DEFAULT_CONFIG, - ...c, - nicheMeasurements: { - ...DEFAULT_CONFIG.nicheMeasurements, - ...(c.nicheMeasurements || {}) - }, - hardware: { - ...DEFAULT_CONFIG.hardware, - ...(c.hardware || {}) - } - })); + setConfig((c) => { + // Legacy sessions: global hasBack derived from the chosen assembly mode, + // then seeded on each column (dos is now per colonne). + const legacyBack = c.hasBack ?? (c.columnMode === 'block'); + const next = { + ...DEFAULT_CONFIG, + ...c, + hasBack: legacyBack, + horizontalSpans: Array.isArray(c.horizontalSpans) ? c.horizontalSpans : [], + modules: (c.modules || []).map((m) => { + const hb = typeof m.hasBack === 'boolean' ? m.hasBack : legacyBack; + return { + ...m, + hasBack: hb, + hasBackTop: typeof m.hasBackTop === 'boolean' ? m.hasBackTop : hb + }; + }), + nicheMeasurements: { + ...DEFAULT_CONFIG.nicheMeasurements, + ...(c.nicheMeasurements || {}) + }, + hardware: { + ...DEFAULT_CONFIG.hardware, + ...(c.hardware || {}) + } + }; + next.horizontalSpans = reconcileHorizontalSpans(next.horizontalSpans, next); + return next; + }); // Run once at startup for legacy session payloads. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -104,10 +287,11 @@ export default function useWardrobeConfig() { setConfig((c) => { const h = c.heightMm; const pl = c.plinthHeightMm ?? 100; - const minS = minShelvesForStructuralHeight(h); const list = c.modules || []; let touched = false; const modules = list.map((mod) => { + // Colonne empilée: la jonction bas/haut est structurelle → pas de rayon minimum imposé. + const minS = minShelvesForColumn(h, mod, c); const nRaw = mod.shelves || 0; const n = Math.max(minS, nRaw); let nextMod = mod; @@ -119,7 +303,8 @@ export default function useWardrobeConfig() { shelfCount: n, heightMm: h, plinthHeightMm: pl, - shelfPositionsMm: nextMod.shelfPositionsMm + shelfPositionsMm: nextMod.shelfPositionsMm, + stack: moduleStackInfo(mod, c) }); const nextShelfPositions = n > 0 ? resolvedShelfPositions : []; const prevShelfPositions = Array.isArray(nextMod.shelfPositionsMm) ? nextMod.shelfPositionsMm : []; @@ -174,6 +359,94 @@ export default function useWardrobeConfig() { next = { ...next, doorType: 'hinged', slidingPanelsCount: 2 }; } + // Empilage vertical PAR COLONNE — modèle N blocks (mod.blockHeightsMm / mod.blockBacks). + // config.stackEnabled / config.stackBottomHeightMm = défauts legacy pour colonnes sans valeur. + // Hauteur > panneau max → nombre minimum de blocks imposé par colonne. + let backTouched = false; + const backModules = (Array.isArray(next.modules) ? next.modules : []).map((mod) => { + const heights = moduleBlockHeightsMm(mod, next); + const backs = moduleBlockBacks(mod, next, heights.length); + const doors = moduleBlockDoors(mod, next, heights.length); + const stacked = heights.length >= 2; + const prevHeights = Array.isArray(mod.blockHeightsMm) ? mod.blockHeightsMm : null; + const blockGeometryChanged = !sameNumberArray(prevHeights, heights); + const prevBacks = Array.isArray(mod.blockBacks) ? mod.blockBacks : null; + const prevDoors = Array.isArray(mod.blockDoors) ? mod.blockDoors : null; + const same = + prevHeights && prevHeights.length === heights.length && + prevHeights.every((v, k) => v === heights[k]) && + prevBacks && prevBacks.length === backs.length && + prevBacks.every((v, k) => v === backs[k]) && + prevDoors && prevDoors.length === doors.length && + prevDoors.every((v, k) => v === doors[k]) && + mod.hasBack === backs[0] && + mod.hasBackTop === backs[backs.length - 1] && + mod.stacked === stacked && + mod.stackBottomHeightMm === heights[0]; + if (same) return mod; + backTouched = true; + return { + ...mod, + blockHeightsMm: heights, + blockBacks: backs, + blockDoors: doors, + // Miroirs legacy (anciens chemins de code / configs sauvegardées). + hasBack: backs[0], + hasBackTop: backs[backs.length - 1], + stacked, + stackBottomHeightMm: heights[0], + shelfPositionsMm: blockGeometryChanged + ? resolveShelfPositionsMm({ + shelfCount: mod.shelves ?? 0, + heightMm: next.heightMm, + plinthHeightMm: next.plinthHeightMm ?? 100, + shelfPositionsMm: undefined, + stack: heights.length >= 2 + ? moduleStackInfo({ ...mod, blockHeightsMm: heights }, next) + : null + }) + : mod.shelfPositionsMm + }; + }); + const allBack = + backModules.length > 0 && + backModules.every((m) => (m.blockBacks || []).every(Boolean)); + const expectedColumnMode = allBack ? 'block' : 'separations'; + let backModeChanged = backTouched || next.columnMode !== expectedColumnMode; + if (backModeChanged) { + next = { ...next, modules: backModules, columnMode: expectedColumnMode }; + } + + // Habillages obligatoires (installation libre): + // - colonne d'extrémité empilée → habillage de ce côté seulement + // - une zone du dessus (dernier block) sans dos → dessus de doublage obligatoire + if (next.installationType === 'free' && backModules.length > 0) { + const sideLeftMandatory = outerColumnStacked(backModules, next, 'left'); + const sideRightMandatory = outerColumnStacked(backModules, next, 'right'); + const topDoublageMandatory = backModules.some((m) => { + const backs = m.blockBacks || []; + return backs.length === 0 ? !m.hasBack : !backs[backs.length - 1]; + }); + const sideDefaults = next.sideFinishesEnabled !== false; + const patch = {}; + if (sideLeftMandatory) { + if (next.exteriorFinishesEnabled === false) patch.exteriorFinishesEnabled = true; + if ((next.sideFinishLeftEnabled ?? sideDefaults) === false) patch.sideFinishLeftEnabled = true; + } + if (sideRightMandatory) { + if (next.exteriorFinishesEnabled === false) patch.exteriorFinishesEnabled = true; + if ((next.sideFinishRightEnabled ?? sideDefaults) === false) patch.sideFinishRightEnabled = true; + } + if (topDoublageMandatory) { + if (next.exteriorFinishesEnabled === false) patch.exteriorFinishesEnabled = true; + if (next.topFinishEnabled === false) patch.topFinishEnabled = true; + } + if (Object.keys(patch).length > 0) { + next = { ...next, ...patch }; + backModeChanged = true; + } + } + const modules = Array.isArray(next.modules) ? [...next.modules] : []; const rawDrawerType = next.hardware?.drawerType || 'visible'; const drawerType = @@ -183,22 +456,29 @@ export default function useWardrobeConfig() { // Drawers constraints: from bottom, depth 400..650 by 50, and drawer columns max 1000 mm. const constrainedModules = modules.map((mod) => { const hasDrawers = (mod.drawers || 0) > 0 || mod.type === 'tiroirs'; - if (!hasDrawers) return mod; - const nextMaxWidth = Math.min(1000, mod.maxWidth || 1200); + const rawModuleDrawerType = mod.drawerType ?? rawDrawerType; + const moduleDrawerType = + rawModuleDrawerType === 'hidden' || rawModuleDrawerType === 'hidden_with_handle' + ? 'hidden' + : 'visible'; + const nextMaxWidth = hasDrawers ? Math.min(1000, mod.maxWidth || 1200) : mod.maxWidth; // Keep same reference when nothing changes to avoid render loops/flicker. - if (mod.maxWidth === nextMaxWidth) return mod; + if (mod.maxWidth === nextMaxWidth && mod.drawerType === moduleDrawerType) return mod; return { ...mod, - maxWidth: nextMaxWidth + maxWidth: nextMaxWidth, + drawerType: moduleDrawerType }; }); const modulesChanged = constrainedModules.some((m, i) => m !== modules[i]); const sanitizedHandleBore = sanitizeStep(next.hardware?.handleBoreHeightMm, 600, 1600, 10); const sanitizedDrawerHeights = (next.hardware?.drawerHeightsMm || [90, 128, 177, 240]).slice(0, 4); - const allowedHeights = [60, 90, 128, 177, 240]; + // Blum Legrabox : hauteurs de côté N/M/K/C/F. Héritage : 60 mm → 66 mm (système N). + const allowedHeights = [66, 90, 128, 177, 240]; const fixedDrawerHeights = sanitizedDrawerHeights.map((h) => { - return allowedHeights.includes(Number(h)) ? Number(h) : 128; + const v = Number(h) === 60 ? 66 : Number(h); + return allowedHeights.includes(v) ? v : 128; }); const drawerDepthStepMm = sanitizeStep(next.depthMm - 10, 400, 650, 50); @@ -211,14 +491,17 @@ export default function useWardrobeConfig() { const minNicheDepth = Math.min(niche.depthTopMm || next.depthMm, niche.depthMidMm || next.depthMm, niche.depthBottomMm || next.depthMm); const minNicheHeight = Math.min(niche.heightLeftMm || next.heightMm, niche.heightRightMm || next.heightMm); + const plinthHeightMm = normalizeAxiloTargetHeightMm(next.plinthHeightMm); + let widthMm = next.widthMm; let depthMm = next.depthMm; let heightMm = next.heightMm; if (next.installationType === 'niche') { const gap = Number(next.sanitaryGapMm || 30); - widthMm = Math.max(400, minNicheWidth - gap * 2); - depthMm = Math.max(400, minNicheDepth - gap * 2); - heightMm = Math.max(1800, minNicheHeight - gap * 2); + widthMm = Math.max(DIMENSION_RANGES.widthMm.min, minNicheWidth - gap * 2); + depthMm = Math.max(DIMENSION_RANGES.depthMm.min, minNicheDepth - gap * 2); + // The wardrobe sits on the floor: keep one sanitary gap above, not one above and below. + heightMm = Math.max(DIMENSION_RANGES.heightMm.min, minNicheHeight - gap); } const hardware = { @@ -230,10 +513,12 @@ export default function useWardrobeConfig() { }; const changed = + backModeChanged || modulesChanged || widthMm !== next.widthMm || depthMm !== next.depthMm || heightMm !== next.heightMm || + plinthHeightMm !== next.plinthHeightMm || sanitizedHandleBore !== (next.hardware?.handleBoreHeightMm || 1000) || effectiveDrawerDepthMm !== next.effectiveDrawerDepthMm || handleType !== (next.hardware?.handleType || 'standard') || @@ -248,11 +533,12 @@ export default function useWardrobeConfig() { widthMm, depthMm, heightMm, + plinthHeightMm, hardware, effectiveDrawerDepthMm }; }); - }, [config.installationType, config.nicheMeasurements, config.sanitaryGapMm, config.doorType, config.depthMm, config.heightMm, config.hardware]); + }, [config.installationType, config.nicheMeasurements, config.sanitaryGapMm, config.doorType, config.depthMm, config.heightMm, config.plinthHeightMm, config.hardware, config.hasBack, config.columnMode, config.modules, config.exteriorFinishesEnabled, config.sideFinishesEnabled, config.sideFinishLeftEnabled, config.sideFinishRightEnabled, config.topFinishEnabled, config.stackEnabled, config.stackBottomHeightMm]); // Fetch initial data useEffect(() => { @@ -296,21 +582,18 @@ export default function useWardrobeConfig() { }, [config, loading, updatePrice]); // Simple setter helper - const set = useCallback((key, value) => setConfig((c) => ({ ...c, [key]: value })), []); + const set = useCallback((key, value) => setConfigUndoable((c) => ({ ...c, [key]: value })), [setConfigUndoable]); - const setDimension = useCallback((key, valueMm) => { - const ranges = { - widthMm: { min: 400, max: 5000, step: 50 }, - heightMm: { min: 1800, max: 2800, step: 50 }, - depthMm: { min: 400, max: 800, step: 10 }, - }; - const rules = ranges[key]; + const applyDimension = useCallback((key, valueMm) => { + const rules = DIMENSION_RANGES[key]; if (!rules) return; const nextRaw = Number(valueMm); if (!Number.isFinite(nextRaw)) return; const nextStepped = Math.round(nextRaw / rules.step) * rules.step; - const next = Math.min(rules.max, Math.max(rules.min, nextStepped)); + const next = key === 'plinthHeightMm' + ? normalizeAxiloTargetHeightMm(nextRaw) + : Math.min(rules.max, Math.max(rules.min, nextStepped)); setConfig((c) => { const patch = { [key]: next }; @@ -329,8 +612,9 @@ export default function useWardrobeConfig() { niche.depthMidMm = nicheOuter; niche.depthBottomMm = nicheOuter; } else if (key === 'heightMm') { - niche.heightLeftMm = nicheOuter; - niche.heightRightMm = nicheOuter; + const nicheHeight = next + gap; + niche.heightLeftMm = nicheHeight; + niche.heightRightMm = nicheHeight; } patch.nicheMeasurements = niche; } @@ -340,6 +624,11 @@ export default function useWardrobeConfig() { }); }, []); + const setDimension = useCallback((key, valueMm) => { + pushUndoSnapshot(); + applyDimension(key, valueMm); + }, [applyDimension, pushUndoSnapshot]); + // Column generation const generateColumns = useCallback((nCols, totalW, existingModules = []) => { const lockedWidth = existingModules @@ -369,16 +658,25 @@ export default function useWardrobeConfig() { locked: false, shelves: typeDef.shelves, drawers: typeDef.drawers, - hasRod: typeDef.hasRod + hasRod: typeDef.hasRod, + drawerType: existing?.drawerType ?? configRef.current?.hardware?.drawerType ?? 'visible', + hasBack: existing?.hasBack ?? (configRef.current?.hasBack === true), + hasBackTop: existing?.hasBackTop ?? existing?.hasBack ?? (configRef.current?.hasBack === true), + stacked: existing?.stacked ?? (configRef.current?.stackEnabled === true), + stackBottomHeightMm: existing?.stackBottomHeightMm ?? configRef.current?.stackBottomHeightMm ?? 1000 }; }); }, []); const handleNumColumnsChange = useCallback((newNum) => { + pushUndoSnapshot(); setNumColumns(newNum); const modules = generateColumns(newNum, config.widthMm, config.modules); - setConfig(c => ({ ...c, modules })); - }, [config.widthMm, config.modules, generateColumns]); + setConfig((c) => { + const next = { ...c, modules }; + return { ...next, horizontalSpans: reconcileHorizontalSpans(c.horizontalSpans, next) }; + }); + }, [config.widthMm, config.modules, generateColumns, pushUndoSnapshot]); // AI layout optimization const handleAIOptimize = useCallback(async () => { @@ -406,13 +704,15 @@ export default function useWardrobeConfig() { if (!res.ok) throw new Error('API error'); const data = await res.json(); if (data.modules && Array.isArray(data.modules)) { + pushUndoSnapshot(); setConfig(c => { const newModules = data.modules.map((m, i) => ({ ...c.modules[i], ...m, id: c.modules[i]?.id || `col-${i}` })); - return { ...c, modules: newModules }; + const next = { ...c, modules: newModules }; + return { ...next, horizontalSpans: reconcileHorizontalSpans(c.horizontalSpans, next) }; }); setNumColumns(data.modules.length); } @@ -424,13 +724,14 @@ export default function useWardrobeConfig() { } finally { setAiOptimizing(false); } - }, [config]); + }, [config, pushUndoSnapshot]); const handleColumnTypeChange = useCallback((index, typeKey) => { + pushUndoSnapshot(); const typeDef = COLUMN_TYPES.find(t => t.key === typeKey) || COLUMN_TYPES[0]; setConfig(c => { const modules = [...c.modules]; - const minS = minShelvesForStructuralHeight(c.heightMm); + const minS = minShelvesForColumn(c.heightMm, modules[index], c); const shelves = Math.max(minS, typeDef.shelves); modules[index] = { ...modules[index], @@ -442,15 +743,58 @@ export default function useWardrobeConfig() { }; return { ...c, modules }; }); - }, []); + }, [pushUndoSnapshot]); + + /** Applique le dos (ou son absence) à toutes les zones (tous blocks) + défaut pour les nouvelles colonnes. */ + const setAllBacks = useCallback((hasBack) => { + pushUndoSnapshot(); + setConfig((c) => ({ + ...c, + hasBack, + modules: (c.modules || []).map((m) => ({ + ...m, + hasBack, + hasBackTop: hasBack, + blockBacks: Array.isArray(m.blockBacks) ? m.blockBacks.map(() => hasBack) : m.blockBacks + })) + })); + }, [pushUndoSnapshot]); + + /** Applique un nombre de blocks (répartition égale) à toutes les colonnes + défaut pour les nouvelles. */ + const setAllBlockCounts = useCallback((count) => { + pushUndoSnapshot(); + setConfig((c) => { + const body = carcassBodyMm(c); + const n = Math.min(Math.max(1, Number(count) || 1), maxBlocksForBody(body)); + const heights = n <= 1 ? [body] : Array.from({ length: n }, () => body / n); + return { + ...c, + stackEnabled: n >= 2, + modules: (c.modules || []).map((m) => { + const nextModule = { ...m, blockHeightsMm: heights.slice() }; + return { + ...nextModule, + shelfPositionsMm: resolveShelfPositionsMm({ + shelfCount: nextModule.shelves ?? 0, + heightMm: c.heightMm, + plinthHeightMm: c.plinthHeightMm ?? 100, + shelfPositionsMm: undefined, + stack: moduleStackInfo(nextModule, c) + }) + }; + }) + }; + }); + }, [pushUndoSnapshot]); const handleColumnLock = useCallback((index) => { + pushUndoSnapshot(); setConfig(c => { const modules = [...c.modules]; modules[index] = { ...modules[index], locked: !modules[index].locked }; return { ...c, modules }; }); - }, []); + }, [pushUndoSnapshot]); const redistributeWidths = useCallback(() => { setConfig(c => { @@ -466,29 +810,46 @@ export default function useWardrobeConfig() { }); }, []); - // Re-distribute when width changes + // Cotes modifiées: la disposition suivie (auto ou type) est recalculée tant que les colonnes n'ont pas été + // personnalisées, sinon simple redistribution des largeurs. Le suivi est évalué sur l'état du rendu, avant que les + // normalisations (blocks, rayons) mises en file par les effets précédents ne modifient les colonnes. useEffect(() => { - if (config.modules.length > 0) { - redistributeWidths(); + if (config.modules.length === 0) return; + if (isLayoutFollowing(config)) { + setConfig(withFollowedLayout); + return; } - }, [config.widthMm]); + const totalMm = config.modules.reduce((sum, m) => sum + (m.widthMm || 0), 0); + if (totalMm !== config.widthMm) redistributeWidths(); + }, [config.widthMm, config.heightMm, config.depthMm, config.plinthHeightMm]); + + useEffect(() => { + setNumColumns(Math.max(1, config.modules.length || 1)); + }, [config.modules.length]); // Initialize columns on first load useEffect(() => { - if (config.modules.length === 0) { - const modules = generateColumns(numColumns, config.widthMm, []); - setConfig(c => ({ ...c, modules })); - } + if (config.modules.length === 0) setConfig(withAutoLayout); }, []); - const handleColumnUpdate = useCallback((index, updates) => { + /** Réactive la proposition automatique (colonnes recalculées selon les cotes). */ + const enableAutoLayout = useCallback(() => { + pushUndoSnapshot(); + setConfig(withAutoLayout); + }, [pushUndoSnapshot]); + + const applyColumnUpdate = useCallback((index, updates) => { setConfig(c => { const modules = [...c.modules]; const prev = modules[index]; const merged = { ...prev, ...updates }; - const minS = minShelvesForStructuralHeight(c.heightMm); + const minS = minShelvesForColumn(c.heightMm, merged, c); const shelves = Math.max(minS, merged.shelves ?? 0); const drawers = merged.drawers ?? 0; + const blockGeometryChanged = !sameNumberArray( + moduleBlockHeightsMm(prev, c), + moduleBlockHeightsMm(merged, c) + ); const withShelves = { ...merged, shelves, @@ -496,7 +857,8 @@ export default function useWardrobeConfig() { shelfCount: shelves, heightMm: c.heightMm, plinthHeightMm: c.plinthHeightMm ?? 100, - shelfPositionsMm: merged.shelfPositionsMm + shelfPositionsMm: blockGeometryChanged ? undefined : merged.shelfPositionsMm, + stack: moduleStackInfo(merged, c) }) }; const needsMixte = shelves > 0 && drawers > 0 && withShelves.type !== 'mixte'; @@ -507,26 +869,139 @@ export default function useWardrobeConfig() { }); }, []); + const handleColumnUpdate = useCallback((index, updates) => { + pushUndoSnapshot(); + applyColumnUpdate(index, updates); + }, [applyColumnUpdate, pushUndoSnapshot]); + const reorderModules = useCallback((fromIndex, toIndex) => { if (fromIndex === toIndex) return; + pushUndoSnapshot(); setConfig((c) => { const list = [...c.modules]; const n = list.length; if (fromIndex < 0 || fromIndex >= n || toIndex < 0 || toIndex >= n) return c; const [item] = list.splice(fromIndex, 1); list.splice(toIndex, 0, item); - return { + const next = { ...c, modules: list.map((m, idx) => ({ ...m, position: idx })), }; + return { ...next, horizontalSpans: reconcileHorizontalSpans(c.horizontalSpans, next) }; }); - }, []); + }, [pushUndoSnapshot]); - const handleResizeModules = useCallback((separatorIndex, deltaXmm) => { + /** + * Create a canonical horizontal span as one undoable state transition. + * Its boundaries and open door bands must land in the same React update as + * the span itself; otherwise rapid queued updates can snapshot stale state. + */ + const createHorizontalSpan = useCallback((spanInput = {}) => { + const id = spanInput.id ?? createHorizontalSpanId(); + pushUndoSnapshot(); + setConfig((c) => { + const moduleIds = Array.isArray(spanInput.moduleIds) ? spanInput.moduleIds : []; + const synchronized = synchronizeModuleSpanBoundaries( + c, + moduleIds, + spanInput.bottomMm, + spanInput.topMm + ); + const selectedIds = new Set(moduleIds.map(String)); + const bottomMm = Number(spanInput.bottomMm); + const topMm = Number(spanInput.topMm); + const modules = synchronized.modules.map((module) => { + if (!selectedIds.has(String(module.id))) return module; + const edges = getModuleVerticalBoundariesMm(module, synchronized); + const doors = moduleBlockDoors(module, synchronized, edges.length - 1); + return { + ...module, + blockDoors: doors.map((hasDoor, index) => ( + edges[index] >= bottomMm - 0.01 && edges[index + 1] <= topMm + 0.01 + ? false + : hasDoor + )) + }; + }); + const next = { ...synchronized, modules }; + const span = normalizeHorizontalSpan({ ...spanInput, id }, modules); + return { ...next, horizontalSpans: [...(c.horizontalSpans || []), span] }; + }); + return id; + }, [pushUndoSnapshot]); + + /** Patch a span without changing its id. */ + const updateHorizontalSpan = useCallback((spanId, updates = {}) => { + if (!(configRef.current.horizontalSpans || []).some((span) => String(span.id) === String(spanId))) return false; + pushUndoSnapshot(); + setConfig((c) => ({ + ...c, + horizontalSpans: (c.horizontalSpans || []).map((span) => + String(span.id) === String(spanId) + ? normalizeHorizontalSpan({ ...span, ...updates, id: span.id }, c.modules) + : span + ) + })); + return true; + }, [pushUndoSnapshot]); + + const deleteHorizontalSpan = useCallback((spanId) => { + if (!(configRef.current.horizontalSpans || []).some((span) => String(span.id) === String(spanId))) return false; + pushUndoSnapshot(); + setConfig((c) => ({ + ...c, + horizontalSpans: (c.horizontalSpans || []).filter((span) => String(span.id) !== String(spanId)) + })); + return true; + }, [pushUndoSnapshot]); + + /** + * Add the selected bottom/top limits to every covered column's vertical block model. + * Existing block back/door flags are inherited by each resulting segment. + */ + const synchronizeHorizontalSpanBoundaries = useCallback((moduleIds, bottomMm, topMm) => { + if (!Array.isArray(moduleIds) || moduleIds.length === 0) return false; + pushUndoSnapshot(); + setConfig((c) => synchronizeModuleSpanBoundaries(c, moduleIds, bottomMm, topMm)); + return true; + }, [pushUndoSnapshot]); + + /** Add the shared 300 mm regular block and move the span boundary in one undoable update. */ + const addHorizontalSpanUpperBlock = useCallback((spanId) => { + const result = addUpperBlockAboveHorizontalSpan(configRef.current, spanId); + if (!result.ok) return result; + pushUndoSnapshot(); + configRef.current = result.config; + setConfig(result.config); + return result; + }, [pushUndoSnapshot]); + + /** Resize the shared upper block across every covered column in one undoable update. */ + const resizeHorizontalSpanUpperBlockHeight = useCallback((spanId, heightMm) => { + const result = resizeHorizontalSpanUpperBlock(configRef.current, spanId, heightMm); + if (!result.ok || result.config === configRef.current) return result; + pushUndoSnapshot(); + configRef.current = result.config; + setConfig(result.config); + return result; + }, [pushUndoSnapshot]); + + const getHorizontalSpanUpperBlock = useCallback( + (spanId) => getHorizontalSpanUpperBlockState(configRef.current, spanId), + [] + ); + + /** `base` (gesture start widths) makes the delta absolute, so repeated moves never accumulate rounding. */ + const handleResizeModules = useCallback((separatorIndex, deltaXmm, base) => { setConfig((c) => { const modules = [...c.modules]; + if (!modules[separatorIndex] || !modules[separatorIndex + 1]) return c; const left = { ...modules[separatorIndex] }; const right = { ...modules[separatorIndex + 1] }; + if (base) { + left.widthMm = base.leftMm; + right.widthMm = base.rightMm; + } const leftMin = left.minWidth || 300; const leftMax = left.maxWidth || 1200; @@ -543,6 +1018,10 @@ export default function useWardrobeConfig() { left.widthMm = Math.round(newLeftW); right.widthMm = Math.round(newRightW); + if ( + left.widthMm === c.modules[separatorIndex].widthMm + && right.widthMm === c.modules[separatorIndex + 1].widthMm + ) return c; modules[separatorIndex] = left; modules[separatorIndex + 1] = right; return { ...c, modules }; @@ -555,6 +1034,7 @@ export default function useWardrobeConfig() { }, []); const handleColorSelect = useCallback((elementType, finish) => { + pushUndoSnapshot(); setConfig((c) => ({ ...c, colors: { @@ -567,9 +1047,10 @@ export default function useWardrobeConfig() { } } })); - }, []); + }, [pushUndoSnapshot]); const handleColorApplyToAll = useCallback((finish) => { + pushUndoSnapshot(); const colorEntry = { finishId: finish.id, label: finish.label || finish.name, @@ -586,18 +1067,20 @@ export default function useWardrobeConfig() { }); return { ...c, colors: newColors }; }); - }, []); + }, [pushUndoSnapshot]); // Hardware const setHardware = useCallback((key, value) => { + pushUndoSnapshot(); setConfig((c) => ({ ...c, hardware: { ...c.hardware, [key]: value } })); - }, []); + }, [pushUndoSnapshot]); // Material management const handleMaterialConfirm = useCallback((materialId, dimensionId) => { + pushUndoSnapshot(); setConfig((c) => { const material = finishes.find(f => f.id === materialId); const updated = { ...c, finishId: materialId, dimensionId }; @@ -619,7 +1102,7 @@ export default function useWardrobeConfig() { } return updated; }); - }, [finishes]); + }, [finishes, pushUndoSnapshot]); // Derived values const totalModuleWidth = useMemo(() => @@ -643,7 +1126,10 @@ export default function useWardrobeConfig() { ); return { - config, setConfig, set, setDimension, + config, setConfig: setConfigUndoable, restoreConfig, applyLayoutPreset, enableAutoLayout, + layoutFollow: isLayoutFollowing(config) ? (config.layoutFollow || { mode: 'auto' }) : null, + set, setDimension, + setDimensionWithoutUndo: applyDimension, finishes, serviceOptions, priceResult, loading, @@ -655,9 +1141,19 @@ export default function useWardrobeConfig() { handleAIOptimize, handleColumnTypeChange, handleColumnLock, + setAllBacks, + setAllBlockCounts, redistributeWidths, handleColumnUpdate, + handleColumnUpdateWithoutUndo: applyColumnUpdate, reorderModules, + createHorizontalSpan, + updateHorizontalSpan, + deleteHorizontalSpan, + synchronizeHorizontalSpanBoundaries, + addHorizontalSpanUpperBlock, + resizeHorizontalSpanUpperBlockHeight, + getHorizontalSpanUpperBlock, handleResizeModules, handleElementClick, handleColorSelect, diff --git a/client/src/index.css b/client/src/index.css index d40f421..a886a57 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -15,6 +15,7 @@ --warning: #f59e0b; --danger: #dc2626; --font: 'DM Sans', system-ui, sans-serif; + --header-height: 63px; } * { diff --git a/client/src/pages/Admin.jsx b/client/src/pages/Admin.jsx index eabd74c..0df891d 100644 --- a/client/src/pages/Admin.jsx +++ b/client/src/pages/Admin.jsx @@ -7,6 +7,10 @@ import { getFormexSyncStreamUrl, getHaefeleSyncStreamUrl, fetchHaefeleMaterials, + getLeubaHiagSyncStreamUrl, + fetchLeubaHiagMaterials, + fetchLeubaHiagTargets, + setFormexMaterialActive, fetchOrderSupplierVouchers, fetchOrderPlanExports, generateOrderPlanExports, @@ -16,8 +20,16 @@ import { deleteOrderPlanExport } from '../api/client'; import Icon from '../components/ui/Icon'; +import SectionNav from '../components/SectionNav'; import styles from './Admin.module.css'; +const ADMIN_SECTIONS = [ + { id: 'sync-formex', label: 'Formex' }, + { id: 'sync-hafele', label: 'Häfele' }, + { id: 'sync-leuba', label: 'Leuba Hiag' }, + { id: 'commandes', label: 'Commandes' } +]; + export default function Admin() { const { isAdmin, logout } = useAuth(); const navigate = useNavigate(); @@ -61,17 +73,42 @@ export default function Admin() { const [haefeleError, setHaefeleError] = useState(''); const haefeleLogsEndRef = useRef(null); + // Leuba Hiag sync state + const [leubaTargets, setLeubaTargets] = useState([]); + const [leubaSelected, setLeubaSelected] = useState([]); + const [leubaActivate, setLeubaActivate] = useState(false); + const [leubaFetchPrices, setLeubaFetchPrices] = useState(true); + const [leubaSyncing, setLeubaSyncing] = useState(false); + const [leubaSyncResult, setLeubaSyncResult] = useState(null); + const [leubaLiveMessages, setLeubaLiveMessages] = useState([]); + const [leubaProgress, setLeubaProgress] = useState({ current: 0, total: 0, collection: '' }); + const [leubaSearch, setLeubaSearch] = useState(''); + const [leubaMaterials, setLeubaMaterials] = useState([]); + const [leubaLoading, setLeubaLoading] = useState(false); + const [leubaError, setLeubaError] = useState(''); + const leubaLogsEndRef = useRef(null); + useEffect(() => { if (!isAdmin) { navigate('/login?next=/admin', { replace: true }); return; } // Fetch orders and sync logs in parallel - Promise.all([fetchOrders(), fetchSyncLogs(10), fetchHaefeleMaterials({ limit: 50 })]) - .then(([ordersData, logsData, haefeleData]) => { + Promise.all([ + fetchOrders(), + fetchSyncLogs(10), + fetchHaefeleMaterials({ limit: 50 }), + fetchLeubaHiagMaterials({ limit: 100 }), + fetchLeubaHiagTargets() + ]) + .then(([ordersData, logsData, haefeleData, leubaData, leubaTargetsData]) => { setOrders(ordersData); setSyncLogs(logsData); setHaefeleMaterials(haefeleData?.materials || []); + setLeubaMaterials(leubaData?.materials || []); + const targets = leubaTargetsData || []; + setLeubaTargets(targets); + setLeubaSelected(targets.map((t) => t.key)); }) .catch((err) => setError(err.message)) .finally(() => setLoading(false)); @@ -90,6 +127,12 @@ export default function Admin() { } }, [haefeleLiveMessages]); + useEffect(() => { + if (leubaLogsEndRef.current) { + leubaLogsEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [leubaLiveMessages]); + useEffect(() => { const saved = window.localStorage.getItem('admin.haefele.urls'); if (saved) setHaefeleUrlsInput(saved); @@ -255,12 +298,118 @@ export default function Admin() { setHaefeleProgress({ current: 0, total: 0, collection: '' }); }; + const refreshLeubaMaterials = async (search = leubaSearch) => { + setLeubaLoading(true); + setLeubaError(''); + try { + const data = await fetchLeubaHiagMaterials({ search, limit: 200 }); + setLeubaMaterials(data?.materials || []); + } catch (err) { + setLeubaError(err.message || 'Erreur de chargement Leuba Hiag'); + } finally { + setLeubaLoading(false); + } + }; + + const toggleLeubaTarget = (key) => { + setLeubaSelected((prev) => + prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key] + ); + }; + + const handleToggleLeubaActive = async (item) => { + const nextActive = !item.active; + // Optimistic update so the change is reflected immediately. + setLeubaMaterials((prev) => + prev.map((m) => (m.id === item.id ? { ...m, active: nextActive ? 1 : 0 } : m)) + ); + try { + await setFormexMaterialActive(item.id, nextActive); + } catch (err) { + // Revert on failure. + setLeubaMaterials((prev) => + prev.map((m) => (m.id === item.id ? { ...m, active: item.active } : m)) + ); + setLeubaError(err.message || 'Échec de la mise à jour du statut'); + } + }; + + const handleLeubaSync = () => { + if (leubaSelected.length === 0) { + setLeubaSyncResult({ success: false, error: 'Sélectionnez au moins une gamme à importer.' }); + return; + } + + setLeubaSyncing(true); + setLeubaSyncResult(null); + setLeubaLiveMessages([]); + setLeubaProgress({ current: 0, total: leubaSelected.length, collection: 'Leuba Hiag' }); + + const eventSource = new EventSource( + getLeubaHiagSyncStreamUrl({ targets: leubaSelected, activate: leubaActivate, fetchPrices: leubaFetchPrices }) + ); + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.type === 'log') { + const msg = data.message; + const lower = msg.toLowerCase(); + const kind = lower.includes('erreur') || lower.includes('error') || lower.includes('✗') + ? 'error' + : lower.includes('✓') || lower.includes('importé') || lower.includes('détecté') + ? 'ok' + : 'info'; + setLeubaLiveMessages((prev) => [...prev, { msg, kind }]); + } else if (data.type === 'progress') { + setLeubaProgress({ + current: data.current, + total: data.total, + collection: data.collection || 'Leuba Hiag' + }); + } else if (data.type === 'complete') { + setLeubaSyncResult(data.result); + setLeubaSyncing(false); + eventSource.close(); + fetchSyncLogs(10).then(setSyncLogs).catch(() => {}); + refreshLeubaMaterials().catch(() => {}); + } else if (data.type === 'error') { + setLeubaLiveMessages((prev) => [...prev, { msg: `ERREUR: ${data.error}`, kind: 'error' }]); + setLeubaSyncResult({ success: false, error: data.error }); + setLeubaSyncing(false); + eventSource.close(); + fetchSyncLogs(10).then(setSyncLogs).catch(() => {}); + } + } catch (e) { + console.error('Leuba Hiag SSE parse error:', e); + } + }; + + eventSource.onerror = () => { + setLeubaLiveMessages((prev) => [...prev, { msg: 'Connexion SSE Leuba Hiag perdue', kind: 'error' }]); + setLeubaSyncResult({ success: false, error: 'Connexion perdue' }); + setLeubaSyncing(false); + eventSource.close(); + fetchSyncLogs(10).then(setSyncLogs).catch(() => {}); + refreshLeubaMaterials().catch(() => {}); + }; + }; + + const handleClearLeubaLogs = () => { + setLeubaLiveMessages([]); + setLeubaSyncResult(null); + setLeubaProgress({ current: 0, total: 0, collection: '' }); + }; + const progressPercent = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0; const haefeleProgressPercent = haefeleProgress.total > 0 ? Math.round((haefeleProgress.current / haefeleProgress.total) * 100) : 0; + const leubaProgressPercent = leubaProgress.total > 0 + ? Math.round((leubaProgress.current / leubaProgress.total) * 100) + : 0; const handleToggleSupplierDetails = async (orderId) => { if (expandedOrderId === orderId) { @@ -365,10 +514,12 @@ export default function Admin() {
{loading &&

Chargement…

} {error &&

{error}

} + + {!loading && !error && } {/* Formex Sync Section */} {!loading && !error && ( -
+

Synchronisation Formex

+ + + {leubaSyncResult && !leubaSyncing && ( + + {leubaSyncResult.success + ? `${leubaSyncResult.itemsCount} produits importés${leubaSyncResult.pricedCount ? `, ${leubaSyncResult.pricedCount} prix` : ''} (${leubaSyncResult.duration}s)` + : `Erreur: ${leubaSyncResult.error}`} + + )} +
+ + {(leubaSyncing || leubaLiveMessages.length > 0) && ( +
+
+
+ {leubaProgress.collection || (leubaSyncing ? 'Initialisation…' : 'Terminé')} + + {leubaSyncing + ? `${leubaProgressPercent}%` + : (leubaSyncResult?.success ? '✓ Succès' : '✗ Échec')} + +
+
+
+
+
+ {leubaProgress.total > 0 && `${leubaProgress.current} / ${leubaProgress.total} gammes`} +
+
+
+
+ Journal Leuba Hiag ({leubaLiveMessages.length} lignes) + {!leubaSyncing && ( + + )} +
+
+ {leubaLiveMessages.map(({ msg, kind }, idx) => ( +
{msg}
+ ))} +
+
+
+
+ )} + +
+

Articles Leuba Hiag importés

+
+ setLeubaSearch(e.target.value)} + placeholder="Rechercher (nom, code, gamme)" + /> + +
+
+ {leubaError &&

{leubaError}

} +
+ + + + + + + + + + + + + + {leubaMaterials.length === 0 ? ( + + + + ) : ( + leubaMaterials.map((item) => ( + + + + + + + + + + )) + )} + +
CodeNomGammeDimensions (mm)Prix CHFActifURL
+ {leubaLoading ? 'Chargement…' : 'Aucun article Leuba Hiag importé.'} +
{item.formex_code || '—'}{item.name || '—'}{item.collection || '—'} + {item.thickness_mm && item.height_mm && item.width_mm + ? `${item.thickness_mm} × ${item.height_mm} × ${item.width_mm}` + : item.thickness_mm ? `ép. ${item.thickness_mm}` : '—'} + {item.price_chf != null ? `${Number(item.price_chf).toFixed(2)}` : '—'} + + + {item.formex_url ? ( + + Voir page + + ) : '—'} +
+
+
+ )} + {/* Orders Section */} {!loading && !error && ( -
+

Commandes

diff --git a/client/src/pages/Admin.module.css b/client/src/pages/Admin.module.css index 136a8e9..9ba458f 100644 --- a/client/src/pages/Admin.module.css +++ b/client/src/pages/Admin.module.css @@ -300,6 +300,70 @@ margin-top: 0.75rem; } +.leubaTargets { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.5rem; + margin: 0.75rem 0; +} + +.leubaTargetItem { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.65rem; + border: 1px solid var(--border, #e2e2e2); + border-radius: 10px; + cursor: pointer; + user-select: none; +} + +.leubaTargetItem:has(input:checked) { + border-color: var(--accent, #e67e22); + background: rgba(230, 126, 34, 0.06); +} + +.leubaTargetItem input[type="checkbox"] { + accent-color: var(--accent, #e67e22); +} + +.leubaTargetItem span { + display: flex; + flex-direction: column; + line-height: 1.2; +} + +.leubaTargetItem small { + color: var(--text-secondary, #777); + font-size: 0.72rem; +} + +.activeToggle { + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.15rem 0.7rem; + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; + line-height: 1.3; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.activeToggleOn { + background: rgba(22, 163, 74, 0.12); + border-color: rgba(22, 163, 74, 0.5); + color: #16a34a; +} + +.activeToggleOff { + background: rgba(120, 120, 120, 0.08); + color: var(--text-secondary, #777); +} + +.activeToggle:hover { + border-color: var(--accent, #e67e22); +} + .urlTextarea { width: 100%; padding: 0.75rem; diff --git a/client/src/pages/Configurator.jsx b/client/src/pages/Configurator.jsx index c5d2c45..a45641c 100644 --- a/client/src/pages/Configurator.jsx +++ b/client/src/pages/Configurator.jsx @@ -14,6 +14,7 @@ import RoomUpload from '../components/RoomUpload'; import ColorPickerModal from '../components/ColorPickerModal'; import Plans2DView from '../components/Plans2DView'; import AIRenderModal from '../components/AIRenderModal'; +import SavedDesignsModal from '../components/SavedDesignsModal'; import styles from './Configurator.module.css'; export default function Configurator() { @@ -22,6 +23,7 @@ export default function Configurator() { // Track which steps have been visited const [touched, setTouched] = useState(new Set([1])); + const [showSavedDesigns, setShowSavedDesigns] = useState(false); const handleStepChange = (step) => { ui.setActiveStep(step); setTouched(prev => new Set(prev).add(step)); @@ -77,6 +79,11 @@ export default function Configurator() { setShowSavedDesigns(true)} + canUndo={wc.canUndo} + canRedo={wc.canRedo} + onUndo={wc.undo} + onRedo={wc.redo} totalChf={totalChf} touched={touched} /> @@ -100,6 +107,18 @@ export default function Configurator() { {/* ===== Modals ===== */} + {showSavedDesigns && ( + { + if (wc.restoreConfig(savedConfig)) { + setTouched(new Set([1, 2, 3, 4, 5, 6])); + } + }} + onClose={() => setShowSavedDesigns(false)} + /> + )} + {ui.showMaterialModal && ui.selectedMaterial && ( @@ -12,7 +21,9 @@ export default function Home() { Configurer mon armoire -
+ + +

Pourquoi SIGNATURE

  • Blocs modulaires — Penderie, rayonnage, mixtes ; assemblez selon vos besoins.
  • @@ -21,7 +32,7 @@ export default function Home() {
-
+

Comment ça marche

  1. Configurez en ligne (dimensions, blocs, portes, finitions).
  2. @@ -31,20 +42,20 @@ export default function Home() {
-
+

Le système modulaire

Armoire composée de blocs juxtaposés : penderie, rayonnage, blocs mixtes. Chaque bloc est paramétrable en largeur. Portes battantes ou coulissantes selon la largeur.

-
+

Tarifs

À partir de CHF 3'900.–

Prix indicatif pour une configuration type. Le configurateur affiche le prix exact selon vos choix.

-
+

Services optionnels

  • Prise de mesure sur site
  • diff --git a/client/src/pages/Legal.jsx b/client/src/pages/Legal.jsx new file mode 100644 index 0000000..c5c8813 --- /dev/null +++ b/client/src/pages/Legal.jsx @@ -0,0 +1,195 @@ +import { Link, useLocation } from 'react-router-dom'; +import styles from './Legal.module.css'; + +const COMPANY = { + name: 'SIGNATURE', + legalName: 'SIGNATURE Sàrl', + address: 'Rue de l\u2019Exemple 1, 1000 Lausanne, Suisse', + email: 'contact@signature.ch', + phone: '+41 21 000 00 00', + ide: 'CHE-000.000.000', + director: 'Direction SIGNATURE' +}; + +const SECTIONS = [ + { id: 'mentions', path: '/mentions-legales', label: 'Mentions légales' }, + { id: 'confidentialite', path: '/confidentialite', label: 'Politique de confidentialité' }, + { id: 'cgv', path: '/conditions-generales', label: 'Conditions générales' }, + { id: 'reglements', path: '/reglements', label: 'Règlements' } +]; + +function sectionFromPath(pathname) { + const match = SECTIONS.find((s) => s.path === pathname); + return match ? match.id : 'mentions'; +} + +export default function Legal() { + const location = useLocation(); + const active = sectionFromPath(location.pathname); + + return ( +
    + + +
    + {active === 'mentions' && } + {active === 'confidentialite' && } + {active === 'cgv' && } + {active === 'reglements' && } +

    Dernière mise à jour : {new Date().toLocaleDateString('fr-CH')}

    +
    +
    + ); +} + +function Mentions() { + return ( +
    +

    Mentions légales / Impressum

    +

    + Le présent site et la plateforme de configuration sont édités par {COMPANY.legalName}. +

    +

    Éditeur

    +
      +
    • Raison sociale : {COMPANY.legalName}
    • +
    • Adresse : {COMPANY.address}
    • +
    • Courriel : {COMPANY.email}
    • +
    • Téléphone : {COMPANY.phone}
    • +
    • N° IDE : {COMPANY.ide}
    • +
    • Responsable de la publication : {COMPANY.director}
    • +
    +

    Hébergement

    +

    + L\u2019application est hébergée sur une infrastructure sécurisée. Les coordonnées de + l\u2019hébergeur peuvent être obtenues sur demande à l\u2019adresse {COMPANY.email}. +

    +

    Propriété intellectuelle

    +

    + L\u2019ensemble des contenus (textes, visuels, modèles 3D, configurateur, marques) est + protégé. Toute reproduction sans autorisation préalable est interdite. +

    +
    + ); +} + +function Confidentialite() { + return ( +
    +

    Politique de confidentialité

    +

    + {COMPANY.legalName} traite les données personnelles conformément à la Loi fédérale sur la + protection des données (nLPD) et, le cas échéant, au RGPD. +

    +

    Données collectées

    +
      +
    • Données d\u2019identification (nom, prénom, courriel, téléphone) lors d\u2019une demande de devis ou commande.
    • +
    • Données de configuration des projets (dimensions, finitions, options).
    • +
    • Données techniques de connexion (journaux, type de navigateur) à des fins de sécurité.
    • +
    +

    Finalités

    +
      +
    • Établissement des devis, traitement et suivi des commandes.
    • +
    • Transmission des bons de commande aux fournisseurs / à l\u2019usine de production.
    • +
    • Amélioration du configurateur et support client.
    • +
    +

    Conservation

    +

    + Les données sont conservées pour la durée nécessaire au traitement de la relation + commerciale et aux obligations légales (notamment comptables). +

    +

    Vos droits

    +

    + Vous disposez d\u2019un droit d\u2019accès, de rectification, d\u2019effacement et d\u2019opposition. + Pour l\u2019exercer, écrivez à {COMPANY.email}. +

    +

    Cookies

    +

    + La plateforme utilise des cookies strictement nécessaires à son fonctionnement (session, + préférences). Aucun cookie publicitaire tiers n\u2019est déposé sans consentement. +

    +
    + ); +} + +function ConditionsGenerales() { + return ( +
    +

    Conditions générales de vente (CGV)

    +

    1. Objet

    +

    + Les présentes conditions régissent la vente de mobilier sur mesure configuré via la + plateforme {COMPANY.name}. +

    +

    2. Devis et commande

    +

    + Toute configuration validée donne lieu à un devis. La commande est ferme après + confirmation écrite et, le cas échéant, versement d\u2019un acompte. +

    +

    3. Prix

    +

    + Les prix sont indiqués en francs suisses (CHF), TVA en sus au taux en vigueur. Les + produits étant fabriqués sur mesure, ils ne sont ni repris ni échangés. +

    +

    4. Délais et livraison

    +

    + Les délais sont communiqués à titre indicatif. {COMPANY.legalName} ne saurait être tenue + responsable des retards dus aux fournisseurs ou à des cas de force majeure. +

    +

    5. Garantie

    +

    + Les produits bénéficient de la garantie légale pour les défauts de conformité. Les + dimensions sur mesure relèvent de la responsabilité du client lors de la validation. +

    +

    6. Droit applicable

    +

    + Les présentes CGV sont soumises au droit suisse. Tout litige relève des tribunaux + compétents du siège de {COMPANY.legalName}. +

    +
    + ); +} + +function Reglements() { + return ( +
    +

    Règlements et conformité

    +

    Matériaux et émissions

    +

    + Les panneaux utilisés répondent aux exigences d\u2019émission de formaldéhyde (classe E1 / + CARB2) conformément à l\u2019ordonnance sur les produits chimiques (OChim). +

    +

    Déclaration du bois (ODoB)

    +

    + Conformément à l\u2019ordonnance sur la déclaration concernant le bois et les produits en + bois, l\u2019essence et la provenance sont indiquées sur la facture lorsque requis. +

    +

    Indication des prix (OIP)

    +

    + Les prix affichés tiennent compte de l\u2019ordonnance sur l\u2019indication des prix, TVA + incluse lorsque applicable. +

    +

    Sécurité des paiements

    +

    + Les paiements en ligne éventuels transitent par une passerelle certifiée PCI-DSS. Aucune + donnée bancaire n\u2019est hébergée par {COMPANY.legalName}. +

    +

    + Ce contenu est fourni à titre informatif et doit être validé par votre conseil juridique + avant publication définitive. +

    +
    + ); +} diff --git a/client/src/pages/Legal.module.css b/client/src/pages/Legal.module.css new file mode 100644 index 0000000..160fc73 --- /dev/null +++ b/client/src/pages/Legal.module.css @@ -0,0 +1,99 @@ +.page { + display: grid; + grid-template-columns: 260px 1fr; + gap: 2rem; + padding: 2rem 0 3rem; + align-items: start; +} + +.pageTitle { + font-size: 1.1rem; + font-weight: 700; + margin: 0 0 1rem; + color: var(--primary, #111827); +} + +.sidebar { + position: sticky; + top: calc(var(--header-height) + 1.5rem); +} + +.nav { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.navLink { + padding: 0.55rem 0.75rem; + border-radius: 8px; + color: var(--text-muted, #6b7280); + font-weight: 500; + text-decoration: none; +} + +.navLink:hover { + background: var(--bg-card, #f3f4f6); + color: var(--primary, #111827); +} + +.navLinkActive { + background: var(--bg-card, #eef2ff); + color: var(--primary, #111827); + font-weight: 600; +} + +.content { + max-width: 760px; + line-height: 1.6; + color: var(--text, #1f2937); +} + +.section h2 { + font-size: 1.5rem; + margin: 0 0 1rem; +} + +.section h3 { + font-size: 1.05rem; + margin: 1.5rem 0 0.5rem; +} + +.section p { + margin: 0 0 0.75rem; +} + +.list { + margin: 0 0 1rem; + padding-left: 1.25rem; +} + +.list li { + margin-bottom: 0.35rem; +} + +.note { + margin-top: 1.5rem; + padding: 0.75rem 1rem; + background: #fff7ed; + border: 1px solid #fed7aa; + border-radius: 8px; + font-size: 0.85rem; + color: #9a3412; +} + +.updated { + margin-top: 2rem; + font-size: 0.8rem; + color: var(--text-muted, #9ca3af); +} + +@media (max-width: 720px) { + .page { + grid-template-columns: 1fr; + } + + .sidebar { + position: static; + } +} diff --git a/client/src/pages/Settings.jsx b/client/src/pages/Settings.jsx index 72ad1c3..7c90584 100644 --- a/client/src/pages/Settings.jsx +++ b/client/src/pages/Settings.jsx @@ -6,31 +6,97 @@ import { fetchSuppliers, createSupplier, updateSupplier, deleteSupplier, fetchEngineeringRules, createEngineeringRule, updateEngineeringRule, deleteEngineeringRule } from '../api/client'; +import Icon from '../components/ui/Icon'; +import SectionNav from '../components/SectionNav'; import styles from './Settings.module.css'; -// Human-readable labels for settings keys -const SETTINGS_META = [ - { key: 'kit_preparation_fee_chf', label: 'Forfait préparation kit (CHF)', type: 'number', min: 0, step: 10, unit: 'CHF' }, - { key: 'panel_thickness_mm', label: 'Épaisseur panneaux', type: 'number', min: 1, step: 1, unit: 'mm' }, - { key: 'plinth_height_min_mm', label: 'Hauteur plinthe minimale', type: 'number', min: 0, step: 5, unit: 'mm' }, - { key: 'plinth_height_max_mm', label: 'Hauteur plinthe maximale', type: 'number', min: 0, step: 5, unit: 'mm' }, - { key: 'sliding_doors_min_width_mm', label: 'Largeur min. portes coulissantes', type: 'number', min: 0, step: 50, unit: 'mm' }, - { key: 'allow_sliding_doors', label: 'Autoriser portes coulissantes (1/0)', type: 'number', min: 0, step: 1, unit: '' }, - { key: 'sanitary_gap_mm', label: 'Vide sanitaire niche', type: 'number', min: 0, step: 1, unit: 'mm' }, - { key: 'drawer_depth_min_mm', label: 'Tiroir profondeur min', type: 'number', min: 0, step: 10, unit: 'mm' }, - { key: 'drawer_depth_max_mm', label: 'Tiroir profondeur max', type: 'number', min: 0, step: 10, unit: 'mm' }, - { key: 'drawer_depth_step_mm', label: 'Tiroir pas profondeur', type: 'number', min: 1, step: 1, unit: 'mm' }, - { key: 'drawer_back_gap_mm', label: 'Vide sanitaire derrière tiroir', type: 'number', min: 0, step: 1, unit: 'mm' }, - { key: 'drawer_hidden_handle_reduction_mm', label: 'Réduction profondeur tiroir caché', type: 'number', min: 0, step: 1, unit: 'mm' }, - { key: 'standard_handle_axis_mm', label: 'Axe perçage poignée standard', type: 'number', min: 0, step: 10, unit: 'mm' }, - { key: 'standard_handle_tolerance_mm', label: 'Tolérance axe poignée', type: 'number', min: 0, step: 10, unit: 'mm' }, - { key: 'groove_width_mm', label: 'Rainure largeur', type: 'number', min: 0, step: 0.5, unit: 'mm' }, - { key: 'groove_depth_mm', label: 'Rainure profondeur', type: 'number', min: 0, step: 0.5, unit: 'mm' }, - { key: 'groove_edge_offset_mm', label: 'Rainure distance au bord', type: 'number', min: 0, step: 0.5, unit: 'mm' }, - { key: 'niche_clearance_multiplier', label: 'Multiplicateur jeu niche', type: 'number', min: 1, step: 0.5, unit: '' }, - { key: 'min_clearance_mm', label: 'Jeu minimum', type: 'number', min: 0, step: 1, unit: 'mm' }, +const SETTINGS_SECTIONS = [ + { id: 'parametres-generaux', label: 'Paramètres généraux' }, + { id: 'regles', label: 'Règles APEX 4.0' }, + { id: 'email', label: 'Email (SMTP)' }, + { id: 'fournisseurs', label: 'Fournisseurs' } ]; +const SETTINGS_GROUPS = [ + { + id: 'pricing', + title: 'Tarification', + description: 'Frais fixes ajoutés au devis.', + icon: 'coins', + fields: [ + { key: 'kit_preparation_fee_chf', label: 'Forfait préparation kit', hint: 'Ajouté une fois par commande', min: 0, step: 10, unit: 'CHF' }, + ], + }, + { + id: 'panels', + title: 'Panneaux & plinthe', + description: 'Matière de base et socle du meuble.', + icon: 'layers', + fields: [ + { key: 'panel_thickness_mm', label: 'Épaisseur panneaux', hint: 'Épaisseur standard des caissons', min: 1, step: 1, unit: 'mm' }, + { key: 'plinth_height_min_mm', label: 'Plinthe — hauteur min', min: 0, step: 5, unit: 'mm' }, + { key: 'plinth_height_max_mm', label: 'Plinthe — hauteur max', min: 0, step: 5, unit: 'mm' }, + ], + }, + { + id: 'sliding', + title: 'Portes coulissantes', + description: 'Disponibilité et contraintes des portes coulissantes.', + icon: 'sliding', + fields: [ + { key: 'allow_sliding_doors', label: 'Autoriser les portes coulissantes', hint: 'Affiche l’option dans le configurateur', kind: 'toggle' }, + { key: 'sliding_doors_min_width_mm', label: 'Largeur minimale', hint: 'En dessous, l’option est masquée', min: 0, step: 50, unit: 'mm' }, + ], + }, + { + id: 'drawers', + title: 'Tiroirs', + description: 'Plage de profondeurs et réductions appliquées.', + icon: 'drawer', + fields: [ + { key: 'drawer_depth_min_mm', label: 'Profondeur min', min: 0, step: 10, unit: 'mm' }, + { key: 'drawer_depth_max_mm', label: 'Profondeur max', min: 0, step: 10, unit: 'mm' }, + { key: 'drawer_depth_step_mm', label: 'Pas de profondeur', hint: 'Incrément entre longueurs de coulisses', min: 1, step: 1, unit: 'mm' }, + { key: 'drawer_back_gap_mm', label: 'Vide arrière', hint: 'Espace laissé derrière le tiroir', min: 0, step: 1, unit: 'mm' }, + { key: 'drawer_hidden_handle_reduction_mm', label: 'Réduction tiroir caché', hint: 'Soustraite pour un tiroir intérieur', min: 0, step: 1, unit: 'mm' }, + ], + }, + { + id: 'handles', + title: 'Poignées', + description: 'Position standard du perçage.', + icon: 'handle', + fields: [ + { key: 'standard_handle_axis_mm', label: 'Axe de perçage standard', hint: 'Hauteur depuis le sol', min: 0, step: 10, unit: 'mm' }, + { key: 'standard_handle_tolerance_mm', label: 'Tolérance axe', hint: 'Écart accepté autour de l’axe', min: 0, step: 10, unit: 'mm' }, + ], + }, + { + id: 'grooves', + title: 'Rainures (fond)', + description: 'Usinage de la rainure de panneau arrière.', + icon: 'groove', + fields: [ + { key: 'groove_width_mm', label: 'Largeur', min: 0, step: 0.5, unit: 'mm' }, + { key: 'groove_depth_mm', label: 'Profondeur', min: 0, step: 0.5, unit: 'mm' }, + { key: 'groove_edge_offset_mm', label: 'Distance au bord', min: 0, step: 0.5, unit: 'mm' }, + ], + }, + { + id: 'clearance', + title: 'Niche & jeux', + description: 'Jeux de pose entre le meuble et la niche.', + icon: 'clearance', + fields: [ + { key: 'sanitary_gap_mm', label: 'Vide sanitaire niche', min: 0, step: 1, unit: 'mm' }, + { key: 'min_clearance_mm', label: 'Jeu minimum', min: 0, step: 1, unit: 'mm' }, + { key: 'niche_clearance_multiplier', label: 'Multiplicateur jeu niche', hint: 'Appliqué au jeu minimum', min: 1, step: 0.5, unit: '×' }, + ], + }, +]; + +const ALL_SETTING_KEYS = SETTINGS_GROUPS.flatMap(g => g.fields.map(f => f.key)); + const SUPPLIER_TYPES = [ { value: 'panels', label: 'Panneaux / Matériaux' }, { value: 'hardware', label: 'Quincaillerie' }, @@ -90,6 +156,9 @@ export default function Settings() { // ─── App Settings ────────────────────────────────────────────────────────── + const isSettingDirty = key => String(settingsDraft[key] ?? '') !== String(settings[key] ?? ''); + const dirtyCount = ALL_SETTING_KEYS.filter(isSettingDirty).length; + function handleSettingChange(key, value) { setSettingsDraft(prev => ({ ...prev, [key]: value })); } @@ -252,57 +321,106 @@ export default function Settings() { return (
    -
    -

    Paramètres

    -
    - {loading &&

    Chargement…

    } {error &&

    {error}

    } {!loading && !error && ( <> + + {/* ── App Settings ─────────────────────────────────────── */} -
    +

    Paramètres généraux

    -
    -
    - {SETTINGS_META.map(({ key, label, type, min, step, unit }) => ( -
    - - handleSettingChange(key, e.target.value)} - /> -
    + +
    + {SETTINGS_GROUPS.map(group => ( +
    + {group.title} +
    + + + +

    {group.title}

    +
    + +
    + {group.fields.map(({ key, label, hint, kind, min, step, unit }) => { + const id = `setting-${key}`; + const dirty = isSettingDirty(key); + + if (kind === 'toggle') { + const checked = Number(settingsDraft[key]) === 1; + return ( + + ); + } + + return ( +
    + +
    + handleSettingChange(key, e.target.value)} + /> + {unit && {unit}} +
    +
    + ); + })} +
    +
    ))}
    -
    - - - {settingsMsg && ( - - {settingsMsg.text} - - )} +
    0 ? styles.saveBarActive : ''}`}> + + {settingsMsg ? ( + {settingsMsg.text} + ) : dirtyCount > 0 ? ( + `${dirtyCount} modification${dirtyCount > 1 ? 's' : ''} non sauvegardée${dirtyCount > 1 ? 's' : ''}` + ) : ( + 'Aucune modification' + )} + +
    + + +
    {/* ── Engineering Rules ───────────────────────────────────── */} -
    +

    Règles APEX 4.0 (ingénierie, ergonomie, légalité)

    {/* ── SMTP / Email info ─────────────────────────────────── */} -
    +

    Configuration email (SMTP)

    Ces valeurs sont définies dans le fichier .env du serveur. Redémarrez le serveur après modification. @@ -465,7 +583,7 @@ export default function Settings() {

    {/* ── Suppliers ─────────────────────────────────────────── */} -
    +

    Fournisseurs

`).join('')}`; + + return ` + +${colsXml} + +${rowsXml.join('\n')} + +`; +} + +function contentTypesXml(sheetCount) { + const overrides = Array.from({ length: sheetCount }, (_, i) => + `` + ).join(''); + return ` + + + + + +${overrides} +`; +} + +function workbookXml(sheets) { + const sheetEls = sheets + .map((s, i) => ``) + .join(''); + return ` + +${sheetEls} +`; +} + +function workbookRelsXml(sheets) { + const rels = sheets + .map((_, i) => ``) + .join(''); + const stylesRid = sheets.length + 1; + return ` + +${rels} + +`; +} + +const ROOT_RELS_XML = ` + + +`; + +const STYLES_XML = ` + + + + + + +`; + +/** + * Construit un buffer .xlsx à partir d'une liste de feuilles. + * @param {Array<{ name:string, columns:string[], rows:Array> }>} sheets + * @returns {Promise} + */ +export async function buildXlsxBuffer(sheets) { + const zip = new JSZip(); + zip.file('[Content_Types].xml', contentTypesXml(sheets.length)); + zip.file('_rels/.rels', ROOT_RELS_XML); + zip.file('xl/workbook.xml', workbookXml(sheets)); + zip.file('xl/_rels/workbook.xml.rels', workbookRelsXml(sheets)); + zip.file('xl/styles.xml', STYLES_XML); + sheets.forEach((s, i) => { + zip.file(`xl/worksheets/sheet${i + 1}.xml`, sheetXml(s)); + }); + return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }); +} + +/** + * Construit le classeur Excel de la liste de débitage (+ quincaillerie + calepinage). + * @param {object} params + * @param {object} params.cuttingList + * @param {object} [params.hardware] résultat de generateHardwareList + * @param {object} [params.cuttingPlan] résultat de generateCuttingPlan + * @returns {Promise} + */ +export async function buildCuttingListXlsx({ cuttingList, hardware = null, cuttingPlan = null }) { + const sheets = []; + + // Feuille 1 — Débitage + const debitColumns = [ + 'Finition', 'Pièce', 'L finie (mm)', 'H finie (mm)', 'Ép. (mm)', 'Qté', + 'L brute (mm)', 'H brute (mm)', 'Surcote (mm)', 'Surface (m²)', + 'Chants (côtés)', 'Sens de collage', 'Usinage' + ]; + const debitRows = []; + for (const group of (cuttingList?.byFinish || [])) { + for (const p of (group.pieces || [])) { + const banding = p.edgeBanding && p.edgeBanding.type !== 'none' + ? `${p.edgeBanding.sides} côtés` + : '—'; + const sens = p.edgeBanding?.direction?.label || '—'; + const usinage = p.machining ? Object.keys(p.machining).join(', ') : '—'; + debitRows.push([ + group.finishLabel || '—', + p.description || '', + Number(p.widthMm) || 0, + Number(p.heightMm) || 0, + Number(p.thicknessMm) || 0, + Number(p.quantity) || 0, + Number(p.rawCutWidthMm) || 0, + Number(p.rawCutHeightMm) || 0, + Number(p.surcoteMm) || 0, + Number((p.surfaceM2 || 0).toFixed(3)), + banding, + sens, + usinage + ]); + } + } + sheets.push({ name: 'Débitage', columns: debitColumns, rows: debitRows }); + + // Feuille 2 — Quincaillerie + if (hardware && Array.isArray(hardware.items) && hardware.items.length) { + const hwColumns = ['Désignation', 'Référence', 'Qté', 'Prix unitaire (CHF)', 'Total (CHF)']; + const hwRows = hardware.items.map((it) => [ + it.label || it.name || '', + it.code || it.reference || '—', + Number(it.quantity) || 0, + it.unitPriceChf != null ? Number(it.unitPriceChf) : '—', + it.lineTotalChf != null ? Number(it.lineTotalChf) : (it.totalChf != null ? Number(it.totalChf) : '—') + ]); + sheets.push({ name: 'Quincaillerie', columns: hwColumns, rows: hwRows }); + } + + // Feuille 3 — Calepinage / plan de coupe + if (cuttingPlan && Array.isArray(cuttingPlan.boards) && cuttingPlan.boards.length) { + const planColumns = [ + 'Panneau', 'Format (mm)', 'Pièces placées', 'Surface utilisée (m²)', + 'Surface panneau (m²)', 'Chute (m²)', 'Chute (%)' + ]; + const planRows = cuttingPlan.boards.map((b, i) => [ + `Panneau ${i + 1}`, + `${b.boardWidthMm} x ${b.boardHeightMm}`, + (b.placements || []).length, + Number((b.usedAreaM2 || 0).toFixed(3)), + Number((b.boardAreaM2 || 0).toFixed(3)), + Number((b.wasteAreaM2 || 0).toFixed(3)), + Number((b.wastePercent || 0).toFixed(1)) + ]); + // Ligne de synthèse globale + planRows.push([ + 'TOTAL', + '', + cuttingPlan.totalPlacedPieces ?? '', + Number((cuttingPlan.totalUsedAreaM2 || 0).toFixed(3)), + Number((cuttingPlan.totalBoardAreaM2 || 0).toFixed(3)), + Number((cuttingPlan.totalWasteAreaM2 || 0).toFixed(3)), + Number((cuttingPlan.globalWastePercent || 0).toFixed(1)) + ]); + sheets.push({ name: 'Calepinage', columns: planColumns, rows: planRows }); + } + + return buildXlsxBuffer(sheets); +} diff --git a/server/services/hardwareListEngine.js b/server/services/hardwareListEngine.js index 2fe475b..b8d128c 100644 --- a/server/services/hardwareListEngine.js +++ b/server/services/hardwareListEngine.js @@ -1,6 +1,7 @@ import pool from '../db/index.js'; import { generateCuttingList } from './cuttingEngine.js'; import { PLINTH, CARCASS_FASTENERS } from '../../shared/manufacturing.js'; +import { LEGRABOX_BOM_RULES } from '../../shared/blumLegrabox.js'; const HAEFELE_PRODUCT_URLS = { hinge_blum_clip_top_blumotion_110: 'https://www.haefele.ch/fr/produkt/charni-re-corps-blum-clip-top-blumotion-110-pose-en-applique/34242663/?MasterSKU=P-00898218', @@ -61,6 +62,24 @@ async function priceById(table, id) { }; } +/** Système de tiroirs Blum avec specs_json (tip_on, load_kg, …) pour les règles BOM Legrabox. */ +async function drawerSystemById(id) { + if (!id) return null; + const row = await fetchOne( + 'SELECT id, code, name, price_chf, specs_json FROM blum_hardware WHERE id = ? AND active = 1', + [id] + ); + if (!row) return null; + return { + id: row.id, + code: row.code, + name: row.name, + unitPriceChf: toMoney(row.price_chf), + source: 'blum_hardware', + specsJson: row.specs_json || null, + }; +} + async function findHaefeleMaterialForModel(model) { const m = String(model || '').trim(); if (!m) return null; @@ -142,7 +161,7 @@ async function findHaefeleMaterialByUrl(url) { }; } -function computeCountsFromCuttingList(cuttingList) { +export function computeCountsFromCuttingList(cuttingList) { const items = new Map(); const pieces = Array.isArray(cuttingList?.pieces) ? cuttingList.pieces : []; @@ -256,10 +275,20 @@ function computeCountsFromCuttingList(cuttingList) { // so we do not count it here to avoid misleading totals. } - if (p?.elementType === 'tiroirs') { + if (p?.elementType === 'tiroirs' || p?.elementType === 'tiroirFacade') { drawersCount += qty; } + // Façades tiroir Legrabox — chevilles expansibles INSERTA (Ø10) selon perçages générés. + if (p?.elementType === 'tiroirFacade' && m.legraboxInserta?.holes?.length && qty > 0) { + addQty(items, 'legrabox_inserta_fixing', { + key: 'legrabox_inserta_fixing', + label: 'Fixation façade Legrabox INSERTA (cheville expansible Ø10)', + quantity: qty * m.legraboxInserta.holes.length, + unit: 'pc', + }); + } + // Fixation "dos" (paroi arrière) — 4 côtés (gauche/droite + haut/bas). // Règle atelier (pour une longueur E = L ou H): // - par côté: 1 pièce à 5cm de chaque extrémité (=> 2) @@ -280,6 +309,30 @@ function computeCountsFromCuttingList(cuttingList) { meta: { model: 'Ixconnect RPC G 13/20' }, }); } + + // AXILO 78 : une platine/pied par centre, et un support de plinthe par pied avant. + if (p?.elementType === 'fond' && m.axiloFootPlates?.centers?.length && qty > 0) { + const centers = m.axiloFootPlates.centers; + const frontCount = + Number(m.axiloFootPlates.frontFootCount) || + centers.filter((center) => center?.row === 'front').length; + addQty(items, 'axilo_78_press_fit_foot', { + key: 'axilo_78_press_fit_foot', + label: `Pied Häfele AXILO 78 à planter — réf. ${PLINTH.mountingPlate.articleNumber}`, + code: PLINTH.mountingPlate.articleNumber, + quantity: qty * centers.length, + unit: 'pc', + meta: { model: PLINTH.mountingPlate.articleNumber } + }); + addQty(items, 'axilo_plinth_support', { + key: 'axilo_plinth_support', + label: `Support de plinthe Häfele AXILO — réf. ${PLINTH.plinthSupport.articleNumber}`, + code: PLINTH.plinthSupport.articleNumber, + quantity: qty * frontCount * PLINTH.plinthSupport.quantityPerFrontFoot, + unit: 'pc', + meta: { model: PLINTH.plinthSupport.articleNumber } + }); + } } return { items: Array.from(items.values()), doorCount, drawersCount }; @@ -289,20 +342,6 @@ export async function generateHardwareList({ config, modules = [], colors = {} } const cuttingList = generateCuttingList(config, colors, modules); const { items, doorCount } = computeCountsFromCuttingList(cuttingList); - // Add plinth clips (simple deterministic rule) - const widthMm = Number(config?.widthMm) || 0; - if (widthMm > 0) { - const clips = Math.max(0, Math.ceil((widthMm / 1000) * (Number(PLINTH.clipsPerMeter) || 0))); - if (clips > 0) { - items.push({ - key: 'plinth_clips', - label: `Clips plinthe (${PLINTH.model})`, - quantity: clips, - unit: 'pc', - }); - } - } - // Selected variants pricing (if configured) const handleVariantId = config?.hardware?.handleVariantId; const hingeVariantId = config?.hardware?.hingeVariantId; @@ -311,7 +350,7 @@ export async function generateHardwareList({ config, modules = [], colors = {} } const [handleVariant, hingeVariant, drawerSystem] = await Promise.all([ priceById('handle_variants', handleVariantId), priceById('hinge_variants', hingeVariantId), - priceById('blum_hardware', drawerSystemId), + drawerSystemById(drawerSystemId), ]); // Add handles as an item when selected @@ -389,17 +428,60 @@ export async function generateHardwareList({ config, modules = [], colors = {} } } // Drawer system: assume priced “per drawer” - if (drawerSystem) { - const totalDrawers = (Array.isArray(config?.modules) ? config.modules : []).reduce((s, m) => s + (Number(m?.drawers) || 0), 0); - if (totalDrawers > 0) { + const configModules = Array.isArray(config?.modules) ? config.modules : []; + const totalDrawers = configModules.reduce((s, m) => s + (Number(m?.drawers) || 0), 0); + if (drawerSystem && totalDrawers > 0) { + items.push({ + key: `drawer_system:${drawerSystem.id}`, + label: `Système tiroirs — ${drawerSystem.name}`, + quantity: totalDrawers, + unit: 'pc', + unitPriceChf: drawerSystem.unitPriceChf, + source: drawerSystem.source, + code: drawerSystem.code, + }); + } + + // Règles automatiques Blum Legrabox (BOM) + if (totalDrawers > 0) { + // 1) Stabilisation de façade — obligatoire si largeur_corps_mm >= 600 (par tiroir concerné). + const wideDrawers = configModules.reduce((s, m) => { + const n = Number(m?.drawers) || 0; + const w = Number(m?.widthMm) || 0; + return s + (n > 0 && w >= LEGRABOX_BOM_RULES.frontStabiliserMinBodyWidthMm ? n : 0); + }, 0); + if (wideDrawers > 0) { items.push({ - key: `drawer_system:${drawerSystem.id}`, - label: `Système tiroirs — ${drawerSystem.name}`, - quantity: totalDrawers, + key: 'legrabox_front_stabiliser', + label: 'Stabilisation de façade Legrabox (largeur corps ≥ 600 mm)', + quantity: wideDrawers, unit: 'pc', - unitPriceChf: drawerSystem.unitPriceChf, - source: drawerSystem.source, - code: drawerSystem.code, + meta: { model: 'ZST.550BA' }, + }); + } + + // 2) Unité de synchronisation — obligatoire avec la variante Tip-On Blumotion. + const openingVariant = String(config?.hardware?.drawerOpeningVariant || '').toLowerCase(); + const specsTipOn = (() => { + try { + const specs = drawerSystem?.specsJson ? JSON.parse(drawerSystem.specsJson) : null; + return Boolean(specs?.tip_on); + } catch { + return false; + } + })(); + const isTipOnBlumotion = + openingVariant === LEGRABOX_BOM_RULES.syncUnitVariant || + openingVariant === 'tip-on blumotion' || + specsTipOn; + if (isTipOnBlumotion) { + const drawerColumns = configModules.filter((m) => (Number(m?.drawers) || 0) > 0).length; + items.push({ + key: 'legrabox_tipon_sync_unit', + label: 'Unité de synchronisation Tip-On Blumotion (set)', + quantity: Math.max(1, drawerColumns), + unit: 'set', + meta: { model: 'T60.000D' }, }); } } diff --git a/server/services/holeSpecifications.js b/server/services/holeSpecifications.js index 60677a5..e1a731a 100644 --- a/server/services/holeSpecifications.js +++ b/server/services/holeSpecifications.js @@ -10,7 +10,11 @@ import { buildFinishHabillageHoleSpecsMm, buildDoorHingeCupHoleSpecsMm, buildDrawerReinforcementHoleSpecsMm, - buildDrawerRenfortPanelIxconnectSpecsMm + buildDrawerRenfortPanelIxconnectSpecsMm, + buildLegraboxRailHoleSpecsMm, + buildLegraboxFacadeInsertaSpecsMm, + buildAxiloFootHoleSpecsMm, + buildSystem32HoleSpecsMm } from '../../shared/cncPanelHoles.js'; export function collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex = 0) { @@ -25,33 +29,8 @@ export function collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex = 0 const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0)); const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0)); - if (m.system32?.positions?.length) { - const frontX = clampX(37); - const backX = clampX(widthMm - 37); - const d = Number(m.system32.holeDiameter || 5); - const z = Number(m.system32.holeDepth || piece.thicknessMm || 12); - const throughHole = Boolean(m.system32.throughHole); - for (const pos of m.system32.positions) { - const y = clampY(pos); - pushHole({ - xMm: frontX, - yMm: y, - diameterMm: d, - depthMm: z, - type: 'system32', - face: 'face', - toolMode: throughHole ? 'through' : 'blind' - }); - pushHole({ - xMm: backX, - yMm: y, - diameterMm: d, - depthMm: z, - type: 'system32', - face: 'face', - toolMode: throughHole ? 'through' : 'blind' - }); - } + for (const spec of buildSystem32HoleSpecsMm(piece, widthMm, heightMm)) { + pushHole(spec); } if (['dessus', 'fond'].includes(piece.elementType || '')) { @@ -60,6 +39,11 @@ export function collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex = 0 } } + // AXILO 78 Press-fit — quatre trous borgnes par platine, exclusivement en sous-face du fond bas. + for (const spec of buildAxiloFootHoleSpecsMm(piece, widthMm, heightMm)) { + pushHole({ ...spec, toolMode: 'blind' }); + } + if (['cotes', 'panneauxInterieurs'].includes(piece.elementType || '')) { for (const spec of buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heightMm)) { pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' }); @@ -67,9 +51,18 @@ export function collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex = 0 for (const spec of buildDrawerRenfortPanelIxconnectSpecsMm(piece, widthMm, heightMm, partIndex)) { pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' }); } + for (const spec of buildLegraboxRailHoleSpecsMm(piece, widthMm, heightMm, partIndex)) { + pushHole({ ...spec, toolMode: 'blind' }); + } } - if (['cotes', 'habillageFinitionGauche', 'habillageFinitionDroite', 'habillageFinitionDessus'].includes(piece.elementType || '')) { + if (piece.elementType === 'tiroirFacade') { + for (const spec of buildLegraboxFacadeInsertaSpecsMm(piece, widthMm, heightMm)) { + pushHole({ ...spec, toolMode: 'blind' }); + } + } + + if (['cotes', 'dessus', 'habillageFinitionGauche', 'habillageFinitionDroite', 'habillageFinitionDessus'].includes(piece.elementType || '')) { for (const spec of buildFinishHabillageHoleSpecsMm(piece, widthMm, heightMm)) { pushHole({ ...spec, toolMode: 'blind' }); } diff --git a/server/services/leubaHiagCrawler.js b/server/services/leubaHiagCrawler.js new file mode 100644 index 0000000..7bd12c8 --- /dev/null +++ b/server/services/leubaHiagCrawler.js @@ -0,0 +1,533 @@ +/** + * Leuba Hiag catalog crawler. + * + * The Leuba Hiag webshop (https://www.leubahiag.ch) is a Laravel/Bagisto shop whose + * product catalog is served by Algolia. The public catalog search page embeds a + * short-lived "secured" Algolia API key (guest scope) inside the page markup. We read + * that key live on every run (it expires), then page through the Algolia index and + * upsert each product into the shared `formex_materials` table under the `leubahiag` + * supplier. + * + * Notes: + * - Algolia exposes the product catalog but NOT prices. Customer-specific prices come + * from the authenticated `/fr/sales/prices-and-availability` endpoint: we log in with + * the account credentials (LEUBAHIAG_EMAIL/PASSWORD) and, after importing each line, + * fetch the per-SKU panel price and store it in price_chf. + * - Imported items are inactive by default so the catalog rows don't flood the + * configurator finish picker. Activate the wanted decors from the Matériaux admin page. + */ + +import pool, { run } from '../db/index.js'; + +const BASE_URL = 'https://www.leubahiag.ch'; +const SEARCH_URL = `${BASE_URL}/fr/catalog/search`; +const CRAWL_DELAY = parseInt(process.env.LEUBAHIAG_CRAWL_DELAY_MS || '300', 10); +const ALGOLIA_HITS_PER_PAGE = 1000; + +/** + * Curated catalog lines to import (per the supplier's agreed assortment). + * Each target maps to an Algolia query and/or brand facet filter. The `label` is used + * as the material `collection` so the configurator groups items by recognisable line. + */ +export const LEUBA_HIAG_TARGETS = [ + { key: 'pfleiderer-xpsm', label: 'Pfleiderer XPSM (Antifingerprint)', tier: 'Entrée de gamme', query: 'XPSM', brands: ['Pfleiderer'] }, + { key: 'swiss-krono', label: 'Swiss Krono', tier: 'Entrée de gamme', query: '', brands: ['Swiss Krono'] }, + { key: 'unilin-master-oak', label: 'Unilin Master Oak', tier: 'Milieu de gamme', query: 'Master Oak', brands: ['UNILIN'] }, + { key: 'shinnoki', label: 'Shinnoki', tier: 'Milieu de gamme', query: 'Shinnoki', brands: [] }, + { key: 'cleaf', label: 'Cleaf', tier: 'Haut de gamme', query: '', brands: ['CLEAF'] }, + { key: 'valchromat', label: 'Valchromat', tier: 'Haut de gamme', query: '', brands: ['Valchromat'] }, +]; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function browserHeaders(extra = {}) { + return { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'fr-CH,fr;q=0.9,en;q=0.5', + ...extra, + }; +} + +function absoluteUrl(url) { + if (!url) return null; + if (url.startsWith('http://') || url.startsWith('https://')) return url; + return `${BASE_URL}${url.startsWith('/') ? '' : '/'}${url}`; +} + +/** Parse a "CHF 1'234.50" string into a number, or null. */ +function parseCHF(text) { + if (typeof text !== 'string') return null; + const clean = text.replace(/CHF/gi, '').replace(/[\u00a0\u202f'’\s]/g, '').replace(',', '.'); + const n = parseFloat(clean); + return Number.isFinite(n) ? n : null; +} + +/** + * Authenticated Leuba Hiag (Laravel) session. Used to read customer-specific prices + * from the `prices-and-availability` endpoint (axios sends the XSRF-TOKEN cookie back + * as an X-XSRF-TOKEN header — we replicate that here). + */ +class LeubaHiagSession { + constructor() { + this.cookies = new Map(); + this.loggedIn = false; + } + + captureCookies(response) { + let raw = []; + if (typeof response.headers.getSetCookie === 'function') { + raw = response.headers.getSetCookie(); + } else { + const header = response.headers.get('set-cookie'); + if (header) raw = header.split(/,\s*(?=[A-Za-z0-9_\-]+=)/); + } + for (const entry of raw) { + const [pair] = entry.split(';'); + const eq = pair.indexOf('='); + if (eq > 0) this.cookies.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()); + } + } + + cookieHeader() { + return [...this.cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; '); + } + + xsrfToken() { + const raw = this.cookies.get('XSRF-TOKEN'); + return raw ? decodeURIComponent(raw) : ''; + } + + async login(emit = () => {}, credentials = {}) { + const email = credentials.username || credentials.email + || process.env.LEUBAHIAG_EMAIL || process.env.LEUBAHIAG_USER; + const password = credentials.password || process.env.LEUBAHIAG_PASSWORD; + if (!email || !password) { + emit({ type: 'log', message: 'Identifiants Leuba Hiag absents : import sans prix.' }); + return false; + } + + const loginUrl = `${BASE_URL}/fr/customer/auth/login`; + try { + const getRes = await fetch(loginUrl, { headers: browserHeaders(), redirect: 'follow' }); + this.captureCookies(getRes); + const html = await getRes.text(); + const token = (html.match(/name="_token"\s+value="([^"]+)"/) || [])[1]; + if (!token) { + emit({ type: 'log', message: 'Jeton CSRF Leuba Hiag introuvable : import sans prix.' }); + return false; + } + + const body = new URLSearchParams(); + body.set('_token', token); + body.set('email', email); + body.set('password', password); + + // redirect:'manual' so we capture Set-Cookie from the post-login 302 (the + // LoggedIn/session cookies live on the redirect response, not the final page). + const postRes = await fetch(loginUrl, { + method: 'POST', + headers: browserHeaders({ + 'Content-Type': 'application/x-www-form-urlencoded', + Referer: loginUrl, + Origin: BASE_URL, + Cookie: this.cookieHeader(), + }), + body: body.toString(), + redirect: 'manual', + }); + this.captureCookies(postRes); + + // Confirm the session and refresh the XSRF-TOKEN from the authenticated catalog page. + const verifyRes = await fetch(`${BASE_URL}/fr/catalog/search`, { + headers: browserHeaders({ Cookie: this.cookieHeader() }), + redirect: 'follow', + }); + this.captureCookies(verifyRes); + const verifyHtml = await verifyRes.text(); + const isLoggedIn = /\\u0022isLoggedIn\\u0022:true/.test(verifyHtml) + || this.cookies.get('LoggedIn') === '1'; + + this.loggedIn = isLoggedIn; + emit({ + type: 'log', + message: this.loggedIn + ? `Connecté à Leuba Hiag (${email}).` + : 'Connexion Leuba Hiag non confirmée : import sans prix.', + }); + return this.loggedIn; + } catch (err) { + emit({ type: 'log', message: `Erreur de connexion Leuba Hiag : ${err.message}` }); + return false; + } + } + + /** POST {skus} to the prices endpoint; returns the per-sku price map (or {}). */ + async fetchPrices(skus) { + if (!this.loggedIn || skus.length === 0) return {}; + const res = await fetch(`${BASE_URL}/fr/sales/prices-and-availability`, { + method: 'POST', + headers: browserHeaders({ + 'Content-Type': 'application/json', + Accept: 'application/json, text/plain, */*', + 'X-Requested-With': 'XMLHttpRequest', + 'X-XSRF-TOKEN': this.xsrfToken(), + Referer: `${BASE_URL}/fr/catalog/search`, + Origin: BASE_URL, + Cookie: this.cookieHeader(), + }), + body: JSON.stringify({ skus }), + }); + this.captureCookies(res); + if (!res.ok) { + throw new Error(`prices-and-availability HTTP ${res.status}`); + } + return res.json(); + } +} + +/** + * Read the live Algolia connection config (appId, index, secured apiKey) from the + * catalog search page. The key is embedded as `\u0022apiKey\u0022:\u0022...\u0022` + * inside the `window.globalSearchConfigBase` JSON blob. + */ +async function fetchAlgoliaConfig(emit = () => {}) { + const res = await fetch(SEARCH_URL, { headers: browserHeaders(), redirect: 'follow' }); + if (!res.ok) { + throw new Error(`Impossible de charger la page catalogue Leuba Hiag (HTTP ${res.status}).`); + } + const html = await res.text(); + + const appId = (html.match(/\\u0022appId\\u0022:\\u0022([A-Za-z0-9]+)\\u0022/) || [])[1] + || process.env.LEUBAHIAG_ALGOLIA_APP_ID; + const indexName = (html.match(/\\u0022indexName\\u0022:\\u0022([A-Za-z0-9_]+)\\u0022/) || [])[1] + || process.env.LEUBAHIAG_ALGOLIA_INDEX; + const apiKey = (html.match(/\\u0022apiKey\\u0022:\\u0022([A-Za-z0-9+/=]+)\\u0022/) || [])[1]; + + if (!appId || !indexName || !apiKey) { + throw new Error('Clé Algolia introuvable sur la page catalogue Leuba Hiag (structure du site modifiée ?).'); + } + + emit({ type: 'log', message: `Catalogue Algolia détecté: index ${indexName} (app ${appId}).` }); + return { appId, indexName, apiKey }; +} + +/** + * Run one Algolia query page. `facetFilters` is an array of arrays per Algolia syntax. + */ +async function queryAlgolia({ appId, indexName, apiKey }, { query = '', page = 0, hitsPerPage = ALGOLIA_HITS_PER_PAGE, facetFilters = [] } = {}) { + const url = `https://${appId}-dsn.algolia.net/1/indexes/${indexName}/query`; + const params = new URLSearchParams(); + params.set('query', query); + params.set('hitsPerPage', String(hitsPerPage)); + params.set('page', String(page)); + if (facetFilters.length > 0) { + params.set('facetFilters', JSON.stringify(facetFilters)); + } + + const res = await fetch(url, { + method: 'POST', + headers: { + 'X-Algolia-Application-Id': appId, + 'X-Algolia-API-Key': apiKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ params: params.toString() }), + }); + + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Requête Algolia échouée (HTTP ${res.status})${body ? `: ${body.slice(0, 160)}` : ''}`); + } + return res.json(); +} + +function firstString(value) { + if (Array.isArray(value)) return value.find((v) => typeof v === 'string') || null; + return typeof value === 'string' ? value : null; +} + +function toNumber(value) { + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + +/** + * Map an Algolia product record to a `formex_materials` row. + * Dimension fields from the index: thickness (mm), length (mm), width (mm). + * @param {string} [collectionOverride] Force the `collection` (curated line label). + */ +function mapHit(hit, collectionOverride) { + const sku = String(hit.sku || hit.objectID || '').trim(); + if (!sku) return null; + + const categoryPath = Array.isArray(hit.categories_with_path) ? hit.categories_with_path : []; + const category = categoryPath.length > 0 + ? categoryPath[categoryPath.length - 1] + : (firstString(hit.categories?.level0) || null); + + const collection = collectionOverride + || firstString(hit.brand) + || firstString(hit.categories?.level0) + || 'Leuba Hiag'; + + return { + sku, + materialCode: `LH-${sku}`, + name: hit.name || hit.group_name || sku, + collection, + category, + width_mm: toNumber(hit.width), + height_mm: toNumber(hit.length), + thickness_mm: toNumber(hit.thickness), + image_url: absoluteUrl(hit.image_url), + formex_url: absoluteUrl(hit.url), + price_chf: null, + price_unit: null, + }; +} + +async function ensureSupplier() { + const existing = await pool.query('SELECT id FROM suppliers WHERE code = ?', ['leubahiag']); + if (existing.rows.length > 0) return existing.rows[0].id; + + await run( + 'INSERT INTO suppliers (code, name, email, type, active) VALUES (?, ?, ?, ?, ?)', + ['leubahiag', 'Leuba Hiag', 'info@leubahiag.ch', 'materials', 1] + ); + const created = await pool.query('SELECT id FROM suppliers WHERE code = ?', ['leubahiag']); + return created.rows[0]?.id || null; +} + +/** + * Upsert a material. Existing rows keep their `active` flag (so admin activations are + * preserved across re-syncs); new rows are inserted with the provided `active` value. + */ +async function upsertMaterial(material, supplierId, activeForNew) { + const existing = await pool.query( + 'SELECT id FROM formex_materials WHERE formex_code = ?', + [material.materialCode] + ); + + if (existing.rows.length > 0) { + await run( + `UPDATE formex_materials + SET name = ?, collection = ?, category = ?, width_mm = ?, height_mm = ?, thickness_mm = ?, + image_url = ?, formex_url = ?, + supplier_id = COALESCE(?, supplier_id), last_synced_at = datetime('now') + WHERE formex_code = ?`, + [ + material.name, + material.collection, + material.category, + material.width_mm, + material.height_mm, + material.thickness_mm, + material.image_url, + material.formex_url, + supplierId, + material.materialCode, + ] + ); + return { id: existing.rows[0].id, created: false }; + } + + await run( + `INSERT INTO formex_materials + (formex_code, name, collection, category, width_mm, height_mm, thickness_mm, + image_url, formex_url, price_chf, price_unit, supplier_id, active, last_synced_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`, + [ + material.materialCode, + material.name, + material.collection, + material.category, + material.width_mm, + material.height_mm, + material.thickness_mm, + material.image_url, + material.formex_url, + material.price_chf ?? null, + material.price_unit ?? null, + supplierId, + activeForNew ? 1 : 0, + ] + ); + + const created = await pool.query('SELECT id FROM formex_materials WHERE formex_code = ?', [material.materialCode]); + return { id: created.rows[0]?.id || null, created: true }; +} + +/** Update the price of an already-imported material by its SKU. */ +async function updateMaterialPrice(sku, priceChf, priceUnit) { + await run( + `UPDATE formex_materials + SET price_chf = ?, price_unit = COALESCE(?, price_unit), last_synced_at = datetime('now') + WHERE formex_code = ?`, + [priceChf, priceUnit, `LH-${sku}`] + ); +} + +async function logSync(status, itemsCount, error = null) { + await run( + 'INSERT INTO sync_logs (source, status, items_count, error) VALUES (?, ?, ?, ?)', + ['leubahiag', status, itemsCount, error] + ); +} + +/** + * Import all products matching one curated target (a single catalog line). + */ +async function importTarget(config, supplierId, target, activate, emit) { + const facetFilters = Array.isArray(target.brands) && target.brands.length > 0 + ? [target.brands.map((b) => `brand:${b}`)] + : []; + + let synced = 0; + let created = 0; + let page = 0; + let nbPages = 1; + let nbHits = 0; + const skus = []; + + do { + if (page > 0) await sleep(CRAWL_DELAY); + const data = await queryAlgolia(config, { query: target.query, page, facetFilters }); + nbPages = data.nbPages || 1; + nbHits = data.nbHits || 0; + + if (page === 0) { + emit({ type: 'log', message: `→ ${target.label} (${target.tier}) : ${nbHits} produit(s).` }); + } + + const hits = Array.isArray(data.hits) ? data.hits : []; + for (const hit of hits) { + const material = mapHit(hit, target.label); + if (!material) continue; + try { + const result = await upsertMaterial(material, supplierId, activate); + synced++; + if (result.created) created++; + skus.push(material.sku); + } catch (err) { + emit({ type: 'log', message: ` ✗ ${material.materialCode}: ${err.message}` }); + } + } + page++; + } while (page < nbPages); + + emit({ type: 'log', message: ` ✓ ${target.label} : ${synced} importé(s) (${created} nouveau(x)).` }); + return { synced, created, skus }; +} + +/** + * Fetch and store customer prices for a list of SKUs (in batches). + * Returns the number of items priced. + */ +async function importPrices(session, skus, emit) { + const BATCH = 50; + let priced = 0; + for (let i = 0; i < skus.length; i += BATCH) { + const batch = skus.slice(i, i + BATCH); + try { + const data = await session.fetchPrices(batch); + for (const sku of batch) { + const info = data?.[sku]; + if (!info) continue; + const tier = Array.isArray(info.tierprices) ? info.tierprices[0] : null; + const priceChf = parseCHF(tier?.price); + if (priceChf == null) continue; + await updateMaterialPrice(sku, priceChf, 'pièce'); + priced++; + } + } catch (err) { + emit({ type: 'log', message: ` ✗ Prix lot ${i / BATCH + 1}: ${err.message}` }); + } + emit({ type: 'log', message: ` Prix: ${priced}/${skus.length} récupérés…` }); + if (i + BATCH < skus.length) await sleep(CRAWL_DELAY); + } + return priced; +} + +/** + * Synchronise the curated Leuba Hiag catalog lines via Algolia. + * + * @param {object} options + * @param {string[]} [options.targets] Subset of target keys to import (default: all). + * @param {boolean} [options.activate] Mark newly imported items active (default false). + * @param {boolean} [options.fetchPrices] Log in and import customer prices (default true). + * @param {object} [options.credentials] { username/email, password } (else env vars). + * @param {function} emit SSE-style event emitter. + */ +export async function syncLeubaHiag(options = {}, emit = () => {}) { + const { targets = null, activate = false, fetchPrices = true, credentials = {} } = options; + + const selected = Array.isArray(targets) && targets.length > 0 + ? LEUBA_HIAG_TARGETS.filter((t) => targets.includes(t.key)) + : LEUBA_HIAG_TARGETS; + + const start = Date.now(); + let synced = 0; + let created = 0; + let priced = 0; + + try { + if (selected.length === 0) { + throw new Error('Aucune gamme Leuba Hiag sélectionnée.'); + } + + const config = await fetchAlgoliaConfig(emit); + const supplierId = await ensureSupplier(); + + // Log in up-front so prices can be fetched after each line is imported. + const session = new LeubaHiagSession(); + if (fetchPrices) { + await session.login(emit, credentials); + } + + emit({ type: 'log', message: `${selected.length} gamme(s) à importer.` }); + emit({ type: 'progress', current: 0, total: selected.length, collection: 'Leuba Hiag' }); + + const allSkus = []; + for (let i = 0; i < selected.length; i++) { + const target = selected[i]; + emit({ type: 'progress', current: i, total: selected.length, collection: target.label }); + try { + const res = await importTarget(config, supplierId, target, activate, emit); + synced += res.synced; + created += res.created; + allSkus.push(...res.skus); + } catch (err) { + emit({ type: 'log', message: ` ✗ ${target.label}: ${err.message}` }); + } + if (i < selected.length - 1) await sleep(CRAWL_DELAY); + } + + if (session.loggedIn && allSkus.length > 0) { + emit({ type: 'log', message: `Récupération des prix client pour ${allSkus.length} article(s)…` }); + priced = await importPrices(session, allSkus, emit); + emit({ type: 'log', message: `✓ ${priced} prix client enregistrés.` }); + } + + const duration = ((Date.now() - start) / 1000).toFixed(1); + await logSync('success', synced, null); + const result = { + success: true, + itemsCount: synced, + createdCount: created, + updatedCount: synced - created, + pricedCount: priced, + duration, + error: null, + }; + emit({ type: 'progress', current: selected.length, total: selected.length, collection: 'Leuba Hiag' }); + emit({ type: 'complete', result }); + return result; + } catch (err) { + await logSync('error', synced, err.message); + emit({ type: 'error', error: err.message }); + return { success: false, itemsCount: synced, error: err.message }; + } +} diff --git a/server/services/mprGenerator.js b/server/services/mprGenerator.js index cc0f4f6..7ac5774 100644 --- a/server/services/mprGenerator.js +++ b/server/services/mprGenerator.js @@ -11,12 +11,16 @@ import { GROOVE, getBackGroovePlanOrientation, BACK_GROOVE_PLAN_VERTICAL_TYPES } const MPR_ARTICLE_CODES = Object.freeze({ plintheBas: 'PL_BAS', + plintheGauche: 'PL_G', + plintheDroite: 'PL_D', bandeauHaut: 'BH', dessus: 'BO_U', fond: 'BO_L', cotes: 'SE', cotesDoublage: 'CD', fileur: 'FI', + fileurCotes: 'FI_C', + fileurHaut: 'FI_H', habillageFinitionGauche: 'FR_L_G', habillageFinitionDroite: 'FR_L_D', habillageFinitionDessus: 'FR_H', @@ -26,7 +30,10 @@ const MPR_ARTICLE_CODES = Object.freeze({ panneauxInterieurs: 'ZA_O', rayonFixe: 'RF_X', etageres: 'EB_M', - renfortsTiroir: 'LABO' + renfortsTiroir: 'LABO', + tiroirFond: 'TI_BO', + tiroirDos: 'TI_RW', + tiroirFacade: 'TI_FR' }); function fmtMm(n) { @@ -45,6 +52,13 @@ function escQuoted(s) { return String(s ?? '').replace(/"/g, "'"); } +/** Désignation « type » pour le bloc [001 (sans suffixe côté/index) — ex. SE_L → SE, ZA_O_2 → ZA. */ +function baseDesignation(slug) { + const s = String(slug || '').trim(); + const cut = s.indexOf('_'); + return cut > 0 ? s.slice(0, cut) : s; +} + /** Scinde ligne « gauche+droit » qty 2 en deux fichiers SE_L / SE_R. */ export function flattenPiecesForMprExport(pieces = []) { const out = []; @@ -63,6 +77,14 @@ export function flattenPiecesForMprExport(pieces = []) { } let baseSlug = MPR_ARTICLE_CODES[piece.elementType] || String(piece.elementType || '').toUpperCase() || 'PIECE'; + if (piece.horizontalSpanId != null) { + const spanId = String(piece.horizontalSpanId).replace(/[^\w-]+/g, '_'); + const role = String(piece.horizontalSpanRole || 'panel').toUpperCase(); + baseSlug = `HS_${spanId}_${role}_${baseSlug}`; + if (piece.horizontalSpanRole === 'door' && Number.isFinite(Number(piece.doorIndex))) { + baseSlug += `_${Number(piece.doorIndex) + 1}`; + } + } for (let qi = 0; qi < qty; qi++) { let slug = baseSlug; @@ -80,11 +102,14 @@ export function flattenPiecesForMprExport(pieces = []) { function mprAnglesForPiece(piece) { const et = piece.elementType || ''; - if (habillageEtPorte(et)) { + const widthMm = toSafe(piece.rawCutWidthMm, piece.widthMm); + const heightMm = toSafe(piece.rawCutHeightMm, piece.heightMm); + const vertical = heightMm > widthMm; + if (vertical) { return { DRWIN: '270.0', beschGedreht: '0', - turnMsg: et === 'portes' ? 'DREHEN X-ACHSE' : 'DREHEN X-ACHSE' + turnMsg: 'DREHEN X-ACHSE' }; } if (['dessus', 'fond'].includes(et)) { @@ -118,7 +143,7 @@ function kantenFlags(piece) { return { kavo: '1.0', kali: '0.0', kahi: '0.0', kare: '0.0' }; } -function grooveSection(piece, _Lmm, _Bmm) { +function grooveSection(piece, Lmm, Bmm) { const m = piece?.machining || {}; const orient = piece?.elementType ? getBackGroovePlanOrientation(piece.elementType) : null; if (!m.groove || !orient) return []; @@ -127,26 +152,60 @@ function grooveSection(piece, _Lmm, _Bmm) { const gd = fmtMm(Number(m.groove.depth ?? GROOVE.back.depth)); const abst = fmtMm(Number(m.groove.axisFromPanelEdge ?? m.groove.axisFromBackEdge ?? GROOVE.back.axisFromPanelEdge)); const vertical = BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(piece.elementType); - const win = vertical ? '180.0' : '270.0'; + const axis = Number(abst); + const start = vertical + ? [Math.max(0, Lmm - axis), 0] + : [0, Math.max(0, Bmm - axis)]; + const end = vertical + ? [Math.max(0, Lmm - axis), Bmm] + : [Lmm, Math.max(0, Bmm - axis)]; return [ '<121 \\Block\\', 'MNM="Kantenbearbeitungen von oben"', 'DP="1"', 'KO="04"', - '<139 \\Komponente\\', - 'IN="RSO/Makro/RSO_Nut_Flaeche.mpr"', - 'MNM="Nut Flaeche rueckseite"', - 'XA="L"', - 'YA="B"', - 'VA="LA L"', - `VA="BR ${gw}"`, - `VA="TI ${gd}"`, - `VA="ABST ${abst}"`, - `VA="WIN ${win}"`, - 'VA="ANF 0.0"', - 'VA="END 0.0"', - 'VA="MAT MAT"', + '<109 \\Nuten\\', + 'MNM="Rainure dos"', + `XA="${fmtMm(start[0])}"`, + `YA="${fmtMm(start[1])}"`, + `XE="${fmtMm(end[0])}"`, + `YE="${fmtMm(end[1])}"`, + `NB="${gw}"`, + 'RK="NOWRK"', + 'EM="MOD2"', + 'TV="0"', + 'MV="GL"', + `TI="${gd}"`, + 'MN="GL"', + 'KO="04"' + ]; +} + +function doorHandleGrooveSection(piece, L, B) { + const groove = piece?.machining?.fullHeightHandleGroove; + if (piece?.elementType !== 'portes' || !groove) return []; + const widthMm = Math.max(1, Number(groove.grooveWidthMm) || 8); + const depthMm = Math.max(0.1, Number(groove.grooveDepthMm) || 3); + const offsetMm = Math.max(0, Number(groove.topBottomOffsetMm) || 0); + const hingeSide = piece?.doorPlanHints?.hingeSide === 'right' ? 'right' : 'left'; + const xMm = hingeSide === 'right' ? widthMm / 2 : L - widthMm / 2; + const y1Mm = Math.min(B, offsetMm); + const y2Mm = Math.max(y1Mm, B - offsetMm); + return [ + '<109 \\Nuten\\', + 'MNM="Rainure poignée toute hauteur"', + `XA="${fmtMm(xMm)}"`, + `YA="${fmtMm(y1Mm)}"`, + `XE="${fmtMm(xMm)}"`, + `YE="${fmtMm(y2Mm)}"`, + `NB="${fmtMm(widthMm)}"`, + 'RK="NOWRK"', + 'EM="MOD2"', + 'TV="0"', + 'MV="GL"', + `TI="${fmtMm(depthMm)}"`, + 'MN="GL"', 'KO="04"' ]; } @@ -158,15 +217,17 @@ function bohrVertLines(spec, thicknessMm) { const edgeHint = Boolean(spec.toolMode === 'edge' || spec.face === 'end'); const throughHint = Boolean(spec.toolMode === 'through'); const looksThrough = depthRaw >= thicknessMm - 0.5; + const fromUnder = spec.face === 'back'; let bm = 'LS'; let ti; if (throughHint || looksThrough) { - bm = 'LSL'; + bm = fromUnder ? 'LSLU' : 'LSL'; ti = `${fmtMm(thicknessMm)} + 1`; } else if (edgeHint && depthRaw >= 38) { bm = 'LSL'; ti = fmtMm(depthRaw); } else { + bm = fromUnder ? 'LSU' : 'LS'; ti = fmtMm(depthRaw); } @@ -192,6 +253,59 @@ function bohrVertLines(spec, thicknessMm) { ]; } +/** + * Un trou de chant → lignes <103 BohrHoriz (perçage horizontal dans un chant). + * `end` désigne le chant d'entrée dans le repère pièce (origine bas-gauche) : + * left → depuis chant gauche (BM=XP), right → chant droit (BM=XM), + * bottom → chant avant Y=0 (BM=YP), top → chant arrière Y=B (BM=YM). + * ZA = position dans l'épaisseur (axe du trou) : `zMm` si fourni, sinon milieu (D/2). + */ +function bohrHorizLines(spec, L, B, D) { + const du = fmtMm(Math.round((Number(spec.diameterMm) || 8) * 10) / 10); + const ti = fmtMm(Number(spec.depthMm) || 25); + const za = fmtMm(Number.isFinite(Number(spec.zMm)) ? Number(spec.zMm) : D / 2); + + let bm; + let xa; + let ya; + switch (spec.end) { + case 'right': + bm = 'XM'; + xa = L; + ya = spec.yMm; + break; + case 'bottom': + bm = 'YP'; + xa = spec.xMm; + ya = 0; + break; + case 'top': + bm = 'YM'; + xa = spec.xMm; + ya = B; + break; + case 'left': + default: + bm = 'XP'; + xa = 0; + ya = spec.yMm; + break; + } + + return [ + '<103 \\BohrHoriz\\', + `MNM="Bohrung ${du} mm"`, + `XA="${fmtMm(xa)}"`, + `YA="${fmtMm(ya)}"`, + `ZA="${za}"`, + `DU="${du}"`, + `TI="${ti}"`, + `BM="${bm}"`, + 'AN="1"', + 'KO="04"' + ]; +} + /** Supprime les doublons numériques (tolérance 0.02 mm — sans fusionner trous légitimes proches). */ function dedupeHoleSpecs(specs, eps = 0.02) { const out = []; @@ -200,14 +314,134 @@ function dedupeHoleSpecs(specs, eps = 0.02) { (o) => Math.abs(o.xMm - s.xMm) < eps && Math.abs(o.yMm - s.yMm) < eps && - Math.abs((o.diameterMm || 0) - (s.diameterMm || 0)) < eps + Math.abs((o.diameterMm || 0) - (s.diameterMm || 0)) < eps && + (o.face || 'face') === (s.face || 'face') && + (o.end || '') === (s.end || '') ); if (!hit) out.push(s); } return out; } -function buildSingleMprText({ +/** + * La fraise à rainurer travaille depuis le dessus de la machine. Pour le + * panneau de dessus, on pose donc la face intérieure caisse vers le haut : + * rainure dos + assemblages caisse dessus, perçages d'habillage dessous. + */ +function orientFaceSpecsForMpr(piece, specs) { + if (piece?.elementType !== 'dessus' || !piece?.machining?.groove) return specs; + return specs.map((spec) => { + if (spec.face === 'face') return { ...spec, face: 'back' }; + if (spec.face === 'back') return { ...spec, face: 'face' }; + return spec; + }); +} + +/* --------------------------------------------------------------------------- + * Coupes d'onglet 45° (habillages de finition) : + * - tourillons perpendiculaires à la face d'onglet → <104 BohrUniv (perçage + * spatial : azimut C `CA` + inclinaison agrégat `WI`), sur le système 04. + * (<102 BohrVert exige un plan parallèle au plan supérieur.) + * - trait de scie 45° → <124 Nut_R (« Nuten im A-Winkel » : sciage avec lame + * inclinée WI, macro documenté du format MPR, équivalent Uni_Saeg_...W45 + * du postprocesseur DXF HOMAG). Directement sur le plan supérieur (KO 04) : + * pas de plan incliné custom, donc affichage propre et génération NC + * possible avec l'agrégat scie pivotante. + * Géométrie (coordonnées pièce) : la pièce occupe Z=0..D. La scie démarre donc + * explicitement sur la face supérieure ZA=D, puis descend à 45° vers le chant + * extérieur. Les trajectoires sont parcourues de sorte que leur normale gauche + * pointe vers la chute : l'axe de scie est parallèle aux tourillons et le plan + * de lame leur est perpendiculaire. Aucun plan S auxiliaire n'est nécessaire. + * ------------------------------------------------------------------------- */ + +const MITER_SAW_KERF_MM = 4; + +function buildMiterSections({ miterSpecs, L, B, D }) { + const empty = { levelLines: [], opLines: [] }; + if (!miterSpecs.length) return empty; + + const kerfCenter = MITER_SAW_KERF_MM / 2; + + // Trajectoires globales sur KO=04. Pour chaque chant, le côté gauche du + // parcours est l'extérieur de la pièce (la chute). + const SIDES = { + left: { + label: 'gauche', + ca: 0, + entry: (sp) => [D / 2, sp.yMm], + sawPath: [[D - kerfCenter, 0], [D - kerfCenter, B]] + }, + right: { + label: 'droite', + ca: 180, + entry: (sp) => [L - D / 2, sp.yMm], + sawPath: [[L - D + kerfCenter, B], [L - D + kerfCenter, 0]] + }, + top: { + label: 'haut', + ca: 270, + entry: (sp) => [sp.xMm, B - D / 2], + sawPath: [[0, B - D + kerfCenter], [L, B - D + kerfCenter]] + } + }; + + const opLines = []; + + for (const [side, def] of Object.entries(SIDES)) { + const specs = miterSpecs.filter((sp) => sp.end === side); + if (!specs.length) continue; + + /* Tourillons : perçage spatial sur le système 04 (repère pièce). */ + for (const sp of specs) { + const [xa, ya] = def.entry(sp); + const du = fmtMm(Number(sp.diameterMm) || 8); + opLines.push( + '<104 \\BohrUniv\\', + `MNM="Tourillon onglet ${def.label} ${du} mm"`, + `XA="${fmtMm(xa)}"`, + `YA="${fmtMm(ya)}"`, + `ZA="${fmtMm(D / 2)}"`, + `CA="${def.ca}"`, + 'WI="45"', + // woodWOP compte TI depuis le point XA/YA/ZA le long de l'axe incliné : + // avec l'entrée au centre de la face d'onglet, la profondeur affichée + // vaut le double de la profondeur utile → on émet la moitié. + `TI="${fmtMm((Number(sp.depthMm) || 15) / 2)}"`, + `DU="${du}"`, + 'AN="1"', + 'F_="STANDARD"', + 'KO="04"' + ); + } + + /* Trait de scie 45° incliné (Nut_R / Nuten im A-Winkel). */ + const [start, end] = def.sawPath; + opLines.push( + '<124 \\Nut_R\\', + `MNM="Coupe onglet 45 ${def.label}"`, + `XA="${fmtMm(start[0])}"`, + `YA="${fmtMm(start[1])}"`, + `XE="${fmtMm(end[0])}"`, + `YE="${fmtMm(end[1])}"`, + // KO=04 a son origine en Z=0 sous la pièce ; la face supérieure est Z=D. + `ZA="${fmtMm(D)}"`, + 'WI="45"', + `NB="${fmtMm(MITER_SAW_KERF_MM)}"`, + 'RK="NOWRK"', + 'EM="MOD2"', + 'TV="0"', + 'MV="GL"', + `TI="${fmtMm(D * Math.SQRT2 + 2)}"`, + 'MN="GL"', + 'F_="STANDARD"', + 'KO="04"' + ); + } + + return { levelLines: [], opLines }; +} + +export function buildSingleMprText({ piece, slug, qtyIndex, @@ -226,43 +460,57 @@ function buildSingleMprText({ const posLabel = `${groupIndex}.${seqGlobal}`; const { specs, thicknessMm } = collectHoleSpecsForPiece(piece, L, B, qtyIndex); - const unique = dedupeHoleSpecs(specs); + const unique = orientFaceSpecsForMpr(piece, dedupeHoleSpecs(specs)); - /* Bloc Bohrungen */ - const holeLines = []; - for (const sp of unique) { - holeLines.push(...bohrVertLines(sp, thicknessMm)); - } - - const dp = Math.max(1, Math.min(98, holeLines.filter((ln) => ln.startsWith('<102')).length)); + // Répartition des perçages par destination d'usinage (repère pièce, origine bas-gauche) : + // - onglet 45° (habillages) → plans inclinés dédiés (buildMiterSections), + // - chant (face='end') → <103 BohrHoriz, regroupés par côté d'entrée, + // - face opposée (face='back') → <102 BohrVert avec cycle WoodWOP « von unten », + // - grande face (défaut) → <102 BohrVert « von oben ». + const miterSpecs = unique.filter((sp) => sp.type === 'tourillon_miter' && sp.end); + const rest = unique.filter((sp) => !miterSpecs.includes(sp)); + const edgeSpecs = rest.filter((sp) => sp.face === 'end'); + const backSpecs = rest.filter((sp) => sp.face === 'back'); + const topSpecs = rest.filter((sp) => sp.face !== 'end' && sp.face !== 'back'); const matKm = escQuoted( piece.finishId != null && piece.finishId !== '' ? piece.finishId : defaultMaterialArticle || 'APEX' ); + // Les opérations restent exprimées dans le repère pièce KO=04. DRWIN tourne + // uniquement la présentation/pose machine (pièces hautes couchées en X). + const miter = buildMiterSections({ miterSpecs, L, B, D }); + + const bez = escQuoted(baseDesignation(slug)); + const hasBack = backSpecs.length > 0; + const lines = []; lines.push('[H', 'VERSION="4.0 Alpha"', 'FW="1400"', 'FM="1"', 'ZS="40"', 'OP="1"'); + // Une pièce plus haute que large est couchée à 270° pour correspondre à + // l'orientation machine; les formules DW conservent son repère d'usinage. lines.push('[001', 'MAT="2"', 'KM="Materialart"', `DRWIN="${angles.DRWIN}"`, 'KM="Drehwinkel 0,90,180,270"'); - lines.push(`AUFT="${escQuoted(orderReference)}"`, `KM="AUFTRAG: ${escQuoted(orderReference)}"`); - lines.push(`STKL="${escQuoted(stackListKm)}"`, `KM="STUECKLISTE: ${escQuoted(stackListKm)}"`); + lines.push('AUFT="0"', `KM="AUFTRAG: ${escQuoted(orderReference)}"`); + lines.push('STKL="0"', `KM="STÜCKLISTE: ${escQuoted(stackListKm)}"`); lines.push(`POS="${escQuoted(posLabel)}"`, `KM="POSITION: ${escQuoted(posLabel)}"`); - lines.push('BEZ="0"', `KM="Bezeichnung: ${escQuoted(slug)}"`); - lines.push(`L="${fmtMm(L)}"`, 'KM="Laenge in X"', `B="${fmtMm(B)}"`, 'KM="Breite in Y"', `D="${fmtMm(D)}"`, 'KM="Dicke in Z"'); - lines.push('ZF="2.0"', 'KM="Zugabe Fraesung"', 'ZB="3.0"', 'KM="Zugabe Bohrung"'); - lines.push('RITZEN="1.0"', 'KM="Vorritztiefe mm"'); + lines.push('BEZ="0"', `KM="Bezeichnung: ${bez}"`); + lines.push(`L="${fmtMm(L)}"`, 'KM="Länge in X"', `B="${fmtMm(B)}"`, 'KM="Breite in Y"', `D="${fmtMm(D)}"`, 'KM="Dicke in Z"'); + lines.push('ZF="2.0"', 'KM="Zugabe für DurchgangsFräsung"', 'ZB="3.0"', 'KM="Zugabe für DurchgangsBohrung"'); + lines.push('RITZEN="1.0"', 'KM="Vorritztiefe in mm - 0=nicht Vorritzen"'); lines.push(`KAVODI="${kant.kavo}"`, 'KM="Kantendicke vorne"', `KALIDI="${kant.kali}"`, 'KM="Kantendicke links"'); lines.push(`KAHIDI="${kant.kahi}"`, 'KM="Kantendicke hinten"', `KAREDI="${kant.kare}"`, 'KM="Kantendicke rechts"'); lines.push('FORMAT="0"', 'KM="Formatieren 0 oder 1"'); - lines.push('ZUGLI="0.0"', 'ZUGRE="0.0"', 'ZUGVO="0.0"', 'ZUGHI="0.0"'); - lines.push('DW="IF DRWIN=0 OR DRWIN=90 OR DRWIN=180 THEN DRWIN ELSE 270"'); - lines.push('ld="IF DW=90 OR DW=270 THEN B ELSE L"', 'bd="IF DW=90 OR DW=270 THEN L ELSE B"'); - lines.push( - 'offxL="IF DW=180 THEN ZUGRE ELSE ZUGLI"', - 'offxB="IF DW=90 THEN ZUGHI ELSE ZUGVO"', - 'offyL="IF DW=180 THEN ZUGHI ELSE ZUGVO"', - 'offyB="IF DW=90 THEN ZUGLI ELSE ZUGRE"' - ); + lines.push('ZUGLI="0.0"', 'KM="Zugabe für Gehrung links"'); + lines.push('ZUGRE="0.0"', 'KM="Zugabe für Gehrung rechts"'); + lines.push('ZUGVO="0.0"', 'KM="Zugabe für Gehrung vorne"'); + lines.push('ZUGHI="0.0"', 'KM="Zugabe für Gehrung hinten"'); + lines.push('DW="IF DRWIN=0 OR DRWIN=90 OR DRWIN=180 THEN DRWIN ELSE 270"', 'KM="DISABLED Drehwinkel korrigiert bei Fehleingabe"'); + lines.push('ld="IF DW=90 OR DW=270 THEN B ELSE L"', 'KM="DISABLED Länge in X (gedreht)"'); + lines.push('bd="IF DW=90 OR DW=270 THEN L ELSE B"', 'KM="DISABLED Breite in Y (gedreht)"'); + lines.push('offxL="IF DW=180 THEN ZUGRE ELSE ZUGLI"', 'KM="DISABLED Offset in X (0° oder 180°)"'); + lines.push('offxB="IF DW=90 THEN ZUGHI ELSE ZUGVO"', 'KM="DISABLED Offset in X (90° oder 270°)"'); + lines.push('offyL="IF DW=180 THEN ZUGHI ELSE ZUGVO"', 'KM="DISABLED Offset in Y (0° oder 180°)"'); + lines.push('offyB="IF DW=90 THEN ZUGLI ELSE ZUGRE"', 'KM="DISABLED Offset in Y (90° oder 270°)"'); lines.push( '[K', '<00 \\Koordinatensystem\\', @@ -310,9 +558,12 @@ function buildSingleMprText({ 'KI="90"', 'D2="0"', 'MI="0"', + ...miter.levelLines, '<100 \\WerkStck\\', - 'LA="IF DW=90 OR DW=270 THEN B ELSE L"', - 'BR="IF DW=90 OR DW=270 THEN L ELSE B"', + // WerkStck uses the piece-local dimensions. KO=04 applies DRWIN to the + // stock and operations together; swapping here would rotate the stock twice. + `LA="${fmtMm(L)}"`, + `BR="${fmtMm(B)}"`, 'DI="D"', 'FNX="IF DW=90 OR DW=270 THEN offxB ELSE offxL"', 'FNY="IF DW=90 OR DW=270 THEN offyB ELSE offyL"', @@ -320,20 +571,25 @@ function buildSingleMprText({ 'AY="IF DW=90 OR DW=270 THEN ZUGLI+ZUGRE ELSE ZUGVO+ZUGHI"', '<101 \\Kommentar\\', `KM="AUFTRAG : ${escQuoted(orderReference)}"`, - `KM="STUECKLISTE : ${escQuoted(stackListKm)}"`, + `KM="STÜCKLISTE : ${escQuoted(stackListKm)}"`, `KM="POSITION : ${escQuoted(posLabel)}"`, `KM="Bezeichnung: ${escQuoted(slug)}"`, 'KM=""', `KM="${escQuoted(angles.turnMsg)}"`, 'KM=""', 'KM="Teileinfo"', - `KM="Groesse: ${fmtMm(L)} x ${fmtMm(B)}"`, - `KM="Staerke: ${fmtMm(D)}"`, + `KM="Größe: ${fmtMm(L)} x ${fmtMm(B)}"`, + `KM="Stärke: ${fmtMm(D)}"`, `KM=""`, `KM="Material: ${matKm}"`, 'KM="Belag IN: "', 'KM="Belag AU: "', - 'KM=""', + 'KM=""' + ); + + // Les usinages de sous-face utilisent directement BM=LSU/LSLU et ne + // nécessitent donc aucun retournement de la pièce. + lines.push( '<139 \\Komponente\\', 'MNM="Beschriftung"', 'IN="RSO/Makro/RSO_Beschriftung.mpr"', @@ -345,7 +601,10 @@ function buildSingleMprText({ 'VA="KAREDI KAREDI"', `VA="GEDREHT ${angles.beschGedreht}"`, 'VA="NCSTOPP 0"', - 'KO="04"', + 'KO="04"' + ); + // Macro de formatage (RSO_Format) — exécutée seulement si FORMAT=1 (??="FORMAT"). + lines.push( '<139 \\Komponente\\', 'MNM="Formatierung"', 'IN="RSO/Makro/RSO_Format.mpr"', @@ -363,15 +622,41 @@ function buildSingleMprText({ 'VA="KAFRMAX 2.0"', 'VA="MAT MAT"', '??="FORMAT"', - 'KO="04"', - '<121 \\Block\\', - 'MNM="Bearbeitungen von oben "', - `DP="${dp}"`, 'KO="04"' ); - lines.push(...holeLines); + // Bloc « Bearbeitungen von oben » — perçages verticaux sur la grande face. + if (topSpecs.length) { + lines.push('<121 \\Block\\', 'MNM="Bearbeitungen von oben "', `DP="${topSpecs.length}"`, 'KO="04"'); + for (const sp of topSpecs) lines.push(...bohrVertLines(sp, thicknessMm)); + } + + // Bloc « Bearbeitungen von unten » — BM=LSU/LSLU indique réellement à + // WoodWOP que l'outil entre par la sous-face. + if (hasBack) { + lines.push('<121 \\Block\\', 'MNM="Bearbeitungen von unten "', `DP="${backSpecs.length}"`, 'KO="04"'); + for (const sp of backSpecs) { + lines.push(...bohrVertLines(sp, thicknessMm)); + } + } + + // Blocs de perçage sur chant — un bloc par côté d'entrée. + const edgeBlocks = [ + ['left', 'Bearbeitungen von links '], + ['right', 'Bearbeitungen von rechts '], + ['bottom', 'Bearbeitungen von vorne '], + ['top', 'Bearbeitungen von hinten '] + ]; + for (const [end, label] of edgeBlocks) { + const group = edgeSpecs.filter((sp) => (sp.end || 'left') === end); + if (!group.length) continue; + lines.push('<121 \\Block\\', `MNM="${label}"`, `DP="${group.length}"`, 'KO="04"'); + for (const sp of group) lines.push(...bohrHorizLines(sp, L, B, D)); + } + + lines.push(...miter.opLines); lines.push(...grooveSection(piece, L, B)); + lines.push(...doorHandleGrooveSection(piece, L, B)); lines.push('!'); return lines.join('\r\n'); } @@ -407,10 +692,12 @@ function buildMprFileRecords(entries, mprOptions) { } async function packMprRecords(records, zipBaseName) { + // Encodage Windows-1252 (Latin-1) : format attendu par woodWOP pour les + // commentaires KM allemands (ä ö ü ß °), comme dans les fichiers fabricant. if (records.length === 1) { const only = records[0]; return { - buffer: Buffer.from(only.content, 'utf8'), + buffer: Buffer.from(only.content, 'latin1'), fileName: only.fileName, mimeType: 'application/octet-stream', fileCount: 1 @@ -419,7 +706,7 @@ async function packMprRecords(records, zipBaseName) { const zip = new JSZip(); for (const rec of records) { - zip.file(rec.fileName, rec.content); + zip.file(rec.fileName, Buffer.from(rec.content, 'latin1')); } const safeBase = safeZipFileName(zipBaseName || 'piece-mpr'); return { diff --git a/server/services/planGenerator.js b/server/services/planGenerator.js index 0db688a..857e969 100644 --- a/server/services/planGenerator.js +++ b/server/services/planGenerator.js @@ -8,7 +8,10 @@ import { PANEL, GROOVE, SYSTEM32, SYSTEM32_SEPARATORS, HINGE, CONNECTORS, getHingeCountForDoor, getHingeCupPositions, getDoorHingeSide, HANDLE, - EDGE_BANDING, REINFORCEMENT, REINFORCEMENT_MACHINING, PLINTH, SHELF_MACHINING + EDGE_BANDING, REINFORCEMENT, REINFORCEMENT_MACHINING, PLINTH, SHELF_MACHINING, + resolveHorizontalSpan, validateHorizontalSpans, getSeparatorVerticalSegments, + hasSideHabillage, hasSidePlinthReturn, getAxiloPlinthReturnLengthMm, + getAxiloPlinthFrontSideDeductionMm } from '../../shared/manufacturing.js'; // Constants for drawing @@ -36,6 +39,8 @@ const SHARED_STYLES = ` .renfort { fill: #c9a96e; stroke: #333; stroke-width: 0.5; opacity: 0.7; } .detail-label { font-family: Arial, sans-serif; font-size: 7px; fill: #666; } .fh-groove { fill: none; stroke: #0d9488; stroke-width: 1; stroke-dasharray: 3, 2; } + .horizontal-span { fill: none; stroke: #b45309; stroke-width: 2; } + .horizontal-span-label { font-family: Arial, sans-serif; font-size: 8px; font-weight: bold; fill: #92400e; } `; // ── Helpers ────────────────────────────────────────────────────────── @@ -140,6 +145,19 @@ function generateDimensionLine(x, y, length, label, direction = 'horizontal') { export function generateAssemblyPlan(config, modules = []) { const { widthMm, heightMm, plinthHeightMm, doorType } = config; const bodyHeight = heightMm - plinthHeightMm; + const planModules = modules.length > 0 ? modules : (Array.isArray(config.modules) ? config.modules : []); + const spanConfig = { ...config, modules: planModules }; + const spanIssues = + Array.isArray(config.horizontalSpans) && config.horizontalSpans.length + ? validateHorizontalSpans(spanConfig) + : []; + const invalidSpanIds = new Set( + spanIssues.map((issue) => issue.spanId).filter((id) => id != null).map(String) + ); + const resolvedSpans = (Array.isArray(config.horizontalSpans) ? config.horizontalSpans : []) + .filter((span) => span?.id != null && !invalidSpanIds.has(String(span.id))) + .map((span) => resolveHorizontalSpan(span, spanConfig)) + .filter(Boolean); const S = DRAWING_SCALE; const svgWidth = widthMm * S + MARGIN * 2; @@ -154,19 +172,49 @@ export function generateAssemblyPlan(config, modules = []) { const oX = MARGIN; const oY = MARGIN; - // Plinth + const habLeft = hasSideHabillage(config, 'left'); + const habRight = hasSideHabillage(config, 'right'); + const returnLeft = hasSidePlinthReturn(config, 'left'); + const returnRight = hasSidePlinthReturn(config, 'right'); + const habT = PANEL.structure * S; + const isNiche = config?.installationType === 'niche'; + const leftDeductMm = isNiche ? 0 : getAxiloPlinthFrontSideDeductionMm(config, 'left'); + const rightDeductMm = isNiche ? 0 : getAxiloPlinthFrontSideDeductionMm(config, 'right'); + const sideSetbackMm = PLINTH.frontSetbackMm; + + // Plinth — shortened where side cladding or a recessed plinth return runs to the floor const plinthY = oY + bodyHeight * S; - svg += ``; + const plinthX = oX + leftDeductMm * S; + const plinthW = (widthMm - leftDeductMm - rightDeductMm) * S; + svg += ``; // Body svg += ``; + if (habLeft) { + svg += ``; + } else if (returnLeft) { + svg += ``; + } + if (habRight) { + svg += ``; + } else if (returnRight) { + svg += ``; + } // Module separators - if (modules.length > 0) { + if (planModules.length > 0) { let currentX = oX; - for (let i = 0; i < modules.length - 1; i++) { - currentX += modules[i].widthMm * S; - svg += ``; + for (let i = 0; i < planModules.length - 1; i++) { + currentX += planModules[i].widthMm * S; + if (resolvedSpans.length === 0) { + svg += ``; + } else { + for (const segment of getSeparatorVerticalSegments(spanConfig, i)) { + const y1 = plinthY - segment.topMm * S; + const y2 = plinthY - segment.bottomMm * S; + svg += ``; + } + } } } @@ -182,9 +230,9 @@ export function generateAssemblyPlan(config, modules = []) { svg += ``; } } else { - const blockCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2); + const blockCount = planModules.length > 0 ? planModules.length : (config.blocks?.length || 2); let currentX = oX; - const items = modules.length > 0 ? modules : (config.blocks || []); + const items = planModules.length > 0 ? planModules : (config.blocks || []); const hw = config.hardware || {}; const fullHeightHandle = hw.handleType === 'full_height'; const widthDeductionMm = fullHeightHandle ? HANDLE.fullHeightWidthDeductionMm : 0; @@ -193,24 +241,116 @@ export function generateAssemblyPlan(config, modules = []) { for (let i = 0; i < blockCount; i++) { const bw = items[i]?.widthMm || (widthMm / blockCount); - const doorWmm = Math.max(100, Math.floor(widthMm / blockCount) - 4 - widthDeductionMm); const modulePx = bw * S; + const doorWmm = Math.max( + 100, + Math.floor(resolvedSpans.length === 0 ? widthMm / blockCount : bw) - + 4 - + widthDeductionMm + ); const doorWPx = doorWmm * S; const doorX = currentX + (modulePx - doorWPx) / 2; - const doorY = oY + gapMm * S; - - svg += ``; - const hingeSide = getDoorHingeSide(i, blockCount, hw); - svg += drawHingeMarkers(doorX, doorY, doorWmm, doorHmm, S, hingeSide); - if (fullHeightHandle) { - svg += drawFullHeightHandleGrooveElevation(doorX, doorY, doorWmm, doorHmm, S, hingeSide); + + if (resolvedSpans.length === 0 || planModules.length === 0) { + const doorY = oY + gapMm * S; + svg += ``; + svg += drawHingeMarkers(doorX, doorY, doorWmm, doorHmm, S, hingeSide); + if (fullHeightHandle) { + svg += drawFullHeightHandleGrooveElevation(doorX, doorY, doorWmm, doorHmm, S, hingeSide); + } + } else { + const module = planModules[i] || {}; + let heights = Array.isArray(module.blockHeightsMm) + ? module.blockHeightsMm.map(Number).filter((height) => Number.isFinite(height) && height > 0) + : []; + if (!heights.length) { + const stacked = module.stacked === true || + (module.stacked == null && config.stackEnabled === true); + const bottom = Number(module.stackBottomHeightMm ?? config.stackBottomHeightMm); + heights = stacked && bottom > 0 && bottom < bodyHeight + ? [bottom, bodyHeight - bottom] + : [bodyHeight]; + } + const blockDoors = Array.isArray(module.blockDoors) ? module.blockDoors : null; + let bottomMm = 0; + heights.forEach((height, blockIndex) => { + const topMm = bottomMm + height; + const covered = resolvedSpans.some((span) => + span.moduleIndexes.includes(i) && + bottomMm >= span.bottomMm - 0.5 && + topMm <= span.topMm + 0.5 + ); + const hasDoor = + blockDoors && typeof blockDoors[blockIndex] === 'boolean' + ? blockDoors[blockIndex] + : true; + if (hasDoor && !covered) { + const segmentDoorHeightMm = Math.max(80, height - 4); + const doorY = plinthY - topMm * S + gapMm * S; + svg += ``; + svg += drawHingeMarkers( + doorX, doorY, doorWmm, segmentDoorHeightMm, S, hingeSide + ); + if (fullHeightHandle) { + svg += drawFullHeightHandleGrooveElevation( + doorX, doorY, doorWmm, segmentDoorHeightMm, S, hingeSide + ); + } + } + bottomMm = topMm; + }); } currentX += modulePx; } } + for (const span of resolvedSpans) { + const x1 = oX + span.leftMm * S; + const x2 = oX + span.rightMm * S; + const yBottom = plinthY - span.bottomMm * S; + const yTop = plinthY - span.topMm * S; + svg += ``; + svg += ``; + svg += `Portée horizontale — colonnes ${span.moduleIndexes.map((index) => index + 1).join('–')}`; + + const doorMode = span.span?.doorMode || 'none'; + if (doorType !== 'sliding' && doorMode !== 'none') { + const hw = config.hardware || {}; + const fullHeightHandle = hw.handleType === 'full_height'; + const widthDeductionMm = fullHeightHandle ? HANDLE.fullHeightWidthDeductionMm : 0; + const doorHeightMm = Math.max(120, span.topMm - span.bottomMm - 4); + const doorY = yTop + 2 * S; + const defs = doorMode === 'single' + ? [{ leftMm: span.leftMm, widthMm: span.widthMm }] + : [ + { + leftMm: span.leftMm, + widthMm: span.widthMm / 2, + hingeSide: 'left' + }, + { + leftMm: span.leftMm + span.widthMm / 2, + widthMm: span.widthMm / 2, + hingeSide: 'right' + } + ]; + defs.forEach((door, doorIndex) => { + const doorWidthMm = Math.max(100, Math.floor(door.widthMm) - 4 - widthDeductionMm); + const doorX = oX + door.leftMm * S + (door.widthMm - doorWidthMm) * S / 2; + const hingeSide = door.hingeSide || getDoorHingeSide(doorIndex, defs.length, hw); + svg += ``; + svg += drawHingeMarkers(doorX, doorY, doorWidthMm, doorHeightMm, S, hingeSide); + if (fullHeightHandle) { + svg += drawFullHeightHandleGrooveElevation( + doorX, doorY, doorWidthMm, doorHeightMm, S, hingeSide + ); + } + }); + } + } + // Reinforcements (Forteresse 16 mm): // width = armoire width - 2×19 (entre côtés), // one at top + one at bottom (offset 19 mm), @@ -257,12 +397,23 @@ export function generateSidePlan(config) { const oX = MARGIN; const oY = MARGIN; - // Plinth - const plinthY = oY + bodyHeight * S; - svg += ``; + const habLeft = hasSideHabillage(config, 'left'); + const returnLeft = hasSidePlinthReturn(config, 'left'); - // Body - svg += ``; + const plinthY = oY + bodyHeight * S; + const plinthSetbackMm = PLINTH.frontSetbackMm; + if (habLeft) { + // Habillage gauche jusqu'au sol : pas de plinthe visible de ce côté. + svg += ``; + } else { + if (returnLeft) { + const returnLenMm = getAxiloPlinthReturnLengthMm(depthMm); + svg += ``; + } else { + svg += ``; + } + svg += ``; + } // Back panel (8mm) shown as a thin rectangle at the back const backX = oX + (depthMm - PANEL.back) * S; diff --git a/server/services/priceEngine.js b/server/services/priceEngine.js index 896e161..1c4d3e3 100644 --- a/server/services/priceEngine.js +++ b/server/services/priceEngine.js @@ -4,13 +4,50 @@ * Specs APEX 4.0 importées de shared/manufacturing.js */ -import { PANEL, DRAWERS, NICHE, getHingeCountForDoor } from '../../shared/manufacturing.js'; +import { + PANEL, + DRAWERS, + NICHE, + HANDLE, + PLINTH, + getAxiloPlinthPanelHeightMm, + hasSidePlinthReturn, + getAxiloPlinthReturnLengthMm, + getHingeCountForDoor, + resolveHorizontalSpan, + validateHorizontalSpans, + usesBlockConstruction, + MAX_PANEL_DIMENSION_MM +} from '../../shared/manufacturing.js'; function numberOr(value, fallback) { const n = typeof value === 'number' ? value : Number(value); return Number.isFinite(n) ? n : fallback; } +function resolvedHorizontalSpansForConfig(config) { + if (!Array.isArray(config?.horizontalSpans) || config.horizontalSpans.length === 0) return []; + const invalidIds = new Set( + validateHorizontalSpans(config) + .map((issue) => issue?.spanId) + .filter((id) => id != null) + .map(String) + ); + return config.horizontalSpans + .filter((span) => span?.id != null && !invalidIds.has(String(span.id))) + .map((span) => resolveHorizontalSpan(span, config)) + .filter(Boolean); +} + +function spanCoversBlock(resolvedSpans, moduleIndex, bottomMm, topMm) { + const eps = 0.5; + return resolvedSpans.some((span) => + span.moduleIndexes.includes(moduleIndex) && + bottomMm >= span.bottomMm - eps && + topMm <= span.topMm + eps + ); +} + /** * Calcule la surface utile des éléments visibles (portes, fileurs, bandeau, plinthe, côtés doublage). * Épaisseur panneaux 19 mm (référence APEX 4.0). @@ -26,14 +63,70 @@ function calculateVisibleSurface(config, panelThicknessMm = PANEL.structure) { if (doorType === 'sliding' && slidingPanelsCount) { const panelWidth = widthMm / slidingPanelsCount; surface += slidingPanelsCount * (panelWidth * (heightMm - plinthHeightMm)) * 2; // deux faces par vantail + } else if (Array.isArray(config.modules) && config.modules.length > 0) { + // 1 porte par block AVEC porte (mod.blockDoors); block sans porte = niche ouverte, pas de façade. + const bodyMm = heightMm - plinthHeightMm; + const resolvedSpans = resolvedHorizontalSpansForConfig(config); + for (let moduleIndex = 0; moduleIndex < config.modules.length; moduleIndex++) { + const m = config.modules[moduleIndex]; + const colW = Number(m?.widthMm) || widthMm / config.modules.length; + const arr = Array.isArray(m?.blockHeightsMm) + ? m.blockHeightsMm.map(Number).filter((v) => Number.isFinite(v) && v > 0) + : null; + let heights; + if (arr && arr.length > 0) { + heights = arr; + } else { + const stacked = typeof m?.stacked === 'boolean' ? m.stacked : config.stackEnabled === true; + heights = stacked && bodyMm >= 600 + ? [Number(m?.stackBottomHeightMm ?? config.stackBottomHeightMm) || 1000] + .flatMap((b) => [b, bodyMm - b]) + : [bodyMm]; + } + const doors = Array.isArray(m?.blockDoors) ? m.blockDoors : null; + let blockBottomMm = 0; + heights.forEach((h, k) => { + const blockTopMm = blockBottomMm + h; + const covered = spanCoversBlock(resolvedSpans, moduleIndex, blockBottomMm, blockTopMm); + blockBottomMm = blockTopMm; + const hasDoor = doors && typeof doors[k] === 'boolean' ? doors[k] : true; + if (hasDoor && !covered) surface += colW * h * 2; // deux faces + }); + } + const fullHeightDeduction = + config.hardware?.handleType === 'full_height' ? HANDLE.fullHeightWidthDeductionMm : 0; + for (const span of resolvedSpans) { + const doorMode = span.span?.doorMode || 'none'; + if (doorMode === 'none') continue; + const doorHeightMm = Math.max(120, span.topMm - span.bottomMm - 4); + if (doorMode === 'single') { + const doorWidthMm = Math.max(100, span.widthMm - 4 - fullHeightDeduction); + surface += doorWidthMm * doorHeightMm * 2; + } else if (doorMode === 'perColumn') { + for (let leafIndex = 0; leafIndex < 2; leafIndex++) { + const doorWidthMm = Math.max( + 100, + Math.floor(span.widthMm / 2) - 4 - fullHeightDeduction + ); + surface += doorWidthMm * doorHeightMm * 2; + } + } + } } else { const doorsCount = blocks.length <= 1 ? Math.min(2, Math.ceil((blocks[0]?.widthMm || widthMm) / 600)) : blocks.length * 2; const doorWidth = widthMm / Math.max(1, doorsCount); surface += doorsCount * (doorWidth * (heightMm - plinthHeightMm)) * 2; } - // Plinthe (face avant) - surface += widthMm * plinthHeightMm; + // Plinthe (face avant + retours latéraux s'il n'y a pas d'habillage) + const plinthH = getAxiloPlinthPanelHeightMm(plinthHeightMm); + surface += widthMm * plinthH; + if (hasSidePlinthReturn(config, 'left')) { + surface += getAxiloPlinthReturnLengthMm(depthMm) * plinthH; + } + if (hasSidePlinthReturn(config, 'right')) { + surface += getAxiloPlinthReturnLengthMm(depthMm) * plinthH; + } // Bandeau et fileurs si niche if (isNiche) { @@ -188,47 +281,101 @@ function calculateModuleCosts(modules, moduleCatalog) { * @param {Object} config - Config with door and drawer counts * @returns {Object} - Hardware costs breakdown */ +/** + * Hauteurs (mm) des portes battantes: 1 porte par block AVEC porte (mod.blockDoors), + * par colonne. Colonne empilée sans flags portes → 1 porte par block. + */ +function hingedDoorHeightsMm(config) { + const bodyMm = (Number(config.heightMm) || 2400) - (Number(config.plinthHeightMm) || 100); + const mods = Array.isArray(config.modules) && config.modules.length > 0 ? config.modules : null; + const cols = mods ?? Array.from({ length: config.blocks?.length || 2 }, () => null); + const resolvedSpans = mods ? resolvedHorizontalSpansForConfig(config) : []; + const out = []; + for (let moduleIndex = 0; moduleIndex < cols.length; moduleIndex++) { + const m = cols[moduleIndex]; + const arr = Array.isArray(m?.blockHeightsMm) + ? m.blockHeightsMm.map(Number).filter((v) => Number.isFinite(v) && v > 0) + : null; + let heights; + if (arr && arr.length > 0) { + heights = arr; + } else { + const stacked = typeof m?.stacked === 'boolean' ? m.stacked : config.stackEnabled === true; + if (stacked && bodyMm >= 600) { + const bottom = Number(m?.stackBottomHeightMm ?? config.stackBottomHeightMm) || 1000; + heights = [bottom, bodyMm - bottom]; + } else { + heights = [bodyMm]; + } + } + const doors = Array.isArray(m?.blockDoors) ? m.blockDoors : null; + let blockBottomMm = 0; + heights.forEach((h, k) => { + const blockTopMm = blockBottomMm + h; + const covered = spanCoversBlock(resolvedSpans, moduleIndex, blockBottomMm, blockTopMm); + blockBottomMm = blockTopMm; + const hasDoor = doors && typeof doors[k] === 'boolean' ? doors[k] : true; + if (hasDoor && !covered) out.push(Math.max(80, h - 4)); + }); + } + for (const span of resolvedSpans) { + const doorMode = span.span?.doorMode || 'none'; + const count = + doorMode === 'single' ? 1 : + doorMode === 'perColumn' ? 2 : 0; + const heightMm = Math.max(120, span.topMm - span.bottomMm - 4); + for (let index = 0; index < count; index++) out.push(heightMm); + } + return out; +} + function calculateHardwareCosts(hardware, hardwareData, config) { const { handles = [], hinges = [], blum = [] } = hardwareData; const breakdown = []; let totalChf = 0; - - // Handle costs - one per door + + const hingedDoorHeights = config.doorType === 'sliding' ? [] : hingedDoorHeightsMm(config); + + // Handle costs - one per door (blocks sans porte exclus) if (hardware.handleVariantId) { const handle = handles.find(h => h.id === hardware.handleVariantId); if (handle) { - const doorCount = config.doorType === 'sliding' + const doorCount = config.doorType === 'sliding' ? (config.slidingPanelsCount || 2) - : (config.modules?.length || config.blocks?.length || 2); - const cost = handle.price_chf * doorCount; - totalChf += cost; - breakdown.push({ - type: 'handle', - name: handle.name, - unitPrice: handle.price_chf, - quantity: doorCount, - totalChf: cost - }); + : hingedDoorHeights.length; + if (doorCount > 0) { + const cost = handle.price_chf * doorCount; + totalChf += cost; + breakdown.push({ + type: 'handle', + name: handle.name, + unitPrice: handle.price_chf, + quantity: doorCount, + totalChf: cost + }); + } } } - - // Hinge costs - dynamic quantity from APEX 4.0 matrix + + // Hinge costs - dynamic quantity from APEX 4.0 matrix, par porte (hauteur du block) if (hardware.hingeVariantId && config.doorType !== 'sliding') { const hinge = hinges.find(h => h.id === hardware.hingeVariantId); if (hinge) { - const doorCount = config.modules?.length || config.blocks?.length || 2; - const doorHeightMm = (config.heightMm || 2400) - (config.plinthHeightMm || 100) - 4; - const hingesPerDoor = getHingeCountForDoor({ heightMm: doorHeightMm }); - const quantity = doorCount * hingesPerDoor; - const cost = hinge.price_chf * quantity; - totalChf += cost; - breakdown.push({ - type: 'hinge', - name: hinge.name, - unitPrice: hinge.price_chf, - quantity, - totalChf: cost - }); + const quantity = hingedDoorHeights.reduce( + (sum, doorHeightMm) => sum + getHingeCountForDoor({ heightMm: doorHeightMm }), + 0 + ); + if (quantity > 0) { + const cost = hinge.price_chf * quantity; + totalChf += cost; + breakdown.push({ + type: 'hinge', + name: hinge.name, + unitPrice: hinge.price_chf, + quantity, + totalChf: cost + }); + } } } @@ -265,6 +412,24 @@ function calculateHardwareCosts(hardware, hardwareData, config) { */ export function calculatePrice(config, data) { const errors = []; + const horizontalSpanIssues = + Array.isArray(config?.horizontalSpans) && config.horizontalSpans.length + ? validateHorizontalSpans(config) + : []; + horizontalSpanIssues.forEach((issue) => errors.push({ + field: issue.field, + message: issue.message + })); + const invalidHorizontalSpanIds = new Set( + horizontalSpanIssues + .map((issue) => issue.spanId) + .filter((id) => id != null) + .map(String) + ); + const resolvedHorizontalSpans = (Array.isArray(config?.horizontalSpans) ? config.horizontalSpans : []) + .filter((span) => span?.id != null && !invalidHorizontalSpanIds.has(String(span.id))) + .map((span) => resolveHorizontalSpan(span, config)) + .filter(Boolean); const settingsArr = data.settings || []; const settings = {}; for (const r of settingsArr) { @@ -274,7 +439,7 @@ export function calculatePrice(config, data) { } const kitFeeChf = numberOr(settings.kit_preparation_fee_chf, 350); const plinthMin = numberOr(settings.plinth_height_min_mm, 60); - const plinthMax = numberOr(settings.plinth_height_max_mm, 160); + const plinthMax = numberOr(settings.plinth_height_max_mm, 200); const slidingMinWidth = numberOr(settings.sliding_doors_min_width_mm, 1200); const allowSlidingDoors = String(settings.allow_sliding_doors ?? '0') === '1'; const standardHandleAxisMm = numberOr(settings.standard_handle_axis_mm, 1000); @@ -287,14 +452,154 @@ export function calculatePrice(config, data) { doorType, slidingPanelsCount, finishId, serviceOptionIds = [], columnMode: columnModeRaw } = config; - const columnMode = columnModeRaw === 'block' ? 'block' : 'separations'; + // Règle: dos → caisson block ; sinon côté ou rayonnage → block ; sinon séparations. + // Legacy: `mod.hasBack` absent → `config.hasBack` global; les deux absents → columnMode envoyé. + const legacyBackDefault = typeof config.hasBack === 'boolean' + ? config.hasBack + : columnModeRaw === 'block'; + // Empilage vertical PAR COLONNE — modèle N blocks: mod.blockHeightsMm / mod.blockBacks + // (legacy 2 blocks: mod.stacked + mod.stackBottomHeightMm; défauts config.stackEnabled / config.stackBottomHeightMm). + const STACK_MIN_MM = 300; + const STACK_MAX_MM = MAX_PANEL_DIMENSION_MM; // panneau max → au-delà, empilage obligatoire + const bodyHeightForStackMm = (Number(config.heightMm) || 0) - (Number(config.plinthHeightMm) || 100); + const minBlocksRequired = Math.max(1, Math.ceil(bodyHeightForStackMm / STACK_MAX_MM)); + const maxBlocksAllowed = Math.max(1, Math.floor(bodyHeightForStackMm / STACK_MIN_MM)); + + /** Hauteurs de blocks d'une colonne (bas → haut) telles qu'envoyées (sans normalisation silencieuse). */ + const columnBlockHeights = (modules || []).map((m) => { + const arr = Array.isArray(m?.blockHeightsMm) + ? m.blockHeightsMm.map(Number).filter((v) => Number.isFinite(v) && v > 0) + : null; + if (arr && arr.length > 0) return arr; + const stacked = typeof m?.stacked === 'boolean' ? m.stacked : config.stackEnabled === true; + if (stacked && bodyHeightForStackMm >= STACK_MIN_MM * 2) { + const bottom = Number(m?.stackBottomHeightMm ?? config.stackBottomHeightMm) || 1000; + return [bottom, bodyHeightForStackMm - bottom]; + } + return [bodyHeightForStackMm]; + }); + const columnBlockCounts = columnBlockHeights.map((hs) => hs.length); + const anyStacked = columnBlockCounts.some((n) => n >= 2) || + ((modules || []).length === 0 && config.stackEnabled === true); + + if (anyStacked && bodyHeightForStackMm < STACK_MIN_MM * 2) { + errors.push({ + field: 'stackEnabled', + message: `Empilage vertical impossible: hauteur de caisse insuffisante (min. ${STACK_MIN_MM * 2} mm hors plinthe).` + }); + } else { + columnBlockHeights.forEach((hs, i) => { + if (hs.length < minBlocksRequired) { + errors.push({ + field: 'blockHeightsMm', + message: `Colonne ${i + 1}: hauteur de caisse ${bodyHeightForStackMm} mm > panneau max ${STACK_MAX_MM} mm — au moins ${minBlocksRequired} blocks empilés obligatoires.` + }); + return; + } + if (hs.length > maxBlocksAllowed) { + errors.push({ + field: 'blockHeightsMm', + message: `Colonne ${i + 1}: trop de blocks (max. ${maxBlocksAllowed} pour ${bodyHeightForStackMm} mm, min. ${STACK_MIN_MM} mm par block).` + }); + return; + } + if (hs.length >= 2) { + const sum = hs.reduce((a, b) => a + b, 0); + if (hs.some((h) => h < STACK_MIN_MM || h > STACK_MAX_MM)) { + errors.push({ + field: 'blockHeightsMm', + message: `Colonne ${i + 1}: hauteur de block invalide (entre ${STACK_MIN_MM} et ${STACK_MAX_MM} mm par block).` + }); + } else if (Math.abs(sum - bodyHeightForStackMm) > 2) { + errors.push({ + field: 'blockHeightsMm', + message: `Colonne ${i + 1}: la somme des blocks (${sum} mm) doit correspondre à la hauteur de caisse (${bodyHeightForStackMm} mm).` + }); + } + } + }); + } + const stackEnabled = anyStacked && bodyHeightForStackMm >= STACK_MIN_MM * 2; + + const columnBackFlags = (modules || []).map((m) => + typeof m?.hasBack === 'boolean' ? m.hasBack : legacyBackDefault + ); + /** Dos par block d'une colonne (bas → haut). */ + const columnBlockBacks = (modules || []).map((m, i) => { + const n = columnBlockCounts[i]; + const arr = Array.isArray(m?.blockBacks) ? m.blockBacks : null; + return Array.from({ length: n }, (_, k) => { + if (arr && typeof arr[k] === 'boolean') return arr[k]; + if (k === n - 1 && n >= 2 && typeof m?.hasBackTop === 'boolean') return m.hasBackTop; + return columnBackFlags[i]; + }); + }); + // Zone du dessus (dernier block, ou zone unique). + const columnBackTopFlags = columnBlockBacks.map((backs, i) => + backs.length > 0 ? backs[backs.length - 1] : columnBackFlags[i] + ); + let blockJointCount = 0; + for (let i = 0; i + 1 < columnBlockBacks.length; i++) { + const leftMod = (modules || [])[i]; + const rightMod = (modules || [])[i + 1]; + const leftBlock = usesBlockConstruction(leftMod, (columnBlockBacks[i] || []).some(Boolean)); + const rightBlock = usesBlockConstruction(rightMod, (columnBlockBacks[i + 1] || []).some(Boolean)); + if (!leftBlock || !rightBlock) continue; + const n = Math.min(columnBlockBacks[i].length, columnBlockBacks[i + 1].length); + blockJointCount += n; + } + const allColumnsBack = + columnBlockBacks.length > 0 && + columnBlockBacks.every((backs) => backs.every(Boolean)); + const anyColumnBack = + columnBlockBacks.some((backs) => backs.some(Boolean)) || + (columnBlockBacks.length === 0 && legacyBackDefault); + const columnMode = allColumnsBack ? 'block' : (anyColumnBack ? 'mixte' : 'separations'); + + // Habillages obligatoires (installation libre, configs récentes avec flags dos explicites): + // - colonne d'extrémité empilée → habillage de ce côté seulement + // - une zone du dessus (niveau haut, ou zone unique) sans dos → dessus de doublage obligatoire + const hasExplicitBackFlags = + typeof config.hasBack === 'boolean' || (modules || []).some((m) => typeof m?.hasBack === 'boolean'); + if (installationType === 'free' && (modules || []).length > 0 && hasExplicitBackFlags) { + const extOn = config.exteriorFinishesEnabled !== false; + const sideDefaults = config.sideFinishesEnabled !== false; + const sideL = config.sideFinishLeftEnabled ?? sideDefaults; + const sideR = config.sideFinishRightEnabled ?? sideDefaults; + const topOn = config.topFinishEnabled !== false; + const leftOuterStacked = columnBlockCounts[0] >= 2; + const rightOuterStacked = columnBlockCounts[columnBlockCounts.length - 1] >= 2; + if (leftOuterStacked && (!extOn || !sideL)) { + errors.push({ + field: 'sideFinishLeftEnabled', + message: 'Colonne gauche empilée (plusieurs blocks en hauteur) : habillage gauche obligatoire.' + }); + } + if (rightOuterStacked && (!extOn || !sideR)) { + errors.push({ + field: 'sideFinishRightEnabled', + message: 'Colonne droite empilée (plusieurs blocks en hauteur) : habillage droit obligatoire.' + }); + } + if (columnBackTopFlags.some((b) => !b) && (!extOn || !topOn)) { + errors.push({ + field: 'topFinishEnabled', + message: 'Une zone du dessus (niveau haut) sans dos : dessus de doublage obligatoire.' + }); + } + } // Determine if using modules (V2) or blocks (legacy) const useModules = modules && modules.length > 0; - // Plinthe 60–160 mm + // Plinthe (bornes paramétrables, défaut 60–200 mm) if (plinthHeightMm < plinthMin || plinthHeightMm > plinthMax) { errors.push({ field: 'plinthHeightMm', message: `La hauteur de plinthe doit être entre ${plinthMin} et ${plinthMax} mm.` }); + } else if (!PLINTH.targetHeightsMm.includes(Number(plinthHeightMm))) { + errors.push({ + field: 'plinthHeightMm', + message: `Hauteur AXILO 78 invalide. Valeurs admises : ${PLINTH.targetHeightsMm.join(', ')} mm.` + }); } // Portes coulissantes : largeur totale >= 1200 mm @@ -431,18 +736,19 @@ export function calculatePrice(config, data) { // Renforts Forteresse — one pair (haut+bas) per panneau vertical de structure // Séparations: 2 côtés + (n−1) séparateurs = n+1 panneaux verticaux - // Block (x caisses): chaque caisse a 2 montants → 2n panneaux verticaux (plus de bois qu’en séparations) + // Chaque jonction caisson↔caisson (dos ou rayonnage adjacents) ajoute 1 montant (double montant) const columnCount = useModules ? Math.max(1, modules.length) : Math.max(1, blocks.length || 1); - const verticalStructurePanelCount = columnMode === 'block' - ? 2 * columnCount - : 2 + Math.max(0, columnCount - 1); + const legacyBlockJoints = !useModules && columnModeRaw === 'block' + ? Math.max(0, columnCount - 1) + : blockJointCount; + const verticalStructurePanelCount = 2 + Math.max(0, columnCount - 1) + legacyBlockJoints; const reinforcementCount = verticalStructurePanelCount * 2; - // Surcoût panneaux 19 mm (structure): en mode block, (n−1) montants de plus qu’en séparations + // Surcoût panneaux 19 mm (structure): 1 montant supplémentaire par jonction caisson↔caisson const bodyHeightMm = (heightMm || 0) - (plinthHeightMm || 100); - const extraBlockVerticalPanels = columnMode === 'block' && columnCount > 1 ? columnCount - 1 : 0; + const extraBlockVerticalPanels = legacyBlockJoints; const extraBlockStructureSurfaceM2 = extraBlockVerticalPanels > 0 ? (extraBlockVerticalPanels * depthMm * bodyHeightMm) / 1_000_000 : 0; @@ -451,6 +757,51 @@ export function calculatePrice(config, data) { : 0; const blockExtraStructureChf = Math.round(extraBlockStructureSurfaceM2 * structureUnitPriceM2 * 100) / 100; + // Empilage vertical PAR COLONNE: chaque jonction d'une colonne empilée ajoute 2 panneaux + // horizontaux (dessus du block inférieur + fond du block supérieur) à sa largeur intérieure. + let stackExtraStructureSurfaceM2 = 0; + if (stackEnabled) { + if (useModules) { + (modules || []).forEach((m, i) => { + const colW = Math.max(0, (Number(m?.widthMm) || widthMm / Math.max(1, modules.length)) - 2 * PANEL.structure); + if (resolvedHorizontalSpans.length === 0) { + const joints = Math.max(0, columnBlockCounts[i] - 1); + stackExtraStructureSurfaceM2 += (joints * 2 * colW * depthMm) / 1_000_000; + return; + } + const boundaries = [0]; + columnBlockHeights[i].forEach((height) => boundaries.push(boundaries[boundaries.length - 1] + height)); + for (let boundaryIndex = 1; boundaryIndex < boundaries.length - 1; boundaryIndex++) { + const boundaryMm = boundaries[boundaryIndex]; + const spanAtBottom = resolvedHorizontalSpans.some((span) => + span.moduleIndexes.includes(i) && Math.abs(span.bottomMm - boundaryMm) <= 0.5 + ); + const spanAtTop = resolvedHorizontalSpans.some((span) => + span.moduleIndexes.includes(i) && Math.abs(span.topMm - boundaryMm) <= 0.5 + ); + const retainedFaces = 2 - Number(spanAtBottom) - Number(spanAtTop); + stackExtraStructureSurfaceM2 += (retainedFaces * colW * depthMm) / 1_000_000; + } + }); + } else { + stackExtraStructureSurfaceM2 = (2 * Math.max(0, widthMm - 2 * PANEL.structure) * depthMm) / 1_000_000; + } + } + if (resolvedHorizontalSpans.length > 0) { + for (const span of resolvedHorizontalSpans) { + const spanPanelWidthMm = Math.max(0, span.widthMm - 2 * PANEL.structure); + stackExtraStructureSurfaceM2 += (2 * spanPanelWidthMm * depthMm) / 1_000_000; + if (span.span?.hasBack) { + // Back panels are 8 mm; price their surface proportionally to the 19 mm structure rate. + stackExtraStructureSurfaceM2 += + (spanPanelWidthMm * Math.max(0, span.topMm - span.bottomMm - 2 * PANEL.structure)) / + 1_000_000 * + (PANEL.back / PANEL.structure); + } + } + } + const stackExtraStructureChf = Math.round(stackExtraStructureSurfaceM2 * structureUnitPriceM2 * 100) / 100; + // Each reinforcement is 100mm × innerDepth × 16mm — price as fraction of a panel const reinforcementSurfaceM2 = (PANEL.reinforcementWidth * (depthMm - PANEL.structure - 20)) / 1_000_000 * reinforcementCount; // Use 50% of panel price per m² for 16mm material (thinner, cheaper) @@ -464,7 +815,7 @@ export function calculatePrice(config, data) { if (opt) optionsChf += Number(opt.price_chf) || 0; } - const totalChf = blocksChf + kitFeeChf + panelsChf + optionsChf + hardwareChf + reinforcementChf + blockExtraStructureChf; + const totalChf = blocksChf + kitFeeChf + panelsChf + optionsChf + hardwareChf + reinforcementChf + blockExtraStructureChf + stackExtraStructureChf; return { totalChf: Math.round(totalChf * 100) / 100, @@ -479,6 +830,9 @@ export function calculatePrice(config, data) { columnMode, verticalStructurePanelCount, blockExtraStructureChf, + stackExtraStructureChf, + stackEnabled, + stackedColumnCount: columnBlockCounts.filter((n) => n >= 2).length, optionsChf, hardwareChf, hardwareBreakdown, diff --git a/server/tests/rules.spec.mjs b/server/tests/rules.spec.mjs index c4b0210..d2d161c 100644 --- a/server/tests/rules.spec.mjs +++ b/server/tests/rules.spec.mjs @@ -1,6 +1,24 @@ import assert from 'node:assert/strict'; import { validateConfig } from '../services/configValidation.js'; import { generateCuttingList } from '../services/cuttingEngine.js'; +import { collectHoleSpecsForPiece } from '../services/holeSpecifications.js'; +import { generateAssemblyPlan } from '../services/planGenerator.js'; +import { calculatePrice } from '../services/priceEngine.js'; +import { generateCncProgram } from '../services/cncGenerator.js'; +import { buildSingleMprText, flattenPiecesForMprExport } from '../services/mprGenerator.js'; +import { computeCountsFromCuttingList } from '../services/hardwareListEngine.js'; +import { + getHingeCountForDoor, + getSeparatorVerticalSegments, + normalizeHorizontalSpan, + validateHorizontalSpans +} from '../../shared/manufacturing.js'; + +function uniqMm(values) { + return [...new Set((values || []).map((v) => Math.round(Number(v) * 1000) / 1000))] + .filter((v) => Number.isFinite(v)) + .sort((a, b) => a - b); +} function run() { const baseConfig = { @@ -37,12 +55,1056 @@ function run() { const nonStandardHandleValidation = validateConfig(nonStandardHandle, {}); assert.ok(nonStandardHandleValidation.warnings.length > 0, 'Handle warning should be emitted'); + const invalidAxiloHeight = validateConfig({ ...baseConfig, plinthHeightMm: 110 }, {}); + assert.ok( + invalidAxiloHeight.errors.some((e) => e.field === 'plinthHeightMm'), + 'AXILO height must use one of the supplier target heights' + ); + const cuttingList = generateCuttingList(baseConfig, {}, baseConfig.modules); + const plinthPiece = cuttingList.pieces.find((p) => p.elementType === 'plintheBas'); + assert.equal(plinthPiece.heightMm, 95, 'Plinth panel height = AXILO target height − 5mm'); + assert.equal(plinthPiece.thicknessMm, 19, 'AXILO plinth panel must be 19mm'); + assert.equal( + plinthPiece.widthMm, + 2400 - 19 - 19, + 'With both side claddings, the plinth sits between the floor-length habillages' + ); + const habillageGauche = cuttingList.pieces.find((p) => p.elementType === 'habillageFinitionGauche'); + const habillageDroite = cuttingList.pieces.find((p) => p.elementType === 'habillageFinitionDroite'); + assert.equal( + habillageGauche.heightMm, + 2400 + 19, + 'Left cladding runs to the floor and miters with the top finish' + ); + assert.equal( + habillageDroite.heightMm, + 2400 + 19, + 'Right cladding runs to the floor and miters with the top finish' + ); + + const bottomPiece = cuttingList.pieces.find((p) => p.id === 'piece-fond'); + const axilo = bottomPiece?.machining?.axiloFootPlates; + assert.ok(axilo, 'Bottom panel should include AXILO plate machining'); + assert.equal(axilo.articleNumber, '637.76.710', 'AXILO press-fit plate reference'); + assert.equal(axilo.centers.length, 10, '4 corner feet + 2 feet every 600mm on a 2400mm armoire'); + assert.equal(axilo.frontFootCount, 5, 'One front foot per support axis'); + assert.equal(axilo.rearFootCount, 5, 'One rear foot per support axis'); + const axiloXs = [...new Set(axilo.centers.map((c) => c.xMm))].sort((a, b) => a - b); + for (let i = 1; i < axiloXs.length; i++) { + assert.ok(axiloXs[i] - axiloXs[i - 1] <= 600 + 0.01, 'AXILO axes stay within 600mm'); + } + const axiloHoles = collectHoleSpecsForPiece( + bottomPiece, + bottomPiece.widthMm, + bottomPiece.heightMm + ).specs.filter((hole) => hole.type === 'axilo_press_fit'); + assert.equal(axiloHoles.length, 40, 'Four Ø10 holes per AXILO plate'); + assert.ok( + axiloHoles.every((hole) => hole.diameterMm === 10 && hole.depthMm === 12 && hole.face === 'back'), + 'AXILO holes must be Ø10×12 blind holes on the underside' + ); + assert.equal( + axilo.rearCenterFromRearMm, + 75, + 'Back groove shifts rear AXILO plate centers forward by 25mm' + ); + const rearAxiloHoles = axiloHoles.filter((hole) => hole.row === 'rear'); + assert.equal( + bottomPiece.heightMm - Math.max(...rearAxiloHoles.map((hole) => hole.yMm)), + 43, + 'Rear AXILO holes keep 43mm from the back edge and clear the groove' + ); + assert.equal( + Math.min(...axiloHoles.map((hole) => hole.xMm)), + 15.5, + 'With side cladding, the nearest AXILO hole is 15.5mm from the panel edge' + ); + const rawAxiloHoles = collectHoleSpecsForPiece( + bottomPiece, + bottomPiece.rawCutWidthMm, + bottomPiece.rawCutHeightMm + ).specs.filter((hole) => hole.type === 'axilo_press_fit'); + assert.equal( + bottomPiece.rawCutWidthMm - Math.max(...rawAxiloHoles.map((hole) => hole.xMm)), + 15.5, + 'Raw-workpiece AXILO holes keep the same 15.5mm inset from the right edge' + ); + const system32SidePiece = cuttingList.pieces.find((piece) => piece.elementType === 'cotes'); + const finishedSystem32Xs = uniqMm( + collectHoleSpecsForPiece( + system32SidePiece, + system32SidePiece.widthMm, + system32SidePiece.heightMm + ).specs + .filter((hole) => hole.type === 'system32') + .map((hole) => hole.xMm) + ); + const rawSystem32Xs = uniqMm( + collectHoleSpecsForPiece( + system32SidePiece, + system32SidePiece.rawCutWidthMm, + system32SidePiece.rawCutHeightMm + ).specs + .filter((hole) => hole.type === 'system32') + .map((hole) => hole.xMm) + ); + assert.deepEqual(finishedSystem32Xs, [37, system32SidePiece.widthMm - 37 - 25]); + assert.deepEqual(rawSystem32Xs, [37, system32SidePiece.rawCutWidthMm - 37 - 25]); + const sideJointXs = uniqMm( + collectHoleSpecsForPiece( + system32SidePiece, + system32SidePiece.widthMm, + system32SidePiece.heightMm + ).specs + .filter((hole) => + hole.face === 'end' && + (hole.type === 'tourillon' || hole.type === 'ixconnect') + ) + .map((hole) => hole.xMm) + ); + assert.deepEqual( + sideJointXs, + [20, 50, 80, 505, 535, 565], + 'Back Ixconnect group moves 25mm forward to clear the back groove' + ); + + const noSideCladding = { + ...baseConfig, + exteriorFinishesEnabled: false, + sideFinishLeftEnabled: false, + sideFinishRightEnabled: false + }; + const noCladdingList = generateCuttingList( + noSideCladding, + {}, + noSideCladding.modules + ); + const noCladdingBottom = noCladdingList.pieces.find((p) => p.id === 'piece-fond'); + assert.equal( + noCladdingList.pieces.find((p) => p.elementType === 'plintheBas').widthMm, + 2400 - (20 + 19) - (20 + 19), + 'Without side cladding, the front plinth sits between the two 20mm-recessed returns' + ); + const leftReturn = noCladdingList.pieces.find((p) => p.elementType === 'plintheGauche'); + const rightReturn = noCladdingList.pieces.find((p) => p.elementType === 'plintheDroite'); + assert.equal(leftReturn?.widthMm, 610 - 20, 'Left plinth return follows carcass depth minus the 20mm front setback'); + assert.equal(rightReturn?.widthMm, 610 - 20, 'Right plinth return follows carcass depth minus the 20mm front setback'); + assert.equal(leftReturn?.heightMm, 95); + assert.equal(leftReturn?.thicknessMm, 19); + assert.deepEqual( + noCladdingBottom.machining.axiloFootPlates.lateralCenterInsetsMm, + { left: 91.5, right: 91.5 }, + 'Without side cladding, plate center = 20.5 + plinth 19 + setback 20 + 32mm hole reach' + ); + const noCladdingAxiloHoles = collectHoleSpecsForPiece( + noCladdingBottom, + noCladdingBottom.widthMm, + noCladdingBottom.heightMm + ).specs.filter((hole) => hole.type === 'axilo_press_fit'); + assert.equal( + Math.min(...noCladdingAxiloHoles.map((hole) => hole.xMm)), + 59.5, + 'Without side cladding, the nearest AXILO hole is 20.5 + 19 + 20 from the panel edge' + ); + + // ── Assemblage conditionnel des côtés derrière habillage ─────────── + const assemblyModule = { + id: 'assembly-col', + type: 'penderie', + widthMm: 2400, + shelves: 0, + drawers: 0, + hasRod: true, + hasBack: true + }; + const assemblyConfig = { + ...baseConfig, + modules: [assemblyModule], + exteriorFinishesEnabled: true, + topFinishEnabled: true + }; + const cuttingForSides = (left, right, module = assemblyModule) => { + const cfg = { + ...assemblyConfig, + modules: [module], + exteriorFinishesEnabled: left || right, + sideFinishLeftEnabled: left, + sideFinishRightEnabled: right + }; + return generateCuttingList(cfg, {}, cfg.modules); + }; + const carcassJointHoles = (piece) => + collectHoleSpecsForPiece(piece, piece.widthMm, piece.heightMm).specs + .filter((hole) => hole.type === 'tourillon' || hole.type === 'ixconnect'); + + const noSideAssembly = cuttingForSides(false, false); + assert.equal( + noSideAssembly.pieces.find((piece) => piece.id === 'piece-fond').widthMm, + 2362, + 'Without side cladding, bottom keeps the legacy inner width' + ); + const legacySides = noSideAssembly.pieces.find((piece) => piece.id === 'piece-cotes'); + assert.equal(legacySides.heightMm, 2300, 'Without cladding, sides keep the full carcass height'); + assert.equal(legacySides.machining.connectorHoles.verticalJointMode, 'face'); + assert.ok( + carcassJointHoles(legacySides).every((hole) => hole.face === 'face'), + 'Without cladding, structural side joints remain face-drilled' + ); + + const leftSideAssembly = cuttingForSides(true, false); + assert.equal(leftSideAssembly.pieces.find((piece) => piece.id === 'piece-fond').widthMm, 2362); + assert.equal( + leftSideAssembly.pieces.find((piece) => piece.elementType === 'plintheBas').widthMm, + 2400 - 19 - (20 + 19), + 'Front plinth sits between left habillage and the 20mm-recessed right return' + ); + assert.equal( + leftSideAssembly.pieces.find((piece) => piece.elementType === 'plintheGauche'), + undefined, + 'Left cladding to the floor omits the left plinth return' + ); + assert.equal( + leftSideAssembly.pieces.find((piece) => piece.elementType === 'plintheDroite')?.widthMm, + 610 - 20, + 'Unclad right side keeps a plinth return' + ); + assert.equal( + leftSideAssembly.pieces.find((piece) => piece.elementType === 'habillageFinitionGauche').heightMm, + 2400 + 19 + ); + assert.equal( + leftSideAssembly.pieces.find((piece) => piece.elementType === 'habillageFinitionDroite'), + undefined + ); + const leftInsetSide = leftSideAssembly.pieces.find((piece) => piece.id === 'piece-cotes-gauche'); + const leftLegacySide = leftSideAssembly.pieces.find((piece) => piece.id === 'piece-cotes-droit'); + assert.equal(leftInsetSide.heightMm, 2262, 'Clad left side is shortened by two panel thicknesses'); + assert.equal(leftLegacySide.heightMm, 2300, 'Unclad right side keeps its legacy height'); + assert.deepEqual( + new Set( + carcassJointHoles(leftInsetSide) + .filter((hole) => hole.face === 'end') + .map((hole) => `${hole.face}:${hole.end}`) + ), + new Set(['end:bottom', 'end:top']), + 'Clad side is drilled through its bottom and top edges' + ); + const leftBottomHoles = carcassJointHoles( + leftSideAssembly.pieces.find((piece) => piece.id === 'piece-fond') + ); + assert.ok(leftBottomHoles.some((hole) => hole.face === 'face')); + assert.ok(leftBottomHoles.some((hole) => hole.face === 'end' && hole.end === 'right')); + + const rightSideAssembly = cuttingForSides(false, true); + assert.equal(rightSideAssembly.pieces.find((piece) => piece.id === 'piece-dessus').widthMm, 2362); + const rightInsetSide = rightSideAssembly.pieces.find((piece) => piece.id === 'piece-cotes-droit'); + assert.equal( + rightInsetSide.heightMm, + 2262 + ); + const rightTopHoles = carcassJointHoles( + rightSideAssembly.pieces.find((piece) => piece.id === 'piece-dessus') + ); + assert.ok(rightTopHoles.some((hole) => hole.face === 'end' && hole.end === 'left')); + assert.ok( + rightTopHoles.some((hole) => hole.face === 'back'), + 'Clad right side joint on dessus is drilled from the underside (FACE=back)' + ); + + const bothSideAssembly = cuttingForSides(true, true); + assert.equal(bothSideAssembly.pieces.find((piece) => piece.id === 'piece-fond').widthMm, 2362); + assert.equal( + bothSideAssembly.pieces.find((piece) => piece.elementType === 'habillageFinitionDessus').widthMm, + 2400, + 'Top cladding stays inside the fixed 2400mm wardrobe envelope' + ); + const insetSidePair = bothSideAssembly.pieces.find((piece) => piece.id === 'piece-cotes'); + assert.equal(insetSidePair.heightMm, 2262); + assert.equal(insetSidePair.quantity, 2); + assert.ok( + (insetSidePair.machining?.finishHabillage?.holes || []).every( + (hole) => hole.face === 'back' + ), + 'All side-panel habillage holes, including middle rows, enter from the outer face' + ); + assert.equal(bothSideAssembly.pieces.find((p) => p.elementType === 'plintheGauche'), undefined); + assert.equal(bothSideAssembly.pieces.find((p) => p.elementType === 'plintheDroite'), undefined); + assert.ok( + carcassJointHoles(bothSideAssembly.pieces.find((piece) => piece.id === 'piece-fond')) + .every((hole) => hole.face === 'face'), + 'With both sides clad, both bottom joints are face-drilled' + ); + const bothTop = bothSideAssembly.pieces.find((piece) => piece.id === 'piece-dessus'); + const bothTopHoles = carcassJointHoles(bothTop); + assert.ok( + bothTopHoles.filter((hole) => hole.face === 'back').length > 0, + 'With both sides clad, dessus carcass joints are underside-drilled' + ); + const bothTopFinishXs = [ + ...new Set( + (bothTop?.machining?.finishHabillage?.holes || []) + .filter((hole) => hole.face === 'face') + .map((hole) => Math.round(hole.xMm)) + ) + ]; + assert.deepEqual( + bothTopFinishXs, + [Math.round(bothTop.widthMm / 2)], + 'Structural dessus has one SC habillage pair centered on the single column' + ); + + const stackedAssemblyModule = { + ...assemblyModule, + blockHeightsMm: [1000, 1300], + blockBacks: [true, true], + blockDoors: [true, true] + }; + const stackedSideAssembly = cuttingForSides(true, false, stackedAssemblyModule); + assert.deepEqual( + stackedSideAssembly.pieces + .filter((piece) => piece.id.startsWith('piece-cotes-gauche-b')) + .map((piece) => piece.heightMm), + [962, 1262], + 'Every stacked block side behind the cladding is shortened independently' + ); + assert.ok( + stackedSideAssembly.pieces + .filter((piece) => piece.id.includes('-jonction')) + .every((piece) => piece.widthMm === 2362), + 'Stacked top/bottom joint panels keep the fixed wardrobe envelope width' + ); + + const stackedHabillage = stackedSideAssembly.pieces.find( + (piece) => piece.elementType === 'habillageFinitionGauche' + ); + const stackedHabillageYs = uniqMm( + (stackedHabillage?.machining?.finishHabillage?.holes || []) + .filter((hole) => hole.face === 'face') + .map((hole) => hole.yMm) + ); + assert.ok( + stackedHabillageYs.some((y) => Math.abs(y - 600) < 1), + 'Left habillage has a hole row at the center of the bottom block (plinth 100 + 1000/2)' + ); + assert.ok( + stackedHabillageYs.some((y) => Math.abs(y - 1750) < 1), + 'Left habillage has a hole row at the center of the top block (plinth 100 + 1000 + 1300/2)' + ); + assert.ok( + stackedHabillageYs.every((y) => Math.abs(y - (2419 / 2)) > 80), + 'Left habillage holes are not centered on the full wardrobe/panel height' + ); + + const twoColumnHabillageCfg = { + ...assemblyConfig, + modules: [ + { ...assemblyModule, id: 'col-a', widthMm: 900 }, + { ...assemblyModule, id: 'col-b', widthMm: 1500 } + ] + }; + const twoColumnList = generateCuttingList(twoColumnHabillageCfg, {}, twoColumnHabillageCfg.modules); + const topHabillage = twoColumnList.pieces.find((piece) => piece.elementType === 'habillageFinitionDessus'); + const topHabillageXs = uniqMm( + (topHabillage?.machining?.finishHabillage?.holes || []) + .filter((hole) => hole.face === 'face') + .map((hole) => hole.xMm) + ); + assert.deepEqual( + topHabillageXs, + [450, 1650], + 'Top habillage Ixconnect holes are centered on each column, not on the wardrobe width' + ); + + const mixedOuterPricingData = { + settings: [{ key: 'allow_sliding_doors', value: '0' }], + modules: [], + finishes: [], + serviceOptions: [], + handles: [], + hinges: [], + blumHardware: [] + }; + const mixedOuterBase = { + installationType: 'free', + widthMm: 2400, + heightMm: 2400, + depthMm: 610, + plinthHeightMm: 100, + hasBack: true, + exteriorFinishesEnabled: true, + topFinishEnabled: true + }; + const wholeLeft = { + type: 'penderie', + widthMm: 1200, + hasBack: true, + blockHeightsMm: [2300], + blockBacks: [true] + }; + const stackedRight = { + type: 'penderie', + widthMm: 1200, + hasBack: true, + blockHeightsMm: [1000, 1300], + blockBacks: [true, true] + }; + const dropLeftHabillage = calculatePrice( + { + ...mixedOuterBase, + sideFinishLeftEnabled: false, + sideFinishRightEnabled: true, + modules: [wholeLeft, stackedRight] + }, + mixedOuterPricingData + ); + assert.equal( + dropLeftHabillage.errors, + undefined, + 'A whole-block left column can drop left habillage even if the right column is stacked' + ); + const dropRightHabillage = calculatePrice( + { + ...mixedOuterBase, + sideFinishLeftEnabled: true, + sideFinishRightEnabled: false, + modules: [stackedRight, wholeLeft] + }, + mixedOuterPricingData + ); + assert.equal( + dropRightHabillage.errors, + undefined, + 'A whole-block right column can drop right habillage even if the left column is stacked' + ); + const stackedLeftMissingHabillage = calculatePrice( + { + ...mixedOuterBase, + sideFinishLeftEnabled: false, + sideFinishRightEnabled: true, + modules: [stackedRight, wholeLeft] + }, + mixedOuterPricingData + ); + assert.ok( + stackedLeftMissingHabillage.errors?.some((error) => error.field === 'sideFinishLeftEnabled'), + 'A stacked left column still requires left habillage' + ); + const sidePiece = cuttingList.pieces.find((p) => p.elementType === 'cotes'); assert.ok(sidePiece?.machining?.groove, 'Side piece should include groove machining'); assert.equal(sidePiece.machining.groove.width, 9, 'Groove width should be 9mm'); assert.equal(sidePiece.machining.groove.depth, 10.5, 'Groove depth should be 10.5mm'); assert.equal(sidePiece.machining.groove.axisFromPanelEdge, 16, 'Groove position should be 16mm from edge'); + const sideMpr = buildSingleMprText({ + piece: sidePiece, + slug: 'SE_L', + qtyIndex: 0, + seqGlobal: 1, + groupIndex: 1, + orderReference: 'orientation-regression', + stackListKm: 'orientation-regression', + defaultMaterialArticle: 'APEX' + }); + assert.ok( + sideMpr.includes('DRWIN="270.0"'), + 'A vertical MPR blank is rotated lengthwise to match the machine orientation' + ); + const topPiece = cuttingList.pieces.find((p) => p.elementType === 'dessus'); + const topMpr = buildSingleMprText({ + piece: topPiece, + slug: 'BO_U', + qtyIndex: 0, + seqGlobal: 1, + groupIndex: 1, + orderReference: 'groove-regression', + stackListKm: 'groove-regression', + defaultMaterialArticle: 'APEX' + }); + assert.ok( + topMpr.includes('DRWIN="0.0"'), + 'A horizontal MPR blank keeps the native machine orientation' + ); + assert.ok(topMpr.includes('<109 \\Nuten\\'), 'Back groove uses the native WoodWOP straight-groove operation'); + assert.ok(!topMpr.includes('RSO_Nut_Flaeche.mpr'), 'Back groove does not depend on an external RSO macro'); + assert.ok( + topMpr.includes(`YA="${topPiece.rawCutHeightMm - 16}.0"`) && + topMpr.includes(`YE="${topPiece.rawCutHeightMm - 16}.0"`), + 'Top-panel back groove stays parallel to X at 16mm from the rear edge' + ); + const bottomPanel = cuttingList.pieces.find((p) => p.elementType === 'fond'); + const bottomPanelWidth = bottomPanel.rawCutWidthMm ?? bottomPanel.widthMm; + const bottomPanelHeight = bottomPanel.rawCutHeightMm ?? bottomPanel.heightMm; + const bottomPanelSpecs = collectHoleSpecsForPiece( + bottomPanel, + bottomPanelWidth, + bottomPanelHeight + ).specs; + const bottomPanelMpr = buildSingleMprText({ + piece: bottomPanel, + slug: 'BO_L', + qtyIndex: 0, + seqGlobal: 1, + groupIndex: 1, + orderReference: 'underside-regression', + stackListKm: 'underside-regression', + defaultMaterialArticle: 'APEX' + }); + const expectedUnderDrillings = bottomPanelSpecs.filter((spec) => spec.face === 'back').length; + assert.ok(expectedUnderDrillings > 0, 'Bottom panel should include AXILO underside holes'); + assert.equal( + (bottomPanelMpr.match(/BM="LSU"/g) || []).length, + expectedUnderDrillings, + 'AXILO holes use the native WoodWOP drilling-from-under cycle' + ); + assert.ok(!bottomPanelMpr.includes('<117 \\NCStop\\'), 'Underside drilling does not request a panel flip'); + assert.ok(bottomPanelMpr.includes('VA="NCSTOPP 0"'), 'The label macro does not request a panel flip'); + + // ── Blum Legrabox pure ────────────────────────────────────────────── + // Config: depth 610 → prof. utile 600, tiroirs visibles, hauteurs par défaut [90,128,177,240]. + // Module 900 mm → X = 900 − 2×19 = 862 mm. NL commun (40 kg) = 550 (systèmes M/K/C, min. K ≤ 597−3). + + // 1. Débit — fond de tiroir 16 mm : L = NL − 10, l = X − 35 + const bottom = cuttingList.pieces.find((p) => p.elementType === 'tiroirFond'); + assert.ok(bottom, 'Cutting list should include Legrabox drawer bottoms'); + assert.equal(bottom.thicknessMm, 16, 'Drawer bottom must be 16mm'); + assert.equal(bottom.widthMm, 550 - 10, 'Bottom length = NL − 10'); + assert.equal(bottom.heightMm, 862 - 35, 'Bottom width = X − 35'); + assert.equal(bottom.quantity, 3, 'One bottom per drawer'); + assert.ok(bottom.machining?.legraboxBottom?.rebate, 'Bottom should carry 38×8 rebate machining'); + + // Débit — parois arrière 16 mm : l = X − 38, h selon système (M 63 / K 101 / C 148) + const backs = cuttingList.pieces.filter((p) => p.elementType === 'tiroirDos'); + assert.ok(backs.length >= 3, 'One back panel line per drawer height system'); + const backHeights = backs.map((p) => p.heightMm).sort((a, b) => a - b); + assert.deepEqual(backHeights, [63, 101, 148], 'Back heights follow M/K/C systems'); + for (const b of backs) { + assert.equal(b.widthMm, 862 - 38, 'Back width = X − 38'); + assert.equal(b.thicknessMm, 16, 'Back must be 16mm'); + } + + // 2. Perçage caisson — positions cumulées depuis le chant avant (40 kg), bornées à NL 550 + const slide = sidePiece.machining.drawerSlideDrilling; + assert.ok(slide?.cotes?.leftInnerFace, 'Side panel should carry Legrabox rail drilling'); + assert.deepEqual( + slide.positionsXFromFrontMm, + [37, 69, 197, 229, 261, 293, 357], + 'Rail positions per 40 kg table (all ≤ NL 550)' + ); + assert.equal(slide.cotes.leftInnerFace.railAxesYMm.length, 3, 'One screw line per drawer'); + + // 3. Façades — INSERTA Ø10 (2 par côté + rangées B pour C/F) + const facades = cuttingList.pieces.filter((p) => p.elementType === 'tiroirFacade'); + assert.ok(facades.length >= 1, 'Cutting list should include drawer facades'); + const facadeC = facades.find((p) => p.machining?.legraboxInserta?.systemKey === 'C'); + assert.ok(facadeC, 'System C facade should exist (177 side height)'); + assert.equal(facadeC.heightMm, 193 - 8, 'Facade height = niche SMS − 8mm gap'); + assert.equal(facadeC.machining.legraboxInserta.holes.length, 8, 'System C: (2 + 2 coteB) × 2 sides'); + assert.equal(facadeC.machining.legraboxInserta.holeDiameterMm, 10, 'INSERTA Ø10'); + + // ── Multi-column horizontal spans ───────────────────────────────── + const openModule = (id, widthMm = 800) => ({ + id, + type: 'penderie', + moduleCode: id, + widthMm, + shelves: 0, + drawers: 0, + hasRod: false, + hasBack: true, + hasBackTop: true, + blockHeightsMm: [700, 800, 800], + blockBacks: [true, true, true], + blockDoors: [false, false, false], + stacked: true, + stackBottomHeightMm: 700 + }); + const spanBase = { + ...baseConfig, + widthMm: 2400, + modules: [openModule('col-a'), openModule('col-b'), openModule('col-c')], + exteriorFinishesEnabled: true, + sideFinishLeftEnabled: true, + sideFinishRightEnabled: true, + topFinishEnabled: true, + hardware: { ...baseConfig.hardware, drawerType: 'visible' } + }; + const twoColumnSpan = { + id: 'span-two', + moduleIds: ['col-a', 'col-b'], + bottomMm: 700, + topMm: 1500, + hasBack: true, + type: 'open' + }; + const threeColumnSpan = { + id: 'span-three', + moduleIds: ['col-a', 'col-b', 'col-c'], + bottomMm: 1500, + topMm: 2300, + hasBack: false, + type: 'open' + }; + const validTwo = { ...spanBase, horizontalSpans: [twoColumnSpan] }; + const validThree = { ...spanBase, horizontalSpans: [threeColumnSpan] }; + + assert.deepEqual(validateHorizontalSpans(validTwo), [], 'Shared validator accepts a valid two-column span'); + assert.deepEqual(validateHorizontalSpans(validThree), [], 'Shared validator accepts a valid three-column span'); + assert.equal(validateConfig(validTwo, {}).errors.length, 0, 'Config validation accepts a valid horizontal span'); + assert.equal(validateConfig(validThree, {}).errors.length, 0, 'Config validation accepts a valid three-column span'); + + const issueCodes = (horizontalSpans, modules = spanBase.modules) => + validateHorizontalSpans({ ...spanBase, modules, horizontalSpans }).map((issue) => issue.code); + assert.ok( + issueCodes([{ ...twoColumnSpan, id: 'gap', moduleIds: ['col-a', 'col-c'] }]).includes('noncontiguous_modules'), + 'Noncontiguous columns are rejected' + ); + assert.ok( + issueCodes([{ ...twoColumnSpan, id: 'misaligned', topMm: 1400 }]).includes('misaligned_boundaries'), + 'Mismatched vertical block boundaries are rejected' + ); + assert.ok( + issueCodes([ + twoColumnSpan, + { ...twoColumnSpan, id: 'overlap', moduleIds: ['col-b', 'col-c'], bottomMm: 700, topMm: 1500 } + ]).includes('overlap'), + 'Overlapping spans sharing a column are rejected' + ); + const doorModules = spanBase.modules.map((module, index) => + index === 1 ? { ...module, blockDoors: [false, true, false] } : module + ); + assert.ok( + issueCodes([twoColumnSpan], doorModules).includes('incompatible_door'), + 'A door in a covered band is rejected' + ); + const drawerModules = spanBase.modules.map((module, index) => + index === 0 ? { ...module, drawers: 1 } : module + ); + const bottomSpan = { ...twoColumnSpan, id: 'bottom', bottomMm: 0, topMm: 700 }; + assert.ok( + issueCodes([bottomSpan], drawerModules).includes('incompatible_drawers'), + 'Drawers in a covered bottom band are rejected' + ); + + assert.deepEqual( + getSeparatorVerticalSegments(validTwo, 0), + [{ bottomMm: 0, topMm: 700 }, { bottomMm: 1500, topMm: 2300 }], + 'Covered separator is split above and below the horizontal opening' + ); + assert.deepEqual( + getSeparatorVerticalSegments(validTwo, 1), + [{ bottomMm: 0, topMm: 2300 }], + 'Uncovered separator remains continuous' + ); + + const spanCutting = generateCuttingList(validTwo, {}, validTwo.modules); + const spanPieces = spanCutting.pieces.filter((piece) => piece.horizontalSpanId === twoColumnSpan.id); + const outerTopPanels = spanCutting.pieces.filter((piece) => /^piece-dessus-col\d+$/.test(piece.id)); + const outerBottomPanels = spanCutting.pieces.filter((piece) => /^piece-fond-col\d+$/.test(piece.id)); + assert.deepEqual( + outerTopPanels.map((piece) => piece.widthMm), + [761, 759, 761], + 'Roof boards stop at the inner face of each montant (19 mm exterior, 20.5 mm at a double-montant joint)' + ); + assert.deepEqual( + outerBottomPanels.map((piece) => piece.widthMm), + [761, 759, 761], + 'Base boards stop at the inner face of each montant (19 mm exterior, 20.5 mm at a double-montant joint)' + ); + assert.equal( + spanCutting.pieces.some((piece) => piece.id === 'piece-dessus' || piece.id === 'piece-fond'), + false, + 'A multi-block wardrobe no longer emits full-width structural boards' + ); + assert.deepEqual( + spanPieces.map((piece) => piece.horizontalSpanRole).sort(), + ['back', 'bottom', 'top'], + 'A backed span emits one continuous bottom, top, and back' + ); + for (const piece of spanPieces) { + assert.equal( + piece.widthMm, + 1561, + 'Horizontal span stops at the inner face of the clad outer side and the double-montant joint' + ); + assert.equal(piece.horizontalSpanId, 'span-two', 'Span metadata keeps the stable id'); + assert.deepEqual(piece.moduleIds, ['col-a', 'col-b'], 'Span metadata keeps ordered module ids'); + assert.equal(piece.bottomMm, 700); + assert.equal(piece.topMm, 1500); + assert.ok(piece.piecePlanSvg.includes(' piece.horizontalSpanRole === 'bottom').heightMm, 610); + assert.equal(spanPieces.find((piece) => piece.horizontalSpanRole === 'top').heightMm, 610); + assert.equal(spanPieces.find((piece) => piece.horizontalSpanRole === 'back').heightMm, 762); + + const threeColumnCutting = generateCuttingList(validThree, {}, validThree.modules); + const threeColumnPieces = threeColumnCutting.pieces.filter( + (piece) => piece.horizontalSpanId === threeColumnSpan.id + ); + const threeColumnRoofs = threeColumnCutting.pieces.filter((piece) => /^piece-dessus-col\d+$/.test(piece.id)); + assert.deepEqual( + threeColumnPieces.map((piece) => [piece.horizontalSpanRole, piece.widthMm]).sort(), + [['bottom', 2362]], + 'A span that reaches the roof reuses the carcass top and only cuts its own floor' + ); + assert.equal(threeColumnRoofs.length, 3, 'Carcass roof boards stay in the cutting list under a top span'); + + const validBottom = { ...spanBase, horizontalSpans: [bottomSpan] }; + const bottomSpanCutting = generateCuttingList(validBottom, {}, validBottom.modules); + const bottomSpanPieces = bottomSpanCutting.pieces.filter( + (piece) => piece.horizontalSpanId === bottomSpan.id + ); + const bottomSpanFloors = bottomSpanCutting.pieces.filter((piece) => /^piece-fond-col\d+$/.test(piece.id)); + assert.deepEqual( + bottomSpanPieces.map((piece) => piece.horizontalSpanRole).sort(), + ['back', 'top'], + 'A span on the floor reuses the carcass bottom and only cuts its own ceiling' + ); + assert.equal(bottomSpanFloors.length, 3, 'Carcass floor boards stay in the cutting list under a bottom span'); + const bottomSpanFootCount = bottomSpanFloors.reduce( + (n, piece) => n + (piece.machining?.axiloFootPlates?.centers?.length || 0), + 0 + ); + assert.equal( + bottomSpanFootCount, + 10, + 'AXILO feet are counted on the armoire (4 + 2 every 600mm), not 4 per bottom board' + ); + assert.ok( + bottomSpanFloors.every((piece) => piece.machining?.axiloFootPlates?.centers?.length > 0), + 'AXILO plates stay on the carcass floor, not on a duplicate span bottom' + ); + assert.ok( + bottomSpanFloors.some((piece) => /Panneau du fond \(bas\)/.test(piece.description)), + 'Cutting list still names the structural floor as panneau du fond (bas)' + ); + + const splitSeparatorPieces = spanCutting.pieces.filter( + (piece) => piece.elementType === 'panneauxInterieurs' && piece.separatorIndex === 0 + ); + assert.deepEqual( + splitSeparatorPieces.map((piece) => [piece.bottomMm, piece.topMm, piece.heightMm]), + [[0, 700, 662], [1500, 2300, 762]], + 'Cutting list emits the two physical separator remnants with stable extents' + ); + assert.equal( + spanCutting.pieces.filter((piece) => piece.id === 'piece-panneauxInterieurs').length, + 0, + 'The replaced full-height separator line is not duplicated' + ); + assert.equal( + spanCutting.pieces.filter((piece) => piece.id.startsWith('piece-dessus-jonction')).reduce((n, p) => n + p.quantity, 0), + 4, + 'Only non-replaced per-column top joint panels remain' + ); + assert.equal( + spanCutting.pieces.filter((piece) => piece.id.startsWith('piece-fond-jonction')).reduce((n, p) => n + p.quantity, 0), + 4, + 'Only non-replaced per-column bottom joint panels remain' + ); + + for (const piece of [...spanPieces, ...splitSeparatorPieces]) { + const { specs } = collectHoleSpecsForPiece(piece, piece.rawCutWidthMm, piece.rawCutHeightMm); + assert.ok( + specs.every((hole) => + Number.isFinite(hole.xMm) && Number.isFinite(hole.yMm) && + hole.xMm >= 0 && hole.xMm <= piece.rawCutWidthMm && + hole.yMm >= 0 && hole.yMm <= piece.rawCutHeightMm + ), + `Machining holes remain inside ${piece.id}` + ); + } + const cnc = generateCncProgram(validTwo, validTwo.modules, {}); + assert.ok(cnc.content.includes('PANNEAU_HORIZONTAL_BAS'), 'CNC output includes span pieces'); + assert.ok(!cnc.content.includes('NaN') && !cnc.content.includes('Infinity'), 'CNC output contains finite coordinates'); + const flattenedSpan = flattenPiecesForMprExport(spanPieces); + assert.equal(flattenedSpan.length, 3, 'MPR export emits one file per continuous span piece'); + for (const [index, entry] of flattenedSpan.entries()) { + const mpr = buildSingleMprText({ + ...entry, + seqGlobal: index + 1, + groupIndex: 1, + orderReference: 'span-regression', + stackListKm: 'span-regression', + defaultMaterialArticle: 'APEX' + }); + assert.ok(mpr.includes('[H') && !mpr.includes('NaN') && !mpr.includes('Infinity'), 'MPR span output is finite'); + } + + const pricingData = { + settings: [{ key: 'allow_sliding_doors', value: '0' }], + modules: [], + finishes: [], + serviceOptions: [], + handles: [], + hinges: [], + blumHardware: [] + }; + const spanPrice = calculatePrice(validTwo, pricingData); + assert.equal(spanPrice.errors, undefined, 'Valid horizontal span prices without validation errors'); + assert.ok(Number.isFinite(spanPrice.totalChf), 'Horizontal span total price is finite'); + + const assemblySvg = generateAssemblyPlan(validTwo, validTwo.modules); + assert.equal((assemblySvg.match(/class="horizontal-span"/g) || []).length, 2, 'Assembly plan draws both span limits'); + assert.ok(assemblySvg.includes('Portée horizontale — colonnes 1–2'), 'Assembly plan labels covered columns'); + + // ── Horizontal-span door modes ──────────────────────────────────── + const unequalModules = [ + openModule('door-col-a', 700), + openModule('door-col-b', 900), + openModule('door-col-c', 800) + ].map((module) => ({ + ...module, + blockDoors: [true, false, true] + })); + const doorSpanBase = { + ...spanBase, + modules: unequalModules, + hardware: { + ...spanBase.hardware, + handleVariantId: 'handle-test', + hingeVariantId: 'hinge-test' + } + }; + const legacyDoorSpan = { + id: 'span-door-modes', + moduleIds: ['door-col-b', 'door-col-a'], + bottomMm: 700, + topMm: 1500, + hasBack: true, + type: 'open' + }; + const normalizedLegacyDoorSpan = normalizeHorizontalSpan(legacyDoorSpan, unequalModules); + assert.equal(normalizedLegacyDoorSpan.doorMode, 'none', 'Missing doorMode normalizes to none'); + assert.deepEqual( + normalizedLegacyDoorSpan.moduleIds, + ['door-col-a', 'door-col-b'], + 'Normalization preserves canonical module order' + ); + assert.equal( + validateHorizontalSpans({ ...doorSpanBase, horizontalSpans: [legacyDoorSpan] }).length, + 0, + 'Missing doorMode remains backward compatible' + ); + assert.ok( + validateHorizontalSpans({ + ...doorSpanBase, + horizontalSpans: [{ ...legacyDoorSpan, doorMode: 'double' }] + }).some((issue) => issue.code === 'bad_door_mode'), + 'Unknown horizontal-span door modes are rejected' + ); + assert.equal( + normalizeHorizontalSpan({ ...legacyDoorSpan, doorMode: 'double' }, unequalModules).doorMode, + 'none', + 'Normalization safely coerces an unknown door mode to none' + ); + + const doorModeConfig = (doorMode) => ({ + ...doorSpanBase, + horizontalSpans: [{ + ...legacyDoorSpan, + moduleIds: ['door-col-a', 'door-col-b', 'door-col-c'], + doorMode + }] + }); + const cuttingByDoorMode = Object.fromEntries( + ['none', 'perColumn', 'single'].map((doorMode) => [ + doorMode, + generateCuttingList(doorModeConfig(doorMode), {}, unequalModules) + ]) + ); + const spanDoors = (doorMode) => cuttingByDoorMode[doorMode].pieces.filter( + (piece) => + piece.horizontalSpanId === legacyDoorSpan.id && + piece.horizontalSpanRole === 'door' + ); + assert.equal(spanDoors('none').length, 0, 'none emits no horizontal-span door pieces'); + + const perColumnDoors = spanDoors('perColumn'); + assert.equal(perColumnDoors.length, 2, 'perColumn emits exactly two doors across a three-column span'); + assert.deepEqual( + perColumnDoors.map((piece) => piece.widthMm), + [1196, 1196], + 'perColumn emits two equal span-half doors with a 2mm reveal on both sides' + ); + assert.deepEqual( + perColumnDoors.map((piece) => piece.doorModuleId), + [null, null], + 'perColumn doors are span leaves rather than module-owned doors' + ); + assert.deepEqual( + perColumnDoors.map((piece) => [piece.doorMode, piece.doorIndex, piece.doorNumber, piece.doorCount]), + [['perColumn', 0, 1, 2], ['perColumn', 1, 2, 2]], + 'perColumn door metadata has stable indexes and totals' + ); + assert.deepEqual( + perColumnDoors.map((piece) => [ + piece.hingeSide, + piece.doorPlanHints?.hingeSide, + piece.doorPlanHints?.hingeSides + ]), + [['left', 'left', ['left']], ['right', 'right', ['right']]], + 'perColumn cutting and MPR metadata force hinges to the two outer span sides' + ); + + const singleDoors = spanDoors('single'); + assert.equal(singleDoors.length, 1, 'single emits exactly one horizontal-span door'); + assert.equal(singleDoors[0].widthMm, 2396, 'single spans 2400mm with a 2mm reveal on both sides'); + assert.equal(singleDoors[0].heightMm, 796, 'Span doors keep a 2mm top and bottom reveal'); + assert.equal(singleDoors[0].doorMode, 'single'); + assert.equal(singleDoors[0].doorCount, 1); + assert.equal(singleDoors[0].doorModuleId, null, 'A single spanning door is not assigned to one module'); + + for (const door of [...perColumnDoors, ...singleDoors]) { + assert.ok( + [door.widthMm, door.heightMm, door.rawCutWidthMm, door.rawCutHeightMm] + .every(Number.isFinite), + `${door.id} dimensions remain finite` + ); + const { specs } = collectHoleSpecsForPiece(door, door.rawCutWidthMm, door.rawCutHeightMm); + assert.ok(specs.length > 0, `${door.id} emits machining hole specifications`); + assert.ok( + specs.every((hole) => + [hole.xMm, hole.yMm, hole.diameterMm, hole.depthMm].every(Number.isFinite) && + hole.xMm >= 0 && hole.xMm <= door.rawCutWidthMm && + hole.yMm >= 0 && hole.yMm <= door.rawCutHeightMm + ), + `${door.id} machining holes stay finite and inside the raw panel` + ); + assert.ok( + !door.piecePlanSvg.includes('NaN') && !door.piecePlanSvg.includes('Infinity'), + `${door.id} piece plan is finite` + ); + } + + const doorMprEntries = flattenPiecesForMprExport([...perColumnDoors, ...singleDoors]); + assert.equal(doorMprEntries.length, 3, 'MPR flattening keeps every span door distinct'); + assert.deepEqual( + doorMprEntries.slice(0, 2).map((entry) => entry.piece.doorPlanHints.hingeSide), + ['left', 'right'], + 'MPR entries preserve the forced outer hinge sides' + ); + doorMprEntries.forEach((entry, index) => { + const mpr = buildSingleMprText({ + ...entry, + seqGlobal: index + 1, + groupIndex: 1, + orderReference: 'span-door-regression', + stackListKm: 'span-door-regression', + defaultMaterialArticle: 'APEX' + }); + assert.ok( + mpr.includes('[H') && !mpr.includes('NaN') && !mpr.includes('Infinity'), + `MPR output is finite for ${entry.piece.id}` + ); + }); + + const expectedRegularDoorCount = 6; + const expectedSpanDoorCounts = { none: 0, perColumn: 2, single: 1 }; + const expectedHingesPerDoor = getHingeCountForDoor({ heightMm: 796 }); + for (const doorMode of ['none', 'perColumn', 'single']) { + const counts = computeCountsFromCuttingList(cuttingByDoorMode[doorMode]); + assert.equal( + counts.doorCount, + expectedRegularDoorCount + expectedSpanDoorCounts[doorMode], + `Hardware list counts regular and ${doorMode} span doors once` + ); + assert.equal( + counts.items.find((item) => item.key === 'hinge')?.quantity, + counts.doorCount * expectedHingesPerDoor, + `Hardware list has the correct hinge quantity for ${doorMode}` + ); + } + assert.equal( + computeCountsFromCuttingList(cuttingByDoorMode.perColumn).doorCount - + computeCountsFromCuttingList(cuttingByDoorMode.none).doorCount, + 2, + 'A three-column perColumn span adds exactly two doors to hardware counts' + ); + + const doorPricingData = { + ...pricingData, + handles: [{ id: 'handle-test', name: 'Test handle', price_chf: 10 }], + hinges: [{ id: 'hinge-test', name: 'Test hinge', price_chf: 2 }], + finishes: [{ + id: 'finish-test', + code: 'finish-test', + price_chf: 100, + width_mm: 2800, + height_mm: 2070 + }] + }; + const pricesByDoorMode = Object.fromEntries( + ['none', 'perColumn', 'single'].map((doorMode) => { + const price = calculatePrice( + { ...doorModeConfig(doorMode), finishId: 'finish-test' }, + doorPricingData + ); + assert.equal(price.errors, undefined, `${doorMode} span-door pricing validates`); + assert.ok( + [price.totalChf, price.surfaceM2, price.details.panelsChf, price.details.hardwareChf] + .every(Number.isFinite), + `${doorMode} pricing remains finite` + ); + const handleLine = price.details.hardwareBreakdown.find((line) => line.type === 'handle'); + const hingeLine = price.details.hardwareBreakdown.find((line) => line.type === 'hinge'); + const expectedDoors = expectedRegularDoorCount + expectedSpanDoorCounts[doorMode]; + assert.equal(handleLine.quantity, expectedDoors, `${doorMode} pricing has one handle per door`); + assert.equal( + hingeLine.quantity, + expectedDoors * expectedHingesPerDoor, + `${doorMode} pricing has matrix-derived hinges per door` + ); + return [doorMode, price]; + }) + ); + assert.ok( + Math.abs( + (pricesByDoorMode.perColumn.surfaceM2 - pricesByDoorMode.none.surfaceM2) - 3.808 + ) < 1e-9, + 'perColumn pricing adds both equal three-column span leaves once' + ); + assert.ok( + Math.abs( + (pricesByDoorMode.single.surfaceM2 - pricesByDoorMode.none.surfaceM2) - 3.814 + ) < 1e-9, + 'single pricing adds the full-span door surface once' + ); + + for (const doorMode of ['none', 'perColumn', 'single']) { + const svg = generateAssemblyPlan(doorModeConfig(doorMode), unequalModules); + const spanDoorRects = svg.match(/]*data-horizontal-span-door="[^"]+"[^>]*>/g) || []; + const allDoorRects = svg.match(/]*class="door"[^>]*>/g) || []; + assert.equal( + spanDoorRects.length, + expectedSpanDoorCounts[doorMode], + `Assembly SVG draws the correct ${doorMode} span-door rectangles` + ); + assert.ok( + spanDoorRects.every((rect) => rect.includes(`data-horizontal-span-door="${doorMode}"`)), + `Assembly SVG labels ${doorMode} rectangles by mode` + ); + if (doorMode === 'perColumn') { + assert.deepEqual( + spanDoorRects.map((rect) => rect.match(/data-hinge-side="([^"]+)"/)?.[1]), + ['left', 'right'], + 'Assembly SVG forces hinges to the outer sides of the two span halves' + ); + } + assert.equal( + allDoorRects.length, + expectedRegularDoorCount + expectedSpanDoorCounts[doorMode], + `Assembly SVG does not draw a full-height overlay through the span band for ${doorMode}` + ); + } + + const legacyWithoutSpans = generateCuttingList(spanBase, {}, spanBase.modules); + const legacyWithEmptySpans = generateCuttingList({ ...spanBase, horizontalSpans: [] }, {}, spanBase.modules); + assert.deepEqual( + legacyWithEmptySpans, + legacyWithoutSpans, + 'Legacy cutting output is unchanged when horizontalSpans is absent versus empty' + ); } run(); diff --git a/shared/blumLegrabox.js b/shared/blumLegrabox.js new file mode 100644 index 0000000..7af67f2 --- /dev/null +++ b/shared/blumLegrabox.js @@ -0,0 +1,368 @@ +/** + * APEX 4.0 — Blum LEGRABOX pure + * Données d'intégration : débit panneaux 16 mm, perçage CNC caisson (coulisses), + * perçage façades (INSERTA/EXPANDO), norme suisse SMS, génération de commande (BOM) + * et accessoires intérieurs. + * + * Source : dossier « INTÉGRATION DATA - APEX 4.0 » (modules usinage CNC / SMS / BOM / + * accessoires) + catalogue technique Blum. Toutes les cotes en mm. + */ + +/** Épaisseur de référence stricte des fonds et parois arrière de tiroir. */ +export const LEGRABOX_PANEL_THICKNESS_MM = 16; + +/** + * Systèmes de hauteur Legrabox (N/M/K/C/F). + * - sideHeightMm : hauteur de côté (profil métallique) — « hauteur_cote_mm » + * - nicheHeightMm : hauteur de niche / encombrement façade min — « hauteur_des_niches_mm » + * - backHeightMm : hauteur C de la paroi arrière 16 mm — « hauteur_C_mm_selon_systeme » + * - hch : code hauteur commercial (S/M/L/XL/XXL) + * - smsFromSixths : norme suisse SMS — disponible à partir de n/6 (1/6 = 127 mm) + */ +export const LEGRABOX_SYSTEMS = Object.freeze({ + N: Object.freeze({ key: 'N', hch: 'S', sideHeightMm: 66, nicheHeightMm: 80, backHeightMm: 39, smsFromSixths: 1 }), + M: Object.freeze({ key: 'M', hch: 'M', sideHeightMm: 90, nicheHeightMm: 106, backHeightMm: 63, smsFromSixths: 1 }), + K: Object.freeze({ key: 'K', hch: 'L', sideHeightMm: 128, nicheHeightMm: 144, backHeightMm: 101, smsFromSixths: 2 }), + C: Object.freeze({ key: 'C', hch: 'XL', sideHeightMm: 177, nicheHeightMm: 193, backHeightMm: 148, smsFromSixths: 2 }), + F: Object.freeze({ key: 'F', hch: 'XXL', sideHeightMm: 240, nicheHeightMm: 257, backHeightMm: 212, smsFromSixths: 3 }) +}); + +/** Hauteurs de côté sélectionnables (remplace l'ancien 60 mm par 66 mm — système N). */ +export const LEGRABOX_SIDE_HEIGHTS_MM = Object.freeze([66, 90, 128, 177, 240]); + +/** Norme suisse SMS — division en sixièmes. */ +export const SMS = Object.freeze({ + sixthMm: 127, + description: 'Système de mesure suisse (Schweizer Mass-System) — division en sixièmes (1/6 = 127 mm)' +}); + +/** @returns {object|null} système Legrabox pour une hauteur de côté (66/90/128/177/240). */ +export function getLegraboxSystemForSideHeight(sideHeightMm) { + const h = Number(sideHeightMm); + for (const sys of Object.values(LEGRABOX_SYSTEMS)) { + if (sys.sideHeightMm === h) return sys; + } + return null; +} + +/** + * Hauteur de niche (encombrement façade min) pour une hauteur de côté. + * Hauteur inconnue → valeur inchangée (compat anciennes configs). + */ +export function legraboxNicheHeightMm(sideHeightMm) { + const sys = getLegraboxSystemForSideHeight(sideHeightMm); + return sys ? sys.nicheHeightMm : Math.max(0, Number(sideHeightMm) || 0); +} + +// ── 1. Débit panneaux — épaisseur 16 mm ───────────────────────────────── + +/** + * Fond de tiroir 16 mm. + * longueur A = NL − 10 · largeur B = X − 35 (X = largeur libre du corps). + * Usinage spécial : feuillure/renfort 38 × 8 mm à gauche et à droite. + * @param {number} nominalLengthMm — longueur nominale NL de la coulisse + * @param {number} internalWidthMm — largeur libre du corps (X) + */ +export function legraboxBottomPanelMm(nominalLengthMm, internalWidthMm) { + const nl = Math.max(0, Number(nominalLengthMm) || 0); + const x = Math.max(0, Number(internalWidthMm) || 0); + if (nl <= 10 || x <= 35) return null; + return { + lengthMm: nl - 10, + widthMm: x - 35, + thicknessMm: LEGRABOX_PANEL_THICKNESS_MM, + rebate: { + widthMm: 38, + depthMm: 8, + sides: ['left', 'right'], + note: 'Fond feuilluré/renforcé à gauche et à droite : 38 × 8 mm' + } + }; +} + +/** + * Paroi arrière (dos de tiroir) 16 mm. + * largeur B = X − 38 · hauteur C selon système (N 39 / M 63 / K 101 / C 148 / F 212). + * @param {number} internalWidthMm — largeur libre du corps (X) + * @param {string} systemKey — 'N' | 'M' | 'K' | 'C' | 'F' + */ +export function legraboxBackPanelMm(internalWidthMm, systemKey) { + const x = Math.max(0, Number(internalWidthMm) || 0); + const sys = LEGRABOX_SYSTEMS[systemKey]; + if (!sys || x <= 38) return null; + return { + widthMm: x - 38, + heightMm: sys.backHeightMm, + thicknessMm: LEGRABOX_PANEL_THICKNESS_MM + }; +} + +// ── 2. Perçage CNC — coulisses sur caisson (Système 32) ──────────────── + +/** + * Positions de perçage cumulées (axe X) en mm depuis le bord AVANT du montant, + * pour le vissage des coulisses, selon capacité de charge. + * Perçage pilote Système 32 : Ø5 × 13 mm (vis à tête plate recommandées par Blum). + */ +export const LEGRABOX_RAIL_DRILLING = Object.freeze({ + compatibleSideHeightsMm: LEGRABOX_SIDE_HEIGHTS_MM, + holeDiameterMm: 5, + holeDepthMm: 13, + /** + * Axe vertical de la ligne de vissage du profil de corps, au-dessus du bas + * de la niche tiroir. Hypothèse atelier (catalogue Blum) — à confirmer au gabarit ZML.3710. + */ + screwLineAboveNicheBottomMm: 9.5, + byLoadKg: Object.freeze({ + 40: Object.freeze([37, 69, 197, 229, 261, 293, 357]), + 70: Object.freeze([37, 69, 261, 293, 357]) + }) +}); + +/** + * Positions X (depuis le chant avant du panneau vertical) pour une capacité donnée, + * bornées à la profondeur utile du panneau. + * @param {40|70} loadKg + * @param {number} [maxXMm] — profondeur maximum perçable (ex. profondeur panneau − retrait) + */ +export function getLegraboxRailHolePositionsMm(loadKg = 40, maxXMm = Infinity) { + const base = LEGRABOX_RAIL_DRILLING.byLoadKg[Number(loadKg) === 70 ? 70 : 40]; + const max = Number.isFinite(Number(maxXMm)) ? Number(maxXMm) : Infinity; + return base.filter((x) => x <= max); +} + +// ── 3. Perçage CNC — façades (INSERTA / cheville expansible) ──────────── + +/** + * Matrice de perçage façade — fixation INSERTA (cheville expansible Ø10, prof. > 12 mm). + * Cotes catalogue conservées telles quelles (validation programmeur CNC) : + * - minFrontHeightMm (A) : encombrement min de la façade + * - coteC / coteD : positions verticales des 2 perçages du support, depuis le bas façade + * - coteB : rangées supplémentaires (façades hautes C / F), depuis le bas façade + * - coteE : retrait horizontal de l'axe depuis la référence côté tiroir + * - coteG : cote catalogue complémentaire (référence gabarit) + */ +export const LEGRABOX_FACADE_INSERTA = Object.freeze({ + toolNote: 'Cheville expansible Ø 10 mm, profondeur de perçage > 12 mm', + holeDiameterMm: 10, + holeDepthMm: 13, + bySystem: Object.freeze({ + N: Object.freeze({ minFrontHeightMm: 80, coteC: 45.5, coteD: 16, coteE: 23.4, coteG: 14 }), + M: Object.freeze({ minFrontHeightMm: 106, coteC: 51, coteD: 32, coteE: 23.4, coteG: 14 }), + K: Object.freeze({ minFrontHeightMm: 144, coteC: 51, coteD: 32, coteE: 23.4, coteG: 14 }), + C: Object.freeze({ minFrontHeightMm: 193, coteB: Object.freeze([147, 179]), coteC: 51, coteD: 32, coteE: 24.4, coteG: 14 }), + F: Object.freeze({ minFrontHeightMm: 257, coteB: Object.freeze([179, 211, 243]), coteC: 51, coteD: 32, coteE: 24.4, coteG: 14 }) + }) +}); + +/** + * Positions XY des perçages INSERTA sur une façade (repère : origine bas-gauche façade). + * Interprétation atelier : 2 perçages verticaux par côté (y = coteD bas, y = coteC haut, + * depuis le bas façade) + rangées coteB pour C/F. Axe horizontal à coteE de la + * référence côté de tiroir (bord de la largeur libre X), soit + * x = (largeur façade − X)/2 + coteE depuis chaque bord de façade. + * @param {string} systemKey — 'N'…'F' + * @param {number} facadeWidthMm + * @param {number} facadeHeightMm + * @param {number} internalWidthMm — largeur libre du corps X + * @returns {{ xMm:number, yMm:number, role:string }[]} + */ +export function buildLegraboxFacadeInsertaHolePositionsMm(systemKey, facadeWidthMm, facadeHeightMm, internalWidthMm) { + const spec = LEGRABOX_FACADE_INSERTA.bySystem[systemKey]; + const W = Math.max(0, Number(facadeWidthMm) || 0); + const H = Math.max(0, Number(facadeHeightMm) || 0); + const X = Math.max(0, Number(internalWidthMm) || 0); + if (!spec || W <= 0 || H <= 0 || X <= 0 || W <= X - 2 * spec.coteE) return []; + + const inset = Math.max(spec.coteG, (W - X) / 2 + spec.coteE); + const xs = [ + { xMm: Math.round(inset * 10) / 10, side: 'gauche' }, + { xMm: Math.round((W - inset) * 10) / 10, side: 'droite' } + ]; + const ys = [ + { yMm: spec.coteD, role: 'bas' }, + { yMm: spec.coteC, role: 'haut' }, + ...(Array.isArray(spec.coteB) ? spec.coteB.map((b, i) => ({ yMm: b, role: `rangee_B${i + 1}` })) : []) + ].filter((p) => p.yMm > 0 && p.yMm < H); + + const out = []; + for (const { xMm, side } of xs) { + for (const { yMm, role } of ys) { + out.push({ xMm, yMm, role: `${side}_${role}` }); + } + } + return out; +} + +// ── 4. Montage / ajustement ────────────────────────────────────────────── + +export const LEGRABOX_ASSEMBLY = Object.freeze({ + montageFacade: 'Sans outils', + demontageFacade: 'Tournevis', + reglageHauteur: "Depuis l'intérieur (devant)", + reglageInclinaison: 'Depuis le bas (devant)', + technologiesOuvertureFermeture: Object.freeze([ + 'Blumotion (Fermeture automatique avec amortissement)', + 'Tip-on Blumotion (Ouverture automatique avec amortissement intégré)', + 'Tip-on (Système d\u2019ouverture mécanique)' + ]) +}); + +// ── 5. Génération de commande (BOM) ───────────────────────────────────── + +export const LEGRABOX_ORDER_OPTIONS = Object.freeze({ + couleursChassis: Object.freeze([ + 'blanc soie', + 'argent polaire', + 'gris orion', + 'noir terra', + 'acier inoxydable (anti-empreintes digitales)' + ]), + typesDeTiroir: Object.freeze([ + 'Type de façade du tiroir (Standard)', + 'Type de tiroir intérieur (Anglais)' + ]), + variantesOuvertureFermeture: Object.freeze(['Blumotion', 'Tip-On Blumotion', 'Tip-On']), + fixationsFacadeDisponibles: Object.freeze(['Vis (Douille d\u2019écartement)', 'Expan-do T']), + etatDeLivraison: Object.freeze(['F', 'C', 'S']) +}); + +/** Règles métier strictes (regles_automatiques_requises). */ +export const LEGRABOX_BOM_RULES = Object.freeze({ + /** Ajouter « Stabilisation de façade » si largeur_corps_mm >= 600. */ + frontStabiliserMinBodyWidthMm: 600, + /** Ajouter « Unité de synchronisation » si variante Tip-On Blumotion. */ + syncUnitVariant: 'tip_on_blumotion' +}); + +/** + * Longueurs nominales disponibles (mm) par système et capacité de charge. + * N absent du dossier BOM — longueurs issues du catalogue Blum (40 kg uniquement). + * Exception : K (hauteur L) indisponible en acier inoxydable. + */ +export const LEGRABOX_NOMINAL_LENGTHS_MM = Object.freeze({ + N: Object.freeze({ 40: Object.freeze([400, 450, 500, 550]), 70: Object.freeze([]) }), + M: Object.freeze({ 40: Object.freeze([270, 300, 350, 400, 450, 500, 550, 600]), 70: Object.freeze([450, 500, 550, 600, 650]) }), + K: Object.freeze({ 40: Object.freeze([300, 350, 400, 450, 500, 550, 600]), 70: Object.freeze([450, 500, 550, 600]) }), + C: Object.freeze({ 40: Object.freeze([270, 300, 350, 400, 450, 500, 550, 600]), 70: Object.freeze([450, 500, 550, 600, 650]) }), + F: Object.freeze({ 40: Object.freeze([400, 450, 500, 550, 600]), 70: Object.freeze([450, 500, 550, 600, 650]) }) +}); + +/** Couleur châssis indisponible : hauteur K (L) en acier inoxydable. */ +export function isLegraboxChassisColorAvailable(systemKey, chassisColor) { + const isInox = String(chassisColor || '').toLowerCase().includes('inox'); + return !(systemKey === 'K' && isInox); +} + +/** + * Plus grande longueur nominale disponible pour un système / capacité, bornée par la + * profondeur utile (profondeur intérieure min = NL + 3 mm gérée par l'appelant). + * @returns {number|null} + */ +export function getLegraboxNominalLengthMm(systemKey, availableDepthMm, loadKg = 40) { + const table = LEGRABOX_NOMINAL_LENGTHS_MM[systemKey]; + if (!table) return null; + const list = table[Number(loadKg) === 70 ? 70 : 40] || []; + const depth = Math.max(0, Number(availableDepthMm) || 0); + let best = null; + for (const nl of list) { + if (nl <= depth && (best == null || nl > best)) best = nl; + } + return best; +} + +// ── 6. Accessoires et subdivisions intérieures (up-sell) ──────────────── + +export const LEGRABOX_INTERIOR_ACCESSORIES = Object.freeze({ + garnituresUniversellesPlastique: Object.freeze({ + label: 'Garniture universelle plastique', + nominalLengthCompatibleMm: 500, + couleurs: Object.freeze(['Anthracite pearl', 'Blanc grainé']), + modelesParHauteur: Object.freeze({ + h40: Object.freeze({ + hauteurMm: 40, + largeursCorpsCompatiblesMm: Object.freeze([600, 800, 900]), + note: 'Particulièrement adapté sous les champs d\u2019induction' + }), + h52: Object.freeze({ + hauteurMm: 52, + largeursCorpsCompatiblesMm: Object.freeze([500, 600, 800, 900]) + }) + }) + }), + assortimentsPorteCouverts: Object.freeze({ + label: 'Assortiment porte-couverts modulaire', + typeFixation: 'Autocollant avec insert en feutre', + couleurs: Object.freeze(['Lie de vin', 'Anthracite']), + configurations: Object.freeze([ + Object.freeze({ pieces: 50, composition: '12 couteaux, 12 fourchettes, 12 cuillères, 12 cuillères à café, 2 grandes pièces' }), + Object.freeze({ pieces: 70, composition: '12 couteaux, 12 fourchettes, 12 cuillères, 12 cuillères à café, 12 fourchettes de cuisine, 6 petites pièces, 4 grandes pièces' }), + Object.freeze({ pieces: 90, composition: '24 couteaux, 24 fourchettes, 12 cuillères, 12 cuillères à café, 12 fourchettes de cuisine, 6 grandes pièces' }) + ]) + }), + garnituresReglablesBoisInox: Object.freeze({ + label: 'Garniture réglable hêtre / acier inox', + finition: 'Hêtre / acier inox', + nominalLengthCompatibleMm: 500, + profondeurReelleMm: 478, + modeles: Object.freeze({ + bacEtroit: Object.freeze({ largeurCorpsCibleMm: [300, 400], largeurReelleAjustableMm: [200, 300] }), + bacLarge: Object.freeze({ largeurCorpsCibleMm: [450, 700], largeurReelleAjustableMm: [350, 600] }) + }) + }), + rangeCouvertsBoisHetre: Object.freeze({ + label: 'Range-couverts réglable hêtre (laqué)', + finition: 'Hêtre (laqué)', + nominalLengthsCompatiblesMm: Object.freeze([500, 550, 600, 650]), + /** Profondeur réelle = longueur nominale − 28 mm */ + profondeurReelleDeductionMm: 28, + modeles: Object.freeze({ + bacEtroit: Object.freeze({ largeurCorpsCibleMm: [300, 400], largeurReelleAjustableMm: [200, 300] }), + bacLarge: Object.freeze({ largeurCorpsCibleMm: [450, 700], largeurReelleAjustableMm: [350, 600] }) + }) + }) +}); + +/** + * Suggestions d'accessoires intérieurs compatibles avec la géométrie du tiroir. + * @param {{ bodyWidthMm:number, nominalLengthMm:number }} params — largeur de corps (KB) et NL + * @returns {{ key:string, label:string, detail:string }[]} + */ +export function getLegraboxAccessorySuggestions({ bodyWidthMm, nominalLengthMm }) { + const kb = Math.max(0, Number(bodyWidthMm) || 0); + const nl = Math.max(0, Number(nominalLengthMm) || 0); + const out = []; + const A = LEGRABOX_INTERIOR_ACCESSORIES; + + if (nl === A.garnituresUniversellesPlastique.nominalLengthCompatibleMm) { + for (const [k, m] of Object.entries(A.garnituresUniversellesPlastique.modelesParHauteur)) { + if (m.largeursCorpsCompatiblesMm.includes(kb)) { + out.push({ + key: `garniture_plastique_${k}`, + label: `${A.garnituresUniversellesPlastique.label} h${m.hauteurMm} mm`, + detail: m.note || `Largeur corps ${kb} mm, NL ${nl} mm` + }); + } + } + } + + const inRange = ([min, max]) => kb >= min && kb <= max; + if (nl === A.garnituresReglablesBoisInox.nominalLengthCompatibleMm) { + if (inRange(A.garnituresReglablesBoisInox.modeles.bacEtroit.largeurCorpsCibleMm)) { + out.push({ key: 'garniture_bois_inox_etroit', label: `${A.garnituresReglablesBoisInox.label} — bac étroit`, detail: `Largeur réelle ajustable 200–300 mm` }); + } + if (inRange(A.garnituresReglablesBoisInox.modeles.bacLarge.largeurCorpsCibleMm)) { + out.push({ key: 'garniture_bois_inox_large', label: `${A.garnituresReglablesBoisInox.label} — bac large`, detail: `Largeur réelle ajustable 350–600 mm` }); + } + } + if (A.rangeCouvertsBoisHetre.nominalLengthsCompatiblesMm.includes(nl)) { + const prof = nl - A.rangeCouvertsBoisHetre.profondeurReelleDeductionMm; + if (inRange(A.rangeCouvertsBoisHetre.modeles.bacEtroit.largeurCorpsCibleMm)) { + out.push({ key: 'range_couverts_hetre_etroit', label: `${A.rangeCouvertsBoisHetre.label} — bac étroit`, detail: `Profondeur réelle ${prof} mm` }); + } + if (inRange(A.rangeCouvertsBoisHetre.modeles.bacLarge.largeurCorpsCibleMm)) { + out.push({ key: 'range_couverts_hetre_large', label: `${A.rangeCouvertsBoisHetre.label} — bac large`, detail: `Profondeur réelle ${prof} mm` }); + } + } + return out; +} diff --git a/shared/cncPanelHoles.js b/shared/cncPanelHoles.js index 27ec874..7eccdd7 100644 --- a/shared/cncPanelHoles.js +++ b/shared/cncPanelHoles.js @@ -12,12 +12,19 @@ import { HINGE, tourillonFaceDrillDepthMm, DRAWER_REINFORCEMENT, - DRAWER_RENFORT_PANEL_IXCONNECT + DRAWER_RENFORT_PANEL_IXCONNECT, + getBackGrooveInnerEndInsetFromBackEdgeMm } from './manufacturing.js'; /** Perçage ferrure en face — Ø8 mm (même diamètre que tourillon / Ixconnect face) */ export const CARCASS_FERRURE_FACE_DIAMETER_MM = 8; +/** Axe X du trou Inbus sur le panneau qui porte le long perçage Ixconnect en chant. */ +function ixconnectInbusXFromEndMm({ endSide, widthMm, clampX }) { + const offset = Number(CONNECTORS.ixconnect.lockScrewInbus?.offsetFromAssemblyEdgeMm) || 25; + return clampX(endSide === 'left' ? offset : Number(widthMm) - offset); +} + /** Montants / séparateurs : rangées Ixconnect jonction dessus·fond — retrait depuis bords haut/bas (mm, origine Y = bas pièce en G-code). */ export const IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM = 23; @@ -32,6 +39,98 @@ export function mapJunctionXAcrossWidthMm(widthMm, xmm) { return clamped; } +/** + * Machining X is stored in finished-panel millimetres (origin = left). Saw blanks + * (`rawCutWidthMm`) are longer; that extra sits on +X, so right-edge features + * (montants, AXILO) look too far from the right until they are re-anchored. + * Features within `edgeBandMm` of the finished right edge keep the same inset + * on the workpiece; everything else stays left-origin. + */ +export function mapFinishedXOntoWorkpieceMm(xMm, finishedWidthMm, workpieceWidthMm, edgeBandMm = 120) { + const x = Number(xMm) || 0; + const fw = Math.max(0, Number(finishedWidthMm) || 0); + const ww = Math.max(0, Number(workpieceWidthMm) || 0); + if (ww < 0.5) return x; + if (fw < 0.5 || Math.abs(ww - fw) < 0.05) return Math.max(0, Math.min(ww, x)); + const distRight = fw - x; + const distLeft = x; + if (distRight <= edgeBandMm && distRight <= distLeft + 0.05) { + return Math.max(0, Math.min(ww, ww - distRight)); + } + return Math.max(0, Math.min(ww, x)); +} + +/** AXILO 78 Press-fit holes on the underside of a bottom panel. */ +export function buildAxiloFootHoleSpecsMm(piece, widthMm, heightMm) { + const axilo = piece?.machining?.axiloFootPlates; + if ((piece?.elementType || '') !== 'fond' || !axilo?.centers?.length) return []; + + const finishedW = Number(piece?.widthMm) > 0 ? Number(piece.widthMm) : widthMm; + const diameterMm = Number(axilo.holeDiameterMm) || 10; + const depthMm = Number(axilo.holeDepthMm) || 12; + const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0)); + const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0)); + const out = []; + + for (const center of axilo.centers) { + for (const offset of axilo.holeOffsetsFromCenterMm || []) { + out.push({ + xMm: clampX( + mapFinishedXOntoWorkpieceMm( + Number(center.xMm) + Number(offset.x || 0), + finishedW, + widthMm + ) + ), + yMm: clampY(Number(center.yMm) + Number(offset.y || 0)), + diameterMm, + depthMm, + type: 'axilo_press_fit', + face: 'back', + row: center.row + }); + } + } + return out; +} + +/** System 32 rows; the rear row clears the back-groove zone when present. */ +export function buildSystem32HoleSpecsMm(piece, widthMm, heightMm) { + const system32 = piece?.machining?.system32; + if (!system32?.positions?.length) return []; + + const finishedW = Number(piece?.widthMm) > 0 ? Number(piece.widthMm) : widthMm; + const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0)); + const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0)); + const mapX = (v) => clampX(mapFinishedXOntoWorkpieceMm(v, finishedW, widthMm)); + const backClearanceMm = piece?.machining?.groove + ? getBackGrooveInnerEndInsetFromBackEdgeMm() + : 0; + const xRows = [ + mapX(37), + mapX(Math.max(0, finishedW - 37 - backClearanceMm)) + ]; + const diameterMm = Number(system32.holeDiameter) || 5; + const depthMm = Number(system32.holeDepth || piece?.thicknessMm) || 12; + const toolMode = system32.throughHole ? 'through' : 'blind'; + const out = []; + + for (const pos of system32.positions) { + for (const xMm of xRows) { + out.push({ + xMm, + yMm: clampY(pos), + diameterMm, + depthMm, + type: 'system32', + face: 'face', + toolMode + }); + } + } + return out; +} + /** Même logique que `getCarcassDowelStationsForPlan` dans cuttingEngine.js */ export function getCarcassDowelStationsForPlan(cf, edgeLengthMm) { const L = Math.max(1, Number(edgeLengthMm) || 0); @@ -50,14 +149,22 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) { const out = []; const m = piece?.machining || {}; const thicknessMm = Math.max(1, Number(piece?.thicknessMm) || 19); - if (!['dessus', 'fond'].includes(piece.elementType || '')) return out; + const et = piece.elementType || ''; + if (!['dessus', 'fond'].includes(et)) return out; const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0)); const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0)); /** Largeur « caisse » (finie) pour repères jonctions — peut être < widthMm (brut CNC). */ const refW = Number(piece?.widthMm) > 0 ? Math.min(widthMm, Number(piece.widthMm)) : widthMm; + const mapX = (xmm) => clampX(mapFinishedXOntoWorkpieceMm(xmm, refW, widthMm)); const edgeTolMm = 0.5; + /** + * Face d’entrée en épaisseur pour jonctions caisse (côtés / séparateurs) : + * — dessus : sous-face (intérieur caisse) → FACE=back (MPR von unten / 3D fromBottom) + * — fond : face supérieure (intérieur caisse) → FACE=face + */ + const carcassFace = et === 'dessus' ? 'back' : 'face'; const cf = m.carcassFasteners; if (cf && (cf.dowelStations?.length || cf.positionsAlongJointMm?.length)) { @@ -69,11 +176,15 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) { const zFerr = Number(cf.ferrureDepthInThicknessMm ?? CARCASS_FASTENERS.ferrureDepthInThicknessMm); const zFerrEdge = Number(cf.ferrureDepthAlongLengthMm ?? 46); - for (const xmm of jax) { + for (let junctionIndex = 0; junctionIndex < jax.length; junctionIndex++) { + const xmm = jax[junctionIndex]; const rawX = clampX(xmm); - const isEnd = rawX <= edgeTolMm || rawX >= refW - edgeTolMm; + const explicitFaceMode = cf.junctionFaceModes?.[junctionIndex]; + const isEnd = explicitFaceMode + ? explicitFaceMode === 'end' + : rawX <= edgeTolMm || rawX >= refW - edgeTolMm; const endSide = rawX <= refW / 2 ? 'left' : 'right'; - const x = clampX(mapJunctionXAcrossWidthMm(widthMm, xmm)); + const x = mapX(mapJunctionXAcrossWidthMm(refW, xmm)); for (const st of stations) { const along = st.holesAlongMm || []; for (let hi = 0; hi < along.length; hi++) { @@ -87,7 +198,7 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) { diameterMm: dTour, depthMm: zTour, type: 'tourillon', - face: isEnd ? 'end' : 'face', + face: isEnd ? 'end' : carcassFace, ...(isEnd ? { end: endSide } : {}) }); } else { @@ -98,32 +209,25 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) { diameterMm: CARCASS_FERRURE_FACE_DIAMETER_MM, depthMm: isEnd ? zFerrEdge : zFerr, type: 'ixconnect', - face: isEnd ? 'end' : 'face', + face: isEnd ? 'end' : carcassFace, ...(isEnd ? { end: endSide } : {}) }); - /* Trou Inbus : même règle que connecteur T–IX–T (un par Ixconnect ferrure). Sans ceci, les jeux côté rainure - * (souvent issus de ferrures caisse seules) n’ont pas de trou violet Inbus en plan / G-code / 3D. */ - { + /* Le Ø6 d'accès appartient au même panneau que le long Ø8 percé en chant. */ + if (isEnd) { const lock = CONNECTORS.ixconnect.lockScrewInbus; const dLock = Number(lock?.diameter) || 6; const zLock = Number(lock?.depth) || 9; - const epsMm = 0.1; - let xInbus = clampX(x); - if (isEnd) { - const depthAlongMm = Math.min(zFerrEdge, widthMm * 0.48, 80); - xInbus = - endSide === 'left' - ? depthAlongMm / 2 + epsMm - : widthMm - depthAlongMm / 2 - epsMm; - xInbus = clampX(xInbus); - } out.push({ - xMm: xInbus, + xMm: ixconnectInbusXFromEndMm({ + endSide, + widthMm, + clampX + }), yMm, diameterMm: dLock, depthMm: zLock, type: 'ixconnect_inbus', - face: 'face' + face: carcassFace }); } } @@ -134,7 +238,14 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) { const ch = m.connectorHoles; const joints = ch?.ixconnectJointsAlongDepth; - if (joints?.length) { + /* Quand les ferrures caisse (carcassFasteners) sont déjà posées, elles portent le trio + * T–IX–T + Inbus le long de chaque jonction. Réémettre ixconnectJointsAlongDepth + * (motif bord avant 20/50/80) doublonne le jeu avant — d’où deux trous au centre Ixconnect. */ + const carcassOwnsHorizontalJoints = + Array.isArray(cf?.dowelStations) && cf.dowelStations.length > 0 + ? true + : Array.isArray(cf?.positionsAlongJointMm) && cf.positionsAlongJointMm.length > 0; + if (joints?.length && !carcassOwnsHorizontalJoints) { const dTour = Number(ch.tourillonDiameter || CONNECTORS.tourillonDiameter || 8); const zTour = tourillonFaceDrillDepthMm(thicknessMm); const dFace = Number(ch.faceHole?.diameter || CONNECTORS.ixconnect.faceHole.diameter || 8); @@ -148,14 +259,15 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) { * clampX(axe droit) recule sous refW−ε et la jonction droite n’est plus « extrémité » → un seul Inbus. */ const axisNominal = Number(j.axisAcrossWidthMm) || 0; const rawAcross = clampX(axisNominal); - const isEnd = - axisNominal <= edgeTolMm || axisNominal >= refW - edgeTolMm; + const isEnd = j.junctionFace + ? j.junctionFace === 'end' + : axisNominal <= edgeTolMm || axisNominal >= refW - edgeTolMm; const endSide = rawAcross <= refW / 2 ? 'left' : 'right'; const x = isEnd ? rawAcross <= edgeTolMm ? 0 - : refW - : clampX(mapJunctionXAcrossWidthMm(widthMm, j.axisAcrossWidthMm)); + : widthMm + : mapX(mapJunctionXAcrossWidthMm(refW, j.axisAcrossWidthMm)); const seq = [ { axis: j.tourillon1Axis, d: dTour, z: isEnd ? zTourEdge : zTour, type: 'tourillon' }, { axis: j.ixconnectAxis, d: isEnd ? dEdge : dFace, z: isEnd ? zEdge : zFace, type: 'ixconnect' }, @@ -170,37 +282,27 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) { diameterMm: h.d, depthMm: h.z, type: h.type, - face: isEnd ? 'end' : 'face', + face: isEnd ? 'end' : carcassFace, ...(isEnd ? { end: endSide } : {}) }); } - /* Trou de serrage Inbus : un par Ixconnect — même (Y) que l’axe Ixconnect ; X aligné sur le centre du - * perçage tel qu’affiché en 3D (même formule que Cnc3DViewer pour FACE=end : min(prof chant, 48 % largeur, 80 mm) / 2). - * Sur jonction face : X = x du trio. Sur extrémité (montant) : entrée toujours face grande (perpendiculaire), - * mais X ne peut pas rester 0/refW sinon le cylindre est sur le bord au lieu du centre du violet sur chant. */ - { + /* Le Ø6 d'accès appartient au même panneau que le long Ø8 percé en chant. */ + if (isEnd) { const lock = CONNECTORS.ixconnect.lockScrewInbus; const dLock = Number(lock?.diameter) || 6; const zLock = Number(lock?.depth) || 9; - const yIx = clampY(Number(j.ixconnectAxis)); - const epsMm = 0.1; - let xInbus = clampX(x); - if (isEnd) { - const depthAlongMm = Math.min(zEdge, widthMm * 0.48, 80); - xInbus = - endSide === 'left' - ? depthAlongMm / 2 + epsMm - : widthMm - depthAlongMm / 2 - epsMm; - xInbus = clampX(xInbus); - } out.push({ - xMm: xInbus, - yMm: yIx, + xMm: ixconnectInbusXFromEndMm({ + endSide, + widthMm, + clampX + }), + yMm: clampY(Number(j.ixconnectAxis)), diameterMm: dLock, depthMm: zLock, type: 'ixconnect_inbus', - face: 'face' + face: carcassFace }); } } @@ -369,6 +471,81 @@ export function buildDrawerRenfortPanelIxconnectSpecsMm(piece, widthMm, heightMm return []; } +/** + * Coulisses Legrabox — perçages de vissage sur montants / séparateurs. + * Repère plan pièce : X depuis chant AVANT (origine bas-gauche), Y depuis bas panneau. + * `machining.drawerSlideDrilling` : { holeDiameterMm, holeDepthMm, positionsXFromFrontMm, + * cotes: { leftInnerFace: { railAxesYMm }, rightInnerFace: { railAxesYMm } }, + * separators: [{ faceTowardLowerModule: { railAxesYMm }, faceTowardHigherModule: { railAxesYMm } }] } + * @param {number} [partIndex] — cotes: 0 = montant gauche, 1 = droit ; séparateurs: index + */ +export function buildLegraboxRailHoleSpecsMm(piece, widthMm, heightMm, partIndex = 0) { + const et = piece?.elementType || ''; + const m = piece?.machining?.drawerSlideDrilling; + if (!m?.positionsXFromFrontMm?.length) return []; + const d = Number(m.holeDiameterMm) || 5; + const z = Number(m.holeDepthMm) || 13; + const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0)); + const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0)); + const out = []; + + const pushFace = (axesYMm, faceKey, tag) => { + if (!Array.isArray(axesYMm) || !axesYMm.length) return; + for (const y of axesYMm) { + for (const x of m.positionsXFromFrontMm) { + out.push({ + xMm: clampX(x), + yMm: clampY(y), + diameterMm: d, + depthMm: z, + type: `legrabox_rail${tag ? `_${tag}` : ''}`, + face: faceKey + }); + } + } + }; + + if (et === 'cotes' && m.cotes) { + const L = m.cotes.leftInnerFace; + const R = m.cotes.rightInnerFace; + const hasL = Array.isArray(L?.railAxesYMm) && L.railAxesYMm.length > 0; + const hasR = Array.isArray(R?.railAxesYMm) && R.railAxesYMm.length > 0; + const face = hasL && hasR ? (partIndex === 0 ? L : R) : hasL ? L : hasR ? R : null; + pushFace(face?.railAxesYMm, 'face'); + return out; + } + + if (et === 'panneauxInterieurs' && Array.isArray(m.separators)) { + const sep = m.separators[partIndex]; + if (!sep) return []; + pushFace(sep.faceTowardLowerModule?.railAxesYMm, 'face', 'sep_lower'); + pushFace(sep.faceTowardHigherModule?.railAxesYMm, 'back', 'sep_higher'); + return out; + } + + return []; +} + +/** + * Façades tiroir Legrabox — fixation INSERTA (cheville expansible Ø10, prof. 13 mm). + * Repère plan façade : origine bas-gauche, X/Y en mm (face arrière de la façade). + */ +export function buildLegraboxFacadeInsertaSpecsMm(piece, widthMm, heightMm) { + if ((piece?.elementType || '') !== 'tiroirFacade') return []; + const ins = piece?.machining?.legraboxInserta; + if (!ins?.holes?.length) return []; + const d = Number(ins.holeDiameterMm) || 10; + const z = Number(ins.holeDepthMm) || 13; + return ins.holes.map((h) => ({ + xMm: Math.max(0, Math.min(widthMm, Number(h.xMm) || 0)), + yMm: Math.max(0, Math.min(heightMm, Number(h.yMm) || 0)), + diameterMm: d, + depthMm: z, + type: 'legrabox_inserta', + face: 'face' + })); +} + export function buildDrawerReinforcementHoleSpecsMm(piece, widthMm, heightMm) { if ((piece?.elementType || '') !== 'renfortsTiroir') return []; const ix = piece?.machining?.drawerReinforcement?.ixconnectFixing; @@ -402,6 +579,19 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh const H = Math.max(1, Number(heightMm) || 1); const W = Math.max(1, Number(widthMm) || 1); + const finishedW = + Number(piece?.widthMm) > 0 ? Math.min(W, Number(piece.widthMm)) : W; + const backClearanceMm = + et === 'cotes' && piece?.machining?.groove + ? getBackGrooveInnerEndInsetFromBackEdgeMm() + : 0; + const backReferenceMm = Math.max(0, finishedW - backClearanceMm); + const mapAxisX = (axisMm) => + mapFinishedXOntoWorkpieceMm( + mapJunctionXAcrossWidthMm(finishedW, axisMm), + finishedW, + W + ); const inset = IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM; if (H < 2 * inset + 5) return out; @@ -420,9 +610,9 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh const t2 = Number(p.tourillon2Axis); const ix = Number(p.ixconnectAxis); const mirrorBack = - W >= IXCONNECT_SEPARATOR_BACK_ROW_MIN_WIDTH_MM && t2 > t1; + finishedW >= IXCONNECT_SEPARATOR_BACK_ROW_MIN_WIDTH_MM && t2 > t1; - if (et === 'panneauxInterieurs') { + if (et === 'panneauxInterieurs' || (et === 'cotes' && ch.verticalJointMode === 'edge')) { const seqFront = [ { axis: t1, d: dTour, z: zTourEdge, type: 'tourillon' }, { axis: ix, d: dEdge, z: zIxEdge, type: 'ixconnect' }, @@ -430,16 +620,21 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh ]; const seqBack = mirrorBack ? [ - { axis: W - t2, d: dTour, z: zTourEdge, type: 'tourillon' }, - { axis: W - ix, d: dEdge, z: zIxEdge, type: 'ixconnect' }, - { axis: W - t1, d: dTour, z: zTourEdge, type: 'tourillon' } + { axis: backReferenceMm - t2, d: dTour, z: zTourEdge, type: 'tourillon' }, + { axis: backReferenceMm - ix, d: dEdge, z: zIxEdge, type: 'ixconnect' }, + { axis: backReferenceMm - t1, d: dTour, z: zTourEdge, type: 'tourillon' } ] : null; + const lock = CONNECTORS.ixconnect.lockScrewInbus; + const dLock = Number(lock?.diameter) || 6; + const zLock = Number(lock?.depth) || 9; + const inbusOffset = Number(lock?.offsetFromAssemblyEdgeMm) || 25; const pushEndRow = (yMm, seq, endSide) => { const cy = clampY(yMm); + const inbusY = clampY(endSide === 'bottom' ? inbusOffset : H - inbusOffset); for (const h of seq) { - const xMm = mapJunctionXAcrossWidthMm(W, Number(h.axis)); + const xMm = mapAxisX(Number(h.axis)); out.push({ xMm, yMm: cy, @@ -449,6 +644,16 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh face: 'end', end: endSide }); + if (h.type === 'ixconnect') { + out.push({ + xMm, + yMm: inbusY, + diameterMm: dLock, + depthMm: zLock, + type: 'ixconnect_inbus', + face: 'face' + }); + } } }; @@ -460,7 +665,8 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh return out; } - /* cotes : face */ + /* cotes sans habillage latéral : logement face uniquement. + * L'accès Inbus est sur la pièce opposée qui porte le long perçage en chant. */ const seqFront = [ { axis: t1, d: dTour, z: zTour, type: 'tourillon' }, { axis: ix, d: dFace, z: zFace, type: 'ixconnect' }, @@ -468,15 +674,15 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh ]; const seqBack = mirrorBack ? [ - { axis: W - t2, d: dTour, z: zTour, type: 'tourillon' }, - { axis: W - ix, d: dFace, z: zFace, type: 'ixconnect' }, - { axis: W - t1, d: dTour, z: zTour, type: 'tourillon' } + { axis: backReferenceMm - t2, d: dTour, z: zTour, type: 'tourillon' }, + { axis: backReferenceMm - ix, d: dFace, z: zFace, type: 'ixconnect' }, + { axis: backReferenceMm - t1, d: dTour, z: zTour, type: 'tourillon' } ] : null; const pushFaceRow = (yMm, seq) => { const cy = clampY(yMm); for (const h of seq) { - const xMm = mapJunctionXAcrossWidthMm(W, Number(h.axis)); + const xMm = mapAxisX(Number(h.axis)); out.push({ xMm, yMm: cy, @@ -487,10 +693,10 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh }); } }; - for (const yMm of yRows) { - pushFaceRow(yMm, seqFront); - if (seqBack) pushFaceRow(yMm, seqBack); - } + pushFaceRow(yRows[0], seqFront); + if (seqBack) pushFaceRow(yRows[0], seqBack); + pushFaceRow(yRows[1], seqFront); + if (seqBack) pushFaceRow(yRows[1], seqBack); return out; } diff --git a/shared/manufacturing.js b/shared/manufacturing.js index 07e00c9..2535119 100644 --- a/shared/manufacturing.js +++ b/shared/manufacturing.js @@ -4,6 +4,8 @@ * 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 @@ -27,6 +29,12 @@ export const EDGE_BANDING = { 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: { @@ -64,7 +72,8 @@ export const CONNECTORS = { 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 */ + /** 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, @@ -189,11 +198,19 @@ export function getJunctionAxesAcrossWidthMm(dimensionsWidthMm, modules = [], co 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))); + axes.push(Math.max(0, Math.min(maxW, acc - leftShift))); } axes.push(maxW); const sorted = [...axes].sort((a, b) => a - b); @@ -209,6 +226,558 @@ export function getJunctionAxesAcrossWidthMm(dimensionsWidthMm, modules = [], co 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 @@ -289,12 +858,202 @@ export function getHingeCupPositions(doorHeightMm, hingeCount = null) { // ── 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 + /** 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 @@ -331,16 +1090,26 @@ export const NICHE = { drawerBackGap: 10, // mm — vide sanitaire derrière tiroir }; -// ── Drawers (manufactured) ────────────────────────────────────────────── +// ── 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) - standardHeights: [60, 90, 128, 177, 240], + /** 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). @@ -432,20 +1201,36 @@ export function getDrawerReinforcementIxconnectHolePositionsMm(widthMm, heightMm } /** - * Hauteur totale du bloc tiroirs (façades + jeux), mm. + * 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 - * @param {number} [drawerGapMm] + * @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 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)); + 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 ────────────────────────────────────── @@ -481,8 +1266,126 @@ export const BLOCK_ASSEMBLY = { 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). */ @@ -493,6 +1396,11 @@ export const CARCASS_FASTENERS = { /** 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) */ diff --git a/shared/manufacturing.test.js b/shared/manufacturing.test.js new file mode 100644 index 0000000..53b5a3a --- /dev/null +++ b/shared/manufacturing.test.js @@ -0,0 +1,350 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + addUpperBlockAboveHorizontalSpan, + getModuleVerticalBoundariesMm, + resizeHorizontalSpanUpperBlock, + resolveHorizontalSpan, + validateHorizontalSpans, + usesBlockConstruction, + columnUsesBlockConstruction, + horizontalConstructionRuns, + getAxiloPlateCentersMm, + getAxiloLateralCenterInsetMm, + getAxiloPlinthFrontSideDeductionMm, + PLINTH +} from './manufacturing.js'; +import { validateConfig } from '../server/services/configValidation.js'; +import { generateCuttingList } from '../server/services/cuttingEngine.js'; + +function makeConfig(columnCount, spanHeightMm = 900) { + const bottomMm = 300; + const topMm = bottomMm + spanHeightMm; + const bodyMm = 1800; + const modules = Array.from({ length: columnCount }, (_, index) => ({ + id: `col-${index + 1}`, + type: 'penderie', + moduleCode: 'penderie', + widthMm: 600, + minWidth: 300, + maxWidth: 1200, + shelves: 0, + drawers: 0, + hasRod: false, + blockHeightsMm: [bottomMm, spanHeightMm, bodyMm - topMm], + blockBacks: [true, index % 2 === 0, false], + blockDoors: [true, false, true], + hasBack: true, + hasBackTop: false + })); + return { + installationType: 'free', + doorType: 'hinged', + widthMm: columnCount * 600, + heightMm: 1900, + depthMm: 600, + plinthHeightMm: 100, + effectiveDrawerDepthMm: 600, + hardware: { drawerType: 'visible', handleBoreHeightMm: 1000 }, + modules, + horizontalSpans: [{ + id: 'span-main', + moduleIds: modules.map((module) => module.id), + bottomMm, + topMm, + hasBack: true, + doorMode: 'none', + type: 'open' + }] + }; +} + +function assertSynchronizedUpperBlock(config, expectedSpanTopMm, expectedUpperHeightMm) { + const resolved = resolveHorizontalSpan(config.horizontalSpans[0], config); + assert.equal(resolved.topMm, expectedSpanTopMm); + for (const module of config.modules) { + const boundaries = getModuleVerticalBoundariesMm(module, config); + assert.ok(boundaries.includes(expectedSpanTopMm)); + assert.ok(boundaries.includes(1200)); + const upperIndex = boundaries.indexOf(expectedSpanTopMm); + assert.equal(boundaries[upperIndex + 1] - boundaries[upperIndex], expectedUpperHeightMm); + assert.equal(module.blockDoors[upperIndex], true); + resolved.coveredBlocks + .find((covered) => covered.moduleId === module.id) + .blockIndexes + .forEach((blockIndex) => assert.equal(module.blockDoors[blockIndex], false)); + assert.equal(module.blockHeightsMm.reduce((sum, height) => sum + height, 0), 1800); + } +} + +for (const columnCount of [2, 3]) { + test(`${columnCount}-column span adds and resizes a synchronized upper block`, () => { + const original = makeConfig(columnCount); + const added = addUpperBlockAboveHorizontalSpan(original, 'span-main'); + assert.equal(added.ok, true); + assert.notEqual(added.config, original); + assert.deepEqual(original.modules[0].blockHeightsMm, [300, 900, 600]); + assertSynchronizedUpperBlock(added.config, 900, 300); + + const resized = resizeHorizontalSpanUpperBlock(added.config, 'span-main', 450); + assert.equal(resized.ok, true); + assertSynchronizedUpperBlock(resized.config, 750, 450); + assert.deepEqual(validateHorizontalSpans(resized.config), []); + assert.deepEqual(validateConfig(resized.config).errors, []); + + const cuttingList = generateCuttingList(resized.config, {}, resized.config.modules); + assert.ok(Array.isArray(cuttingList.pieces)); + assert.ok(cuttingList.pieces.length > 0); + }); +} + +test('too-short span rejects upper block without mutation', () => { + const config = makeConfig(2, 500); + const snapshot = structuredClone(config); + const result = addUpperBlockAboveHorizontalSpan(config, 'span-main'); + assert.equal(result.ok, false); + assert.equal(result.code, 'span_too_short'); + assert.deepEqual(config, snapshot); +}); + +test('resize rejects non-step heights without mutation', () => { + const added = addUpperBlockAboveHorizontalSpan(makeConfig(2), 'span-main'); + assert.equal(added.ok, true); + const snapshot = structuredClone(added.config); + const result = resizeHorizontalSpanUpperBlock(added.config, 'span-main', 425); + assert.equal(result.ok, false); + assert.equal(result.code, 'invalid_height'); + assert.deepEqual(added.config, snapshot); +}); + +test('construction: dos → block ; sinon côté ou rayonnage → block', () => { + assert.equal(usesBlockConstruction({ type: 'penderie' }, true), true); + assert.equal(usesBlockConstruction({ type: 'penderie' }, false), false); + assert.equal(usesBlockConstruction({ type: 'tiroirs' }, false), false); + assert.equal(usesBlockConstruction({ type: 'rayonnage' }, false), true); + assert.equal(usesBlockConstruction({ moduleCode: 'rayonnage' }, false), true); + + assert.equal(usesBlockConstruction({ type: 'penderie', shelves: 1 }, false), false); + assert.equal(usesBlockConstruction({ type: 'penderie', shelves: 4 }, false), true); + assert.equal(usesBlockConstruction({ type: 'mixte', shelves: 2 }, false), true); + + const penderie = { type: 'penderie', shelves: 1, hasBack: false, blockBacks: [false] }; + const rayonnage = { type: 'rayonnage', hasBack: false, blockBacks: [false] }; + const shelvedPenderie = { type: 'penderie', shelves: 4, hasBack: false, blockBacks: [false] }; + const withDos = { type: 'penderie', hasBack: true, blockBacks: [true] }; + const config = { hasBack: false }; + + assert.equal(columnUsesBlockConstruction(penderie, config), false); + assert.equal(columnUsesBlockConstruction(rayonnage, config), true); + assert.equal(columnUsesBlockConstruction(shelvedPenderie, config), true); + assert.equal(columnUsesBlockConstruction(withDos, config), true); + + const modules = [ + { type: 'penderie', widthMm: 600, hasBack: false, blockBacks: [false] }, + { type: 'penderie', widthMm: 600, hasBack: false, blockBacks: [false] }, + { type: 'rayonnage', widthMm: 500, hasBack: false, blockBacks: [false] }, + { type: 'penderie', widthMm: 600, hasBack: false, blockBacks: [false] } + ]; + assert.deepEqual( + horizontalConstructionRuns(modules, config, 'top').map(({ start, end, isBlock }) => ({ start, end, isBlock })), + [ + { start: 0, end: 1, isBlock: false }, + { start: 2, end: 2, isBlock: true }, + { start: 3, end: 3, isBlock: false } + ] + ); + + const allPenderie = modules.map((m, i) => ( + i === 2 ? { ...m, type: 'penderie', moduleCode: 'penderie', shelves: 0 } : m + )); + assert.deepEqual( + horizontalConstructionRuns(allPenderie, config, 'top').map(({ start, end, isBlock }) => ({ start, end, isBlock })), + [{ start: 0, end: 3, isBlock: false }] + ); +}); + +test('horizontal block at shell top replaces per-column roofs with one panel', () => { + const modules = [1, 2, 3].map((index) => ({ + id: `col-${index}`, + type: 'rayonnage', + widthMm: 600, + blockHeightsMm: [1800], + blockBacks: [false] + })); + const config = { + widthMm: 1800, + heightMm: 1900, + plinthHeightMm: 100, + modules, + horizontalSpans: [{ + id: 'span-top', + moduleIds: ['col-1', 'col-2'], + bottomMm: 0, + topMm: 1800, + hasBack: false, + doorMode: 'none', + type: 'open' + }] + }; + + assert.deepEqual( + horizontalConstructionRuns(modules, config, 'top') + .map(({ start, end, horizontalSpanId }) => ({ start, end, horizontalSpanId })), + [ + { start: 0, end: 1, horizontalSpanId: 'span-top' }, + { start: 2, end: 2, horizontalSpanId: undefined } + ] + ); +}); + +test('cutting list: merged dessus for separations, split for rayonnage', () => { + const baseModule = (index, type) => ({ + id: `col-${index + 1}`, + type, + moduleCode: type, + widthMm: 600, + minWidth: 300, + maxWidth: 1200, + shelves: type === 'rayonnage' ? 4 : 0, + drawers: 0, + hasRod: type === 'penderie', + blockHeightsMm: [1800], + blockBacks: [false], + blockDoors: [true], + hasBack: false, + hasBackTop: false + }); + const configOf = (types) => ({ + installationType: 'free', + doorType: 'hinged', + widthMm: types.length * 600, + heightMm: 1900, + depthMm: 600, + plinthHeightMm: 100, + hasBack: false, + columnMode: 'separations', + hardware: { drawerType: 'visible', handleBoreHeightMm: 1000 }, + modules: types.map((type, index) => baseModule(index, type)), + horizontalSpans: [] + }); + const dessusPieces = (config) => + generateCuttingList(config, {}, config.modules).pieces.filter((piece) => piece.elementType === 'dessus'); + + const merged = dessusPieces(configOf(['penderie', 'penderie', 'penderie'])); + assert.equal(merged.length, 1); + assert.match(merged[0].id, /col1-3/); + + const split = dessusPieces(configOf(['penderie', 'rayonnage', 'penderie'])); + assert.equal(split.length, 3); +}); + +test('AXILO feet: 4 corners plus a pair every 600mm on the armoire', () => { + const inset = getAxiloLateralCenterInsetMm(true); + const narrow = getAxiloPlateCentersMm(500, 610, { + leftEdgeCenterInsetMm: inset, + rightEdgeCenterInsetMm: inset + }); + assert.equal(narrow.length, 4, 'A bay shorter than 600mm keeps only the 4 corner feet'); + + const wide = getAxiloPlateCentersMm(2362, 610, { + leftEdgeCenterInsetMm: inset, + rightEdgeCenterInsetMm: inset + }); + assert.equal(wide.length, 10, '2400mm armoire: 4 corners + 3 intermediate pairs'); + const xs = [...new Set(wide.map((c) => c.xMm))].sort((a, b) => a - b); + assert.equal(xs.length, 5); + for (let i = 1; i < xs.length; i++) { + assert.ok(xs[i] - xs[i - 1] <= PLINTH.maxFootSpacingMm + 0.01); + } + + const left = getAxiloPlateCentersMm(761, 610, { + leftEdgeCenterInsetMm: inset, + rightEdgeCenterInsetMm: inset, + spanWidthMm: 2362, + panelOffsetMm: 0, + assignMinMm: 0, + assignMaxMm: 781, + assignMaxInclusive: false + }); + const mid = getAxiloPlateCentersMm(759, 610, { + leftEdgeCenterInsetMm: inset, + rightEdgeCenterInsetMm: inset, + spanWidthMm: 2362, + panelOffsetMm: 801.5, + assignMinMm: 781, + assignMaxMm: 1581, + assignMaxInclusive: false + }); + const right = getAxiloPlateCentersMm(761, 610, { + leftEdgeCenterInsetMm: inset, + rightEdgeCenterInsetMm: inset, + spanWidthMm: 2362, + panelOffsetMm: 1601.5, + assignMinMm: 1581, + assignMaxMm: 2362, + assignMaxInclusive: true + }); + assert.equal(left.length + mid.length + right.length, 10, 'Split fonds share the armoire foot count'); +}); + +test('grooved bottom staggers the rear Ixconnect row away from AXILO holes', () => { + const config = makeConfig(1); + const bottom = generateCuttingList(config, {}, config.modules).pieces.find( + (piece) => + piece.elementType === 'fond' && + piece.machining?.groove && + piece.machining?.axiloFootPlates && + piece.machining?.carcassFasteners + ); + assert.ok(bottom, 'Expected a grooved bottom with AXILO and carcass machining'); + + const rearFootYs = bottom.machining.axiloFootPlates.centers + .filter((center) => center.row === 'rear') + .flatMap((center) => + bottom.machining.axiloFootPlates.holeOffsetsFromCenterMm.map( + (offset) => center.yMm + offset.y + ) + ); + const rearJoint = bottom.machining.carcassFasteners.dowelStations.at(-1); + const minimumAxisClearanceMm = Math.min( + ...rearJoint.holesAlongMm.flatMap((jointY) => + rearFootYs.map((footY) => Math.abs(jointY - footY)) + ) + ); + + assert.ok( + minimumAxisClearanceMm >= 14, + `Rear T–IX–T and AXILO axes need clearance; received ${minimumAxisClearanceMm} mm` + ); +}); + +test('niche front plinth spans the full wardrobe width', () => { + const config = { + ...makeConfig(3), + installationType: 'niche', + exteriorFinishesEnabled: true, + sideFinishLeftEnabled: true, + sideFinishRightEnabled: true + }; + + assert.equal(getAxiloPlinthFrontSideDeductionMm(config, 'left'), 0); + assert.equal(getAxiloPlinthFrontSideDeductionMm(config, 'right'), 0); + + const plinth = generateCuttingList(config, {}, config.modules).pieces + .find((piece) => piece.elementType === 'plintheBas'); + assert.ok(plinth); + assert.equal(plinth.widthMm, config.widthMm); + + const cuttingList = generateCuttingList(config, {}, config.modules); + const sideFillers = cuttingList.pieces.find((piece) => piece.elementType === 'fileurCotes'); + const topFiller = cuttingList.pieces.find((piece) => piece.elementType === 'fileurHaut'); + assert.ok(sideFillers); + assert.equal(sideFillers.widthMm, 50); + assert.equal(sideFillers.heightMm, config.heightMm + 30); + assert.equal(sideFillers.quantity, 2); + assert.ok(topFiller); + assert.equal(topFiller.widthMm, config.widthMm + 60); + assert.equal(topFiller.heightMm, 50); + assert.equal(topFiller.quantity, 1); +});