v1.0
This commit is contained in:
@@ -0,0 +1,801 @@
|
||||
import { useRef, Suspense, useMemo, useState, useCallback, 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';
|
||||
|
||||
const SCALE = 1 / 1000;
|
||||
const PANEL_THICKNESS = 0.019; // 19mm in meters
|
||||
|
||||
/* ===================================================================
|
||||
ResizableSeparator - a draggable handle between two modules
|
||||
=================================================================== */
|
||||
function ResizableSeparator({
|
||||
position,
|
||||
height,
|
||||
depth,
|
||||
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';
|
||||
|
||||
return (
|
||||
<group position={position}>
|
||||
{/* Visual panel (thin) */}
|
||||
<WardrobeElement
|
||||
elementType="panneauxInterieurs"
|
||||
geometryType="box"
|
||||
geometryArgs={[PANEL_THICKNESS, height, depth]}
|
||||
position={[0, 0, PANEL_THICKNESS / 2]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
/>
|
||||
{/* Invisible wider hit area for grabbing */}
|
||||
<mesh
|
||||
ref={meshRef}
|
||||
onPointerOver={handlePointerOver}
|
||||
onPointerOut={handlePointerOut}
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
<boxGeometry args={[PANEL_THICKNESS * 6, height, depth]} />
|
||||
<meshBasicMaterial transparent opacity={0} />
|
||||
</mesh>
|
||||
{/* Highlight overlay when hovered/resizing */}
|
||||
{(hovered || isResizing) && (
|
||||
<mesh>
|
||||
<boxGeometry args={[PANEL_THICKNESS * 1.5, height * 0.95, depth * 0.95]} />
|
||||
<meshBasicMaterial color={baseColor} transparent opacity={0.35} />
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
WidthLabel - floating HTML label showing module width in mm
|
||||
=================================================================== */
|
||||
function WidthLabel({ position, widthMm }) {
|
||||
return (
|
||||
<Html position={position} center style={{ pointerEvents: 'none' }}>
|
||||
<div style={{
|
||||
background: 'rgba(79, 70, 229, 0.9)',
|
||||
color: '#fff',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
userSelect: 'none',
|
||||
}}>
|
||||
{Math.round(widthMm)}mm
|
||||
</div>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
DrawerBox - renders drawer front faces stacked vertically
|
||||
=================================================================== */
|
||||
function DrawerBox({ moduleCenter, moduleWidth, bodyH, plinthH, drawersCount, 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;
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: drawersCount }).map((_, di) => {
|
||||
const y = startY + actualDrawerH / 2 + di * (actualDrawerH + gap);
|
||||
return (
|
||||
<WardrobeElement
|
||||
key={`drawer-${di}`}
|
||||
elementType="tiroirs"
|
||||
geometryType="box"
|
||||
geometryArgs={[moduleWidth * 0.92, actualDrawerH, PANEL_THICKNESS]}
|
||||
position={[moduleCenter, plinthH / 2 + y, moduleWidth > 0 ? 0.28 : 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,
|
||||
}) {
|
||||
const { gl, camera, raycaster } = useThree();
|
||||
const useModules = modules && modules.length > 0;
|
||||
|
||||
// --- 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 isNiche = config.installationType === 'niche';
|
||||
const isSliding = config.doorType === 'sliding';
|
||||
|
||||
// --- 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 [resizeState, setResizeState] = useState(null); // { separatorIndex, startX }
|
||||
const planeRef = useRef(new THREE.Plane(new THREE.Vector3(0, 1, 0), 0));
|
||||
const intersectPoint = useRef(new THREE.Vector3());
|
||||
|
||||
// --- 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();
|
||||
const worldX = getWorldX(e);
|
||||
if (worldX === null) return;
|
||||
setResizeState({ separatorIndex, startX: worldX });
|
||||
}, [getWorldX]);
|
||||
|
||||
// Attach window-level pointer events for resize
|
||||
const onPointerMoveRef = useRef(null);
|
||||
const onPointerUpRef = useRef(null);
|
||||
|
||||
useMemo(() => {
|
||||
const handleMove = (e) => {
|
||||
if (resizeState) {
|
||||
const worldX = getWorldX(e);
|
||||
if (worldX !== null) {
|
||||
const deltaX = worldX - resizeState.startX;
|
||||
const deltaXmm = deltaX / SCALE;
|
||||
if (onResizeModules && Math.abs(deltaXmm) > 1) {
|
||||
onResizeModules(resizeState.separatorIndex, deltaXmm);
|
||||
setResizeState(prev => prev ? { ...prev, startX: worldX } : null);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
if (resizeState) {
|
||||
setResizeState(null);
|
||||
gl.domElement.style.cursor = 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
onPointerMoveRef.current = handleMove;
|
||||
onPointerUpRef.current = handleUp;
|
||||
}, [resizeState, getWorldX, onResizeModules, gl]);
|
||||
|
||||
// Attach/detach event listeners
|
||||
useMemo(() => {
|
||||
const el = gl.domElement;
|
||||
const move = (e) => onPointerMoveRef.current?.(e);
|
||||
const up = (e) => onPointerUpRef.current?.(e);
|
||||
|
||||
el.addEventListener('pointermove', move);
|
||||
el.addEventListener('pointerup', up);
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('pointermove', move);
|
||||
el.removeEventListener('pointerup', up);
|
||||
};
|
||||
}, [gl]);
|
||||
|
||||
// Disable OrbitControls during resize
|
||||
const interactionActive = resizeState !== null;
|
||||
|
||||
return (
|
||||
<group position={[0, height / 2, 0]}>
|
||||
{/* Plinthe bas */}
|
||||
<WardrobeElement
|
||||
elementType="plintheBas"
|
||||
geometryType="box"
|
||||
geometryArgs={[width, plinthH, depth]}
|
||||
position={[0, -height / 2 + plinthH / 2, 0]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
materialProps={{ roughness: 0.7, metalness: 0.1 }}
|
||||
/>
|
||||
|
||||
{/* Bandeau haut */}
|
||||
<WardrobeElement
|
||||
elementType="bandeauHaut"
|
||||
geometryType="box"
|
||||
geometryArgs={[width, PANEL_THICKNESS, depth]}
|
||||
position={[0, bodyH / 2 + plinthH / 2 - PANEL_THICKNESS / 2, 0]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
/>
|
||||
|
||||
{/* Dessus (top panel) */}
|
||||
<WardrobeElement
|
||||
elementType="dessus"
|
||||
geometryType="box"
|
||||
geometryArgs={[width - PANEL_THICKNESS * 2, PANEL_THICKNESS, depth - PANEL_THICKNESS]}
|
||||
position={[0, bodyH / 2 + plinthH / 2 - PANEL_THICKNESS * 1.5, PANEL_THICKNESS / 2]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
/>
|
||||
|
||||
{/* Fond (bottom panel) */}
|
||||
<WardrobeElement
|
||||
elementType="fond"
|
||||
geometryType="box"
|
||||
geometryArgs={[width - PANEL_THICKNESS * 2, PANEL_THICKNESS, depth - PANEL_THICKNESS]}
|
||||
position={[0, -height / 2 + plinthH + PANEL_THICKNESS / 2, PANEL_THICKNESS / 2]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
/>
|
||||
|
||||
{/* Côté gauche */}
|
||||
<WardrobeElement
|
||||
elementType="cotes"
|
||||
geometryType="box"
|
||||
geometryArgs={[PANEL_THICKNESS, bodyH, depth]}
|
||||
position={[-width / 2 + PANEL_THICKNESS / 2, plinthH / 2, 0]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
/>
|
||||
|
||||
{/* Côté droit */}
|
||||
<WardrobeElement
|
||||
elementType="cotes"
|
||||
geometryType="box"
|
||||
geometryArgs={[PANEL_THICKNESS, bodyH, depth]}
|
||||
position={[width / 2 - PANEL_THICKNESS / 2, plinthH / 2, 0]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
/>
|
||||
|
||||
{/* Dos (back panel) */}
|
||||
<WardrobeElement
|
||||
elementType="dos"
|
||||
geometryType="box"
|
||||
geometryArgs={[width - PANEL_THICKNESS * 2, bodyH - PANEL_THICKNESS * 2, PANEL_THICKNESS / 2]}
|
||||
position={[0, plinthH / 2, -depth / 2 + PANEL_THICKNESS / 4]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
materialProps={{ roughness: 0.8 }}
|
||||
/>
|
||||
|
||||
{/* Fileurs (si encastré) */}
|
||||
{isNiche && (
|
||||
<>
|
||||
<WardrobeElement
|
||||
elementType="fileur"
|
||||
geometryType="box"
|
||||
geometryArgs={[PANEL_THICKNESS / 2, bodyH, depth]}
|
||||
position={[-width / 2 - PANEL_THICKNESS / 4, plinthH / 2, 0]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
/>
|
||||
<WardrobeElement
|
||||
elementType="fileur"
|
||||
geometryType="box"
|
||||
geometryArgs={[PANEL_THICKNESS / 2, bodyH, depth]}
|
||||
position={[width / 2 + PANEL_THICKNESS / 4, plinthH / 2, 0]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Separators between modules (resizable) */}
|
||||
{useModules && onResizeModules
|
||||
? separatorPositions.map((pos, i) => (
|
||||
<ResizableSeparator
|
||||
key={`sep-resize-${i}`}
|
||||
position={[pos.x, plinthH / 2, 0]}
|
||||
height={bodyH - PANEL_THICKNESS * 2}
|
||||
depth={depth - PANEL_THICKNESS}
|
||||
separatorIndex={pos.index}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
isResizing={resizeState?.separatorIndex === pos.index}
|
||||
onResizePointerDown={handleResizePointerDown}
|
||||
/>
|
||||
))
|
||||
: separatorPositions.map((pos, i) => (
|
||||
<WardrobeElement
|
||||
key={`sep-${i}`}
|
||||
elementType="panneauxInterieurs"
|
||||
geometryType="box"
|
||||
geometryArgs={[PANEL_THICKNESS, bodyH - PANEL_THICKNESS * 2, depth - PANEL_THICKNESS]}
|
||||
position={[pos.x, plinthH / 2, PANEL_THICKNESS / 2]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
{/* Width labels -- show during resize or hover */}
|
||||
{modules.length > 0 && (resizeState !== null || hoveredModule !== null) && (
|
||||
<>
|
||||
{resizeState !== null && (
|
||||
<>
|
||||
{modules[resizeState.separatorIndex] && (
|
||||
<WidthLabel
|
||||
position={[modulePositions[resizeState.separatorIndex]?.center || 0, bodyH / 2 + plinthH / 2 + 0.06, depth / 2]}
|
||||
widthMm={modules[resizeState.separatorIndex].widthMm}
|
||||
/>
|
||||
)}
|
||||
{modules[resizeState.separatorIndex + 1] && (
|
||||
<WidthLabel
|
||||
position={[modulePositions[resizeState.separatorIndex + 1]?.center || 0, bodyH / 2 + plinthH / 2 + 0.06, depth / 2]}
|
||||
widthMm={modules[resizeState.separatorIndex + 1].widthMm}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{resizeState === null && hoveredModule !== null && modules[hoveredModule] && (
|
||||
<WidthLabel
|
||||
position={[modulePositions[hoveredModule]?.center || 0, bodyH / 2 + plinthH / 2 + 0.06, depth / 2]}
|
||||
widthMm={modules[hoveredModule].widthMm}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Module contents: doors, shelves, rods, drawers + click-to-select */}
|
||||
{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;
|
||||
|
||||
return (
|
||||
<group key={`mod-${module.id}`}>
|
||||
{/* Invisible click/hover area for module selection */}
|
||||
<mesh
|
||||
position={[moduleCenter, plinthH / 2, 0]}
|
||||
onPointerOver={() => setHoveredModule(moduleIndex)}
|
||||
onPointerOut={() => setHoveredModule(prev => prev === moduleIndex ? null : prev)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onModuleClick) onModuleClick(moduleIndex);
|
||||
}}
|
||||
>
|
||||
<boxGeometry args={[moduleWidth - PANEL_THICKNESS * 2, bodyH - PANEL_THICKNESS * 2, depth * 0.5]} />
|
||||
<meshBasicMaterial transparent opacity={0} />
|
||||
</mesh>
|
||||
|
||||
{/* Selection highlight */}
|
||||
{isSelected && (
|
||||
<mesh position={[moduleCenter, plinthH / 2, depth / 2 + 0.005]}>
|
||||
<planeGeometry args={[moduleWidth * 0.98, bodyH * 0.98]} />
|
||||
<meshBasicMaterial color="#4f46e5" transparent opacity={0.08} />
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{/* Portes battantes per module */}
|
||||
{showDoors && !isSliding && (
|
||||
<WardrobeElement
|
||||
elementType="portes"
|
||||
geometryType="plane"
|
||||
geometryArgs={[moduleWidth * 0.96, bodyH * 0.96]}
|
||||
position={[moduleCenter, plinthH / 2, depth / 2 + 0.002]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
transparent={true}
|
||||
opacity={0.9}
|
||||
materialProps={{ roughness: 0.5 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Shelves */}
|
||||
{Array.from({ length: shelfCount }).map((_, shelfIndex) => {
|
||||
const shelfY = -height / 2 + plinthH + PANEL_THICKNESS +
|
||||
((bodyH - PANEL_THICKNESS * 2) / (shelfCount + 1)) * (shelfIndex + 1);
|
||||
return (
|
||||
<WardrobeElement
|
||||
key={`shelf-${moduleIndex}-${shelfIndex}`}
|
||||
elementType="panneauxInterieurs"
|
||||
geometryType="box"
|
||||
geometryArgs={[moduleWidth - PANEL_THICKNESS * 2, PANEL_THICKNESS, depth - PANEL_THICKNESS * 2]}
|
||||
position={[moduleCenter, shelfY, 0]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Drawers */}
|
||||
{drawersCount > 0 && (
|
||||
<DrawerBox
|
||||
moduleCenter={moduleCenter}
|
||||
moduleWidth={moduleWidth}
|
||||
bodyH={bodyH}
|
||||
plinthH={plinthH}
|
||||
drawersCount={drawersCount}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Rod (penderie) */}
|
||||
{module.hasRod && (
|
||||
<WardrobeElement
|
||||
elementType="penderie"
|
||||
geometryType="cylinder"
|
||||
geometryArgs={[0.012, 0.012, moduleWidth - 0.06, 16]}
|
||||
position={[moduleCenter, height / 2 - plinthH / 2 - 0.15, 0]}
|
||||
rotation={[0, 0, Math.PI / 2]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
materialProps={{ roughness: 0.3, metalness: 0.8 }}
|
||||
/>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Portes coulissantes (sliding) */}
|
||||
{showDoors && isSliding && (
|
||||
<WardrobeElement
|
||||
elementType="portes"
|
||||
geometryType="plane"
|
||||
geometryArgs={[width * 0.98, bodyH * 0.98]}
|
||||
position={[0, plinthH / 2, depth / 2 + 0.002]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
transparent={true}
|
||||
opacity={0.85}
|
||||
materialProps={{ roughness: 0.5 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Module content is rendered above in the module loop */}
|
||||
{/* Non-module door rendering is also above */}
|
||||
|
||||
{/* Pass interaction state to parent for OrbitControls toggling */}
|
||||
<InteractionBlocker active={interactionActive} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
InteractionBlocker - disables OrbitControls during drag/resize
|
||||
Uses useThree to access the controls
|
||||
=================================================================== */
|
||||
function InteractionBlocker({ active }) {
|
||||
const { controls } = useThree();
|
||||
useMemo(() => {
|
||||
if (controls) {
|
||||
controls.enabled = !active;
|
||||
}
|
||||
}, [active, controls]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
CameraController - exposes zoom/reset via ref
|
||||
=================================================================== */
|
||||
const CameraController = forwardRef(({ roomConfig, config }, ref) => {
|
||||
const { camera, gl } = useThree();
|
||||
const controlsRef = useRef();
|
||||
|
||||
const minDistance = roomConfig ? 1 : 0.5;
|
||||
const maxDistance = roomConfig ? 15 : 8;
|
||||
|
||||
const cameraTarget = useMemo(() => {
|
||||
if (roomConfig) {
|
||||
return [0, roomConfig.height / 2, 0];
|
||||
}
|
||||
return [0, (config.heightMm || 2400) * SCALE * 0.5, 0];
|
||||
}, [roomConfig, config]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
zoomIn: () => {
|
||||
if (controlsRef.current) {
|
||||
const currentDistance = camera.position.distanceTo(controlsRef.current.target);
|
||||
const newDistance = Math.max(minDistance, currentDistance * 0.8);
|
||||
const direction = camera.position.clone().sub(controlsRef.current.target).normalize();
|
||||
camera.position.copy(controlsRef.current.target).add(direction.multiplyScalar(newDistance));
|
||||
controlsRef.current.update();
|
||||
}
|
||||
},
|
||||
zoomOut: () => {
|
||||
if (controlsRef.current) {
|
||||
const currentDistance = camera.position.distanceTo(controlsRef.current.target);
|
||||
const newDistance = Math.min(maxDistance, currentDistance * 1.25);
|
||||
const direction = camera.position.clone().sub(controlsRef.current.target).normalize();
|
||||
camera.position.copy(controlsRef.current.target).add(direction.multiplyScalar(newDistance));
|
||||
controlsRef.current.update();
|
||||
}
|
||||
},
|
||||
resetView: () => {
|
||||
if (controlsRef.current) {
|
||||
const defaultDist = roomConfig ? Math.max(roomConfig.width, roomConfig.length) * 1.2 : 3;
|
||||
camera.position.set(defaultDist * 0.7, defaultDist * 0.5, defaultDist * 0.7);
|
||||
controlsRef.current.update();
|
||||
}
|
||||
}
|
||||
}), [camera, minDistance, maxDistance, roomConfig]);
|
||||
|
||||
return (
|
||||
<OrbitControls
|
||||
ref={controlsRef}
|
||||
enablePan={true}
|
||||
enableZoom={true}
|
||||
minDistance={minDistance}
|
||||
maxDistance={maxDistance}
|
||||
target={cameraTarget}
|
||||
makeDefault
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
/* ===================================================================
|
||||
Scene - assembles everything
|
||||
=================================================================== */
|
||||
function Scene({ config, roomConfig, controllerRef, colors, modules, onElementClick, onModuleClick, selectedModuleIndex, showDoors, onResizeModules }) {
|
||||
const wardrobeTransform = useMemo(() => {
|
||||
if (roomConfig) {
|
||||
return calculateWardrobeTransform(roomConfig, config);
|
||||
}
|
||||
return { position: [0, 0, 0], rotation: [0, 0, 0] };
|
||||
}, [roomConfig, config]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ambientLight intensity={roomConfig ? 0.5 : 0.6} />
|
||||
<directionalLight
|
||||
position={[4, 6, 5]}
|
||||
intensity={roomConfig ? 1.0 : 1.2}
|
||||
castShadow
|
||||
shadow-mapSize={[1024, 1024]}
|
||||
shadow-camera-far={20}
|
||||
shadow-camera-left={-5}
|
||||
shadow-camera-right={5}
|
||||
shadow-camera-top={5}
|
||||
shadow-camera-bottom={-5}
|
||||
/>
|
||||
<directionalLight position={[-2, 4, 3]} intensity={0.4} />
|
||||
|
||||
{roomConfig && (
|
||||
<Room3D roomConfig={roomConfig} wardrobeConfig={config} />
|
||||
)}
|
||||
|
||||
<group
|
||||
position={wardrobeTransform.position}
|
||||
rotation={wardrobeTransform.rotation}
|
||||
>
|
||||
<WardrobeMesh
|
||||
config={config}
|
||||
colors={colors}
|
||||
modules={modules}
|
||||
onElementClick={onElementClick}
|
||||
onModuleClick={onModuleClick}
|
||||
selectedModuleIndex={selectedModuleIndex}
|
||||
showDoors={showDoors}
|
||||
onResizeModules={onResizeModules}
|
||||
/>
|
||||
</group>
|
||||
|
||||
<CameraController ref={controllerRef} roomConfig={roomConfig} config={config} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
WardrobePreview - main exported component
|
||||
=================================================================== */
|
||||
export default function WardrobePreview({
|
||||
config,
|
||||
roomConfig,
|
||||
colors = {},
|
||||
modules = [],
|
||||
onElementClick,
|
||||
onModuleClick,
|
||||
onResizeModules,
|
||||
selectedModuleIndex,
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const controllerRef = useRef(null);
|
||||
const [selectedElement, setSelectedElement] = useState(null);
|
||||
const [showDoors, setShowDoors] = useState(true);
|
||||
|
||||
const cameraPosition = 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 handleZoomIn = useCallback(() => {
|
||||
controllerRef.current?.zoomIn();
|
||||
}, []);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
controllerRef.current?.zoomOut();
|
||||
}, []);
|
||||
|
||||
const handleResetView = useCallback(() => {
|
||||
controllerRef.current?.resetView();
|
||||
}, []);
|
||||
|
||||
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: '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]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={styles.container} style={{ background: roomConfig ? '#87CEEB' : '#e8e4df' }}>
|
||||
{selectedElement && (
|
||||
<div className={styles.selectedElement}>
|
||||
Élément sélectionné: <strong>{selectedElement.label}</strong>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<div className={styles.controls}>
|
||||
<button type="button" className={styles.controlBtn} onClick={handleZoomIn} title="Zoom avant">+</button>
|
||||
<button type="button" className={styles.controlBtn} onClick={handleZoomOut} title="Zoom arrière">−</button>
|
||||
<button type="button" className={styles.controlBtn} onClick={handleResetView} title="Réinitialiser la vue">◊</button>
|
||||
<div className={styles.controlDivider} />
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.controlBtn} ${!showDoors ? styles.controlBtnActive : ''}`}
|
||||
onClick={handleToggleDoors}
|
||||
title={showDoors ? 'Masquer les portes' : 'Afficher les portes'}
|
||||
>
|
||||
{showDoors ? '\u{1F6AA}' : '\u{1F441}\uFE0F'}
|
||||
</button>
|
||||
<div className={styles.controlDivider} />
|
||||
<select
|
||||
className={styles.elementSelect}
|
||||
onChange={handleElementSelect}
|
||||
defaultValue=""
|
||||
title="Choisir un élément pour changer sa couleur"
|
||||
>
|
||||
<option value="" disabled>🎨 Couleur…</option>
|
||||
{PICKABLE_ELEMENTS.map(el => (
|
||||
<option key={el.type} value={el.type}>{el.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={<div className={styles.loading}>Chargement 3D…</div>}>
|
||||
<Canvas
|
||||
shadows
|
||||
camera={{ position: cameraPosition, fov: roomConfig ? 60 : 45 }}
|
||||
gl={{ antialias: true, alpha: false }}
|
||||
style={{ width: '100%', height: '100%', display: 'block' }}
|
||||
>
|
||||
<Scene
|
||||
config={config}
|
||||
roomConfig={roomConfig}
|
||||
controllerRef={controllerRef}
|
||||
colors={colors}
|
||||
modules={modules}
|
||||
onElementClick={handleElementClick}
|
||||
onModuleClick={onModuleClick}
|
||||
selectedModuleIndex={selectedModuleIndex}
|
||||
showDoors={showDoors}
|
||||
onResizeModules={onResizeModules}
|
||||
/>
|
||||
</Canvas>
|
||||
</Suspense>
|
||||
|
||||
{/* Hint */}
|
||||
<div className={styles.hint}>
|
||||
Cliquer un module = configurer {'\u2022'} Cliquer un {'\u00e9'}l{'\u00e9'}ment = couleur {'\u2022'} Tirer s{'\u00e9'}parateurs = redimensionner
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user