1904 lines
70 KiB
React
1904 lines
70 KiB
React
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">◊</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)"
|
||
>
|
||
←
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`${styles.controlBtn} ${!canRedo3d ? styles.controlBtnDisabled : ''}`}
|
||
onClick={onRedo3d}
|
||
disabled={!canRedo3d}
|
||
title="Rétablir (Ctrl+Shift+Z ou Ctrl+Y)"
|
||
>
|
||
→
|
||
</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>
|
||
);
|
||
}
|