import { useRef, Suspense, useMemo, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react';
import { Canvas, useThree } from '@react-three/fiber';
import { OrbitControls, Html } from '@react-three/drei';
import * as THREE from 'three';
import Room3D, { calculateWardrobeTransform } from './Room3D';
import WardrobeElement from './WardrobeElement';
import styles from './WardrobePreview.module.css';
import { GROOVE, PANEL_M, getSeparatorDepthMm, getDoorHingeSide } from '@shared/manufacturing.js';
import { shelfCenterYsMeters, minShelvesForStructuralHeight, resolveShelfPositionsMm, system32ShelfPositionsMm } from '../constants/wardrobe';
const SCALE = 1 / 1000;
const PANEL_THICKNESS = PANEL_M.structure; // 0.019 m (19 mm)
const BACK_PANEL_THICKNESS = PANEL_M.back; // 0.008 m (8 mm)
const REINFORCEMENT_THICKNESS = PANEL_M.reinforcement; // 0.016 m (16 mm)
const REINFORCEMENT_WIDTH = PANEL_M.reinforcementWidth; // 0.100 m (100 mm)
const DIMENSION_RULES = {
widthMm: { min: 400, max: 5000, step: 50 },
heightMm: { min: 1800, max: 2800, step: 50 },
depthMm: { min: 400, max: 800, step: 10 },
};
/** Edges shorter than this (m) get a grip that extends past the geometry for easier grabs. */
const THIN_EDGE_M = 0.45;
const MIN_EDGE_GRIP_M = 0.2;
const GRIP_OVERFLOW_WHEN_THIN_M = 0.08;
const STRUCTURAL_WINDOW_HALF_MM = 200;
/**
* Visible grip length along an edge (width, height, or depth of the carcass).
* When the edge is narrow, the grip is at least slightly longer than the element.
*/
function gripSpanAlongEdge(edgeLenM) {
if (!(edgeLenM > 0)) return MIN_EDGE_GRIP_M;
const inset = edgeLenM * 0.96;
if (edgeLenM < THIN_EDGE_M) {
return Math.max(MIN_EDGE_GRIP_M, edgeLenM + GRIP_OVERFLOW_WHEN_THIN_M, inset);
}
return Math.max(0.18, inset);
}
/** Perpendicular thickness for DimensionAxisGrip hit boxes; grows when the main span is thin. */
function gripHitCrossAxis(mainSpanM) {
return Math.max(0.1, 0.06 + mainSpanM * 0.12);
}
/* ===================================================================
ResizableSeparator - a draggable handle between two modules
=================================================================== */
function ResizableSeparator({
position,
height,
depth,
zCenter,
separatorIndex,
colors,
onElementClick,
isResizing,
onResizePointerDown,
}) {
const [hovered, setHovered] = useState(false);
const { gl } = useThree();
const meshRef = useRef();
const handlePointerOver = useCallback((e) => {
e.stopPropagation();
setHovered(true);
gl.domElement.style.cursor = 'col-resize';
}, [gl]);
const handlePointerOut = useCallback((e) => {
e.stopPropagation();
if (!isResizing) {
setHovered(false);
gl.domElement.style.cursor = 'auto';
}
}, [gl, isResizing]);
const handlePointerDown = useCallback((e) => {
e.stopPropagation();
if (onResizePointerDown) {
onResizePointerDown(separatorIndex, e);
}
}, [separatorIndex, onResizePointerDown]);
const baseColor = hovered || isResizing ? '#4f46e5' : '#d4c4a4';
const hitX = Math.max(PANEL_THICKNESS * 8, 0.11);
const hitY = Math.max(height, gripSpanAlongEdge(height));
const hitZ = Math.max(depth, gripSpanAlongEdge(depth));
return (
{/* Séparateur : plaqué au dos, sans rainure ; repère Z aligné arrière avec dessus/fond */}
{/* Invisible hit area: wider/taller/deeper than the panel when the carcass is shallow or short */}
{/* Highlight overlay when hovered/resizing */}
{(hovered || isResizing) && (
)}
);
}
/**
* Rainure dos APEX 4.0 : 9×10,5 mm, axe à 16 mm du bord arrière (face intérieure caisse).
* Repère local identique au panneau : Z− = arrière, dessus = rainure sur face bas (−Y), fond = face haut (+Y).
*/
function BackGrooveVisual3D({ variant, innerW, innerD, thickness, panelH, panelD }) {
const gw = GROOVE.back.width * SCALE;
const gd = GROOVE.back.depth * SCALE;
const ax = GROOVE.back.axisFromPanelEdge * SCALE;
const eps = 0.0003;
const mat = (
);
if (variant === 'horizontal-top') {
const zHalf = innerD / 2;
const zc = -zHalf + ax;
return (
{mat}
);
}
if (variant === 'horizontal-bottom') {
const zHalf = innerD / 2;
const zc = -zHalf + ax;
return (
{mat}
);
}
if (variant === 'vertical-left') {
const zHalf = panelD / 2;
const zc = -zHalf + ax;
return (
{mat}
);
}
if (variant === 'vertical-right') {
const zHalf = panelD / 2;
const zc = -zHalf + ax;
return (
{mat}
);
}
return null;
}
/* ===================================================================
WidthLabel - floating HTML label showing module width in mm
=================================================================== */
function WidthLabel({ position, widthMm }) {
return (
{Math.round(widthMm)}mm
);
}
/* ===================================================================
DimensionAxisGrip - CAD-like edge grip for bounding box resize
=================================================================== */
function DimensionAxisGrip({
position,
rotation = [0, 0, 0],
size,
hitSize,
label,
cursor,
active,
tintColor = '#4f46e5',
chipOffset = [0, 0.06, 0],
onPointerDown
}) {
const [hovered, setHovered] = useState(false);
const { gl } = useThree();
const handlePointerOver = useCallback((e) => {
e.stopPropagation();
setHovered(true);
gl.domElement.style.cursor = cursor;
}, [gl, cursor]);
const handlePointerOut = useCallback((e) => {
e.stopPropagation();
if (!active) {
setHovered(false);
gl.domElement.style.cursor = 'auto';
}
}, [gl, active]);
const handlePointerDown = useCallback((e) => {
e.stopPropagation();
e.target?.setPointerCapture?.(e.pointerId);
onPointerDown?.(e);
}, [onPointerDown]);
return (
{label}
);
}
/* ===================================================================
DrawerBox - renders drawer front faces stacked vertically
=================================================================== */
function DrawerBox({
moduleCenter,
moduleWidth,
bodyH,
plinthH,
drawersCount,
depth,
behindDoor,
drawerDepthMm,
colors,
onElementClick
}) {
const drawerHeight = 0.15 * SCALE * 1000; // ~150mm in scene units
const gap = 0.008; // gap between drawers
const totalDrawerZone = Math.min(bodyH * 0.6, drawersCount * (drawerHeight + gap));
const actualDrawerH = (totalDrawerZone - gap * (drawersCount - 1)) / drawersCount;
const startY = -bodyH / 2 + PANEL_THICKNESS;
/** Profondeur utile tiroir (mm) — réduite en mode caché. */
const effMm = Math.max(350, Number(drawerDepthMm) || 550);
const drawerBodyDepthM = Math.max(
PANEL_THICKNESS * 2,
Math.min(effMm * SCALE, Math.max(0.08, depth - PANEL_THICKNESS * 3))
);
/** Face avant des tiroirs (côté porte) : +Z vers l’avant du caisson. */
const frontFaceZ = behindDoor
? depth / 2 - PANEL_THICKNESS - 0.15
: depth / 2 - PANEL_THICKNESS - 0.002;
const centerZ = frontFaceZ - drawerBodyDepthM / 2;
return (
<>
{Array.from({ length: drawersCount }).map((_, di) => {
const y = startY + actualDrawerH / 2 + di * (actualDrawerH + gap);
return (
0 ? centerZ : 0]}
colors={colors}
onElementClick={onElementClick}
materialProps={{ roughness: 0.5, metalness: 0.05 }}
/>
);
})}
>
);
}
/* ===================================================================
WardrobeMesh - Full wardrobe model with drag/resize/drawers
=================================================================== */
function WardrobeMesh({
config,
colors = {},
onElementClick,
onModuleClick,
selectedModuleIndex,
modules = [],
showDoors = true,
onResizeModules,
onUpdateModule,
onSetDimension,
onBeginUndoableGesture,
activeStep = 1,
suppressHtmlOverlays = false,
}) {
const { gl, camera, raycaster } = useThree();
const useModules = modules && modules.length > 0;
const shellOnlyMode = activeStep === 1;
// --- Dimensions ---
const totalWidth = useModules
? modules.reduce((s, m) => s + (m.widthMm || 0), 0)
: config.widthMm || 2400;
const width = totalWidth * SCALE;
const height = (config.heightMm || 2400) * SCALE;
const depth = (config.depthMm || 600) * SCALE;
const plinthH = (config.plinthHeightMm || 100) * SCALE;
const bodyH = height - plinthH;
const exteriorFinishesEnabled = !!config.exteriorFinishesEnabled;
const sideDefaultsEnabled = config?.sideFinishesEnabled !== false;
const sideFinishLeftEnabled = config?.sideFinishLeftEnabled ?? sideDefaultsEnabled;
const sideFinishRightEnabled = config?.sideFinishRightEnabled ?? sideDefaultsEnabled;
const topFinishEnabled = config?.topFinishEnabled !== false;
const isBlockMode = config.columnMode === 'block';
/** Gap between double montants (assemblage block) */
const BLOCK_JOINT_GAP_M = 0.003;
const separatorDepthM = getSeparatorDepthMm(config.depthMm || 600) * SCALE;
const separatorZ = PANEL_THICKNESS - depth / 2 + separatorDepthM / 2;
const isNiche = config.installationType === 'niche';
const isSliding = config.doorType === 'sliding';
const hw = config.hardware || {};
const doorOpenAngle = 0.32; // radians (~18°) - visible “ajar” preview
/** Séparateurs: en block, montants alignés sur la hauteur habillage (body + bandeau si habillage dessus). */
const sepCenterY = isBlockMode
? (topFinishEnabled ? plinthH / 2 + PANEL_THICKNESS / 2 : plinthH / 2)
: plinthH / 2;
const sepHeight = isBlockMode
? (topFinishEnabled ? bodyH + PANEL_THICKNESS : bodyH)
: bodyH - PANEL_THICKNESS * 2;
/** Single full-width top on step 1 (dimensions); per-block split from step 2 when separators matter. */
const splitTopPerBlock =
isBlockMode && useModules && modules.length > 1 && !shellOnlyMode;
const dimGrip = useMemo(() => {
const w = gripSpanAlongEdge(width);
const h = gripSpanAlongEdge(bodyH);
const d = gripSpanAlongEdge(depth);
const gripOutset = 0.03;
const rightExteriorX =
width / 2
+ (isNiche ? PANEL_THICKNESS * 0.6 : 0)
+ ((!isNiche && exteriorFinishesEnabled && sideFinishRightEnabled) ? PANEL_THICKNESS : 0)
+ gripOutset;
const frontExteriorZ = depth / 2 + gripOutset;
const bottomY = -height / 2 + plinthH + 0.02;
return {
w,
h,
d,
crossW: gripHitCrossAxis(width),
crossH: gripHitCrossAxis(bodyH),
crossD: gripHitCrossAxis(depth),
rightExteriorX,
frontExteriorZ,
bottomY,
};
}, [width, bodyH, depth, height, plinthH, isNiche, exteriorFinishesEnabled, sideFinishRightEnabled]);
// --- Module positions ---
const modulePositions = useMemo(() => {
const items = useModules ? modules : [];
const result = [];
let offset = 0;
items.forEach((item) => {
const w = (item.widthMm || 0) * SCALE;
result.push({ x0: offset, width: w, center: -width / 2 + offset + w / 2 });
offset += w;
});
return result;
}, [useModules, modules, width]);
// --- Separator positions (between modules) ---
const separatorPositions = useMemo(() => {
const items = useModules ? modules : [];
return items.slice(0, -1).map((item, i) => {
const x = modulePositions[i].x0 + modulePositions[i].width;
return { x: -width / 2 + x, index: i };
});
}, [modulePositions, width, useModules, modules]);
// --- Hover state for width labels ---
const [hoveredModule, setHoveredModule] = useState(null);
const [hoveredShelfKey, setHoveredShelfKey] = useState(null);
const [resizeState, setResizeState] = useState(null); // { separatorIndex, startX }
const [shelfDragState, setShelfDragState] = useState(null); // { moduleIndex, shelfIndex, startClientY, startPosMm, shelfPositionsMm, slotsMm }
const [dimensionResizeState, setDimensionResizeState] = useState(null); // { type, startClientX, startClientY, startValue }
const dimensionResizeStateRef = useRef(null);
const planeRef = useRef(new THREE.Plane(new THREE.Vector3(0, 1, 0), 0));
const intersectPoint = useRef(new THREE.Vector3());
const dimensionEditMode = activeStep === 1 && typeof onSetDimension === 'function';
/** Drei Html portals use a high z-index and can paint above fixed modals (e.g. plans). */
const showDimensionHtml = dimensionEditMode && !suppressHtmlOverlays;
const applyDimensionValue = useCallback((type, rawValue) => {
if (!dimensionEditMode) return;
const rules = DIMENSION_RULES[type];
if (!rules) return;
const parsed = Number(rawValue);
if (!Number.isFinite(parsed)) return;
const clamped = Math.min(rules.max, Math.max(rules.min, parsed));
const stepped = Math.round(clamped / rules.step) * rules.step;
onSetDimension?.(type, stepped);
}, [dimensionEditMode, onSetDimension]);
// --- Helpers to project pointer to world X ---
const getWorldX = useCallback((e) => {
const rect = gl.domElement.getBoundingClientRect();
const mouse = new THREE.Vector2(
((e.clientX - rect.left) / rect.width) * 2 - 1,
-((e.clientY - rect.top) / rect.height) * 2 + 1
);
raycaster.setFromCamera(mouse, camera);
planeRef.current.set(new THREE.Vector3(0, 1, 0), -height / 2);
if (raycaster.ray.intersectPlane(planeRef.current, intersectPoint.current)) {
return intersectPoint.current.x;
}
return null;
}, [gl, camera, raycaster, height]);
// --- Resize pointer down ---
const handleResizePointerDown = useCallback((separatorIndex, e) => {
e.stopPropagation();
e.target?.setPointerCapture?.(e.pointerId);
const worldX = getWorldX(e);
if (worldX === null) return;
onBeginUndoableGesture?.();
setResizeState({ separatorIndex, startX: worldX });
gl.domElement.style.cursor = 'col-resize';
}, [getWorldX, onBeginUndoableGesture]);
const handleDimensionPointerDown = useCallback((type, e) => {
if (!dimensionEditMode) return;
onBeginUndoableGesture?.();
const nextState = {
type,
startClientX: e.clientX,
startClientY: e.clientY,
startValue: Number(config[type]) || 0,
};
dimensionResizeStateRef.current = nextState;
setDimensionResizeState(nextState);
if (type === 'heightMm') gl.domElement.style.cursor = 'ns-resize';
if (type === 'widthMm') gl.domElement.style.cursor = 'ew-resize';
if (type === 'depthMm') gl.domElement.style.cursor = 'ew-resize';
}, [dimensionEditMode, gl, config, onBeginUndoableGesture]);
const handleShelfPointerDown = useCallback((moduleIndex, shelfIndex, isStructural, e) => {
if (activeStep !== 2 || typeof onUpdateModule !== 'function') return;
const mod = modules[moduleIndex];
const shelfCount = Math.max(0, Number(mod?.shelves) || 0);
if (shelfCount <= 0) return;
const slotsMm = system32ShelfPositionsMm(config.heightMm, config.plinthHeightMm ?? 100);
if (!slotsMm.length) return;
const shelfPositionsMm = resolveShelfPositionsMm({
shelfCount,
heightMm: config.heightMm,
plinthHeightMm: config.plinthHeightMm ?? 100,
shelfPositionsMm: mod?.shelfPositionsMm
});
const startPosMm = shelfPositionsMm[shelfIndex];
if (!Number.isFinite(startPosMm)) return;
e.stopPropagation();
e.target?.setPointerCapture?.(e.pointerId);
onBeginUndoableGesture?.();
setShelfDragState({
moduleIndex,
shelfIndex,
startClientY: e.clientY,
startPosMm,
shelfPositionsMm,
slotsMm
});
gl.domElement.style.cursor = 'ns-resize';
}, [activeStep, onUpdateModule, modules, config.heightMm, config.plinthHeightMm, onBeginUndoableGesture, gl]);
// Attach window-level pointer events for resize
const onPointerMoveRef = useRef(null);
const onPointerUpRef = useRef(null);
useEffect(() => {
const handleMove = (e) => {
if (resizeState) {
const worldX = getWorldX(e);
if (worldX !== null) {
const deltaX = worldX - resizeState.startX;
const deltaXmm = deltaX / SCALE;
if (onResizeModules && Math.abs(deltaXmm) > 1) {
onResizeModules(resizeState.separatorIndex, deltaXmm);
setResizeState(prev => prev ? { ...prev, startX: worldX } : null);
}
}
}
if (dimensionResizeStateRef.current) {
const { type, startClientX, startClientY, startValue } = dimensionResizeStateRef.current;
const mmPerPx = e.shiftKey ? 0.5 : 2;
const deltaPx = type === 'heightMm'
? startClientY - e.clientY
: type === 'depthMm'
? startClientX - e.clientX
: e.clientX - startClientX;
const nextValue = startValue + deltaPx * mmPerPx;
applyDimensionValue(type, nextValue);
}
if (shelfDragState) {
const mmPerPx = e.shiftKey ? 0.6 : 1.5;
const rawMm = shelfDragState.startPosMm + (shelfDragState.startClientY - e.clientY) * mmPerPx;
const slots = shelfDragState.slotsMm;
let nearestIdx = 0;
let best = Infinity;
for (let i = 0; i < slots.length; i++) {
const d = Math.abs(slots[i] - rawMm);
if (d < best) {
best = d;
nearestIdx = i;
}
}
const n = shelfDragState.shelfPositionsMm.length;
const minIdx = shelfDragState.shelfIndex;
const maxIdx = slots.length - (n - shelfDragState.shelfIndex);
nearestIdx = Math.max(minIdx, Math.min(maxIdx, nearestIdx));
const nextShelfPositions = [...shelfDragState.shelfPositionsMm];
nextShelfPositions[shelfDragState.shelfIndex] = slots[nearestIdx];
onUpdateModule?.(shelfDragState.moduleIndex, { shelfPositionsMm: nextShelfPositions });
}
};
const handleUp = () => {
if (resizeState) {
setResizeState(null);
gl.domElement.style.cursor = 'auto';
}
if (dimensionResizeStateRef.current) {
dimensionResizeStateRef.current = null;
setDimensionResizeState(null);
gl.domElement.style.cursor = 'auto';
}
if (shelfDragState) {
setShelfDragState(null);
setHoveredShelfKey(null);
gl.domElement.style.cursor = 'auto';
}
};
onPointerMoveRef.current = handleMove;
onPointerUpRef.current = handleUp;
}, [resizeState, dimensionResizeState, shelfDragState, getWorldX, onResizeModules, applyDimensionValue, onUpdateModule, gl]);
// Attach/detach event listeners
useEffect(() => {
const move = (e) => onPointerMoveRef.current?.(e);
const up = (e) => onPointerUpRef.current?.(e);
// Use window-level listeners so resize remains live even if pointer leaves canvas.
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
return () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
}, []);
// Disable OrbitControls during resize
const interactionActive = resizeState !== null || dimensionResizeState !== null || shelfDragState !== null;
const elementClickHandler = dimensionEditMode ? undefined : onElementClick;
const moduleClickHandler = dimensionEditMode ? null : onModuleClick;
return (
{/* Plinthe bas */}
{/* Dessus (top panel) — 1 pièce (séparations) ou 1 par caisson (block) */}
{splitTopPerBlock ? (
modulePositions.map((mp, i) => {
const segW = Math.max(0.02, mp.width - 2 * PANEL_THICKNESS - (isBlockMode ? BLOCK_JOINT_GAP_M : 0));
return (
);
})
) : (
)}
{/* Fond (bottom panel) */}
{/* Côté gauche */}
{/* Côté droit */}
{/* Habillages de finition (Libre + Premium) */}
{!isNiche && exteriorFinishesEnabled && (
<>
{/* Habillage gauche */}
{sideFinishLeftEnabled && (
)}
{/* Habillage droite */}
{sideFinishRightEnabled && (
)}
{/* Habillage dessus — 1 bandeau ou 1 par caisson (block) */}
{topFinishEnabled && (
splitTopPerBlock ? (
modulePositions.map((mp, i) => {
const n = modules.length;
const tl = i === 0 ? (sideFinishLeftEnabled ? PANEL_THICKNESS : 0) : PANEL_THICKNESS;
const tr = i === n - 1 ? (sideFinishRightEnabled ? PANEL_THICKNESS : 0) : PANEL_THICKNESS;
const habitW = Math.max(0.001, mp.width + tl + tr);
const cx = mp.center + (tr - tl) / 2;
return (
);
})
) : (
(() => {
const tl = sideFinishLeftEnabled ? PANEL_THICKNESS : 0;
const tr = sideFinishRightEnabled ? PANEL_THICKNESS : 0;
const habitW = Math.max(0.001, width + tl + tr);
const cx = (tr - tl) / 2;
return (
);
})()
)
)}
>
)}
{/* Dos (back panel) — 8 mm per APEX 4.0 spec */}
{/* Outline to keep wardrobe edges readable through niche wall */}
{config?.installationType === 'niche' && (
)}
{/* Fileurs (si encastré) */}
{isNiche && (
<>
>
)}
{/* Separators between modules — en block: double montant + hauteur habillage */}
{!shellOnlyMode && useModules && onResizeModules
? separatorPositions.flatMap((pos, i) => {
const jointHalf = (BLOCK_JOINT_GAP_M + PANEL_THICKNESS) / 2;
const xs = isBlockMode
? [pos.x - jointHalf, pos.x + jointHalf]
: [pos.x];
return xs.map((xSep, j) => (
));
})
: !shellOnlyMode && separatorPositions.flatMap((pos, i) => {
const jointHalf = (BLOCK_JOINT_GAP_M + PANEL_THICKNESS) / 2;
const xs = isBlockMode
? [pos.x - jointHalf, pos.x + jointHalf]
: [pos.x];
return xs.map((xSep, j) => (
));
})
}
{/* Width labels -- show during resize or hover */}
{!shellOnlyMode && !suppressHtmlOverlays && modules.length > 0 && (resizeState !== null || hoveredModule !== null) && (
<>
{resizeState !== null && (
<>
{modules[resizeState.separatorIndex] && (
)}
{modules[resizeState.separatorIndex + 1] && (
)}
>
)}
{resizeState === null && hoveredModule !== null && modules[hoveredModule] && (
)}
>
)}
{/* Module contents: doors, shelves, rods, drawers + click-to-select */}
{!shellOnlyMode && useModules && modules.map((module, moduleIndex) => {
const pos = modulePositions[moduleIndex];
if (!pos) return null;
const moduleCenter = pos.center;
const moduleWidth = pos.width;
const shelfCount = module.shelves || 0;
const drawersCount = module.drawers || 0;
const isSelected = selectedModuleIndex === moduleIndex;
const needsMiddleFix = minShelvesForStructuralHeight(config.heightMm || 2400) > 0;
const bodyHmm = (Number(config.heightMm) || 0) - (Number(config.plinthHeightMm) || 0);
const bodyMidMm = bodyHmm / 2;
const structuralMinMm = bodyMidMm - STRUCTURAL_WINDOW_HALF_MM;
const structuralMaxMm = bodyMidMm + STRUCTURAL_WINDOW_HALF_MM;
const resolvedShelfPositionsMm = resolveShelfPositionsMm({
shelfCount,
heightMm: config.heightMm,
plinthHeightMm: config.plinthHeightMm ?? 100,
shelfPositionsMm: module.shelfPositionsMm
});
const hasShelfInStructuralWindow =
!needsMiddleFix ||
resolvedShelfPositionsMm.some((p) => p >= structuralMinMm && p <= structuralMaxMm);
const structuralZoneCenterY =
-height / 2 + plinthH + (bodyMidMm / 1000);
const structuralZoneHeight = (STRUCTURAL_WINDOW_HALF_MM * 2) / 1000;
const moduleSpanForZone = Math.max(0.02, moduleWidth - PANEL_THICKNESS * 2);
const depthSpanForZone = Math.max(0.02, depth * 0.9);
const shelfCenterYs = shelfCenterYsMeters({
shelfCount,
heightM: height,
plinthHM: plinthH,
shelfGapMm: module.shelfGapMm,
shelfPositionsMm: module.shelfPositionsMm,
});
return (
{/* Invisible click/hover area for module selection */}
{activeStep !== 2 && (
setHoveredModule(moduleIndex)}
onPointerOut={() => setHoveredModule(prev => prev === moduleIndex ? null : prev)}
onClick={(e) => {
e.stopPropagation();
if (moduleClickHandler) moduleClickHandler(moduleIndex);
}}
>
)}
{/* Selection highlight */}
{isSelected && (
)}
{activeStep === 2 && needsMiddleFix && !hasShelfInStructuralWindow && (
)}
{/* Portes battantes per module */}
{showDoors && !isSliding && (
(() => {
const doorW = moduleWidth * 0.96;
const drawerCount = Math.max(0, Number(module?.drawers) || 0);
const drawerHeightsMm = Array.isArray(hw.drawerHeightsMm) ? hw.drawerHeightsMm : [90, 128, 177, 240];
const drawerGapMm = 8;
const drawerStackMmRaw =
drawerCount > 0
? drawerHeightsMm
.slice(0, Math.min(drawerCount, drawerHeightsMm.length))
.reduce((s, v) => s + (Number(v) || 0), 0) +
Math.max(0, drawerCount - 1) * drawerGapMm
: 0;
const drawerStackM = Math.min(bodyH * 0.78, Math.max(0, drawerStackMmRaw) * SCALE);
/** Caché : porte pleine hauteur ; Visible : porte au-dessus du bandeau tiroirs. */
const drawerHidden = ['hidden', 'hidden_with_handle'].includes(hw.drawerType || 'visible');
const splitDoorOverDrawers = drawerCount > 0 && !drawerHidden;
const drawerStackForDoor = splitDoorOverDrawers ? drawerStackM : 0;
const doorH = Math.max(0.12, bodyH * 0.96 - drawerStackForDoor);
const doorCenterY = splitDoorOverDrawers
? plinthH / 2 + drawerStackM / 2
: plinthH / 2;
const doorCount = Math.max(1, modules.length);
const overrideSides =
Array.isArray(hw.doorHingeSides) && hw.doorHingeSides.length === doorCount
? hw.doorHingeSides
: null;
const hingeSide =
overrideSides
? (overrideSides[moduleIndex] === 'right' ? 'right' : 'left')
: getDoorHingeSide(moduleIndex, doorCount, hw);
// Rotate so the free edge swings toward the front (+Z), i.e. "outside" the carcass.
const angle = hingeSide === 'left' ? -doorOpenAngle : doorOpenAngle;
const hingeX = hingeSide === 'left'
? moduleCenter - doorW / 2
: moduleCenter + doorW / 2;
const localX = hingeSide === 'left' ? doorW / 2 : -doorW / 2;
return (
);
})()
)}
{/* Shelves */}
{(() => {
const bottomInside = -height / 2 + plinthH + PANEL_THICKNESS;
const innerSpan = bodyH - 2 * PANEL_THICKNESS;
const yMiddle = bottomInside + innerSpan / 2;
const tolM = 0.2; // ±20 cm tolerance around middle
let structuralIdx = null;
let best = Infinity;
for (let i = 0; i < shelfCount; i++) {
const y = shelfCenterYs[i];
if (y == null) continue;
const d = Math.abs(y - yMiddle);
if (d < best) {
best = d;
structuralIdx = i;
}
}
return (
<>
{Array.from({ length: shelfCount }).map((_, shelfIndex) => {
const shelfY = shelfCenterYs[shelfIndex];
const isStructural = needsMiddleFix && shelfIndex === structuralIdx;
const shelfKey = `${moduleIndex}-${shelfIndex}`;
const isShelfHovered = hoveredShelfKey === shelfKey;
const isShelfDragging =
shelfDragState?.moduleIndex === moduleIndex && shelfDragState?.shelfIndex === shelfIndex;
return (
{activeStep === 2 && (
{
e.stopPropagation();
setHoveredShelfKey(shelfKey);
gl.domElement.style.cursor = 'ns-resize';
}}
onPointerOut={(e) => {
e.stopPropagation();
setHoveredShelfKey((prev) => (prev === shelfKey ? null : prev));
if (!shelfDragState) gl.domElement.style.cursor = 'auto';
}}
onPointerDown={(e) => handleShelfPointerDown(moduleIndex, shelfIndex, isStructural, e)}
>
)}
);
})}
>
);
})()}
{/* Fixed structural shelf above drawers */}
{drawersCount > 0 && (
(() => {
const drawerHeightsMm = Array.isArray(hw.drawerHeightsMm) ? hw.drawerHeightsMm : [90, 128, 177, 240];
const drawerGapMm = 8;
const drawerStackMmRaw =
drawersCount > 0
? drawerHeightsMm
.slice(0, Math.min(drawersCount, drawerHeightsMm.length))
.reduce((s, v) => s + (Number(v) || 0), 0) +
Math.max(0, drawersCount - 1) * drawerGapMm
: 0;
const drawerStackM = Math.min(bodyH * 0.78, Math.max(0, drawerStackMmRaw) * SCALE);
const yFixed = (-height / 2 + plinthH + PANEL_THICKNESS) + drawerStackM + PANEL_THICKNESS / 2;
return (
);
})()
)}
{/* Drawers */}
{drawersCount > 0 && (
)}
{/* Rod (penderie) */}
{module.hasRod && (
)}
);
})}
{/* Portes coulissantes (sliding) */}
{showDoors && isSliding && (
)}
{/* Module content is rendered above in the module loop */}
{/* Non-module door rendering is also above */}
{/* Step 1 dimension editor: bounding box + edge grips + rulers */}
{showDimensionHtml && (
<>
handleDimensionPointerDown('widthMm', e)}
/>
handleDimensionPointerDown('heightMm', e)}
/>
handleDimensionPointerDown('depthMm', e)}
/>
>
)}
{/* Pass interaction state to parent for OrbitControls toggling */}
);
}
function WardrobeOutline({ width, height, depth }) {
const geom = useMemo(() => {
const g = new THREE.EdgesGeometry(new THREE.BoxGeometry(width, height, depth), 18);
return g;
}, [width, height, depth]);
useEffect(() => () => geom.dispose(), [geom]);
return (
);
}
/* ===================================================================
InteractionBlocker - disables OrbitControls during drag/resize
Uses useThree to access the controls
=================================================================== */
function InteractionBlocker({ active }) {
const { controls } = useThree();
useEffect(() => {
if (controls) {
controls.enabled = !active;
}
}, [active, controls]);
return null;
}
/* ===================================================================
CameraController - exposes zoom/reset via ref
=================================================================== */
const CameraController = forwardRef(({ roomConfig, config, modules, activeStep }, ref) => {
const { camera, gl } = useThree();
const controlsRef = useRef();
const isDimensionStep = activeStep === 1;
const snapAnimRef = useRef(null);
const cameraTarget = useMemo(() => {
if (roomConfig) {
return [0, roomConfig.height / 2, 0];
}
return [0, (config.heightMm || 2400) * SCALE * 0.5, 0];
}, [roomConfig, config]);
// Keep OrbitControls target in sync as dimensions/modules change.
useEffect(() => {
if (!controlsRef.current) return;
const [x, y, z] = cameraTarget;
controlsRef.current.target.set(x, y, z);
controlsRef.current.update();
}, [cameraTarget]);
const totalWidthMm = useMemo(() => {
if (Array.isArray(modules) && modules.length > 0) {
const sum = modules.reduce((s, m) => s + (Number(m?.widthMm) || 0), 0);
return sum > 0 ? sum : (config.widthMm || 2400);
}
return config.widthMm || 2400;
}, [modules, config.widthMm]);
const fitDistance = useMemo(() => {
if (roomConfig) {
return Math.max(roomConfig.width, roomConfig.length) * 1.2;
}
const w = totalWidthMm * SCALE;
const h = (config.heightMm || 2400) * SCALE;
const d = (config.depthMm || 600) * SCALE;
const maxDim = Math.max(w, h, d);
const fov = THREE.MathUtils.degToRad(45);
const dist = (maxDim / 2) / Math.tan(fov / 2);
return Math.max(1.2, dist * 1.25);
}, [roomConfig, totalWidthMm, config.heightMm, config.depthMm]);
const getDefaultDistance = useCallback(
() => fitDistance,
[fitDistance]
);
const setView = useCallback((view) => {
if (!controlsRef.current) return;
const target = controlsRef.current.target;
const dist = getDefaultDistance();
if (view === 'front') camera.position.set(0, target.y, dist);
if (view === 'side') camera.position.set(dist, target.y, 0);
if (view === 'top') camera.position.set(0.001, target.y + dist, 0.001);
if (view === 'iso') camera.position.set(dist * 0.75, target.y + dist * 0.35, dist * 0.75);
if (view === 'inside') {
// View from slightly in front of the wardrobe, looking into it.
const depth = (config.depthMm || 600) * SCALE;
camera.position.set(0, target.y, Math.max(0.25, depth * 0.35));
}
controlsRef.current.update();
}, [camera, getDefaultDistance, config]);
const cancelSnap = useCallback(() => {
if (snapAnimRef.current) {
cancelAnimationFrame(snapAnimRef.current.raf);
snapAnimRef.current = null;
}
}, []);
// Intentionally no snapping rules: keep mouse movement direct.
useImperativeHandle(ref, () => ({
resetView: () => {
if (controlsRef.current) {
cancelSnap();
const defaultDist = getDefaultDistance();
if (isDimensionStep) {
camera.position.set(defaultDist * 0.75, defaultDist * 0.7, defaultDist * 0.9);
if (camera.isOrthographicCamera) {
camera.zoom = 85;
camera.updateProjectionMatrix();
}
} else {
// Default to a consistent 3/4 view
camera.position.set(defaultDist * 0.75, defaultDist * 0.55, defaultDist * 0.75);
}
controlsRef.current.update();
}
},
}), [camera, cancelSnap, getDefaultDistance, isDimensionStep]);
useEffect(() => {
if (controlsRef.current) {
controlsRef.current.minPolarAngle = isDimensionStep ? 0 : 0;
controlsRef.current.maxPolarAngle = Math.PI;
}
}, [isDimensionStep]);
const handleControlStart = useCallback(() => {
cancelSnap();
gl.domElement.style.cursor = 'grabbing';
}, [cancelSnap, gl]);
const handleControlEnd = useCallback(() => {
gl.domElement.style.cursor = 'auto';
}, [gl]);
return (
);
});
/* ===================================================================
Scene - assembles everything
=================================================================== */
function Scene({
config,
roomConfig,
controllerRef,
colors,
modules,
onElementClick,
onModuleClick,
selectedModuleIndex,
showDoors,
onResizeModules,
onUpdateModule,
onSetDimension,
onBeginUndoableGesture,
activeStep,
suppressHtmlOverlays,
}) {
const wardrobeTransform = useMemo(() => {
if (roomConfig) {
return calculateWardrobeTransform(roomConfig, config);
}
return { position: [0, 0, 0], rotation: [0, 0, 0] };
}, [roomConfig, config]);
const nicheBackdrop = useMemo(() => {
if (roomConfig) return null;
if (config?.installationType !== 'niche') return null;
const OUTER_WALL_EXTENT_M = 200; // effectively infinite wall planes
const gapSideM = 0.05; // 5cm clearance on sides
const gapTopM = 0; // no clearance on top
const gapBottomM = 0; // no clearance under (sit on floor)
const gapDepthM = 0.05; // 5cm clearance front/back
const widthMmRaw = Array.isArray(modules) && modules.length > 0
? modules.reduce((s, m) => s + (Number(m?.widthMm) || 0), 0)
: (config.widthMm || 2400);
const widthM = Math.max(0.4, (widthMmRaw || 2400) * SCALE);
const heightM = Math.max(1.8, (config.heightMm || 2400) * SCALE);
const depthM = Math.max(0.4, (config.depthMm || 600) * SCALE);
const nicheW = widthM + gapSideM * 2;
const nicheH = heightM + gapTopM + gapBottomM;
const nicheD = depthM + gapDepthM * 2;
return {
outerWallW: OUTER_WALL_EXTENT_M,
outerWallH: OUTER_WALL_EXTENT_M,
outerWallD: OUTER_WALL_EXTENT_M,
gapSideM,
gapTopM,
gapBottomM,
gapDepthM,
widthM,
heightM,
depthM,
nicheW,
nicheH,
nicheD,
// wardrobe spans y=[0..heightM], z=[-depthM/2..+depthM/2]
floorY: -gapBottomM,
ceilY: heightM + gapTopM,
backZ: -depthM / 2 - gapDepthM,
frontZ: depthM / 2 + gapDepthM,
leftX: -widthM / 2 - gapSideM,
rightX: widthM / 2 + gapSideM,
};
}, [roomConfig, config?.installationType, config?.widthMm, config?.heightMm, config?.depthMm, modules]);
const freeBackdrop = useMemo(() => {
if (roomConfig) return null;
if (config?.installationType !== 'free') return null;
const depthM = (config.depthMm || 600) * SCALE;
const heightM = (config.heightMm || 2400) * SCALE;
// Place the wall right behind the wardrobe back face.
const wallZ = -depthM / 2 - 0.01;
const wallY = heightM * 0.6;
return { wallZ, wallY };
}, [roomConfig, config?.installationType, config?.depthMm, config?.heightMm]);
return (
<>
{/* Niche "alcove" backdrop with 5cm clearance */}
{/* All backdrop meshes opt out of raycasting: they are decorative only and
must never intercept pointer events meant for wardrobe handles. */}
{nicheBackdrop && (
{/* floor */}
null}>
{/* back wall (niche sized) */}
null}>
{/* left wall (niche sized) */}
null}>
{/* right wall (niche sized) */}
null}>
{/* ceiling (niche sized) */}
null}>
{/* front wall: everywhere except the niche opening */}
)}
{/* Backdrop wall/floor for "Libre" (free-standing) view */}
{freeBackdrop && !nicheBackdrop && (
{/* floor */}
null}>
{/* wall behind wardrobe (Z-), flush to back */}
null}>
)}
{roomConfig && (
)}
>
);
}
function FrontWallWithOpening({ niche }) {
const shape = useMemo(() => {
// Outer wall (slightly larger than niche box so it reads as the room wall)
const outerW = Math.max(6, niche.outerWallW || niche.nicheW * 3);
const outerH = Math.max(3, niche.outerWallH || niche.nicheH * 2.2);
// Hole = niche opening (exact nicheW/nicheH), positioned around the wardrobe volume
const holeW = niche.nicheW;
const holeH = niche.nicheH;
const s = new THREE.Shape();
s.moveTo(-outerW / 2, -outerH / 2);
s.lineTo(outerW / 2, -outerH / 2);
s.lineTo(outerW / 2, outerH / 2);
s.lineTo(-outerW / 2, outerH / 2);
s.closePath();
const hole = new THREE.Path();
// Hole Y range: from floorY to floorY + holeH
const holeBottom = niche.floorY;
const holeTop = niche.floorY + holeH;
hole.moveTo(-holeW / 2, holeBottom);
hole.lineTo(holeW / 2, holeBottom);
hole.lineTo(holeW / 2, holeTop);
hole.lineTo(-holeW / 2, holeTop);
hole.closePath();
s.holes.push(hole);
return s;
}, [niche]);
return (
// raycast disabled: purely decorative wall must not intercept clicks on
// dimension grips that sit just outside the niche opening (e.g. width
// handle above the wardrobe top is behind the solid wall in 3D space).
null}>
);
}
/* ===================================================================
WardrobePreview - main exported component
=================================================================== */
export default function WardrobePreview({
config,
activeStep = 1,
roomConfig,
colors = {},
modules = [],
onElementClick,
onModuleClick,
onResizeModules,
onUpdateModule,
onSetDimension,
onBeginUndoableGesture,
canUndo3d = false,
canRedo3d = false,
onUndo3d,
onRedo3d,
selectedModuleIndex,
suppressHtmlOverlays = false,
}) {
const containerRef = useRef(null);
const controllerRef = useRef(null);
const [selectedElement, setSelectedElement] = useState(null);
const [showDoors, setShowDoors] = useState(true);
const isDimensionStep = activeStep === 1;
const perspectiveCameraPosition = useMemo(() => {
if (roomConfig) {
const roomSize = Math.max(roomConfig.width, roomConfig.length);
return [roomSize * 0.8, roomSize * 0.5, roomSize * 0.8];
}
return [2.5, 1.5, 2.5];
}, [roomConfig]);
const handleResetView = useCallback(() => {
controllerRef.current?.resetView();
}, []);
useEffect(() => {
// Re-anchor camera when entering/leaving the dedicated dimension mode.
controllerRef.current?.resetView();
}, [isDimensionStep]);
const handleToggleDoors = useCallback(() => {
setShowDoors(prev => !prev);
}, []);
const handleElementClick = useCallback((elementType, label) => {
setSelectedElement({ type: elementType, label });
if (onElementClick) {
onElementClick(elementType, label);
}
}, [onElementClick]);
// All element types that can be color-picked (including back panel)
const PICKABLE_ELEMENTS = useMemo(() => [
{ type: 'portes', label: 'Portes' },
{ type: 'cotes', label: 'Côtés' },
{ type: 'dessus', label: 'Dessus' },
{ type: 'fond', label: 'Fond (bas)' },
{ type: 'dos', label: 'Dos (arrière)' },
{ type: 'plintheBas', label: 'Plinthe' },
{ type: 'bandeauHaut', label: 'Bandeau haut' },
{ type: 'panneauxInterieurs', label: 'Panneaux int.' },
{ type: 'separateurs', label: 'Séparateurs' },
{ type: 'etageres', label: 'Étagères' },
{ type: 'rayonFixe', label: 'Rayon fixe' },
{ type: 'renfortsTiroir', label: 'Renforts tiroir' },
{ type: 'tiroirs', label: 'Tiroirs' },
{ type: 'penderie', label: 'Tringle' },
], []);
const handleElementSelect = useCallback((e) => {
const val = e.target.value;
if (!val) return;
const item = PICKABLE_ELEMENTS.find(p => p.type === val);
if (item) {
handleElementClick(item.type, item.label);
}
// Reset select to placeholder
e.target.value = '';
}, [PICKABLE_ELEMENTS, handleElementClick]);
const showDoorToggle = activeStep === 2 || activeStep === 3 || activeStep === 6;
const showColorSelector = activeStep === 4 || activeStep === 6;
const showReset = isDimensionStep || activeStep === 2 || activeStep === 6;
const show3dUndo = typeof onUndo3d === 'function';
const effectiveShowDoors = isDimensionStep ? true : showDoors;
return (
{selectedElement && !isDimensionStep && (
Élément sélectionné: {selectedElement.label}
)}
{/* Zoom Controls */}
{showReset && (
)}
{show3dUndo && (
<>
>
)}
{showDoorToggle && (
<>
>
)}
{showColorSelector && (
<>
>
)}
Chargement 3D… }>
);
}