This commit is contained in:
lejay
2026-02-13 00:21:53 +01:00
parent 81d2412835
commit 6923c319a1
85 changed files with 17836 additions and 0 deletions
+801
View File
@@ -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">&minus;</button>
<button type="button" className={styles.controlBtn} onClick={handleResetView} title="Réinitialiser la vue">&loz;</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>
);
}