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 * as THREE from 'three'; import Room3D, { calculateWardrobeTransform } from './Room3D'; import WardrobeElement from './WardrobeElement'; import styles from './WardrobePreview.module.css'; import { GROOVE, PANEL_M, getSeparatorDepthMm, getDoorHingeSide } from '@shared/manufacturing.js'; import { shelfCenterYsMeters, minShelvesForStructuralHeight, resolveShelfPositionsMm, system32ShelfPositionsMm } from '../constants/wardrobe'; const SCALE = 1 / 1000; 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) const DIMENSION_RULES = { widthMm: { min: 400, max: 5000, step: 50 }, heightMm: { min: 1800, max: 2800, step: 50 }, depthMm: { min: 400, max: 800, step: 10 }, }; /** 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). * 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); } /* =================================================================== ResizableSeparator - a draggable handle between two modules =================================================================== */ function ResizableSeparator({ position, height, depth, zCenter, 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 ; repère Z aligné arrière avec dessus/fond */} {/* 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 }) { return (
{Math.round(widthMm)}mm
); } /* =================================================================== 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, 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; /** 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 centerZ = frontFaceZ - drawerBodyDepthM / 2; return ( <> {Array.from({ length: drawersCount }).map((_, di) => { const y = startY + actualDrawerH / 2 + di * (actualDrawerH + gap); return ( 0 ? centerZ : 0]} colors={colors} onElementClick={onElementClick} materialProps={{ roughness: 0.5, metalness: 0.05 }} /> ); })} ); } /* =================================================================== WardrobeMesh - Full wardrobe model with drag/resize/drawers =================================================================== */ function WardrobeMesh({ config, colors = {}, onElementClick, onModuleClick, selectedModuleIndex, modules = [], showDoors = true, onResizeModules, onUpdateModule, onSetDimension, onBeginUndoableGesture, activeStep = 1, suppressHtmlOverlays = false, }) { const { gl, camera, raycaster } = 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 bodyH = height - plinthH; 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'; /** 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 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; 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]); // --- 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 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]); // --- 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 --- const handleResizePointerDown = useCallback((separatorIndex, e) => { e.stopPropagation(); e.target?.setPointerCapture?.(e.pointerId); const worldX = getWorldX(e); if (worldX === null) return; onBeginUndoableGesture?.(); setResizeState({ separatorIndex, startX: worldX }); gl.domElement.style.cursor = 'col-resize'; }, [getWorldX, onBeginUndoableGesture]); const handleDimensionPointerDown = useCallback((type, e) => { if (!dimensionEditMode) return; 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]); 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); if (!slotsMm.length) return; const shelfPositionsMm = resolveShelfPositionsMm({ shelfCount, heightMm: config.heightMm, plinthHeightMm: config.plinthHeightMm ?? 100, shelfPositionsMm: mod?.shelfPositionsMm }); const startPosMm = shelfPositionsMm[shelfIndex]; if (!Number.isFinite(startPosMm)) return; e.stopPropagation(); e.target?.setPointerCapture?.(e.pointerId); onBeginUndoableGesture?.(); setShelfDragState({ moduleIndex, shelfIndex, startClientY: e.clientY, startPosMm, shelfPositionsMm, slotsMm }); gl.domElement.style.cursor = 'ns-resize'; }, [activeStep, onUpdateModule, modules, config.heightMm, config.plinthHeightMm, onBeginUndoableGesture, gl]); // Attach window-level pointer events for resize const onPointerMoveRef = useRef(null); const onPointerUpRef = useRef(null); useEffect(() => { 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); } } } 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 (shelfDragState) { const mmPerPx = e.shiftKey ? 0.6 : 1.5; const rawMm = shelfDragState.startPosMm + (shelfDragState.startClientY - e.clientY) * mmPerPx; const slots = shelfDragState.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 = shelfDragState.shelfPositionsMm.length; const minIdx = shelfDragState.shelfIndex; const maxIdx = slots.length - (n - shelfDragState.shelfIndex); nearestIdx = Math.max(minIdx, Math.min(maxIdx, nearestIdx)); const nextShelfPositions = [...shelfDragState.shelfPositionsMm]; nextShelfPositions[shelfDragState.shelfIndex] = slots[nearestIdx]; onUpdateModule?.(shelfDragState.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'; } }; onPointerMoveRef.current = handleMove; onPointerUpRef.current = handleUp; }, [resizeState, dimensionResizeState, shelfDragState, getWorldX, 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. window.addEventListener('pointermove', move); window.addEventListener('pointerup', up); return () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); }; }, []); // Disable OrbitControls during resize const interactionActive = resizeState !== null || dimensionResizeState !== null || shelfDragState !== null; const elementClickHandler = dimensionEditMode ? undefined : onElementClick; const moduleClickHandler = dimensionEditMode ? null : onModuleClick; return ( {/* Plinthe bas */} {/* 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)); return ( ); }) ) : ( )} {/* Fond (bottom panel) */} {/* Côté gauche */} {/* Côté droit */} {/* Habillages de finition (Libre + Premium) */} {!isNiche && exteriorFinishesEnabled && ( <> {/* Habillage gauche */} {sideFinishLeftEnabled && ( )} {/* Habillage droite */} {sideFinishRightEnabled && ( )} {/* Habillage dessus — 1 bandeau ou 1 par caisson (block) */} {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; 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; return ( ); })() ) )} )} {/* Dos (back panel) — 8 mm per APEX 4.0 spec */} {/* Outline to keep wardrobe edges readable through niche wall */} {config?.installationType === 'niche' && ( )} {/* Fileurs (si encastré) */} {isNiche && ( <> )} {/* Separators between modules — en block: double montant + hauteur habillage */} {!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]; return xs.map((xSep, j) => ( )); }) : !shellOnlyMode && separatorPositions.flatMap((pos, i) => { 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) => ( )); }) } {/* 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] && ( )} )} {/* 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 shelfCount = module.shelves || 0; const drawersCount = module.drawers || 0; const isSelected = selectedModuleIndex === moduleIndex; const needsMiddleFix = 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 }); 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 = Math.max(0.02, moduleWidth - PANEL_THICKNESS * 2); const depthSpanForZone = Math.max(0.02, depth * 0.9); const shelfCenterYs = shelfCenterYsMeters({ shelfCount, heightM: height, plinthHM: plinthH, shelfGapMm: module.shelfGapMm, shelfPositionsMm: module.shelfPositionsMm, }); return ( {/* Invisible click/hover area for module selection */} {activeStep !== 2 && ( setHoveredModule(moduleIndex)} onPointerOut={() => setHoveredModule(prev => prev === moduleIndex ? null : prev)} onClick={(e) => { e.stopPropagation(); if (moduleClickHandler) moduleClickHandler(moduleIndex); }} > )} {/* Selection highlight */} {isSelected && ( )} {activeStep === 2 && needsMiddleFix && !hasShelfInStructuralWindow && ( )} {/* Portes battantes per module */} {showDoors && !isSliding && ( (() => { const doorW = moduleWidth * 0.96; 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 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 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 ? 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; return ( ); })() )} {/* 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]; 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)} > )} ); })} ); })()} {/* Fixed structural shelf above drawers */} {drawersCount > 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 drawerStackM = Math.min(bodyH * 0.78, Math.max(0, drawerStackMmRaw) * SCALE); const yFixed = (-height / 2 + plinthH + PANEL_THICKNESS) + drawerStackM + PANEL_THICKNESS / 2; return ( ); })() )} {/* Drawers */} {drawersCount > 0 && ( )} {/* Rod (penderie) */} {module.hasRod && ( )} ); })} {/* Portes coulissantes (sliding) */} {showDoors && isSliding && ( )} {/* 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 }, 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 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]); const getDefaultDistance = useCallback( () => fitDistance, [fitDistance] ); 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 cancelSnap = useCallback(() => { if (snapAnimRef.current) { cancelAnimationFrame(snapAnimRef.current.raf); snapAnimRef.current = null; } }, []); // Intentionally no snapping rules: keep mouse movement direct. useImperativeHandle(ref, () => ({ 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(); } }, }), [camera, cancelSnap, getDefaultDistance, isDimensionStep]); 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, showDoors, onResizeModules, onUpdateModule, onSetDimension, onBeginUndoableGesture, activeStep, suppressHtmlOverlays, }) { 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 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 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, 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 with 5cm clearance */} {/* 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, suppressHtmlOverlays = false, }) { const containerRef = useRef(null); const controllerRef = useRef(null); const [selectedElement, setSelectedElement] = useState(null); const [showDoors, setShowDoors] = useState(true); 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 handleResetView = useCallback(() => { controllerRef.current?.resetView(); }, []); useEffect(() => { // Re-anchor camera when entering/leaving the dedicated dimension mode. controllerRef.current?.resetView(); }, [isDimensionStep]); 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 (including back panel) 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)' }, { 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' }, ], []); 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 === 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); }} >
); }