Files
apex/client/src/components/WardrobePreview.jsx
T
lejayandCursor 6c0d7e47b8 v2.0
Co-authored-by: Cursor <[email protected]>
2026-05-28 07:20:38 +02:00

1904 lines
70 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<group position={position}>
{/* Séparateur : plaqué au dos, sans rainure ; repère Z aligné arrière avec dessus/fond */}
<group position={[0, 0, zCenter]}>
<WardrobeElement
elementType="panneauxInterieurs"
geometryType="box"
geometryArgs={[PANEL_THICKNESS, height, depth]}
position={[0, 0, 0]}
colors={colors}
onElementClick={onElementClick}
/>
</group>
{/* Invisible hit area: wider/taller/deeper than the panel when the carcass is shallow or short */}
<mesh
ref={meshRef}
onPointerOver={handlePointerOver}
onPointerOut={handlePointerOut}
onPointerDown={handlePointerDown}
>
<boxGeometry args={[hitX, hitY, hitZ]} />
<meshBasicMaterial transparent opacity={0} />
</mesh>
{/* Highlight overlay when hovered/resizing */}
{(hovered || isResizing) && (
<mesh>
<boxGeometry args={[Math.max(PANEL_THICKNESS * 1.5, hitX * 0.85), height * 0.95, depth * 0.95]} />
<meshBasicMaterial color={baseColor} transparent opacity={0.35} />
</mesh>
)}
</group>
);
}
/**
* 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 = (
<meshStandardMaterial
color="#2a9d8f"
roughness={0.55}
metalness={0.05}
transparent
opacity={0.52}
polygonOffset
polygonOffsetFactor={-1}
polygonOffsetUnits={-1}
/>
);
if (variant === 'horizontal-top') {
const zHalf = innerD / 2;
const zc = -zHalf + ax;
return (
<mesh position={[0, -thickness / 2 + gd / 2 + eps, zc]}>
<boxGeometry args={[Math.max(0.001, innerW - eps * 4), gd, gw]} />
{mat}
</mesh>
);
}
if (variant === 'horizontal-bottom') {
const zHalf = innerD / 2;
const zc = -zHalf + ax;
return (
<mesh position={[0, thickness / 2 - gd / 2 - eps, zc]}>
<boxGeometry args={[Math.max(0.001, innerW - eps * 4), gd, gw]} />
{mat}
</mesh>
);
}
if (variant === 'vertical-left') {
const zHalf = panelD / 2;
const zc = -zHalf + ax;
return (
<mesh position={[thickness / 2 - gd / 2 - eps, 0, zc]}>
<boxGeometry args={[gd, Math.max(0.001, panelH - eps * 4), gw]} />
{mat}
</mesh>
);
}
if (variant === 'vertical-right') {
const zHalf = panelD / 2;
const zc = -zHalf + ax;
return (
<mesh position={[-thickness / 2 + gd / 2 + eps, 0, zc]}>
<boxGeometry args={[gd, Math.max(0.001, panelH - eps * 4), gw]} />
{mat}
</mesh>
);
}
return null;
}
/* ===================================================================
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>
);
}
/* ===================================================================
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 (
<group position={position}>
<mesh
rotation={rotation}
onPointerOver={handlePointerOver}
onPointerOut={handlePointerOut}
onPointerDown={handlePointerDown}
>
<boxGeometry args={size} />
<meshStandardMaterial color={active || hovered ? '#4f46e5' : '#f59e0b'} emissive={active || hovered ? '#3730a3' : '#000000'} />
</mesh>
<mesh
rotation={rotation}
onPointerOver={handlePointerOver}
onPointerOut={handlePointerOut}
onPointerDown={handlePointerDown}
>
<boxGeometry args={hitSize || [size[0] * 2.8, size[1] * 2.8, size[2] * 2.8]} />
<meshBasicMaterial
color={tintColor}
transparent
opacity={active ? 0.28 : hovered ? 0.2 : 0.1}
/>
</mesh>
<Html position={chipOffset} center style={{ pointerEvents: 'none' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 0,
background: active ? 'rgba(79,70,229,0.96)' : 'rgba(15,23,42,0.86)',
color: '#fff',
fontSize: 10,
fontWeight: 700,
padding: '4px 7px',
borderRadius: 6,
whiteSpace: 'nowrap',
userSelect: 'none',
border: active ? '1px solid rgba(255,255,255,0.65)' : '1px solid rgba(255,255,255,0.18)',
boxShadow: active ? '0 4px 16px rgba(0,0,0,0.32)' : '0 2px 8px rgba(0,0,0,0.22)',
}}
>
<span>{label}</span>
</div>
</Html>
</group>
);
}
/* ===================================================================
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 (
<WardrobeElement
key={`drawer-${di}`}
elementType="tiroirs"
geometryType="box"
geometryArgs={[moduleWidth * 0.92, actualDrawerH, drawerBodyDepthM]}
position={[moduleCenter, plinthH / 2 + y, moduleWidth > 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 (
<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={elementClickHandler}
materialProps={{ roughness: 0.7, metalness: 0.1 }}
/>
{/* 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 (
<group
key={`dessus-block-${modules[i]?.id ?? i}`}
position={[mp.center, bodyH / 2 + plinthH / 2 - PANEL_THICKNESS / 2, PANEL_THICKNESS / 2]}
>
<WardrobeElement
elementType="dessus"
geometryType="box"
geometryArgs={[segW, PANEL_THICKNESS, depth - PANEL_THICKNESS]}
position={[0, 0, 0]}
colors={colors}
onElementClick={elementClickHandler}
/>
<BackGrooveVisual3D
variant="horizontal-top"
innerW={segW}
innerD={depth - PANEL_THICKNESS}
thickness={PANEL_THICKNESS}
/>
</group>
);
})
) : (
<group position={[0, bodyH / 2 + plinthH / 2 - PANEL_THICKNESS / 2, PANEL_THICKNESS / 2]}>
<WardrobeElement
elementType="dessus"
geometryType="box"
geometryArgs={[width - PANEL_THICKNESS * 2, PANEL_THICKNESS, depth - PANEL_THICKNESS]}
position={[0, 0, 0]}
colors={colors}
onElementClick={elementClickHandler}
/>
<BackGrooveVisual3D
variant="horizontal-top"
innerW={width - PANEL_THICKNESS * 2}
innerD={depth - PANEL_THICKNESS}
thickness={PANEL_THICKNESS}
/>
</group>
)}
{/* Fond (bottom panel) */}
<group position={[0, -height / 2 + plinthH + PANEL_THICKNESS / 2, PANEL_THICKNESS / 2]}>
<WardrobeElement
elementType="fond"
geometryType="box"
geometryArgs={[width - PANEL_THICKNESS * 2, PANEL_THICKNESS, depth - PANEL_THICKNESS]}
position={[0, 0, 0]}
colors={colors}
onElementClick={elementClickHandler}
/>
<BackGrooveVisual3D
variant="horizontal-bottom"
innerW={width - PANEL_THICKNESS * 2}
innerD={depth - PANEL_THICKNESS}
thickness={PANEL_THICKNESS}
/>
</group>
{/* Côté gauche */}
<group position={[-width / 2 + PANEL_THICKNESS / 2, plinthH / 2, 0]}>
<WardrobeElement
elementType="cotes"
geometryType="box"
geometryArgs={[PANEL_THICKNESS, bodyH, depth]}
position={[0, 0, 0]}
colors={colors}
onElementClick={elementClickHandler}
/>
<BackGrooveVisual3D
variant="vertical-left"
thickness={PANEL_THICKNESS}
panelH={bodyH}
panelD={depth}
/>
</group>
{/* Côté droit */}
<group position={[width / 2 - PANEL_THICKNESS / 2, plinthH / 2, 0]}>
<WardrobeElement
elementType="cotes"
geometryType="box"
geometryArgs={[PANEL_THICKNESS, bodyH, depth]}
position={[0, 0, 0]}
colors={colors}
onElementClick={elementClickHandler}
/>
<BackGrooveVisual3D
variant="vertical-right"
thickness={PANEL_THICKNESS}
panelH={bodyH}
panelD={depth}
/>
</group>
{/* Habillages de finition (Libre + Premium) */}
{!isNiche && exteriorFinishesEnabled && (
<>
{/* Habillage gauche */}
{sideFinishLeftEnabled && (
<WardrobeElement
elementType="habillageFinitionGauche"
geometryType="box"
geometryArgs={[
PANEL_THICKNESS,
topFinishEnabled ? bodyH + PANEL_THICKNESS : bodyH,
depth
]}
position={[
-width / 2 - PANEL_THICKNESS / 2,
topFinishEnabled ? plinthH / 2 + PANEL_THICKNESS / 2 : plinthH / 2,
0
]}
colors={colors}
onElementClick={elementClickHandler}
/>
)}
{/* Habillage droite */}
{sideFinishRightEnabled && (
<WardrobeElement
elementType="habillageFinitionDroite"
geometryType="box"
geometryArgs={[
PANEL_THICKNESS,
topFinishEnabled ? bodyH + PANEL_THICKNESS : bodyH,
depth
]}
position={[
width / 2 + PANEL_THICKNESS / 2,
topFinishEnabled ? plinthH / 2 + PANEL_THICKNESS / 2 : plinthH / 2,
0
]}
colors={colors}
onElementClick={elementClickHandler}
/>
)}
{/* 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 (
<WardrobeElement
key={`hab-top-${modules[i]?.id ?? i}`}
elementType="habillageFinitionDessus"
geometryType="box"
geometryArgs={[habitW, PANEL_THICKNESS, depth]}
position={[cx, plinthH / 2 + bodyH / 2 + PANEL_THICKNESS / 2, 0]}
colors={colors}
onElementClick={elementClickHandler}
/>
);
})
) : (
(() => {
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 (
<WardrobeElement
elementType="habillageFinitionDessus"
geometryType="box"
geometryArgs={[habitW, PANEL_THICKNESS, depth]}
position={[cx, plinthH / 2 + bodyH / 2 + PANEL_THICKNESS / 2, 0]}
colors={colors}
onElementClick={elementClickHandler}
/>
);
})()
)
)}
</>
)}
{/* Dos (back panel) — 8 mm per APEX 4.0 spec */}
<WardrobeElement
elementType="dos"
geometryType="box"
geometryArgs={[width - PANEL_THICKNESS * 2, bodyH - PANEL_THICKNESS * 2, BACK_PANEL_THICKNESS]}
position={[0, plinthH / 2, -depth / 2 + BACK_PANEL_THICKNESS / 2]}
colors={colors}
onElementClick={elementClickHandler}
materialProps={{ roughness: 0.8 }}
/>
{/* Outline to keep wardrobe edges readable through niche wall */}
{config?.installationType === 'niche' && (
<WardrobeOutline width={width} height={height} depth={depth} />
)}
{/* 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={elementClickHandler}
/>
<WardrobeElement
elementType="fileur"
geometryType="box"
geometryArgs={[PANEL_THICKNESS / 2, bodyH, depth]}
position={[width / 2 + PANEL_THICKNESS / 4, plinthH / 2, 0]}
colors={colors}
onElementClick={elementClickHandler}
/>
</>
)}
{/* 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) => (
<ResizableSeparator
key={`sep-resize-${i}-${j}`}
position={[xSep, sepCenterY, 0]}
height={sepHeight}
depth={separatorDepthM}
zCenter={separatorZ}
separatorIndex={pos.index}
colors={colors}
onElementClick={elementClickHandler}
isResizing={resizeState?.separatorIndex === pos.index}
onResizePointerDown={handleResizePointerDown}
/>
));
})
: !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) => (
<group key={`sep-${i}-${j}`} position={[xSep, sepCenterY, separatorZ]}>
<WardrobeElement
elementType="panneauxInterieurs"
geometryType="box"
geometryArgs={[PANEL_THICKNESS, sepHeight, separatorDepthM]}
position={[0, 0, 0]}
colors={colors}
onElementClick={elementClickHandler}
/>
</group>
));
})
}
{/* Width labels -- show during resize or hover */}
{!shellOnlyMode && !suppressHtmlOverlays && 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 */}
{!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 (
<group key={`mod-${module.id}`}>
{/* Invisible click/hover area for module selection */}
{activeStep !== 2 && (
<mesh
position={[moduleCenter, plinthH / 2, 0]}
onPointerOver={() => setHoveredModule(moduleIndex)}
onPointerOut={() => setHoveredModule(prev => prev === moduleIndex ? null : prev)}
onClick={(e) => {
e.stopPropagation();
if (moduleClickHandler) moduleClickHandler(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>
)}
{activeStep === 2 && needsMiddleFix && !hasShelfInStructuralWindow && (
<group position={[moduleCenter, structuralZoneCenterY, 0]}>
<mesh>
<boxGeometry args={[moduleSpanForZone, structuralZoneHeight, depthSpanForZone]} />
<meshBasicMaterial color="#ef4444" transparent opacity={0.12} />
</mesh>
<mesh position={[0, 0, depth / 2 + 0.004]}>
<planeGeometry args={[moduleSpanForZone, structuralZoneHeight]} />
<meshBasicMaterial color="#dc2626" transparent opacity={0.2} />
</mesh>
</group>
)}
{/* 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 (
<group position={[hingeX, doorCenterY, depth / 2 + 0.002]} rotation={[0, angle, 0]}>
<WardrobeElement
elementType="portes"
geometryType="plane"
geometryArgs={[doorW, doorH]}
position={[localX, 0, 0]}
colors={colors}
onElementClick={elementClickHandler}
transparent={true}
opacity={0.9}
materialProps={{ roughness: 0.5 }}
/>
</group>
);
})()
)}
{/* 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 (
<group key={`shelf-${moduleIndex}-${shelfIndex}`}>
<WardrobeElement
elementType={isStructural ? 'rayonFixe' : 'panneauxInterieurs'}
geometryType="box"
geometryArgs={[moduleWidth - PANEL_THICKNESS * 2, PANEL_THICKNESS, depth - PANEL_THICKNESS * 2]}
position={[moduleCenter, shelfY, 0]}
colors={colors}
onElementClick={elementClickHandler}
/>
{activeStep === 2 && (
<mesh
position={[
moduleCenter,
shelfY,
(depth - PANEL_THICKNESS * 2) / 2 + Math.max(0.004, PANEL_THICKNESS * 0.18)
]}
onPointerOver={(e) => {
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)}
>
<boxGeometry
args={[
moduleWidth - PANEL_THICKNESS * 1.8,
Math.max(0.03, PANEL_THICKNESS * 1.25),
Math.max(0.018, PANEL_THICKNESS * 0.95)
]}
/>
<meshBasicMaterial
color="#4f46e5"
transparent
opacity={isShelfDragging ? 0.28 : isShelfHovered ? 0.2 : 0.08}
/>
</mesh>
)}
</group>
);
})}
</>
);
})()}
{/* 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 (
<WardrobeElement
key={`fixed-shelf-drawers-${moduleIndex}`}
elementType="rayonFixe"
geometryType="box"
geometryArgs={[moduleWidth - PANEL_THICKNESS * 2, PANEL_THICKNESS, depth - PANEL_THICKNESS * 2]}
position={[moduleCenter, yFixed, 0]}
colors={colors}
onElementClick={elementClickHandler}
/>
);
})()
)}
{/* Drawers */}
{drawersCount > 0 && (
<DrawerBox
moduleCenter={moduleCenter}
moduleWidth={moduleWidth}
bodyH={bodyH}
plinthH={plinthH}
drawersCount={drawersCount}
depth={depth}
behindDoor={['hidden', 'hidden_with_handle'].includes(hw.drawerType || 'visible')}
drawerDepthMm={
config.effectiveDrawerDepthMm ??
Math.max(350, (config.depthMm || 600) - 10)
}
colors={colors}
onElementClick={elementClickHandler}
/>
)}
{/* 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={elementClickHandler}
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={elementClickHandler}
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 */}
{/* Step 1 dimension editor: bounding box + edge grips + rulers */}
{showDimensionHtml && (
<>
<DimensionAxisGrip
position={[0, bodyH / 2 + plinthH / 2 + 0.01, dimGrip.frontExteriorZ]}
size={[dimGrip.w, 0.012, 0.012]}
hitSize={[
Math.max(dimGrip.w + 0.05, MIN_EDGE_GRIP_M + 0.04),
dimGrip.crossW,
dimGrip.crossW,
]}
label="L"
cursor="ew-resize"
tintColor="#2563eb"
chipOffset={[0, 0.07, 0]}
active={dimensionResizeState?.type === 'widthMm'}
onPointerDown={(e) => handleDimensionPointerDown('widthMm', e)}
/>
<DimensionAxisGrip
position={[dimGrip.rightExteriorX, plinthH / 2, dimGrip.frontExteriorZ]}
size={[0.012, dimGrip.h, 0.012]}
hitSize={[
dimGrip.crossH,
Math.max(dimGrip.h + 0.05, MIN_EDGE_GRIP_M + 0.04),
dimGrip.crossH,
]}
label="H"
cursor="ns-resize"
tintColor="#9333ea"
chipOffset={[0.09, 0, 0]}
active={dimensionResizeState?.type === 'heightMm'}
onPointerDown={(e) => handleDimensionPointerDown('heightMm', e)}
/>
<DimensionAxisGrip
position={[dimGrip.rightExteriorX, dimGrip.bottomY, dimGrip.frontExteriorZ]}
size={[0.012, 0.012, dimGrip.d]}
hitSize={[
dimGrip.crossD,
dimGrip.crossD,
Math.max(dimGrip.d + 0.05, MIN_EDGE_GRIP_M + 0.06),
]}
label="P"
cursor="ew-resize"
tintColor="#0891b2"
chipOffset={[0.09, 0, 0.09]}
active={dimensionResizeState?.type === 'depthMm'}
onPointerDown={(e) => handleDimensionPointerDown('depthMm', e)}
/>
</>
)}
{/* Pass interaction state to parent for OrbitControls toggling */}
<InteractionBlocker active={interactionActive} />
</group>
);
}
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 (
<lineSegments geometry={geom} renderOrder={999}>
<lineBasicMaterial
color="#111827"
transparent
opacity={0.55}
stencilWrite={false}
stencilRef={1}
stencilFunc={THREE.EqualStencilFunc}
stencilFail={THREE.KeepStencilOp}
stencilZFail={THREE.KeepStencilOp}
stencilZPass={THREE.KeepStencilOp}
// Only show where the wall is in front (occluding).
depthTest
depthFunc={THREE.GreaterEqualDepth}
depthWrite={false}
/>
</lineSegments>
);
}
/* ===================================================================
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 (
<OrbitControls
ref={controlsRef}
enablePan={false}
enableZoom={true}
enableRotate={true}
enableDamping={true}
dampingFactor={0.08}
rotateSpeed={0.55}
zoomSpeed={0.9}
panSpeed={0.85}
minDistance={isDimensionStep ? Math.max(0.1, getDefaultDistance() * 0.8) : getDefaultDistance() * 0.9}
maxDistance={isDimensionStep ? 30 : getDefaultDistance() * 2.6}
target={cameraTarget}
onStart={handleControlStart}
onEnd={handleControlEnd}
makeDefault
/>
);
});
/* ===================================================================
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 (
<>
<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} />
{/* 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 && (
<group>
{/* floor */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, nicheBackdrop.floorY, 0]} receiveShadow raycast={() => null}>
<planeGeometry args={[nicheBackdrop.outerWallW, nicheBackdrop.outerWallD]} />
<meshStandardMaterial color="#d1d5db" roughness={0.98} metalness={0} />
</mesh>
{/* back wall (niche sized) */}
<mesh position={[0, nicheBackdrop.heightM / 2, nicheBackdrop.backZ]} receiveShadow raycast={() => null}>
<planeGeometry args={[nicheBackdrop.nicheW, nicheBackdrop.nicheH]} />
<meshStandardMaterial color="#f8fafc" roughness={0.98} metalness={0} />
</mesh>
{/* left wall (niche sized) */}
<mesh rotation={[0, Math.PI / 2, 0]} position={[nicheBackdrop.leftX, nicheBackdrop.heightM / 2, 0]} receiveShadow raycast={() => null}>
<planeGeometry args={[nicheBackdrop.nicheD, nicheBackdrop.nicheH]} />
<meshStandardMaterial color="#f8fafc" roughness={0.98} metalness={0} />
</mesh>
{/* right wall (niche sized) */}
<mesh rotation={[0, -Math.PI / 2, 0]} position={[nicheBackdrop.rightX, nicheBackdrop.heightM / 2, 0]} receiveShadow raycast={() => null}>
<planeGeometry args={[nicheBackdrop.nicheD, nicheBackdrop.nicheH]} />
<meshStandardMaterial color="#f8fafc" roughness={0.98} metalness={0} />
</mesh>
{/* ceiling (niche sized) */}
<mesh rotation={[Math.PI / 2, 0, 0]} position={[0, nicheBackdrop.ceilY, 0]} receiveShadow raycast={() => null}>
<planeGeometry args={[nicheBackdrop.nicheW, nicheBackdrop.nicheD]} />
<meshStandardMaterial color="#f8fafc" roughness={0.98} metalness={0} />
</mesh>
{/* front wall: everywhere except the niche opening */}
<FrontWallWithOpening niche={nicheBackdrop} />
</group>
)}
{/* Backdrop wall/floor for "Libre" (free-standing) view */}
{freeBackdrop && !nicheBackdrop && (
<group>
{/* floor */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]} receiveShadow raycast={() => null}>
<planeGeometry args={[200, 200]} />
<meshStandardMaterial color="#d1d5db" roughness={0.95} metalness={0} />
</mesh>
{/* wall behind wardrobe (Z-), flush to back */}
<mesh position={[0, freeBackdrop.wallY, freeBackdrop.wallZ]} receiveShadow raycast={() => null}>
<planeGeometry args={[200, 120]} />
<meshStandardMaterial color="#f9fafb" roughness={0.98} metalness={0} />
</mesh>
</group>
)}
{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}
onUpdateModule={onUpdateModule}
onSetDimension={onSetDimension}
onBeginUndoableGesture={onBeginUndoableGesture}
activeStep={activeStep}
suppressHtmlOverlays={suppressHtmlOverlays}
/>
</group>
<CameraController ref={controllerRef} roomConfig={roomConfig} config={config} modules={modules} activeStep={activeStep} />
</>
);
}
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).
<mesh position={[0, 0, niche.frontZ]} receiveShadow raycast={() => null}>
<shapeGeometry args={[shape]} />
<meshStandardMaterial
color="#f8fafc"
roughness={0.98}
metalness={0}
stencilWrite
stencilRef={1}
stencilFunc={THREE.AlwaysStencilFunc}
stencilZPass={THREE.ReplaceStencilOp}
/>
</mesh>
);
}
/* ===================================================================
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 (
<div
ref={containerRef}
className={styles.container}
data-room={roomConfig ? '1' : '0'}
onDoubleClick={handleResetView}
title="Glisser pour tourner. Molette pour zoom. Double-clic pour réinitialiser."
>
{selectedElement && !isDimensionStep && (
<div className={styles.selectedElement}>
Élément sélectionné: <strong>{selectedElement.label}</strong>
</div>
)}
{/* Zoom Controls */}
<div className={styles.controls}>
{showReset && (
<button type="button" className={styles.controlBtn} onClick={handleResetView} title="Réinitialiser la vue">&loz;</button>
)}
{show3dUndo && (
<>
<div className={styles.controlDivider} />
<button
type="button"
className={`${styles.controlBtn} ${!canUndo3d ? styles.controlBtnDisabled : ''}`}
onClick={onUndo3d}
disabled={!canUndo3d}
title="Annuler la dernière modification 3D (Ctrl+Z)"
>
&larr;
</button>
<button
type="button"
className={`${styles.controlBtn} ${!canRedo3d ? styles.controlBtnDisabled : ''}`}
onClick={onRedo3d}
disabled={!canRedo3d}
title="Rétablir (Ctrl+Shift+Z ou Ctrl+Y)"
>
&rarr;
</button>
</>
)}
{showDoorToggle && (
<>
<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>
</>
)}
{showColorSelector && (
<>
<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
key={isDimensionStep ? 'ortho-step1' : 'perspective-default'}
shadows
orthographic={false}
camera={{ position: perspectiveCameraPosition, fov: roomConfig ? 60 : 45 }}
gl={{ antialias: true, alpha: true, stencil: true }}
style={{ width: '100%', height: '100%', display: 'block' }}
onCreated={({ gl }) => {
gl.setClearColor(0x000000, 0);
}}
>
<Scene
config={config}
roomConfig={roomConfig}
controllerRef={controllerRef}
colors={colors}
modules={modules}
onElementClick={handleElementClick}
onModuleClick={onModuleClick}
selectedModuleIndex={selectedModuleIndex}
showDoors={effectiveShowDoors}
onResizeModules={onResizeModules}
onUpdateModule={onUpdateModule}
onSetDimension={onSetDimension}
onBeginUndoableGesture={onBeginUndoableGesture}
activeStep={activeStep}
suppressHtmlOverlays={suppressHtmlOverlays}
/>
</Canvas>
</Suspense>
</div>
);
}