import { useRef, Suspense, useMemo, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'; 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, 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: 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; /** * Visible grip length along an edge (width, height, or depth of the carcass). * When the edge is narrow, the grip is at least slightly longer than the element. */ function gripSpanAlongEdge(edgeLenM) { if (!(edgeLenM > 0)) return MIN_EDGE_GRIP_M; const inset = edgeLenM * 0.96; if (edgeLenM < THIN_EDGE_M) { return Math.max(MIN_EDGE_GRIP_M, edgeLenM + GRIP_OVERFLOW_WHEN_THIN_M, inset); } return Math.max(0.18, inset); } /** Perpendicular thickness for DimensionAxisGrip hit boxes; grows when the main span is thin. */ 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 =================================================================== */ function ResizableSeparator({ position, height, depth, zCenter, visibleSegments, separatorIndex, colors, onElementClick, isResizing, onResizePointerDown, }) { const [hovered, setHovered] = useState(false); const { gl } = useThree(); const meshRef = useRef(); const handlePointerOver = useCallback((e) => { e.stopPropagation(); setHovered(true); gl.domElement.style.cursor = 'col-resize'; }, [gl]); const handlePointerOut = useCallback((e) => { e.stopPropagation(); if (!isResizing) { setHovered(false); gl.domElement.style.cursor = 'auto'; } }, [gl, isResizing]); const handlePointerDown = useCallback((e) => { e.stopPropagation(); if (onResizePointerDown) { onResizePointerDown(separatorIndex, e); } }, [separatorIndex, onResizePointerDown]); const baseColor = hovered || isResizing ? '#4f46e5' : '#d4c4a4'; const hitX = Math.max(PANEL_THICKNESS * 8, 0.11); const hitY = Math.max(height, gripSpanAlongEdge(height)); const hitZ = Math.max(depth, gripSpanAlongEdge(depth)); return ( {/* 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 */} {/* Highlight overlay when hovered/resizing */} {(hovered || isResizing) && ( )} ); } /** * Rainure dos APEX 4.0 : 9×10,5 mm, axe à 16 mm du bord arrière (face intérieure caisse). * Repère local identique au panneau : Z− = arrière, dessus = rainure sur face bas (−Y), fond = face haut (+Y). */ function BackGrooveVisual3D({ variant, innerW, innerD, thickness, panelH, panelD }) { const gw = GROOVE.back.width * SCALE; const gd = GROOVE.back.depth * SCALE; const ax = GROOVE.back.axisFromPanelEdge * SCALE; const eps = 0.0003; const mat = ( ); if (variant === 'horizontal-top') { const zHalf = innerD / 2; const zc = -zHalf + ax; return ( {mat} ); } if (variant === 'horizontal-bottom') { const zHalf = innerD / 2; const zc = -zHalf + ax; return ( {mat} ); } if (variant === 'vertical-left') { const zHalf = panelD / 2; const zc = -zHalf + ax; return ( {mat} ); } if (variant === 'vertical-right') { const zHalf = panelD / 2; const zc = -zHalf + ax; return ( {mat} ); } return null; } /* =================================================================== WidthLabel - floating HTML label showing module width in mm =================================================================== */ function WidthLabel({ position, widthMm, hint, snapped = false }) { return (
{Math.round(widthMm)}mm {hint && {hint}}
); } /* =================================================================== DimensionAxisGrip - CAD-like edge grip for bounding box resize =================================================================== */ function DimensionAxisGrip({ position, rotation = [0, 0, 0], size, hitSize, label, cursor, active, tintColor = '#4f46e5', chipOffset = [0, 0.06, 0], onPointerDown }) { const [hovered, setHovered] = useState(false); const { gl } = useThree(); const handlePointerOver = useCallback((e) => { e.stopPropagation(); setHovered(true); gl.domElement.style.cursor = cursor; }, [gl, cursor]); const handlePointerOut = useCallback((e) => { e.stopPropagation(); if (!active) { setHovered(false); gl.domElement.style.cursor = 'auto'; } }, [gl, active]); const handlePointerDown = useCallback((e) => { e.stopPropagation(); e.target?.setPointerCapture?.(e.pointerId); onPointerDown?.(e); }, [onPointerDown]); return (
{label}
); } /* =================================================================== DrawerBox - renders drawer front faces stacked vertically =================================================================== */ function DrawerBox({ moduleCenter, moduleWidth, bodyH, plinthH, drawersCount, depth, behindDoor, drawerDepthMm, drawerHeightsMm, colors, onElementClick }) { 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( PANEL_THICKNESS * 2, Math.min(effMm * SCALE, Math.max(0.08, depth - PANEL_THICKNESS * 3)) ); /** Face avant des tiroirs (côté porte) : +Z vers l’avant du caisson. */ const frontFaceZ = behindDoor ? depth / 2 - PANEL_THICKNESS - 0.15 : depth / 2 - PANEL_THICKNESS - 0.002; 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 ( <> {behindDoor && moduleWidth > 0 && drawersCount > 0 && ( <> )} {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 }} /> ))} ); } /* =================================================================== 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, onUpdateModule, onSetDimension, onBeginUndoableGesture, activeStep = 1, suppressHtmlOverlays = false, onInteractionChange, }) { const { gl, camera, controls } = useThree(); const useModules = modules && modules.length > 0; const shellOnlyMode = activeStep === 1; // --- Dimensions --- const totalWidth = useModules ? modules.reduce((s, m) => s + (m.widthMm || 0), 0) : config.widthMm || 2400; const width = totalWidth * SCALE; 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 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 = (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: 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); const h = gripSpanAlongEdge(bodyH); const d = gripSpanAlongEdge(depth); const gripOutset = 0.03; const rightExteriorX = width / 2 + (isNiche ? PANEL_THICKNESS * 0.6 : 0) + ((!isNiche && exteriorFinishesEnabled && sideFinishRightEnabled) ? PANEL_THICKNESS : 0) + gripOutset; const frontExteriorZ = depth / 2 + gripOutset; const bottomY = -height / 2 + plinthH + 0.02; return { w, h, d, crossW: gripHitCrossAxis(width), crossH: gripHitCrossAxis(bodyH), crossD: gripHitCrossAxis(depth), rightExteriorX, frontExteriorZ, bottomY, }; }, [width, bodyH, depth, height, plinthH, isNiche, exteriorFinishesEnabled, sideFinishRightEnabled]); // --- Module positions --- const modulePositions = useMemo(() => { const items = useModules ? modules : []; const result = []; let offset = 0; items.forEach((item) => { const w = (item.widthMm || 0) * SCALE; result.push({ x0: offset, width: w, center: -width / 2 + offset + w / 2 }); offset += w; }); return result; }, [useModules, modules, width]); // --- Separator positions (between modules) --- const separatorPositions = useMemo(() => { const items = useModules ? modules : []; return items.slice(0, -1).map((item, i) => { const x = modulePositions[i].x0 + modulePositions[i].width; return { x: -width / 2 + x, index: i }; }); }, [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, 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). */ const showDimensionHtml = dimensionEditMode && !suppressHtmlOverlays; const applyDimensionValue = useCallback((type, rawValue) => { if (!dimensionEditMode) return; const rules = DIMENSION_RULES[type]; if (!rules) return; const parsed = Number(rawValue); if (!Number.isFinite(parsed)) return; const clamped = Math.min(rules.max, Math.max(rules.min, parsed)); const stepped = Math.round(clamped / rules.step) * rules.step; onSetDimension?.(type, stepped); }, [dimensionEditMode, onSetDimension]); // --- 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 { 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?.(); gestureRef.current = { kind: 'separator', separatorIndex, drag, startLeft, startRight, pair, lo, hi, otherWidths }; setResizeState({ separatorIndex, hint: null, snapped: false }); gl.domElement.style.cursor = 'col-resize'; }, [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?.(); 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; // 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, 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?.(); gestureRef.current = { kind: 'shelf', moduleIndex, shelfIndex, drag, startPosMm, shelfPositionsMm, originalShelfPositionsMm: mod?.shelfPositionsMm, slotsMm }; setShelfDragState({ moduleIndex, shelfIndex }); gl.domElement.style.cursor = 'ns-resize'; }, [activeStep, onUpdateModule, modules, config.heightMm, config.plinthHeightMm, onBeginUndoableGesture, camera, gl, controls]); // 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) => { 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 (g.kind === 'dimension') { applyDimensionValue(g.type, g.startValue + deltaMm * g.factor); return; } 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++) { const d = Math.abs(slots[i] - rawMm); if (d < best) { best = d; nearestIdx = i; } } 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 = [...g.shelfPositionsMm]; nextShelfPositions[g.shelfIndex] = slots[nearestIdx]; onUpdateModule?.(g.moduleIndex, { shelfPositionsMm: nextShelfPositions }); } }; 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 = endGesture; onKeyDownRef.current = handleKeyDown; }, [onResizeModules, applyDimensionValue, onUpdateModule, gl]); useEffect(() => { const move = (e) => onPointerMoveRef.current?.(e); const up = (e) => onPointerUpRef.current?.(e); 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 — planche 19 mm, reculée 20 mm du chant avant (et 20 mm des côtés libres). */} {leftPlinthReturn && ( )} {rightPlinthReturn && ( )} {/* 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 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 && ( )} )} {/* 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é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 && ( <> {/* Habillage gauche */} {sideFinishLeftEnabled && ( )} {/* Habillage droite */} {sideFinishRightEnabled && ( )} {/* Habillage dessus — 1 bandeau continu ou 1 par zone (caissons avec dos) */} {topFinishEnabled && ( 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 habitW = Math.max(0.001, width); const cx = 0; return ( ); })() ) )} )} {/* 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 de niche surdimensionnés à 50 mm, recoupés sur place au profil des murs. */} {isNiche && ( <> )} {/* Separators between modules — jonction caisson↔caisson: double montant; sinon panneau simple */} {!shellOnlyMode && useModules && onResizeModules ? separatorPositions.flatMap((pos, i) => { const style = boundaryAnyBlock(pos.index) ? sepStyleBlock : sepStyleSeparations; const xs = montantXsAt(pos.x, pos.index); return xs.map((xSep, j) => ( )); }) : !shellOnlyMode && separatorPositions.flatMap((pos, i) => { 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) && ( <> {resizeState !== null && ( <> {modules[resizeState.separatorIndex] && ( )} {modules[resizeState.separatorIndex + 1] && ( )} )} {resizeState === null && hoveredModule !== null && modules[hoveredModule] && ( )} )} {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 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; const structuralMaxMm = bodyMidMm + STRUCTURAL_WINDOW_HALF_MM; const resolvedShelfPositionsMm = resolveShelfPositionsMm({ shelfCount, heightMm: config.heightMm, plinthHeightMm: config.plinthHeightMm ?? 100, shelfPositionsMm: module.shelfPositionsMm, stack: moduleStack }); const hasShelfInStructuralWindow = !needsMiddleFix || resolvedShelfPositionsMm.some((p) => p >= structuralMinMm && p <= structuralMaxMm); const structuralZoneCenterY = -height / 2 + plinthH + (bodyMidMm / 1000); const structuralZoneHeight = (STRUCTURAL_WINDOW_HALF_MM * 2) / 1000; const moduleSpanForZone = innerWidth; const depthSpanForZone = Math.max(0.02, depth * 0.9); const shelfCenterYs = shelfCenterYsMeters({ shelfCount, heightM: height, plinthHM: plinthH, shelfGapMm: module.shelfGapMm, shelfPositionsMm: resolvedShelfPositionsMm, }); return ( {/* 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) => ( { 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, zone.blockIndex); }} > ))} {/* Highlight only on Colonnes. On Matériaux the block must not stay marked as selected. */} {activeStep === 2 && isSelected && selectedBlock && ( null} > )} {activeStep === 2 && needsMiddleFix && !hasShelfInStructuralWindow && ( )} {/* Portes battantes per module — 1 porte par block avec porte (block sans porte = niche ouverte) */} {showDoors && !isSliding && ( (() => { 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 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( module.drawerType || hw.drawerType || 'visible' ); const splitDoorOverDrawers = drawerCount > 0 && !drawerHidden; const doorCount = Math.max(1, modules.length); const overrideSides = Array.isArray(hw.doorHingeSides) && hw.doorHingeSides.length === doorCount ? hw.doorHingeSides : null; const hingeSide = overrideSides ? (overrideSides[moduleIndex] === 'right' ? 'right' : 'left') : getDoorHingeSide(moduleIndex, doorCount, hw); // Rotate so the free edge swings toward the front (+Z), i.e. "outside" the carcass. const angle = hingeSide === 'left' ? -doorOpenAngle : doorOpenAngle; const hingeX = hingeSide === 'left' ? moduleCenter - doorW / 2 : moduleCenter + doorW / 2; const localX = hingeSide === 'left' ? doorW / 2 : -doorW / 2; 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; })() )} {/* Shelves */} {(() => { const bottomInside = -height / 2 + plinthH + PANEL_THICKNESS; const innerSpan = bodyH - 2 * PANEL_THICKNESS; const yMiddle = bottomInside + innerSpan / 2; const tolM = 0.2; // ±20 cm tolerance around middle let structuralIdx = null; let best = Infinity; for (let i = 0; i < shelfCount; i++) { const y = shelfCenterYs[i]; if (y == null) continue; const d = Math.abs(y - yMiddle); if (d < best) { best = d; structuralIdx = i; } } return ( <> {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; const isShelfDragging = shelfDragState?.moduleIndex === moduleIndex && shelfDragState?.shelfIndex === shelfIndex; return ( {activeStep === 2 && ( { e.stopPropagation(); setHoveredShelfKey(shelfKey); gl.domElement.style.cursor = 'ns-resize'; }} onPointerOut={(e) => { e.stopPropagation(); setHoveredShelfKey((prev) => (prev === shelfKey ? null : prev)); if (!shelfDragState) gl.domElement.style.cursor = 'auto'; }} onPointerDown={(e) => handleShelfPointerDown(moduleIndex, shelfIndex, isStructural, e)} onClick={(e) => e.stopPropagation()} > )} ); })} ); })()} {/* Fixed structural shelf above drawers */} {drawersCount > 0 && ( (() => { const drawerHeightsMm = Array.isArray(hw.drawerHeightsMm) ? hw.drawerHeightsMm : [90, 128, 177, 240]; 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 && !spanCoversBlock(moduleIndex, 0) && ( )} {/* Rod (penderie) */} {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 */} {/* Non-module door rendering is also above */} {/* Step 1 dimension editor: bounding box + edge grips + rulers */} {showDimensionHtml && ( <> handleDimensionPointerDown('widthMm', e)} /> handleDimensionPointerDown('heightMm', e)} /> handleDimensionPointerDown('depthMm', e)} /> )} {/* Pass interaction state to parent for OrbitControls toggling */} ); } function WardrobeOutline({ width, height, depth }) { const geom = useMemo(() => { const g = new THREE.EdgesGeometry(new THREE.BoxGeometry(width, height, depth), 18); return g; }, [width, height, depth]); useEffect(() => () => geom.dispose(), [geom]); return ( ); } /* =================================================================== InteractionBlocker - disables OrbitControls during drag/resize Uses useThree to access the controls =================================================================== */ function InteractionBlocker({ active }) { const { controls } = useThree(); useEffect(() => { if (controls) { controls.enabled = !active; } }, [active, controls]); return null; } /* =================================================================== CameraController - exposes zoom/reset via 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 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) { const sum = modules.reduce((s, m) => s + (Number(m?.widthMm) || 0), 0); return sum > 0 ? sum : (config.widthMm || 2400); } return config.widthMm || 2400; }, [modules, config.widthMm]); const fitDistance = useMemo(() => { if (roomConfig) { return Math.max(roomConfig.width, roomConfig.length) * 1.2; } const w = totalWidthMm * SCALE; const h = (config.heightMm || 2400) * SCALE; const d = (config.depthMm || 600) * SCALE; const maxDim = Math.max(w, h, d); const fov = THREE.MathUtils.degToRad(45); const dist = (maxDim / 2) / Math.tan(fov / 2); 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( () => 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; const dist = getDefaultDistance(); if (view === 'front') camera.position.set(0, target.y, dist); if (view === 'side') camera.position.set(dist, target.y, 0); if (view === 'top') camera.position.set(0.001, target.y + dist, 0.001); if (view === 'iso') camera.position.set(dist * 0.75, target.y + dist * 0.35, dist * 0.75); if (view === 'inside') { // View from slightly in front of the wardrobe, looking into it. const depth = (config.depthMm || 600) * SCALE; camera.position.set(0, target.y, Math.max(0.25, depth * 0.35)); } controlsRef.current.update(); }, [camera, getDefaultDistance, config]); 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: () => { placeDefaultView(); }, }), [fitView, placeDefaultView]); useEffect(() => { if (controlsRef.current) { controlsRef.current.minPolarAngle = isDimensionStep ? 0 : 0; controlsRef.current.maxPolarAngle = Math.PI; } }, [isDimensionStep]); const handleControlStart = useCallback(() => { cancelSnap(); gl.domElement.style.cursor = 'grabbing'; }, [cancelSnap, gl]); const handleControlEnd = useCallback(() => { gl.domElement.style.cursor = 'auto'; }, [gl]); return ( ); }); /* =================================================================== Scene - assembles everything =================================================================== */ function Scene({ config, roomConfig, controllerRef, colors, modules, onElementClick, onModuleClick, selectedModuleIndex, selectedBlockIndex, selectedHorizontalSpanId, onHorizontalSpanClick, showDoors, onResizeModules, onUpdateModule, onSetDimension, onBeginUndoableGesture, activeStep, suppressHtmlOverlays, }) { const [interactionActive, setInteractionActive] = useState(false); const wardrobeTransform = useMemo(() => { if (roomConfig) { return calculateWardrobeTransform(roomConfig, config); } return { position: [0, 0, 0], rotation: [0, 0, 0] }; }, [roomConfig, config]); const nicheBackdrop = useMemo(() => { if (roomConfig) return null; if (config?.installationType !== 'niche') return null; const OUTER_WALL_EXTENT_M = 200; // effectively infinite wall planes 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; const nicheD = depthM + gapDepthM * 2; return { outerWallW: OUTER_WALL_EXTENT_M, outerWallH: OUTER_WALL_EXTENT_M, outerWallD: OUTER_WALL_EXTENT_M, gapSideM, gapTopM, gapBottomM, gapDepthM, widthM, heightM, depthM, nicheW, nicheH, nicheD, // wardrobe spans y=[0..heightM], z=[-depthM/2..+depthM/2] floorY: -gapBottomM, ceilY: heightM + gapTopM, backZ: -depthM / 2 - gapDepthM, frontZ: depthM / 2 + gapDepthM, leftX: -widthM / 2 - gapSideM, rightX: widthM / 2 + gapSideM, }; }, [ roomConfig, config?.installationType, config?.widthMm, config?.heightMm, config?.depthMm, config?.sanitaryGapMm, config?.nicheMeasurements, modules ]); const freeBackdrop = useMemo(() => { if (roomConfig) return null; if (config?.installationType !== 'free') return null; const depthM = (config.depthMm || 600) * SCALE; const heightM = (config.heightMm || 2400) * SCALE; // Place the wall right behind the wardrobe back face. const wallZ = -depthM / 2 - 0.01; const wallY = heightM * 0.6; return { wallZ, wallY, }; }, [roomConfig, config?.installationType, config?.depthMm, config?.heightMm]); return ( <> {/* 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 && ( {/* floor */} null}> {/* back wall (niche sized) */} null}> {/* left wall (niche sized) */} null}> {/* right wall (niche sized) */} null}> {/* ceiling (niche sized) */} null}> {/* front wall: everywhere except the niche opening */} )} {/* Backdrop wall/floor for "Libre" (free-standing) view */} {freeBackdrop && !nicheBackdrop && ( {/* floor */} null}> {/* wall behind wardrobe (Z-), flush to back */} null}> )} {roomConfig && ( )} ); } function FrontWallWithOpening({ niche }) { const shape = useMemo(() => { // Outer wall (slightly larger than niche box so it reads as the room wall) const outerW = Math.max(6, niche.outerWallW || niche.nicheW * 3); const outerH = Math.max(3, niche.outerWallH || niche.nicheH * 2.2); // Hole = niche opening (exact nicheW/nicheH), positioned around the wardrobe volume const holeW = niche.nicheW; const holeH = niche.nicheH; const s = new THREE.Shape(); s.moveTo(-outerW / 2, -outerH / 2); s.lineTo(outerW / 2, -outerH / 2); s.lineTo(outerW / 2, outerH / 2); s.lineTo(-outerW / 2, outerH / 2); s.closePath(); const hole = new THREE.Path(); // Hole Y range: from floorY to floorY + holeH const holeBottom = niche.floorY; const holeTop = niche.floorY + holeH; hole.moveTo(-holeW / 2, holeBottom); hole.lineTo(holeW / 2, holeBottom); hole.lineTo(holeW / 2, holeTop); hole.lineTo(-holeW / 2, holeTop); hole.closePath(); s.holes.push(hole); return s; }, [niche]); return ( // raycast disabled: purely decorative wall must not intercept clicks on // dimension grips that sit just outside the niche opening (e.g. width // handle above the wardrobe top is behind the solid wall in 3D space). null}> ); } /* =================================================================== WardrobePreview - main exported component =================================================================== */ export default function WardrobePreview({ config, activeStep = 1, roomConfig, colors = {}, modules = [], onElementClick, onModuleClick, onResizeModules, onUpdateModule, onSetDimension, onBeginUndoableGesture, canUndo3d = false, canRedo3d = false, 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(activeStep !== 2); const isDimensionStep = activeStep === 1; const perspectiveCameraPosition = useMemo(() => { 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(() => { 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); }, []); const handleElementClick = useCallback((elementType, label) => { setSelectedElement({ type: elementType, label }); if (onElementClick) { onElementClick(elementType, label); } }, [onElementClick]); // 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)' }, ...(hasBackOption ? [{ type: 'dos', label: 'Dos (arrière)' }] : []), { type: 'plintheBas', label: 'Plinthe' }, { type: 'bandeauHaut', label: 'Bandeau haut' }, { type: 'panneauxInterieurs', label: 'Panneaux int.' }, { type: 'separateurs', label: 'Séparateurs' }, { type: 'etageres', label: 'Étagères' }, { type: 'rayonFixe', label: 'Rayon fixe' }, { type: 'renfortsTiroir', label: 'Renforts tiroir' }, { type: 'tiroirs', label: 'Tiroirs' }, { type: 'penderie', label: 'Tringle' }, ], [hasBackOption]); const handleElementSelect = useCallback((e) => { const val = e.target.value; if (!val) return; const item = PICKABLE_ELEMENTS.find(p => p.type === val); if (item) { handleElementClick(item.type, item.label); } // Reset select to placeholder e.target.value = ''; }, [PICKABLE_ELEMENTS, handleElementClick]); 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'; const effectiveShowDoors = isDimensionStep ? true : showDoors; return (
{selectedElement && !isDimensionStep && (
Élément sélectionné: {selectedElement.label}
)} {/* Zoom Controls */}
{showReset && ( )} {show3dUndo && ( <>
)} {showDoorToggle && ( <>
)} {showColorSelector && ( <>
)}
Chargement 3D…
}> { gl.setClearColor(0x000000, 0); }} >
); }