Ship configurator updates with catalog DB included for server deploy. Co-authored-by: Cursor <[email protected]>
3345 lines
130 KiB
React
3345 lines
130 KiB
React
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">◊</button>
|
||
)}
|
||
|
||
{show3dUndo && (
|
||
<>
|
||
<div className={styles.controlDivider} />
|
||
<button
|
||
type="button"
|
||
className={`${styles.controlBtn} ${!canUndo3d ? styles.controlBtnDisabled : ''}`}
|
||
onClick={onUndo3d}
|
||
disabled={!canUndo3d}
|
||
title="Annuler la dernière modification 3D (Ctrl+Z)"
|
||
>
|
||
←
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`${styles.controlBtn} ${!canRedo3d ? styles.controlBtnDisabled : ''}`}
|
||
onClick={onRedo3d}
|
||
disabled={!canRedo3d}
|
||
title="Rétablir (Ctrl+Shift+Z ou Ctrl+Y)"
|
||
>
|
||
→
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
{showDoorToggle && (
|
||
<>
|
||
<div className={styles.controlDivider} />
|
||
<button
|
||
type="button"
|
||
className={`${styles.controlBtn} ${!showDoors ? styles.controlBtnActive : ''}`}
|
||
onClick={handleToggleDoors}
|
||
title={showDoors ? 'Masquer les portes' : 'Afficher les portes'}
|
||
>
|
||
<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>
|
||
);
|
||
}
|