Files
apex/client/src/components/WardrobePreview.jsx
T
lejayandCursor 30921aade6 v2.1
Ship configurator updates with catalog DB included for server deploy.

Co-authored-by: Cursor <[email protected]>
2026-10-07 17:13:03 +02:00

3345 lines
130 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useRef, Suspense, useMemo, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { OrbitControls, Html, Edges } from '@react-three/drei';
import * as THREE from 'three';
import Room3D, { calculateWardrobeTransform } from './Room3D';
import WardrobeElement from './WardrobeElement';
import Icon from './ui/Icon';
import styles from './WardrobePreview.module.css';
import {
GROOVE,
PANEL_M,
PLINTH,
getAxiloPlinthPanelHeightMm,
getAxiloPlinthReturnLengthMm,
getAxiloPlinthFrontSideDeductionMm,
hasSidePlinthReturn,
getDoorHingeSide,
DRAWER_REINFORCEMENT,
drawerStackHeightMm,
drawerNicheHeightsMm,
HORIZONTAL_SPAN_DOOR_MODES,
resolveHorizontalSpan,
getSeparatorVerticalSegments,
validateHorizontalSpans,
usesBlockConstruction,
columnUsesBlockConstruction,
horizontalConstructionRuns,
BLOCK_ASSEMBLY
} from '@shared/manufacturing.js';
import { shelfCenterYsMeters, minShelvesForStructuralHeight, minShelvesForColumn, moduleStackInfo, moduleBlockHeightsMm, moduleBlockDoors, moduleHangingZoneMm, resolveShelfPositionsMm, system32ShelfPositionsMm, STRUCTURAL_WINDOW_HALF_MM, DIMENSION_RANGES } from '../constants/wardrobe';
const SCALE = 1 / 1000;
/** Three-quarter from the front: facade stays readable, side depth still shows. */
const DEFAULT_VIEW_YAW = Math.PI / 8;
/** Camera rise above the look-at point, before the direction is normalized. */
const DEFAULT_VIEW_ELEV = 0.22;
function defaultViewDirection() {
return new THREE.Vector3(
Math.sin(DEFAULT_VIEW_YAW),
DEFAULT_VIEW_ELEV,
Math.cos(DEFAULT_VIEW_YAW),
).normalize();
}
function carcassBox(totalWidthMm, heightMm, depthMm) {
return {
w: (totalWidthMm || 2400) * SCALE,
h: (heightMm || 2400) * SCALE,
d: (depthMm || 600) * SCALE,
};
}
/** Camera-to-target distance that keeps the carcass inside the frame. */
function distanceToFitCarcass(box, targetY, dir, fovDeg, aspect) {
const back = dir.clone().normalize();
const xAxis = new THREE.Vector3().crossVectors(new THREE.Vector3(0, 1, 0), back).normalize();
const yAxis = new THREE.Vector3().crossVectors(back, xAxis).normalize();
const tanV = Math.tan(THREE.MathUtils.degToRad(fovDeg) / 2) * 0.86;
const tanH = tanV * Math.max(0.2, aspect || 1);
let need = 0;
const p = new THREE.Vector3();
for (const sx of [-1, 1]) {
for (const sy of [0, 1]) {
for (const sz of [-1, 1]) {
p.set(sx * box.w / 2, sy * box.h - targetY, sz * box.d / 2);
const cz = p.dot(back);
need = Math.max(need, cz + Math.abs(p.dot(xAxis)) / tanH, cz + Math.abs(p.dot(yAxis)) / tanV);
}
}
}
return Math.max(0.8, need);
}
function defaultCameraPosition({ roomConfig, totalWidthMm, heightMm, depthMm, aspect = 1.6 }) {
if (roomConfig) {
const roomSize = Math.max(roomConfig.width, roomConfig.length);
return [roomSize * 0.8, roomSize * 0.5, roomSize * 0.8];
}
const box = carcassBox(totalWidthMm, heightMm, depthMm);
const targetY = box.h * 0.5;
const dir = defaultViewDirection();
const dist = distanceToFitCarcass(box, targetY, dir, 45, aspect);
return [dir.x * dist, targetY + dir.y * dist, dir.z * dist];
}
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)
/** Centre du dos sur l'axe de la rainure, mesuré depuis le chant arrière de la caisse. */
const BACK_PANEL_AXIS_FROM_REAR_M = GROOVE.back.axisFromPanelEdge * SCALE;
/** Partie cachée du dos engagée dans chaque rainure périphérique. */
const BACK_PANEL_GROOVE_INSERT_M = GROOVE.back.depth * SCALE;
const DIMENSION_RULES = {
widthMm: DIMENSION_RANGES.widthMm,
heightMm: DIMENSION_RANGES.heightMm,
depthMm: DIMENSION_RANGES.depthMm,
};
/**
* Jeu de façade (reveal) par côté, en mètres. 1.5 mm de chaque côté en hauteur
* et en largeur → 3 mm de jeu total entre façades adjacentes (portes et faces de tiroirs).
*/
const FACADE_REVEAL_MM = 1.5;
const FACADE_REVEAL_M = FACADE_REVEAL_MM * SCALE;
/** Tiny embed so coplanar carcass faces don't leak the background at 90° junctions. */
const JOIN_OVERLAP_M = 0.0008;
/** 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;
/**
* 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);
}
/** Below this sin² between view ray and drag axis, the axis points at the camera and ray projection is unstable. */
const AXIS_DRAG_MIN_DENOM = 0.04;
/** Magnetic snap radius in screen pixels (converted to mm at drag start). */
const SNAP_RADIUS_PX = 10;
function rayFromClient(clientX, clientY, camera, domElement) {
const rect = domElement.getBoundingClientRect();
const ndc = new THREE.Vector2(
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1
);
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(ndc, camera);
return raycaster.ray;
}
function worldToClient(point, camera, domElement) {
const rect = domElement.getBoundingClientRect();
const p = point.clone().project(camera);
return new THREE.Vector2(
rect.left + ((p.x + 1) / 2) * rect.width,
rect.top + ((1 - p.y) / 2) * rect.height
);
}
/**
* Maps pointer motion to a signed displacement (m) along a world axis through `origin`,
* so the grabbed handle stays under the cursor whatever the zoom or orbit angle.
* The camera must stay still for the duration of the gesture.
*/
function createAxisDrag({ origin, axis, camera, domElement, clientX, clientY }) {
const o = origin.clone();
const a = axis.clone().normalize();
const screenStart = worldToClient(o, camera, domElement);
const screenAxis = worldToClient(o.clone().addScaledVector(a, 1), camera, domElement).sub(screenStart);
const pxPerMeter = Math.max(1, screenAxis.length());
const projectOnAxis = (cx, cy) => {
const ray = rayFromClient(cx, cy, camera, domElement);
const w0 = ray.origin.clone().sub(o);
const b = ray.direction.dot(a);
const denom = 1 - b * b;
if (denom < AXIS_DRAG_MIN_DENOM) return null;
return (a.dot(w0) - b * ray.direction.dot(w0)) / denom;
};
const startT = projectOnAxis(clientX, clientY);
const useRay = startT !== null && pxPerMeter > 40;
let last = 0;
return {
mmPerPx: 1000 / pxPerMeter,
deltaMeters(cx, cy) {
if (useRay) {
const t = projectOnAxis(cx, cy);
if (t !== null) last = t - startT;
return last;
}
if (pxPerMeter > 40) {
const d = new THREE.Vector2(cx - clientX, cy - clientY);
return d.dot(screenAxis) / (pxPerMeter * pxPerMeter);
}
// Axis seen end-on: fall back to a fixed rate, up/right = positive.
return ((cx - clientX) - (cy - clientY)) * 0.002;
},
};
}
function axisDragFromEvent(e, localAxis, camera, domElement) {
const object = e.object;
object.updateWorldMatrix(true, false);
const origin = new THREE.Vector3().setFromMatrixPosition(object.matrixWorld);
const axis = localAxis.clone().transformDirection(object.matrixWorld);
return {
origin,
axis,
drag: createAxisDrag({ origin, axis, camera, domElement, clientX: e.clientX, clientY: e.clientY }),
};
}
/** First candidate (in priority order) within `radius` of `value`, else null. */
function magneticSnap(value, candidates, radius) {
for (const c of candidates) {
if (Number.isFinite(c.value) && Math.abs(c.value - value) <= radius) return c;
}
return null;
}
/* ===================================================================
ResizableSeparator - a draggable handle between two modules
=================================================================== */
function ResizableSeparator({
position,
height,
depth,
zCenter,
visibleSegments,
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 ; même emprise Z que dessus/fond */}
{(visibleSegments || [{ centerY: 0, height }]).map((segment, index) => (
<group key={`visible-segment-${index}`} position={[0, segment.centerY, zCenter]}>
<WardrobeElement
elementType="panneauxInterieurs"
geometryType="box"
geometryArgs={[PANEL_THICKNESS, segment.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, hint, snapped = false }) {
return (
<Html position={position} center style={{ pointerEvents: 'none' }}>
<div style={{
background: snapped ? 'rgba(5, 150, 105, 0.95)' : '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
{hint && <span style={{ marginLeft: 6, opacity: 0.85, fontWeight: 500 }}>{hint}</span>}
</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,
drawerHeightsMm,
colors,
onElementClick
}) {
const reveal = FACADE_REVEAL_M;
const gapMm = 8; // jeu de fabrication entre faces de tiroirs (mm) — pris DANS la niche Legrabox
// Hauteurs de NICHE Legrabox par tiroir (SMS) — mêmes valeurs que porte / rayon fixe / renforts.
const heightsMm = drawerNicheHeightsMm(drawersCount, drawerHeightsMm);
const totalMm = heightsMm.reduce((s, v) => s + v, 0);
// Même capping que le rayon fixe au-dessus des tiroirs, pour rester aligné.
const totalM = Math.min(bodyH * 0.78, totalMm * SCALE);
const k = totalMm > 0 ? totalM / (totalMm * SCALE) : 1;
/** 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 bodyCenterZ = frontFaceZ - drawerBodyDepthM / 2 - PANEL_THICKNESS / 2;
const faceW = Math.max(0.04, moduleWidth - 2 * reveal);
const startY = -bodyH / 2 + PANEL_THICKNESS;
let cursor = startY;
const items = [];
for (let di = 0; di < drawersCount; di++) {
const hM = heightsMm[di] * SCALE * k;
const yc = cursor + hM / 2;
// Façade = niche − jeu (8 mm pris dans la niche) − jeux visuels haut/bas.
const faceH = Math.max(0.02, hM - gapMm * SCALE * k - 2 * reveal);
items.push({ di, yc, faceH });
cursor += hM;
}
// Cales de compensation (renforts) — uniquement pour tiroirs cachés derrière une porte
// à charnières : un montant gauche + un droit, en retrait avant pour ne pas toucher les charnières.
const caleThicknessM = DRAWER_REINFORCEMENT.thicknessMm * SCALE;
const caleDepthM = Math.max(
0.08,
depth -
(DRAWER_REINFORCEMENT.backInsetFromRearMm + DRAWER_REINFORCEMENT.hingeFrontClearanceMm) * SCALE -
PANEL_THICKNESS
);
const carcassFrontZ = depth / 2 - PANEL_THICKNESS;
const caleFrontZ = carcassFrontZ - DRAWER_REINFORCEMENT.hingeFrontClearanceMm * SCALE;
const caleCenterZ = caleFrontZ - caleDepthM / 2;
const caleHeightM = Math.max(0.02, totalM);
const caleCenterY = plinthH / 2 + startY + totalM / 2;
const caleLeftX = moduleCenter - moduleWidth / 2 + PANEL_THICKNESS + caleThicknessM / 2;
const caleRightX = moduleCenter + moduleWidth / 2 - PANEL_THICKNESS - caleThicknessM / 2;
return (
<>
{behindDoor && moduleWidth > 0 && drawersCount > 0 && (
<>
<WardrobeElement
elementType="renfortsTiroir"
geometryType="box"
geometryArgs={[caleThicknessM, caleHeightM, caleDepthM]}
position={[caleLeftX, caleCenterY, caleCenterZ]}
colors={colors}
onElementClick={onElementClick}
materialProps={{ roughness: 0.7, metalness: 0.02 }}
/>
<WardrobeElement
elementType="renfortsTiroir"
geometryType="box"
geometryArgs={[caleThicknessM, caleHeightM, caleDepthM]}
position={[caleRightX, caleCenterY, caleCenterZ]}
colors={colors}
onElementClick={onElementClick}
materialProps={{ roughness: 0.7, metalness: 0.02 }}
/>
</>
)}
{items.map(({ di, yc, faceH }) => (
<group key={`drawer-${di}`}>
{/* Corps (volume du tiroir, en retrait derrière la face) */}
<WardrobeElement
elementType="tiroirs"
geometryType="box"
geometryArgs={[Math.max(0.03, faceW - 0.02), Math.max(0.02, faceH - 0.008), drawerBodyDepthM]}
position={[moduleCenter, plinthH / 2 + yc, moduleWidth > 0 ? bodyCenterZ : 0]}
colors={colors}
onElementClick={onElementClick}
materialProps={{ roughness: 0.6, metalness: 0.03 }}
/>
{/* Face avant alignée (jeu 1.5 mm de chaque côté) */}
<WardrobeElement
elementType="tiroirs"
geometryType="box"
geometryArgs={[faceW, faceH, PANEL_THICKNESS]}
position={[moduleCenter, plinthH / 2 + yc, moduleWidth > 0 ? frontFaceZ : 0]}
colors={colors}
onElementClick={onElementClick}
materialProps={{ roughness: 0.45, metalness: 0.05 }}
/>
</group>
))}
</>
);
}
/* ===================================================================
WardrobeMesh - Full wardrobe model with drag/resize/drawers
=================================================================== */
const GHOST_COLOR = '#6366f1';
const ignoreRaycast = () => null;
function GhostBox({ size, position }) {
return (
<mesh position={position} raycast={ignoreRaycast} renderOrder={2}>
<boxGeometry args={size} />
<meshBasicMaterial color={GHOST_COLOR} transparent opacity={0.14} depthWrite={false} />
<Edges color={GHOST_COLOR} transparent opacity={0.55} />
</mesh>
);
}
/** Étape Espace: aperçu de l'agencement intérieur en transparence (non interactif). */
function LayoutGhost({ config, modules, modulePositions, height, depth, plinthH }) {
const T = PANEL_THICKNESS;
const baseY = -height / 2 + plinthH;
const innerDepth = Math.max(0.02, depth - T * 2);
return (
<group>
{modules.map((mod, i) => {
const pos = modulePositions[i];
if (!pos) return null;
const innerW = Math.max(0.02, pos.width - T * 2);
const heightsMm = moduleBlockHeightsMm(mod, config);
const junctionsMm = heightsMm.slice(0, -1).reduce((acc, h) => [...acc, (acc[acc.length - 1] || 0) + h], []);
const shelvesMm = resolveShelfPositionsMm({
shelfCount: Math.max(minShelvesForColumn(config.heightMm, mod, config), Number(mod.shelves) || 0),
heightMm: config.heightMm,
plinthHeightMm: config.plinthHeightMm ?? 100,
shelfPositionsMm: mod.shelfPositionsMm,
stack: moduleStackInfo(mod, config)
});
const drawers = Math.max(0, Number(mod.drawers) || 0);
const drawerH = drawers > 0 ? (drawerStackHeightMm(drawers, config.hardware?.drawerHeightsMm) * SCALE) / drawers : 0;
const zone = moduleHangingZoneMm(mod, config);
return (
<group key={mod.id || i}>
{i > 0 && (
<GhostBox size={[T, height - plinthH - T * 2, innerDepth]} position={[pos.center - pos.width / 2, baseY + (height - plinthH) / 2, 0]} />
)}
{junctionsMm.map((mm) => (
<GhostBox key={`j${mm}`} size={[innerW, T * 2, innerDepth]} position={[pos.center, baseY + mm * SCALE, 0]} />
))}
{shelvesMm.map((mm) => (
<GhostBox key={`s${mm}`} size={[innerW, T, innerDepth]} position={[pos.center, baseY + mm * SCALE, 0]} />
))}
{Array.from({ length: drawers }, (_, d) => (
<GhostBox
key={`d${d}`}
size={[innerW - 0.01, drawerH - 0.006, innerDepth * 0.9]}
position={[pos.center, baseY + T + drawerH * (d + 0.5), 0]}
/>
))}
{zone && (
<mesh position={[pos.center, baseY + zone.rodMm * SCALE, 0]} rotation={[0, 0, Math.PI / 2]} raycast={ignoreRaycast} renderOrder={2}>
<cylinderGeometry args={[0.012, 0.012, innerW - 0.02, 12]} />
<meshBasicMaterial color={GHOST_COLOR} transparent opacity={0.6} depthWrite={false} />
</mesh>
)}
</group>
);
})}
</group>
);
}
function WardrobeMesh({
config,
colors = {},
onElementClick,
onModuleClick,
selectedModuleIndex,
selectedBlockIndex = 0,
selectedHorizontalSpanId,
onHorizontalSpanClick,
modules = [],
showDoors = true,
onResizeModules,
onUpdateModule,
onSetDimension,
onBeginUndoableGesture,
activeStep = 1,
suppressHtmlOverlays = false,
onInteractionChange,
}) {
const { gl, camera, controls } = 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 plinthPanelH = getAxiloPlinthPanelHeightMm(config.plinthHeightMm || 100) * SCALE;
const plinthThicknessM = PLINTH.panelThicknessMm * SCALE;
const plinthSetbackM = PLINTH.frontSetbackMm * SCALE;
const plinthZ = depth / 2 - plinthSetbackM - plinthThicknessM / 2;
const bodyH = height - plinthH;
const isNiche = config.installationType === 'niche';
const nicheMeasurements = config.nicheMeasurements || {};
const smallestNicheMeasureM = (values, fallbackMm) => {
const valid = values.map(Number).filter((value) => Number.isFinite(value) && value > 0);
return (valid.length > 0 ? Math.min(...valid) : fallbackMm) * SCALE;
};
const nicheOpeningWidth = isNiche
? smallestNicheMeasureM(
[
nicheMeasurements.widthTopMm,
nicheMeasurements.widthMidMm,
nicheMeasurements.widthBottomMm
],
totalWidth + 2 * (Number(config.sanitaryGapMm) || 30)
)
: width;
const nicheOpeningHeight = isNiche
? smallestNicheMeasureM(
[nicheMeasurements.heightLeftMm, nicheMeasurements.heightRightMm],
(config.heightMm || 2400) + (Number(config.sanitaryGapMm) || 30)
)
: height;
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 sideAssemblyLeft =
config.installationType === 'free' && exteriorFinishesEnabled && sideFinishLeftEnabled;
const sideAssemblyRight =
config.installationType === 'free' && exteriorFinishesEnabled && sideFinishRightEnabled;
const leftPlinthReturn = hasSidePlinthReturn(config, 'left');
const rightPlinthReturn = hasSidePlinthReturn(config, 'right');
const leftPlinthDeductM = getAxiloPlinthFrontSideDeductionMm(config, 'left') * SCALE;
const rightPlinthDeductM = getAxiloPlinthFrontSideDeductionMm(config, 'right') * SCALE;
const frontPlinthWidth = isNiche
? width
: Math.max(0.02, width - leftPlinthDeductM - rightPlinthDeductM);
const frontPlinthCenterX = isNiche
? 0
: (leftPlinthDeductM - rightPlinthDeductM) / 2;
const horizontalPanelLeftInset = PANEL_THICKNESS;
const horizontalPanelRightInset = PANEL_THICKNESS;
const horizontalPanelWidth =
Math.max(0.02, width - horizontalPanelLeftInset - horizontalPanelRightInset);
const horizontalPanelCenterX = (horizontalPanelLeftInset - horizontalPanelRightInset) / 2;
/**
* Dos optionnel PAR ZONE (colonne).
* Construction: dos → caisson block ; sinon côté (montant extérieur) ou rayonnage → block ;
* sinon séparations. Legacy sans flags: dos présent partout (ancien comportement).
*/
const legacyBack = config?.hasBack !== false;
/**
* Empilage vertical PAR COLONNE — N blocks: `mod.blockHeightsMm` / `mod.blockBacks`
* (legacy: stacked + stackBottomHeightMm / hasBack + hasBackTop). Jonction = double panneau horizontal.
*/
const modHeightsMm = (i) => moduleBlockHeightsMm(modules?.[i] ?? null, config);
/** Dos par block (bas → haut); legacy sans flags → dos présent (ancien comportement). */
const modBacks = (i) => {
const m = modules?.[i];
const n = modHeightsMm(i).length;
const arr = Array.isArray(m?.blockBacks) ? m.blockBacks : null;
const base = m && typeof m.hasBack === 'boolean' ? m.hasBack : legacyBack;
return Array.from({ length: n }, (_, k) => {
if (arr && typeof arr[k] === 'boolean') return arr[k];
if (k === n - 1 && n >= 2 && m && typeof m.hasBackTop === 'boolean') return m.hasBackTop;
return base;
});
};
/** Y (repère groupe) des jonctions entre blocks de la colonne i ([] si 1 block). */
const modJointYs = (i) => {
const hs = modHeightsMm(i);
if (hs.length < 2) return [];
const ys = [];
let acc = 0;
for (let k = 0; k < hs.length - 1; k++) {
acc += hs[k];
ys.push(-height / 2 + plinthH + acc * SCALE);
}
return ys;
};
const modStacked = (i) => modHeightsMm(i).length >= 2;
const anyStacked = useModules ? modules.some((_, i) => modStacked(i)) : modStacked(0);
const modBack = (i) => modBacks(i)[0] ?? legacyBack;
/** Dos de la zone du dessus (dernier block, ou zone unique). */
const modBackTop = (i) => {
const b = modBacks(i);
return b.length > 0 ? b[b.length - 1] : legacyBack;
};
const modBackAny = (i) => modBacks(i).some(Boolean);
const anyBack = useModules ? modules.some((_, i) => modBackAny(i)) : legacyBack;
const backAtLeftSide = useModules ? modBackAny(0) : legacyBack;
const backAtRightSide = useModules ? modBackAny(modules.length - 1) : legacyBack;
/** Colonne en caisson block: dos, ou rayonnage (même sans dos). Les côtés extérieurs restent des montants block. */
const modIsBlock = (i) => {
if (!useModules) return usesBlockConstruction(null, legacyBack);
return columnUsesBlockConstruction(modules[i], config);
};
/** Jonction i (entre colonnes i et i+1): double montant si caisson des deux côtés. */
const boundaryAnyBlock = (i) => modIsBlock(i) || modIsBlock(i + 1);
/** Gap between double montants (assemblage block) */
const BLOCK_JOINT_GAP_M = (BLOCK_ASSEMBLY.jointGapMm || 3) * SCALE;
/** Same Z footprint as dessus/fond so front/back corners meet the horizontals. */
const separatorDepthM = depth - PANEL_THICKNESS;
const separatorZ = PANEL_THICKNESS / 2;
const isSliding = config.doorType === 'sliding';
const hw = config.hardware || {};
const doorOpenAngle = 0.32; // radians (~18°) - visible “ajar” preview
/** Séparateurs: côté caisson (block), montants alignés sur la hauteur habillage (body + bandeau si habillage dessus). */
const sepStyleBlock = {
y: topFinishEnabled ? plinthH / 2 + PANEL_THICKNESS / 2 : plinthH / 2,
h: topFinishEnabled ? bodyH + PANEL_THICKNESS : bodyH
};
const sepStyleSeparations = {
y: plinthH / 2,
h: bodyH - PANEL_THICKNESS * 2 + JOIN_OVERLAP_M
};
/**
* Inset of a column's horizontal board from a module edge.
* Verticals sit between dessus/fond, so the board must cover their thickness
* (outer face of a clad side, joint gap of a double montant, centerline of a shared separator).
*/
const horizontalInsetAt = (moduleIndex, side) => {
const neighbor = side === 'left' ? moduleIndex - 1 : moduleIndex + 1;
if (neighbor < 0 || neighbor >= modules.length) return PANEL_THICKNESS;
if (modIsBlock(moduleIndex)) {
return PANEL_THICKNESS + (modIsBlock(neighbor) ? BLOCK_JOINT_GAP_M / 2 : 0);
}
return 0;
};
const montantXsAt = (boundaryX, boundaryIndex) => {
const jointHalf = (BLOCK_JOINT_GAP_M + PANEL_THICKNESS) / 2;
const leftBlock = modIsBlock(boundaryIndex);
const rightBlock = modIsBlock(boundaryIndex + 1);
if (leftBlock && rightBlock) return [boundaryX - jointHalf, boundaryX + jointHalf];
if (leftBlock) return [boundaryX - PANEL_THICKNESS / 2];
if (rightBlock) return [boundaryX + PANEL_THICKNESS / 2];
return [boundaryX];
};
/** Single full-width shell on step 1; structural boards are split by block afterwards. */
const splitTopPerZone =
anyBack && 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]);
const spanConfig = useMemo(() => ({ ...config, modules }), [config, modules]);
const invalidHorizontalSpanIds = useMemo(
() => new Set(validateHorizontalSpans(spanConfig).map((issue) => String(issue.spanId))),
[spanConfig]
);
const resolvedHorizontalSpans = useMemo(
() => (Array.isArray(config?.horizontalSpans) ? config.horizontalSpans : [])
.filter((span) => !invalidHorizontalSpanIds.has(String(span?.id)))
.map((span) => resolveHorizontalSpan(span, spanConfig))
.filter((resolved) => (
resolved &&
resolved.moduleIndexes.length >= 2 &&
resolved.widthMm > 0 &&
Number.isFinite(resolved.bottomMm) &&
Number.isFinite(resolved.topMm) &&
resolved.topMm > resolved.bottomMm &&
resolved.moduleIndexes.every(
(moduleIndex, index) => index === 0 || moduleIndex === resolved.moduleIndexes[index - 1] + 1
)
)),
[config?.horizontalSpans, invalidHorizontalSpanIds, spanConfig]
);
const renderedSpanConfig = useMemo(
() => ({ ...spanConfig, horizontalSpans: resolvedHorizontalSpans.map((resolved) => resolved.span) }),
[spanConfig, resolvedHorizontalSpans]
);
const spanCoversBlock = (moduleIndex, blockIndex) =>
resolvedHorizontalSpans.some((resolved) => resolved.coveredBlocks.some(
(covered) => covered.moduleIndex === moduleIndex && covered.blockIndexes.includes(blockIndex)
));
const spanCoversY = (moduleIndex, y) => {
const bodyYmm = (y - (-height / 2 + plinthH)) / SCALE;
return resolvedHorizontalSpans.some(
(resolved) =>
resolved.moduleIndexes.includes(moduleIndex) &&
bodyYmm >= resolved.bottomMm - 0.5 &&
bodyYmm <= resolved.topMm + 0.5
);
};
const stackPanelCovered = (moduleIndex, jointY, side) => {
const jointMm = (jointY - (-height / 2 + plinthH)) / SCALE;
return resolvedHorizontalSpans.some((resolved) => {
if (!resolved.moduleIndexes.includes(moduleIndex)) return false;
if (jointMm > resolved.bottomMm + 0.5 && jointMm < resolved.topMm - 0.5) return true;
if (side === 'upper' && Math.abs(jointMm - resolved.bottomMm) <= 0.5) return true;
if (side === 'lower' && Math.abs(jointMm - resolved.topMm) <= 0.5) return true;
return false;
});
};
const separatorSegmentsFor = (separatorIndex, style) => {
const styleBottom = style.y - style.h / 2;
const styleTop = style.y + style.h / 2;
return getSeparatorVerticalSegments(renderedSpanConfig, separatorIndex)
.map((segment) => {
const bottom = Math.max(
styleBottom,
-height / 2 + plinthH + segment.bottomMm * SCALE
);
const top = Math.min(
styleTop,
-height / 2 + plinthH + segment.topMm * SCALE
);
return {
centerY: (bottom + top) / 2 - style.y,
height: top - bottom
};
})
.filter((segment) => segment.height > 0.001);
};
/**
* Habillage dessus: 1 bandeau par caisson avec dos, colonnes sans dos contiguës fusionnées.
*/
const topSegments = useMemo(() => {
if (!useModules || modulePositions.length === 0) return null;
const n = modules.length;
const runs = [];
let i = 0;
while (i < n) {
if (modBackTop(i)) {
runs.push({ start: i, end: i, hasBack: true });
i += 1;
} else {
let j = i;
while (j + 1 < n && !modBackTop(j + 1)) j += 1;
runs.push({ start: i, end: j, hasBack: false });
i = j + 1;
}
}
return runs.map((seg) => {
const x0 = modulePositions[seg.start].x0;
const x1 = modulePositions[seg.end].x0 + modulePositions[seg.end].width;
const leftInset = horizontalInsetAt(seg.start, 'left');
const rightInset = horizontalInsetAt(seg.end, 'right');
const xa = x0 + leftInset;
const xb = x1 - rightInset;
return {
...seg,
rawX0: x0,
rawX1: x1,
center: -width / 2 + (xa + xb) / 2,
w: Math.max(0.02, xb - xa)
};
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
modulePositions,
width,
useModules,
modules,
legacyBack,
config?.stackEnabled,
bodyH,
]);
const geometryFromRuns = (runs) => {
if (!runs || runs.length === 0) return null;
return runs.map((seg) => {
const x0 = modulePositions[seg.start].x0;
const x1 = modulePositions[seg.end].x0 + modulePositions[seg.end].width;
const leftInset = horizontalInsetAt(seg.start, 'left');
const rightInset = horizontalInsetAt(seg.end, 'right');
const xa = x0 + leftInset;
const xb = x1 - rightInset;
return {
...seg,
center: -width / 2 + (xa + xb) / 2,
w: Math.max(0.02, xb - xa),
showsBack: Boolean(seg.hasBack)
};
});
};
/** Dessus structurel: 1 panneau par caisson block, séparations contiguës = 1 panneau continu. */
const topConstructionSegments = useMemo(() => {
if (!useModules || modulePositions.length === 0) return null;
return geometryFromRuns(horizontalConstructionRuns(modules, config, 'top'));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [modulePositions, width, useModules, modules, legacyBack, config?.stackEnabled, config?.horizontalSpans, bodyH]);
/** Fond structurel: même règle que le dessus, au niveau bas. */
const bottomConstructionSegments = useMemo(() => {
if (!useModules || modulePositions.length === 0) return null;
return geometryFromRuns(horizontalConstructionRuns(modules, config, 'bottom'));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [modulePositions, width, useModules, modules, legacyBack, config?.stackEnabled, config?.horizontalSpans, bodyH]);
// --- Hover state for width labels ---
const [hoveredModule, setHoveredModule] = useState(null);
const [hoveredShelfKey, setHoveredShelfKey] = useState(null);
const [resizeState, setResizeState] = useState(null); // { separatorIndex, hint, snapped }
const [shelfDragState, setShelfDragState] = useState(null); // { moduleIndex, shelfIndex }
const [dimensionResizeState, setDimensionResizeState] = useState(null); // { type, startValue }
/** Live gesture data (axis tracker, start values, limits); kept out of state so moves never read stale closures. */
const gestureRef = useRef(null);
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]);
// --- Separator drag: the panel follows the cursor along the carcass X axis ---
const handleResizePointerDown = useCallback((separatorIndex, e) => {
e.stopPropagation();
const left = modules[separatorIndex];
const right = modules[separatorIndex + 1];
if (!left || !right) return;
e.target?.setPointerCapture?.(e.pointerId);
const { drag } = axisDragFromEvent(e, new THREE.Vector3(1, 0, 0), camera, gl.domElement);
const startLeft = left.widthMm;
const startRight = right.widthMm;
const pair = startLeft + startRight;
const lo = Math.max(left.minWidth || 300, pair - (right.maxWidth || 1200));
const hi = Math.min(left.maxWidth || 1200, pair - (right.minWidth || 300));
const otherWidths = modules
.map((m, i) => ({ value: m.widthMm, index: i }))
.filter((m) => m.index !== separatorIndex && m.index !== separatorIndex + 1);
if (controls) controls.enabled = false;
onBeginUndoableGesture?.();
gestureRef.current = { kind: 'separator', separatorIndex, drag, startLeft, startRight, pair, lo, hi, otherWidths };
setResizeState({ separatorIndex, hint: null, snapped: false });
gl.domElement.style.cursor = 'col-resize';
}, [modules, camera, gl, controls, onBeginUndoableGesture]);
const handleDimensionPointerDown = useCallback((type, e) => {
if (!dimensionEditMode) return;
let localAxis;
let factor;
if (type === 'widthMm') {
// Carcass grows symmetrically: the grabbed half of the bar follows the cursor outward.
e.object.updateWorldMatrix(true, false);
const center = new THREE.Vector3().setFromMatrixPosition(e.object.matrixWorld);
const worldX = new THREE.Vector3(1, 0, 0).transformDirection(e.object.matrixWorld);
const side = e.point && e.point.clone().sub(center).dot(worldX) < 0 ? -1 : 1;
localAxis = new THREE.Vector3(side, 0, 0);
factor = 2;
} else if (type === 'heightMm') {
localAxis = new THREE.Vector3(0, 1, 0);
factor = 1;
} else {
localAxis = new THREE.Vector3(0, 0, 1);
factor = 2;
}
const { drag } = axisDragFromEvent(e, localAxis, camera, gl.domElement);
const startValue = Number(config[type]) || 0;
if (controls) controls.enabled = false;
onBeginUndoableGesture?.();
gestureRef.current = { kind: 'dimension', type, drag, factor, startValue };
setDimensionResizeState({ type, startValue });
gl.domElement.style.cursor = type === 'heightMm' ? 'ns-resize' : 'ew-resize';
}, [dimensionEditMode, camera, gl, controls, 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;
// Colonne empilée: rangées System 32 propres à chaque block (rien dans la jonction).
const dragStack = moduleStackInfo(mod, config);
const slotsMm = system32ShelfPositionsMm(config.heightMm, config.plinthHeightMm ?? 100, dragStack);
if (!slotsMm.length) return;
const shelfPositionsMm = resolveShelfPositionsMm({
shelfCount,
heightMm: config.heightMm,
plinthHeightMm: config.plinthHeightMm ?? 100,
shelfPositionsMm: mod?.shelfPositionsMm,
stack: dragStack
});
const startPosMm = shelfPositionsMm[shelfIndex];
if (!Number.isFinite(startPosMm)) return;
e.stopPropagation();
e.target?.setPointerCapture?.(e.pointerId);
const { drag } = axisDragFromEvent(e, new THREE.Vector3(0, 1, 0), camera, gl.domElement);
if (controls) controls.enabled = false;
onBeginUndoableGesture?.();
gestureRef.current = {
kind: 'shelf',
moduleIndex,
shelfIndex,
drag,
startPosMm,
shelfPositionsMm,
originalShelfPositionsMm: mod?.shelfPositionsMm,
slotsMm
};
setShelfDragState({ moduleIndex, shelfIndex });
gl.domElement.style.cursor = 'ns-resize';
}, [activeStep, onUpdateModule, modules, config.heightMm, config.plinthHeightMm, onBeginUndoableGesture, camera, gl, controls]);
// Window-level listeners so drags stay live when the pointer leaves the canvas.
const onPointerMoveRef = useRef(null);
const onPointerUpRef = useRef(null);
const onKeyDownRef = useRef(null);
useEffect(() => {
const endGesture = () => {
const g = gestureRef.current;
if (!g) return;
gestureRef.current = null;
if (g.kind === 'separator') setResizeState(null);
if (g.kind === 'dimension') setDimensionResizeState(null);
if (g.kind === 'shelf') {
setShelfDragState(null);
setHoveredShelfKey(null);
}
gl.domElement.style.cursor = 'auto';
};
const handleMove = (e) => {
const g = gestureRef.current;
if (!g || g.cancelled) return;
const deltaMm = g.drag.deltaMeters(e.clientX, e.clientY) / SCALE;
if (g.kind === 'separator') {
const raw = g.startLeft + deltaMm;
const fine = e.shiftKey;
let next = fine ? Math.round(raw) : Math.round(raw / 10) * 10;
let snap = null;
if (!fine && !e.altKey) {
const radius = Math.max(4, SNAP_RADIUS_PX * g.drag.mmPerPx);
const roundFifty = Math.round(raw / 50) * 50;
snap = magneticSnap(raw, [
{ value: g.pair / 2, hint: '= égales' },
...g.otherWidths.map((m) => ({ value: m.value, hint: `= col. ${m.index + 1}` })),
{ value: g.startLeft, hint: 'initial' },
{ value: roundFifty, hint: null },
], radius);
if (snap) next = Math.round(snap.value);
}
next = Math.min(g.hi, Math.max(g.lo, next));
const clamped = next === g.lo || next === g.hi;
onResizeModules?.(g.separatorIndex, next - g.startLeft, { leftMm: g.startLeft, rightMm: g.startRight });
const hint = snap?.hint ?? (clamped && Math.abs(raw - next) > 1 ? 'limite' : null);
setResizeState((prev) => (
prev && (prev.hint !== hint || prev.snapped !== !!snap?.hint)
? { ...prev, hint, snapped: !!snap?.hint }
: prev
));
return;
}
if (g.kind === 'dimension') {
applyDimensionValue(g.type, g.startValue + deltaMm * g.factor);
return;
}
if (g.kind === 'shelf') {
const rawMm = g.startPosMm + deltaMm;
const slots = g.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 = g.shelfPositionsMm.length;
const minIdx = g.shelfIndex;
const maxIdx = slots.length - (n - g.shelfIndex);
nearestIdx = Math.max(minIdx, Math.min(maxIdx, nearestIdx));
const nextShelfPositions = [...g.shelfPositionsMm];
nextShelfPositions[g.shelfIndex] = slots[nearestIdx];
onUpdateModule?.(g.moduleIndex, { shelfPositionsMm: nextShelfPositions });
}
};
const handleKeyDown = (e) => {
const g = gestureRef.current;
if (!g || g.cancelled || e.key !== 'Escape') return;
e.preventDefault();
if (g.kind === 'separator') {
onResizeModules?.(g.separatorIndex, 0, { leftMm: g.startLeft, rightMm: g.startRight });
} else if (g.kind === 'dimension') {
applyDimensionValue(g.type, g.startValue);
} else if (g.kind === 'shelf') {
onUpdateModule?.(g.moduleIndex, { shelfPositionsMm: g.originalShelfPositionsMm });
}
// Stay "interacting" until the button is released so the orbit controls don't pick up the held pointer.
g.cancelled = true;
if (g.kind === 'separator') setResizeState((prev) => (prev ? { ...prev, hint: 'annulé', snapped: false } : prev));
};
onPointerMoveRef.current = handleMove;
onPointerUpRef.current = endGesture;
onKeyDownRef.current = handleKeyDown;
}, [onResizeModules, applyDimensionValue, onUpdateModule, gl]);
useEffect(() => {
const move = (e) => onPointerMoveRef.current?.(e);
const up = (e) => onPointerUpRef.current?.(e);
const key = (e) => onKeyDownRef.current?.(e);
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
window.addEventListener('pointercancel', up);
window.addEventListener('keydown', key);
return () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
window.removeEventListener('pointercancel', up);
window.removeEventListener('keydown', key);
};
}, []);
// Disable OrbitControls during resize
const interactionActive = resizeState !== null || dimensionResizeState !== null || shelfDragState !== null;
const dimensionChipLabel = (letter, type) => {
const value = Math.round(Number(config[type]) || 0);
if (dimensionResizeState?.type !== type) return `${letter} ${value}`;
const delta = value - Math.round(dimensionResizeState.startValue);
return `${letter} ${value} mm${delta ? ` (${delta > 0 ? '+' : ''}${delta})` : ''}`;
};
useEffect(() => {
onInteractionChange?.(interactionActive);
}, [interactionActive, onInteractionChange]);
const elementClickHandler = dimensionEditMode ? undefined : onElementClick;
const moduleClickHandler = dimensionEditMode ? null : onModuleClick;
const activeStepRef = useRef(activeStep);
activeStepRef.current = activeStep;
// Invisible column/span volumes must not steal clicks off the Colonnes step.
// A stable raycast reads the ref so a stale mesh still lets the piece behind receive the click.
const columnSelectRaycast = useCallback(function columnSelectRaycast(raycaster, intersects) {
if (activeStepRef.current !== 2) return;
THREE.Mesh.prototype.raycast.call(this, raycaster, intersects);
}, []);
/**
* Colonnes: clicking a block's panels selects that block.
* Matériaux and the other steps color the piece that was actually hit, including the
* interior (dos, étagère) when the click lands inside the block.
*/
const moduleSelectFor = (moduleIndex, blockIndex = 0) => (elementType, label) => {
if (activeStepRef.current === 2 && moduleClickHandler) {
moduleClickHandler(moduleIndex, blockIndex);
return;
}
elementClickHandler?.(elementType, label);
};
return (
<group position={[0, height / 2, 0]}>
{/* Plinthe bas — planche 19 mm, reculée 20 mm du chant avant (et 20 mm des côtés libres). */}
<WardrobeElement
elementType="plintheBas"
geometryType="box"
geometryArgs={[
frontPlinthWidth,
plinthPanelH,
plinthThicknessM
]}
position={[
frontPlinthCenterX,
-height / 2 + plinthPanelH / 2,
plinthZ
]}
colors={colors}
onElementClick={elementClickHandler}
materialProps={{ roughness: 0.7, metalness: 0.1 }}
/>
{leftPlinthReturn && (
<WardrobeElement
elementType="plintheGauche"
geometryType="box"
geometryArgs={[
plinthThicknessM,
plinthPanelH,
Math.max(0.02, getAxiloPlinthReturnLengthMm(config.depthMm || 600) * SCALE)
]}
position={[
-width / 2 + plinthSetbackM + plinthThicknessM / 2,
-height / 2 + plinthPanelH / 2,
-plinthSetbackM / 2
]}
colors={colors}
onElementClick={elementClickHandler}
materialProps={{ roughness: 0.7, metalness: 0.1 }}
/>
)}
{rightPlinthReturn && (
<WardrobeElement
elementType="plintheDroite"
geometryType="box"
geometryArgs={[
plinthThicknessM,
plinthPanelH,
Math.max(0.02, getAxiloPlinthReturnLengthMm(config.depthMm || 600) * SCALE)
]}
position={[
width / 2 - plinthSetbackM - plinthThicknessM / 2,
-height / 2 + plinthPanelH / 2,
-plinthSetbackM / 2
]}
colors={colors}
onElementClick={elementClickHandler}
materialProps={{ roughness: 0.7, metalness: 0.1 }}
/>
)}
{/* Dessus structurel — 1 panneau par caisson block, séparations contiguës fusionnées.
Pleine profondeur caisse : le dos 8 mm s'engage dans la rainure (axe 16 mm). */}
{!shellOnlyMode && topConstructionSegments ? (
topConstructionSegments.map((sg) => {
const closed = sg.showsBack;
return (
<group
key={`dessus-block-${sg.start}-${sg.end}`}
position={[sg.center, bodyH / 2 + plinthH / 2 - PANEL_THICKNESS / 2, 0]}
>
<WardrobeElement
elementType="dessus"
geometryType="box"
geometryArgs={[sg.w + JOIN_OVERLAP_M * 2, PANEL_THICKNESS, depth]}
position={[0, 0, 0]}
colors={colors}
onElementClick={elementClickHandler}
/>
{closed && (
<BackGrooveVisual3D
variant="horizontal-top"
innerW={sg.w}
innerD={depth}
thickness={PANEL_THICKNESS}
/>
)}
</group>
);
})
) : (
<group position={[horizontalPanelCenterX, bodyH / 2 + plinthH / 2 - PANEL_THICKNESS / 2, 0]}>
<WardrobeElement
elementType="dessus"
geometryType="box"
geometryArgs={[horizontalPanelWidth + JOIN_OVERLAP_M * 2, PANEL_THICKNESS, depth]}
position={[0, 0, 0]}
colors={colors}
onElementClick={elementClickHandler}
/>
{anyBack && (
<BackGrooveVisual3D
variant="horizontal-top"
innerW={horizontalPanelWidth}
innerD={depth}
thickness={PANEL_THICKNESS}
/>
)}
</group>
)}
{/* Fond structurel — 1 panneau par caisson block, séparations contiguës fusionnées.
Pleine profondeur caisse : le dos 8 mm s'engage dans la rainure (axe 16 mm). */}
{!shellOnlyMode && bottomConstructionSegments ? (
bottomConstructionSegments.map((sg) => {
const closed = sg.showsBack;
return (
<group
key={`fond-block-${sg.start}-${sg.end}`}
position={[sg.center, -height / 2 + plinthH + PANEL_THICKNESS / 2, 0]}
>
<WardrobeElement
elementType="fond"
geometryType="box"
geometryArgs={[sg.w + JOIN_OVERLAP_M * 2, PANEL_THICKNESS, depth]}
position={[0, 0, 0]}
colors={colors}
onElementClick={elementClickHandler}
/>
{closed && (
<BackGrooveVisual3D
variant="horizontal-bottom"
innerW={sg.w}
innerD={depth}
thickness={PANEL_THICKNESS}
/>
)}
</group>
);
})
) : (
<group position={[horizontalPanelCenterX, -height / 2 + plinthH + PANEL_THICKNESS / 2, 0]}>
<WardrobeElement
elementType="fond"
geometryType="box"
geometryArgs={[horizontalPanelWidth + JOIN_OVERLAP_M * 2, PANEL_THICKNESS, depth]}
position={[0, 0, 0]}
colors={colors}
onElementClick={elementClickHandler}
/>
{anyBack && (
<BackGrooveVisual3D
variant="horizontal-bottom"
innerW={horizontalPanelWidth}
innerD={depth}
thickness={PANEL_THICKNESS}
/>
)}
</group>
)}
{/* Empilage vertical PAR COLONNE: chaque jonction = dessus du block inférieur + fond du block supérieur.
Masqué à l'étape Espace (carcasse seule). */}
{!shellOnlyMode && (useModules ? (
modules.flatMap((m, i) => {
const jointYs = modJointYs(i);
if (jointYs.length === 0) return [];
const backs = modBacks(i);
const leftInset = horizontalInsetAt(i, 'left');
const rightInset = horizontalInsetAt(i, 'right');
const panelW = Math.max(0.02, modulePositions[i].width - leftInset - rightInset);
const cx = -width / 2 + modulePositions[i].x0 + leftInset + panelW / 2;
const renderW = panelW + JOIN_OVERLAP_M * 2;
return jointYs.flatMap((jointY, k) => [
!stackPanelCovered(i, jointY, 'lower') && <group key={`stack-joint-${m?.id ?? i}-${k}-bas`} position={[cx, jointY - PANEL_THICKNESS / 2, 0]}>
<WardrobeElement
elementType="dessus"
geometryType="box"
geometryArgs={[renderW, PANEL_THICKNESS, depth]}
position={[0, 0, 0]}
colors={colors}
onElementClick={elementClickHandler}
/>
{backs[k] && (
<BackGrooveVisual3D
variant="horizontal-top"
innerW={panelW}
innerD={depth}
thickness={PANEL_THICKNESS}
/>
)}
</group>,
!stackPanelCovered(i, jointY, 'upper') && <group key={`stack-joint-${m?.id ?? i}-${k}-haut`} position={[cx, jointY + PANEL_THICKNESS / 2, 0]}>
<WardrobeElement
elementType="fond"
geometryType="box"
geometryArgs={[renderW, PANEL_THICKNESS, depth]}
position={[0, 0, 0]}
colors={colors}
onElementClick={elementClickHandler}
/>
{backs[k + 1] && (
<BackGrooveVisual3D
variant="horizontal-bottom"
innerW={panelW}
innerD={depth}
thickness={PANEL_THICKNESS}
/>
)}
</group>
].filter(Boolean));
})
) : (
anyStacked &&
modJointYs(0).flatMap((jointY, k) => [
<group key={`stack-joint-legacy-${k}-bas`} position={[horizontalPanelCenterX, jointY - PANEL_THICKNESS / 2, 0]}>
<WardrobeElement
elementType="dessus"
geometryType="box"
geometryArgs={[horizontalPanelWidth + JOIN_OVERLAP_M * 2, PANEL_THICKNESS, depth]}
position={[0, 0, 0]}
colors={colors}
onElementClick={elementClickHandler}
/>
</group>,
<group key={`stack-joint-legacy-${k}-haut`} position={[horizontalPanelCenterX, jointY + PANEL_THICKNESS / 2, 0]}>
<WardrobeElement
elementType="fond"
geometryType="box"
geometryArgs={[horizontalPanelWidth + JOIN_OVERLAP_M * 2, PANEL_THICKNESS, depth]}
position={[0, 0, 0]}
colors={colors}
onElementClick={elementClickHandler}
/>
</group>
])
))}
{/* Côtés extérieurs — chaque block passe entre ses panneaux horizontaux s'il est habillé.
Étape Espace: un côté continu (pas de joints de blocks). */}
{[
{
side: 'left',
x: -width / 2 + PANEL_THICKNESS / 2 + (sideAssemblyLeft ? PANEL_THICKNESS : 0),
inset: sideAssemblyLeft,
heights: shellOnlyMode ? [bodyH / SCALE] : modHeightsMm(0),
backs: shellOnlyMode ? [backAtLeftSide] : modBacks(0),
grooveVariant: 'vertical-left',
fallbackBack: backAtLeftSide
},
{
side: 'right',
x: width / 2 - PANEL_THICKNESS / 2 - (sideAssemblyRight ? PANEL_THICKNESS : 0),
inset: sideAssemblyRight,
heights: shellOnlyMode
? [bodyH / SCALE]
: modHeightsMm(Math.max(0, modules.length - 1)),
backs: shellOnlyMode
? [backAtRightSide]
: modBacks(Math.max(0, modules.length - 1)),
grooveVariant: 'vertical-right',
fallbackBack: backAtRightSide
}
].flatMap((sideInfo) => {
let offsetMm = 0;
return sideInfo.heights.map((blockHeightMm, blockIndex) => {
const blockHeight = blockHeightMm * SCALE;
const panelHeight = Math.max(
0.02,
blockHeight - (sideInfo.inset ? PANEL_THICKNESS * 2 : 0) + JOIN_OVERLAP_M
);
const centerY = -height / 2 + plinthH + offsetMm * SCALE + blockHeight / 2;
offsetMm += blockHeightMm;
const hasBack = sideInfo.backs?.[blockIndex] ?? sideInfo.fallbackBack;
return (
<group
key={`cote-${sideInfo.side}-block-${blockIndex}`}
position={[sideInfo.x, centerY, 0]}
>
<WardrobeElement
elementType="cotes"
geometryType="box"
geometryArgs={[PANEL_THICKNESS, panelHeight, depth]}
position={[0, 0, 0]}
colors={colors}
onElementClick={moduleSelectFor(
sideInfo.side === 'left' ? 0 : Math.max(0, modules.length - 1),
blockIndex
)}
/>
{hasBack && (
<BackGrooveVisual3D
variant={sideInfo.grooveVariant}
thickness={PANEL_THICKNESS}
panelH={panelHeight}
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) + plinthH,
depth
]}
position={[
-width / 2 + PANEL_THICKNESS / 2,
topFinishEnabled ? PANEL_THICKNESS / 2 : 0,
0
]}
colors={colors}
onElementClick={elementClickHandler}
/>
)}
{/* Habillage droite */}
{sideFinishRightEnabled && (
<WardrobeElement
elementType="habillageFinitionDroite"
geometryType="box"
geometryArgs={[
PANEL_THICKNESS,
(topFinishEnabled ? bodyH + PANEL_THICKNESS : bodyH) + plinthH,
depth
]}
position={[
width / 2 - PANEL_THICKNESS / 2,
topFinishEnabled ? PANEL_THICKNESS / 2 : 0,
0
]}
colors={colors}
onElementClick={elementClickHandler}
/>
)}
{/* Habillage dessus — 1 bandeau continu ou 1 par zone (caissons avec dos) */}
{topFinishEnabled && (
splitTopPerZone && topSegments ? (
topSegments.map((sg, i) => {
const isFirst = sg.start === 0;
const isLast = sg.end === modules.length - 1;
const rawW = sg.rawX1 - sg.rawX0;
const rawC = -width / 2 + (sg.rawX0 + sg.rawX1) / 2;
const tl = isFirst ? 0 : PANEL_THICKNESS;
const tr = isLast ? 0 : PANEL_THICKNESS;
const habitW = Math.max(0.001, rawW + tl + tr);
const cx = rawC + (tr - tl) / 2;
return (
<WardrobeElement
key={`hab-top-seg-${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 habitW = Math.max(0.001, width);
const cx = 0;
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 — par zone (colonne × niveau si empilage) */}
{useModules && !shellOnlyMode ? (
modules.flatMap((m, i) => {
const leftInset = horizontalInsetAt(i, 'left');
const rightInset = horizontalInsetAt(i, 'right');
const innerW = Math.max(0.02, modulePositions[i].width - leftInset - rightInset);
const panelW = innerW + BACK_PANEL_GROOVE_INSERT_M * 2;
const panelCenter = -width / 2 + modulePositions[i].x0 + leftInset + innerW / 2;
const heightsM = modHeightsMm(i).map((h) => h * SCALE);
const backs = modBacks(i);
let accY = -height / 2 + plinthH;
const zones = heightsM.map((hM, k) => {
const zone = {
key: `blk${k}`,
blockIndex: k,
on: backs[k],
h: hM - PANEL_THICKNESS * 2 + BACK_PANEL_GROOVE_INSERT_M * 2,
y: accY + hM / 2
};
accY += hM;
return zone;
});
return zones
.filter((z) => z.on && z.h > 0.02 && !spanCoversBlock(i, z.blockIndex))
.map((z) => (
<WardrobeElement
key={`dos-${m?.id ?? i}-${z.key}`}
elementType="dos"
geometryType="box"
geometryArgs={[panelW, z.h, BACK_PANEL_THICKNESS]}
position={[panelCenter, z.y, -depth / 2 + BACK_PANEL_AXIS_FROM_REAR_M]}
colors={colors}
onElementClick={moduleSelectFor(i, z.blockIndex)}
materialProps={{ roughness: 0.8 }}
/>
));
})
) : (
anyBack && (
<WardrobeElement
elementType="dos"
geometryType="box"
geometryArgs={[
width - PANEL_THICKNESS * 2 + BACK_PANEL_GROOVE_INSERT_M * 2,
bodyH - PANEL_THICKNESS * 2 + BACK_PANEL_GROOVE_INSERT_M * 2,
BACK_PANEL_THICKNESS
]}
position={[0, plinthH / 2, -depth / 2 + BACK_PANEL_AXIS_FROM_REAR_M]}
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 de niche surdimensionnés à 50 mm, recoupés sur place au profil des murs. */}
{isNiche && (
<>
<WardrobeElement
elementType="fileur"
geometryType="box"
geometryArgs={[0.05, nicheOpeningHeight, PANEL_THICKNESS]}
position={[
-nicheOpeningWidth / 2 + 0.025,
(nicheOpeningHeight - height) / 2,
depth / 2 + PANEL_THICKNESS / 2
]}
colors={colors}
onElementClick={elementClickHandler}
/>
<WardrobeElement
elementType="fileur"
geometryType="box"
geometryArgs={[0.05, nicheOpeningHeight, PANEL_THICKNESS]}
position={[
nicheOpeningWidth / 2 - 0.025,
(nicheOpeningHeight - height) / 2,
depth / 2 + PANEL_THICKNESS / 2
]}
colors={colors}
onElementClick={elementClickHandler}
/>
<WardrobeElement
elementType="fileur"
geometryType="box"
geometryArgs={[nicheOpeningWidth, 0.05, PANEL_THICKNESS]}
position={[
0,
nicheOpeningHeight - height / 2 - 0.025,
depth / 2 + PANEL_THICKNESS / 2
]}
colors={colors}
onElementClick={elementClickHandler}
/>
</>
)}
{/* Separators between modules — jonction caisson↔caisson: double montant; sinon panneau simple */}
{!shellOnlyMode && useModules && onResizeModules
? separatorPositions.flatMap((pos, i) => {
const style = boundaryAnyBlock(pos.index) ? sepStyleBlock : sepStyleSeparations;
const xs = montantXsAt(pos.x, pos.index);
return xs.map((xSep, j) => (
<ResizableSeparator
key={`sep-resize-${i}-${j}`}
position={[xSep, style.y, 0]}
height={style.h}
depth={separatorDepthM}
zCenter={separatorZ}
visibleSegments={separatorSegmentsFor(pos.index, style)}
separatorIndex={pos.index}
colors={colors}
onElementClick={elementClickHandler}
isResizing={resizeState?.separatorIndex === pos.index}
onResizePointerDown={handleResizePointerDown}
/>
));
})
: !shellOnlyMode && separatorPositions.flatMap((pos, i) => {
const style = boundaryAnyBlock(pos.index) ? sepStyleBlock : sepStyleSeparations;
const xs = montantXsAt(pos.x, pos.index);
const visibleSegments = separatorSegmentsFor(pos.index, style);
return xs.flatMap((xSep, j) => visibleSegments.map((segment, segmentIndex) => (
<group
key={`sep-${i}-${j}-${segmentIndex}`}
position={[xSep, style.y + segment.centerY, separatorZ]}
>
<WardrobeElement
elementType="panneauxInterieurs"
geometryType="box"
geometryArgs={[PANEL_THICKNESS, segment.height, separatorDepthM]}
position={[0, 0, 0]}
colors={colors}
onElementClick={elementClickHandler}
/>
</group>
)));
})
}
{/* Multi-column horizontal spans: shared panels/back and one span-wide selection target. */}
{!shellOnlyMode && resolvedHorizontalSpans.map((resolved) => {
const firstIndex = resolved.moduleIndexes[0];
const lastIndex = resolved.moduleIndexes[resolved.moduleIndexes.length - 1];
const leftInset = horizontalInsetAt(firstIndex, 'left');
const rightInset = horizontalInsetAt(lastIndex, 'right');
const rawLeft = -width / 2 + resolved.leftMm * SCALE;
const rawRight = -width / 2 + resolved.rightMm * SCALE;
const panelLeft = rawLeft + leftInset;
const panelRight = rawRight - rightInset;
const panelWidth = Math.max(0.02, panelRight - panelLeft);
const renderPanelWidth = panelWidth + JOIN_OVERLAP_M * 2;
const centerX = (panelLeft + panelRight) / 2;
const backLeft =
rawLeft + (firstIndex === 0 ? PANEL_THICKNESS : PANEL_THICKNESS / 2);
const backRight =
rawRight - (lastIndex === modules.length - 1 ? PANEL_THICKNESS : PANEL_THICKNESS / 2);
const backWidth = Math.max(0.02, backRight - backLeft);
const backCenterX = (backLeft + backRight) / 2;
const bodyBottomY = -height / 2 + plinthH;
const bottomY = bodyBottomY + resolved.bottomMm * SCALE;
const topY = bodyBottomY + resolved.topMm * SCALE;
const spanHeight = Math.max(0.02, topY - bottomY);
const hasOwnBottom = resolved.bottomMm > 0.5;
const hasOwnTop = resolved.topMm < bodyH / SCALE - 0.5;
const selected = String(selectedHorizontalSpanId) === String(resolved.span.id);
const clickSpan = (e) => {
if (e.delta > 5) return;
e.stopPropagation();
onHorizontalSpanClick?.(resolved.span.id);
};
const doorMode = resolved.span.doorMode ?? HORIZONTAL_SPAN_DOOR_MODES.none;
const spanDoorClick =
activeStep === 2 && onHorizontalSpanClick
? () => onHorizontalSpanClick(resolved.span.id)
: elementClickHandler;
const makeSpanDoor = ({
key,
doorCenterX,
doorWidth,
hingeModuleIndex,
hingeSideOverride
}) => {
const doorHeight = Math.max(0.04, spanHeight - 2 * FACADE_REVEAL_M);
const facadeWidth = Math.max(0.04, doorWidth - 2 * FACADE_REVEAL_M);
const doorCount = Math.max(1, modules.length);
const overrideSides =
Array.isArray(hw.doorHingeSides) && hw.doorHingeSides.length === doorCount
? hw.doorHingeSides
: null;
const hingeSide =
hingeSideOverride
? hingeSideOverride
: overrideSides
? (overrideSides[hingeModuleIndex] === 'right' ? 'right' : 'left')
: getDoorHingeSide(hingeModuleIndex, doorCount, hw);
const angle = hingeSide === 'left' ? -doorOpenAngle : doorOpenAngle;
const hingeX = hingeSide === 'left'
? doorCenterX - facadeWidth / 2
: doorCenterX + facadeWidth / 2;
const localX = hingeSide === 'left' ? facadeWidth / 2 : -facadeWidth / 2;
return (
<group
key={key}
position={[hingeX, (bottomY + topY) / 2, depth / 2 + 0.004]}
rotation={[0, angle, 0]}
>
<WardrobeElement
elementType="portes"
geometryType="plane"
geometryArgs={[facadeWidth, doorHeight]}
position={[localX, 0, 0]}
colors={colors}
onElementClick={spanDoorClick}
transparent={true}
opacity={0.9}
materialProps={{ roughness: 0.5 }}
/>
</group>
);
};
return (
<group key={`horizontal-span-${resolved.span.id}`}>
{hasOwnBottom && (
<group position={[centerX, bottomY + PANEL_THICKNESS / 2, 0]}>
<WardrobeElement
elementType="fond"
geometryType="box"
geometryArgs={[renderPanelWidth, PANEL_THICKNESS, depth]}
position={[0, 0, 0]}
colors={colors}
onElementClick={elementClickHandler}
/>
{resolved.span.hasBack && (
<BackGrooveVisual3D
variant="horizontal-bottom"
innerW={panelWidth}
innerD={depth}
thickness={PANEL_THICKNESS}
/>
)}
</group>
)}
{hasOwnTop && (
<group position={[centerX, topY - PANEL_THICKNESS / 2, 0]}>
<WardrobeElement
elementType="dessus"
geometryType="box"
geometryArgs={[renderPanelWidth, PANEL_THICKNESS, depth]}
position={[0, 0, 0]}
colors={colors}
onElementClick={elementClickHandler}
/>
{resolved.span.hasBack && (
<BackGrooveVisual3D
variant="horizontal-top"
innerW={panelWidth}
innerD={depth}
thickness={PANEL_THICKNESS}
/>
)}
</group>
)}
{resolved.span.hasBack && spanHeight > PANEL_THICKNESS * 2 && (
<WardrobeElement
elementType="dos"
geometryType="box"
geometryArgs={[
backWidth + BACK_PANEL_GROOVE_INSERT_M * 2,
spanHeight - PANEL_THICKNESS * 2 + BACK_PANEL_GROOVE_INSERT_M * 2,
BACK_PANEL_THICKNESS
]}
position={[
backCenterX,
(bottomY + topY) / 2,
-depth / 2 + BACK_PANEL_AXIS_FROM_REAR_M
]}
colors={colors}
onElementClick={elementClickHandler}
materialProps={{ roughness: 0.8 }}
/>
)}
<mesh
position={[centerX, (bottomY + topY) / 2, depth * 0.12]}
raycast={columnSelectRaycast}
onClick={(e) => {
if (activeStepRef.current !== 2) return;
clickSpan(e);
}}
>
<boxGeometry args={[panelWidth, spanHeight, depth * 0.72]} />
<meshBasicMaterial transparent opacity={0} depthWrite={false} />
</mesh>
{showDoors && doorMode === HORIZONTAL_SPAN_DOOR_MODES.perColumn && (
<>
{makeSpanDoor({
key: `horizontal-span-door-${resolved.span.id}-left`,
doorCenterX: rawLeft + (rawRight - rawLeft) / 4,
doorWidth: (rawRight - rawLeft) / 2,
hingeModuleIndex: firstIndex,
hingeSideOverride: 'left'
})}
{makeSpanDoor({
key: `horizontal-span-door-${resolved.span.id}-right`,
doorCenterX: rawLeft + 3 * (rawRight - rawLeft) / 4,
doorWidth: (rawRight - rawLeft) / 2,
hingeModuleIndex: lastIndex,
hingeSideOverride: 'right'
})}
</>
)}
{showDoors && doorMode === HORIZONTAL_SPAN_DOOR_MODES.single && (
makeSpanDoor({
key: `horizontal-span-door-${resolved.span.id}-single`,
doorCenterX: (rawLeft + rawRight) / 2,
doorWidth: rawRight - rawLeft,
hingeModuleIndex: firstIndex
})
)}
{activeStep === 2 && selected && (
<mesh
position={[centerX, (bottomY + topY) / 2, 0]}
renderOrder={42}
raycast={() => null}
>
<boxGeometry args={[panelWidth, spanHeight, depth * 0.96]} />
<meshBasicMaterial
color="#7c3aed"
transparent
opacity={0.2}
depthWrite={false}
/>
<Edges color="#c084fc" threshold={15} scale={1.008} renderOrder={43} />
</mesh>
)}
</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}
hint={resizeState.hint}
snapped={resizeState.snapped}
/>
)}
{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}
snapped={resizeState.snapped}
/>
)}
</>
)}
{resizeState === null && hoveredModule !== null && modules[hoveredModule] && (
<WidthLabel
position={[modulePositions[hoveredModule]?.center || 0, bodyH / 2 + plinthH / 2 + 0.06, depth / 2]}
widthMm={modules[hoveredModule].widthMm}
/>
)}
</>
)}
{shellOnlyMode && useModules && (
<LayoutGhost
config={config}
modules={modules}
modulePositions={modulePositions}
height={height}
depth={depth}
plinthH={plinthH}
/>
)}
{/* 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 innerLeftInset = horizontalInsetAt(moduleIndex, 'left');
const innerRightInset = horizontalInsetAt(moduleIndex, 'right');
const innerWidth = Math.max(0.02, moduleWidth - innerLeftInset - innerRightInset);
const innerCenter = moduleCenter + (innerLeftInset - innerRightInset) / 2;
const shelfCount = module.shelves || 0;
const drawersCount = module.drawers || 0;
const isSelected = selectedModuleIndex === moduleIndex;
const blockHeightsM = modHeightsMm(moduleIndex).map((h) => h * SCALE);
const blockZones = [];
let blockOffsetY = -height / 2 + plinthH;
blockHeightsM.forEach((blockHeight, blockIndex) => {
blockZones.push({
blockIndex,
height: blockHeight,
centerY: blockOffsetY + blockHeight / 2
});
blockOffsetY += blockHeight;
});
const selectedBlock = blockZones[Math.min(Math.max(0, selectedBlockIndex || 0), blockZones.length - 1)];
const blockIndexAtY = (y) => {
const match = blockZones.find((zone) => (
y >= zone.centerY - zone.height / 2 && y <= zone.centerY + zone.height / 2
));
return match?.blockIndex ?? 0;
};
// Colonne empilée: la jonction bas/haut est structurelle → pas de fenêtre structurelle à imposer.
const moduleStack = moduleStackInfo(module, config);
const needsMiddleFix = !moduleStack && 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,
stack: moduleStack
});
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 = innerWidth;
const depthSpanForZone = Math.max(0.02, depth * 0.9);
const shelfCenterYs = shelfCenterYsMeters({
shelfCount,
heightM: height,
plinthHM: plinthH,
shelfGapMm: module.shelfGapMm,
shelfPositionsMm: resolvedShelfPositionsMm,
});
return (
<group key={`mod-${module.id}`}>
{/* Invisible volume filling the block. It only receives clicks on Colonnes.
On Matériaux the raycast is skipped so the click hits the piece inside (dos, étagère…). */}
{blockZones.map((zone) => (
<mesh
key={`block-hit-${moduleIndex}-${zone.blockIndex}`}
position={[moduleCenter, zone.centerY, 0]}
raycast={columnSelectRaycast}
onPointerOver={() => {
if (activeStepRef.current !== 2) return;
setHoveredModule(moduleIndex);
}}
onPointerOut={() => setHoveredModule(prev => prev === moduleIndex ? null : prev)}
onClick={(e) => {
if (activeStepRef.current !== 2) return;
// Ignore clicks that end a drag gesture (shelf/separator resize)
if (e.delta > 5) return;
e.stopPropagation();
if (moduleClickHandler) moduleClickHandler(moduleIndex, zone.blockIndex);
}}
>
<boxGeometry args={[moduleWidth - PANEL_THICKNESS * 2, Math.max(0.02, zone.height - PANEL_THICKNESS * 2), depth * 0.5]} />
<meshBasicMaterial transparent opacity={0} depthWrite={false} />
</mesh>
))}
{/* Highlight only on Colonnes. On Matériaux the block must not stay marked as selected. */}
{activeStep === 2 && isSelected && selectedBlock && (
<mesh
position={[moduleCenter, selectedBlock.centerY, 0]}
renderOrder={40}
raycast={() => null}
>
<boxGeometry args={[moduleWidth * 0.98, selectedBlock.height * 0.98, depth * 0.98]} />
<meshBasicMaterial
color="#0066ff"
transparent
opacity={0.18}
depthWrite={false}
/>
<Edges
color="#00eaff"
threshold={15}
scale={1.008}
renderOrder={41}
/>
</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 — 1 porte par block avec porte (block sans porte = niche ouverte) */}
{showDoors && !isSliding && (
(() => {
const doorW = Math.max(0.04, moduleWidth - 2 * FACADE_REVEAL_M);
const drawerCount = Math.max(0, Number(module?.drawers) || 0);
const drawerHeightsMm = Array.isArray(hw.drawerHeightsMm) ? hw.drawerHeightsMm : [90, 128, 177, 240];
const drawerStackMmRaw = drawerStackHeightMm(drawerCount, drawerHeightsMm);
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(
module.drawerType || hw.drawerType || 'visible'
);
const splitDoorOverDrawers = drawerCount > 0 && !drawerHidden;
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;
const blockDoors = moduleBlockDoors(module, config, blockHeightsM.length);
const out = [];
let startY = 0; // depuis le bas de caisse (m)
for (let k = 0; k < blockHeightsM.length; k++) {
const hk = blockHeightsM[k];
const blockStart = startY;
startY += hk;
if (!blockDoors[k] || spanCoversBlock(moduleIndex, k)) continue;
// Tiroirs (partent du bas): la porte du block bas commence au-dessus du bandeau tiroirs.
const drawerStackForDoor = k === 0 && splitDoorOverDrawers ? drawerStackM : 0;
const doorH = Math.max(
0.08,
hk - 2 * FACADE_REVEAL_M - drawerStackForDoor - (drawerStackForDoor > 0 ? 2 * FACADE_REVEAL_M : 0)
);
const doorCenterY = -height / 2 + plinthH + blockStart + hk / 2 + drawerStackForDoor / 2;
out.push(
<group
key={`door-${module?.id ?? moduleIndex}-${k}`}
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={moduleSelectFor(moduleIndex, k)}
transparent={true}
opacity={0.9}
materialProps={{ roughness: 0.5 }}
/>
</group>
);
}
return out;
})()
)}
{/* 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];
if (spanCoversY(moduleIndex, shelfY)) return null;
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={[innerWidth + JOIN_OVERLAP_M * 2, PANEL_THICKNESS, depth - PANEL_THICKNESS * 2]}
position={[innerCenter, shelfY, 0]}
colors={colors}
onElementClick={moduleSelectFor(moduleIndex, blockIndexAtY(shelfY))}
/>
{activeStep === 2 && (
<mesh
position={[
innerCenter,
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)}
onClick={(e) => e.stopPropagation()}
>
<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 drawerStackMmRaw = drawerStackHeightMm(drawersCount, drawerHeightsMm);
const drawerStackM = Math.min(bodyH * 0.78, Math.max(0, drawerStackMmRaw) * SCALE);
const yFixed = (-height / 2 + plinthH + PANEL_THICKNESS) + drawerStackM + PANEL_THICKNESS / 2;
if (spanCoversY(moduleIndex, yFixed)) return null;
return (
<WardrobeElement
key={`fixed-shelf-drawers-${moduleIndex}`}
elementType="rayonFixe"
geometryType="box"
geometryArgs={[innerWidth + JOIN_OVERLAP_M * 2, PANEL_THICKNESS, depth - PANEL_THICKNESS * 2]}
position={[innerCenter, yFixed, 0]}
colors={colors}
onElementClick={moduleSelectFor(moduleIndex, 0)}
/>
);
})()
)}
{/* Drawers */}
{drawersCount > 0 && !spanCoversBlock(moduleIndex, 0) && (
<DrawerBox
moduleCenter={moduleCenter}
moduleWidth={moduleWidth}
bodyH={bodyH}
plinthH={plinthH}
drawersCount={drawersCount}
depth={depth}
behindDoor={['hidden', 'hidden_with_handle'].includes(
module.drawerType || hw.drawerType || 'visible'
)}
drawerDepthMm={Math.max(
350,
Math.round(Math.max(400, Math.min(650, (config.depthMm || 600) - 10)) / 50) * 50 -
(['hidden', 'hidden_with_handle'].includes(module.drawerType || hw.drawerType || 'visible') ? 40 : 0)
)}
drawerHeightsMm={hw.drawerHeightsMm}
colors={colors}
onElementClick={moduleSelectFor(moduleIndex, 0)}
/>
)}
{/* Rod (penderie) */}
{module.hasRod && (() => {
const zone = moduleHangingZoneMm(module, config);
const rodY = -height / 2 + plinthH + zone.rodMm * SCALE;
if (spanCoversY(moduleIndex, rodY)) return null;
return (
<WardrobeElement
elementType="penderie"
geometryType="cylinder"
geometryArgs={[0.012, 0.012, moduleWidth - 0.06, 16]}
position={[moduleCenter, rodY, 0]}
rotation={[0, 0, Math.PI / 2]}
colors={colors}
onElementClick={moduleSelectFor(moduleIndex, zone.blockIndex)}
materialProps={{ roughness: 0.3, metalness: 0.8 }}
/>
);
})()}
</group>
);
})}
{/* Portes coulissantes (sliding) */}
{showDoors && isSliding && (
resolvedHorizontalSpans.length === 0 ? (
<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 }}
/>
) : (() => {
const bodyBottomY = -height / 2 + plinthH;
const xEdges = [
-width / 2,
width / 2,
...resolvedHorizontalSpans.flatMap((resolved) => [
-width / 2 + resolved.leftMm * SCALE,
-width / 2 + resolved.rightMm * SCALE
])
].sort((a, b) => a - b);
const yEdges = [
bodyBottomY,
bodyBottomY + bodyH,
...resolvedHorizontalSpans.flatMap((resolved) => [
bodyBottomY + resolved.bottomMm * SCALE,
bodyBottomY + resolved.topMm * SCALE
])
].sort((a, b) => a - b);
const cells = [];
for (let xi = 0; xi < xEdges.length - 1; xi++) {
const x0 = xEdges[xi];
const x1 = xEdges[xi + 1];
if (x1 - x0 < 0.001) continue;
for (let yi = 0; yi < yEdges.length - 1; yi++) {
const y0 = yEdges[yi];
const y1 = yEdges[yi + 1];
if (y1 - y0 < 0.001) continue;
const midXmm = ((x0 + x1) / 2 + width / 2) / SCALE;
const midYmm = ((y0 + y1) / 2 - bodyBottomY) / SCALE;
const covered = resolvedHorizontalSpans.some((resolved) => (
midXmm >= resolved.leftMm - 0.5 &&
midXmm <= resolved.rightMm + 0.5 &&
midYmm >= resolved.bottomMm - 0.5 &&
midYmm <= resolved.topMm + 0.5
));
if (!covered) cells.push({ x0, x1, y0, y1 });
}
}
return cells.map((cell, index) => (
<WardrobeElement
key={`sliding-door-cell-${index}`}
elementType="portes"
geometryType="plane"
geometryArgs={[
Math.max(0.001, cell.x1 - cell.x0 - FACADE_REVEAL_M),
Math.max(0.001, cell.y1 - cell.y0 - FACADE_REVEAL_M)
]}
position={[
(cell.x0 + cell.x1) / 2,
(cell.y0 + cell.y1) / 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={dimensionChipLabel('L', 'widthMm')}
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={dimensionChipLabel('H', 'heightMm')}
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={dimensionChipLabel('P', 'depthMm')}
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, interactionActive = false }, ref) => {
const { camera, gl } = useThree();
const controlsRef = useRef();
const isDimensionStep = activeStep === 1;
const snapAnimRef = useRef(null);
const targetY = roomConfig ? roomConfig.height / 2 : (config.heightMm || 2400) * SCALE * 0.5;
const cameraTarget = useMemo(() => [0, targetY, 0], [targetY]);
/** Initial target only: later changes are animated, never snapped through the prop. */
const [initialTarget] = useState(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]);
// Zoom limits stay frozen while a handle is dragged so the view never moves under the cursor.
const frozenFitRef = useRef(null);
if (interactionActive && frozenFitRef.current === null) frozenFitRef.current = fitDistance;
if (!interactionActive) frozenFitRef.current = null;
const effectiveFitDistance = frozenFitRef.current ?? fitDistance;
const getDefaultDistance = useCallback(
() => effectiveFitDistance,
[effectiveFitDistance]
);
const box = useMemo(() => ({
w: totalWidthMm * SCALE,
h: (config.heightMm || 2400) * SCALE,
d: (config.depthMm || 600) * SCALE,
}), [totalWidthMm, config.heightMm, config.depthMm]);
/** Distance along the current view direction at which the whole carcass fits the viewport. */
const distanceToFit = useCallback((target) => {
camera.updateMatrixWorld();
const right = new THREE.Vector3();
const up = new THREE.Vector3();
const back = new THREE.Vector3();
camera.matrixWorld.extractBasis(right, up, back);
const tanV = Math.tan(THREE.MathUtils.degToRad(camera.fov || 45) / 2) * 0.86;
const tanH = tanV * (camera.aspect || 1);
let need = 0;
const p = new THREE.Vector3();
for (const sx of [-1, 1]) for (const sy of [0, 1]) for (const sz of [-1, 1]) {
p.set(sx * box.w / 2, sy * box.h, sz * box.d / 2).sub(target);
const cz = p.dot(back);
need = Math.max(need, cz + Math.abs(p.dot(right)) / tanH, cz + Math.abs(p.dot(up)) / tanV);
}
return need;
}, [camera, box]);
const distanceLimits = useCallback(() => {
const fit = getDefaultDistance();
return isDimensionStep
? [Math.max(0.1, fit * 0.8), 30]
: [fit * 0.35, fit * 2.6];
}, [getDefaultDistance, isDimensionStep]);
/** Smoothly moves target/distance while keeping the user's orbit angle. */
const animateFraming = useCallback(({ toTarget, toDistance, duration = 0.45 }) => {
const controls = controlsRef.current;
if (!controls) return;
const fromTarget = controls.target.clone();
const offset = camera.position.clone().sub(fromTarget);
const fromDistance = offset.length();
const [minD, maxD] = distanceLimits();
snapAnimRef.current = {
t: 0,
duration,
dir: offset.normalize(),
fromTarget,
toTarget: toTarget.clone(),
fromDistance,
toDistance: Math.min(maxD, Math.max(minD, toDistance ?? fromDistance)),
};
}, [camera, distanceLimits]);
useFrame((_, dt) => {
const anim = snapAnimRef.current;
const controls = controlsRef.current;
if (!anim || !controls) return;
anim.t = Math.min(1, anim.t + dt / anim.duration);
const k = 1 - Math.pow(1 - anim.t, 3);
controls.target.lerpVectors(anim.fromTarget, anim.toTarget, k);
const dist = anim.fromDistance + (anim.toDistance - anim.fromDistance) * k;
camera.position.copy(controls.target).addScaledVector(anim.dir, dist);
const [minD, maxD] = distanceLimits();
controls.minDistance = Math.min(minD, dist);
controls.maxDistance = Math.max(maxD, dist);
controls.update();
if (anim.t >= 1) snapAnimRef.current = null;
});
// Limits are applied imperatively and loosened to the current distance so tightening them never snaps the camera.
useEffect(() => {
const controls = controlsRef.current;
if (!controls || snapAnimRef.current) return;
const [minD, maxD] = distanceLimits();
const current = camera.position.distanceTo(controls.target);
controls.minDistance = Math.min(minD, current);
controls.maxDistance = Math.max(maxD, current);
}, [distanceLimits, camera]);
const cancelSnap = useCallback(() => {
snapAnimRef.current = null;
}, []);
const placeDefaultView = useCallback(() => {
const controls = controlsRef.current;
if (!controls) return;
cancelSnap();
const target = new THREE.Vector3(...cameraTarget);
controls.target.copy(target);
camera.up.set(0, 1, 0);
if (roomConfig) {
const roomSize = Math.max(roomConfig.width, roomConfig.length);
camera.position.set(roomSize * 0.8, roomSize * 0.5, roomSize * 0.8);
} else {
const dir = defaultViewDirection();
camera.position.copy(target).addScaledVector(dir, 2);
camera.lookAt(target);
camera.updateMatrixWorld();
camera.position.copy(target).addScaledVector(dir, distanceToFit(target));
}
const [minD, maxD] = distanceLimits();
const current = camera.position.distanceTo(controls.target);
controls.minDistance = Math.min(minD, current);
controls.maxDistance = Math.max(maxD, current);
controls.update();
}, [camera, cameraTarget, roomConfig, cancelSnap, distanceToFit, distanceLimits]);
const fitView = useCallback(() => {
const target = new THREE.Vector3(...cameraTarget);
animateFraming({ toTarget: target, toDistance: roomConfig ? undefined : distanceToFit(target) });
}, [animateFraming, cameraTarget, distanceToFit, roomConfig]);
// After a size change (deferred until the drag ends), re-centre and back off only if the carcass no longer fits.
const framingKey = `${targetY}|${box.w}|${box.h}|${box.d}`;
const lastFramingKeyRef = useRef(framingKey);
useEffect(() => {
if (interactionActive || lastFramingKeyRef.current === framingKey) return;
lastFramingKeyRef.current = framingKey;
const controls = controlsRef.current;
if (!controls) return;
const target = new THREE.Vector3(...cameraTarget);
const current = camera.position.distanceTo(controls.target);
const toDistance = roomConfig ? current : Math.max(current, distanceToFit(target));
animateFraming({ toTarget: target, toDistance });
}, [framingKey, interactionActive, cameraTarget, camera, roomConfig, distanceToFit, animateFraming]);
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 size = useThree((s) => s.size);
const didPlaceDefaultRef = useRef(false);
useFrame(() => {
if (didPlaceDefaultRef.current || !controlsRef.current) return;
if (size.width < 2 || size.height < 2) return;
didPlaceDefaultRef.current = true;
placeDefaultView();
});
useImperativeHandle(ref, () => ({
fitView,
resetView: () => {
placeDefaultView();
},
}), [fitView, placeDefaultView]);
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}
target={initialTarget}
onStart={handleControlStart}
onEnd={handleControlEnd}
makeDefault
/>
);
});
/* ===================================================================
Scene - assembles everything
=================================================================== */
function Scene({
config,
roomConfig,
controllerRef,
colors,
modules,
onElementClick,
onModuleClick,
selectedModuleIndex,
selectedBlockIndex,
selectedHorizontalSpanId,
onHorizontalSpanClick,
showDoors,
onResizeModules,
onUpdateModule,
onSetDimension,
onBeginUndoableGesture,
activeStep,
suppressHtmlOverlays,
}) {
const [interactionActive, setInteractionActive] = useState(false);
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 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 sanitaryGapMm = Math.max(0, Number(config.sanitaryGapMm) || 0);
const niche = config.nicheMeasurements || {};
const smallestPositive = (values, fallback) => {
const valid = values.map(Number).filter((value) => Number.isFinite(value) && value > 0);
return valid.length > 0 ? Math.min(...valid) : fallback;
};
const nicheWidthM = smallestPositive(
[niche.widthTopMm, niche.widthMidMm, niche.widthBottomMm],
widthMmRaw + sanitaryGapMm * 2
) * SCALE;
const nicheHeightM = smallestPositive(
[niche.heightLeftMm, niche.heightRightMm],
(config.heightMm || 2400) + sanitaryGapMm * 2
) * SCALE;
const nicheDepthM = smallestPositive(
[niche.depthTopMm, niche.depthMidMm, niche.depthBottomMm],
(config.depthMm || 600) + sanitaryGapMm * 2
) * SCALE;
const gapSideM = Math.max(0, (nicheWidthM - widthM) / 2);
// The wardrobe sits on the floor, so all remaining vertical clearance is visible above it.
const gapTopM = Math.max(0, nicheHeightM - heightM);
const gapBottomM = 0;
const gapDepthM = Math.max(0, (nicheDepthM - depthM) / 2);
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,
config?.sanitaryGapMm,
config?.nicheMeasurements,
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 using the measured installation clearances. */}
{/* 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.floorY + nicheBackdrop.ceilY) / 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.floorY + nicheBackdrop.ceilY) / 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.floorY + nicheBackdrop.ceilY) / 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}
selectedBlockIndex={selectedBlockIndex}
selectedHorizontalSpanId={selectedHorizontalSpanId}
onHorizontalSpanClick={onHorizontalSpanClick}
showDoors={showDoors}
onResizeModules={onResizeModules}
onUpdateModule={onUpdateModule}
onSetDimension={onSetDimension}
onBeginUndoableGesture={onBeginUndoableGesture}
activeStep={activeStep}
suppressHtmlOverlays={suppressHtmlOverlays}
onInteractionChange={setInteractionActive}
/>
</group>
<CameraController
ref={controllerRef}
roomConfig={roomConfig}
config={config}
modules={modules}
activeStep={activeStep}
interactionActive={interactionActive}
/>
</>
);
}
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,
selectedBlockIndex = 0,
selectedHorizontalSpanId,
onHorizontalSpanClick,
suppressHtmlOverlays = false,
}) {
const containerRef = useRef(null);
const controllerRef = useRef(null);
const [selectedElement, setSelectedElement] = useState(null);
const [showDoors, setShowDoors] = useState(activeStep !== 2);
const isDimensionStep = activeStep === 1;
const perspectiveCameraPosition = useMemo(() => {
const totalWidthMm = Array.isArray(modules) && modules.length > 0
? modules.reduce((sum, mod) => sum + (Number(mod?.widthMm) || 0), 0) || (config.widthMm || 2400)
: (config.widthMm || 2400);
return defaultCameraPosition({
roomConfig,
totalWidthMm,
heightMm: config.heightMm,
depthMm: config.depthMm,
});
}, [roomConfig, modules, config.widthMm, config.heightMm, config.depthMm]);
const handleResetView = useCallback(() => {
controllerRef.current?.resetView();
}, []);
const handleFitView = useCallback(() => {
controllerRef.current?.fitView();
}, []);
useEffect(() => {
const onKeyDown = (e) => {
if (e.key !== 'f' && e.key !== 'F') return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
const el = e.target;
if (el && (el.closest?.('input, textarea, select') || el.isContentEditable)) return;
e.preventDefault();
controllerRef.current?.fitView();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);
useEffect(() => {
// Columns: hide doors so interior layout is visible; Portes: show them.
if (activeStep === 2) setShowDoors(false);
else if (activeStep === 3) setShowDoors(true);
}, [activeStep]);
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 (back panel only when enabled)
const hasBackOption = (modules && modules.length > 0)
? modules.some((m) => (typeof m?.hasBack === 'boolean' ? m.hasBack : config?.hasBack !== false))
: config?.hasBack !== false;
const PICKABLE_ELEMENTS = useMemo(() => [
{ type: 'portes', label: 'Portes' },
{ type: 'cotes', label: 'Côtés' },
{ type: 'dessus', label: 'Dessus' },
{ type: 'fond', label: 'Fond (bas)' },
...(hasBackOption ? [{ 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' },
], [hasBackOption]);
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 === 4 || 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}>
<button type="button" className={styles.controlBtn} onClick={handleFitView} title="Recadrer sur l'armoire (F)">
<Icon name="fit" size={20} />
</button>
{showReset && (
<button type="button" className={styles.controlBtn} onClick={handleResetView} title="Réinitialiser la vue">&loz;</button>
)}
{show3dUndo && (
<>
<div className={styles.controlDivider} />
<button
type="button"
className={`${styles.controlBtn} ${!canUndo3d ? styles.controlBtnDisabled : ''}`}
onClick={onUndo3d}
disabled={!canUndo3d}
title="Annuler la dernière modification 3D (Ctrl+Z)"
>
&larr;
</button>
<button
type="button"
className={`${styles.controlBtn} ${!canRedo3d ? styles.controlBtnDisabled : ''}`}
onClick={onRedo3d}
disabled={!canRedo3d}
title="Rétablir (Ctrl+Shift+Z ou Ctrl+Y)"
>
&rarr;
</button>
</>
)}
{showDoorToggle && (
<>
<div className={styles.controlDivider} />
<button
type="button"
className={`${styles.controlBtn} ${!showDoors ? styles.controlBtnActive : ''}`}
onClick={handleToggleDoors}
title={showDoors ? 'Masquer les portes' : 'Afficher les portes'}
>
<Icon name={showDoors ? 'door-off' : 'door'} size={22} />
</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
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}
selectedBlockIndex={selectedBlockIndex}
selectedHorizontalSpanId={selectedHorizontalSpanId}
onHorizontalSpanClick={onHorizontalSpanClick}
showDoors={effectiveShowDoors}
onResizeModules={onResizeModules}
onUpdateModule={onUpdateModule}
onSetDimension={onSetDimension}
onBeginUndoableGesture={onBeginUndoableGesture}
activeStep={activeStep}
suppressHtmlOverlays={suppressHtmlOverlays}
/>
</Canvas>
</Suspense>
</div>
);
}