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 (
{/* Séparateur : plaqué au dos, sans rainure ; même emprise Z que dessus/fond */}
{(visibleSegments || [{ centerY: 0, height }]).map((segment, index) => (
))}
{/* Invisible hit area: wider/taller/deeper than the panel when the carcass is shallow or short */}
{/* Highlight overlay when hovered/resizing */}
{(hovered || isResizing) && (
)}
);
}
/**
* Rainure dos APEX 4.0 : 9×10,5 mm, axe à 16 mm du bord arrière (face intérieure caisse).
* Repère local identique au panneau : Z− = arrière, dessus = rainure sur face bas (−Y), fond = face haut (+Y).
*/
function BackGrooveVisual3D({ variant, innerW, innerD, thickness, panelH, panelD }) {
const gw = GROOVE.back.width * SCALE;
const gd = GROOVE.back.depth * SCALE;
const ax = GROOVE.back.axisFromPanelEdge * SCALE;
const eps = 0.0003;
const mat = (
);
if (variant === 'horizontal-top') {
const zHalf = innerD / 2;
const zc = -zHalf + ax;
return (
{mat}
);
}
if (variant === 'horizontal-bottom') {
const zHalf = innerD / 2;
const zc = -zHalf + ax;
return (
{mat}
);
}
if (variant === 'vertical-left') {
const zHalf = panelD / 2;
const zc = -zHalf + ax;
return (
{mat}
);
}
if (variant === 'vertical-right') {
const zHalf = panelD / 2;
const zc = -zHalf + ax;
return (
{mat}
);
}
return null;
}
/* ===================================================================
WidthLabel - floating HTML label showing module width in mm
=================================================================== */
function WidthLabel({ position, widthMm, hint, snapped = false }) {
return (
{Math.round(widthMm)}mm
{hint && {hint}}
);
}
/* ===================================================================
DimensionAxisGrip - CAD-like edge grip for bounding box resize
=================================================================== */
function DimensionAxisGrip({
position,
rotation = [0, 0, 0],
size,
hitSize,
label,
cursor,
active,
tintColor = '#4f46e5',
chipOffset = [0, 0.06, 0],
onPointerDown
}) {
const [hovered, setHovered] = useState(false);
const { gl } = useThree();
const handlePointerOver = useCallback((e) => {
e.stopPropagation();
setHovered(true);
gl.domElement.style.cursor = cursor;
}, [gl, cursor]);
const handlePointerOut = useCallback((e) => {
e.stopPropagation();
if (!active) {
setHovered(false);
gl.domElement.style.cursor = 'auto';
}
}, [gl, active]);
const handlePointerDown = useCallback((e) => {
e.stopPropagation();
e.target?.setPointerCapture?.(e.pointerId);
onPointerDown?.(e);
}, [onPointerDown]);
return (
{label}
);
}
/* ===================================================================
DrawerBox - renders drawer front faces stacked vertically
=================================================================== */
function DrawerBox({
moduleCenter,
moduleWidth,
bodyH,
plinthH,
drawersCount,
depth,
behindDoor,
drawerDepthMm,
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 && (
<>
>
)}
{items.map(({ di, yc, faceH }) => (
{/* Corps (volume du tiroir, en retrait derrière la face) */}
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é) */}
0 ? frontFaceZ : 0]}
colors={colors}
onElementClick={onElementClick}
materialProps={{ roughness: 0.45, metalness: 0.05 }}
/>
))}
>
);
}
/* ===================================================================
WardrobeMesh - Full wardrobe model with drag/resize/drawers
=================================================================== */
const GHOST_COLOR = '#6366f1';
const ignoreRaycast = () => null;
function GhostBox({ size, position }) {
return (
);
}
/** É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 (
{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 (
{i > 0 && (
)}
{junctionsMm.map((mm) => (
))}
{shelvesMm.map((mm) => (
))}
{Array.from({ length: drawers }, (_, d) => (
))}
{zone && (
)}
);
})}
);
}
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 (
{/* Plinthe bas — planche 19 mm, reculée 20 mm du chant avant (et 20 mm des côtés libres). */}
{leftPlinthReturn && (
)}
{rightPlinthReturn && (
)}
{/* 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 (
{closed && (
)}
);
})
) : (
{anyBack && (
)}
)}
{/* 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 (
{closed && (
)}
);
})
) : (
{anyBack && (
)}
)}
{/* 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') &&
{backs[k] && (
)}
,
!stackPanelCovered(i, jointY, 'upper') &&
{backs[k + 1] && (
)}
].filter(Boolean));
})
) : (
anyStacked &&
modJointYs(0).flatMap((jointY, k) => [
,
])
))}
{/* 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 (
{hasBack && (
)}
);
});
})}
{/* Habillages de finition (Libre + Premium) */}
{!isNiche && exteriorFinishesEnabled && (
<>
{/* Habillage gauche */}
{sideFinishLeftEnabled && (
)}
{/* Habillage droite */}
{sideFinishRightEnabled && (
)}
{/* 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 (
);
})
) : (
(() => {
const habitW = Math.max(0.001, width);
const cx = 0;
return (
);
})()
)
)}
>
)}
{/* 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) => (
));
})
) : (
anyBack && (
)
)}
{/* Outline to keep wardrobe edges readable through niche wall */}
{config?.installationType === 'niche' && (
)}
{/* Fileurs de niche surdimensionnés à 50 mm, recoupés sur place au profil des murs. */}
{isNiche && (
<>
>
)}
{/* 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) => (
));
})
: !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) => (
)));
})
}
{/* 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 (
);
};
return (
{hasOwnBottom && (
{resolved.span.hasBack && (
)}
)}
{hasOwnTop && (
{resolved.span.hasBack && (
)}
)}
{resolved.span.hasBack && spanHeight > PANEL_THICKNESS * 2 && (
)}
{
if (activeStepRef.current !== 2) return;
clickSpan(e);
}}
>
{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 && (
null}
>
)}
);
})}
{/* Width labels -- show during resize or hover */}
{!shellOnlyMode && !suppressHtmlOverlays && modules.length > 0 && (resizeState !== null || hoveredModule !== null) && (
<>
{resizeState !== null && (
<>
{modules[resizeState.separatorIndex] && (
)}
{modules[resizeState.separatorIndex + 1] && (
)}
>
)}
{resizeState === null && hoveredModule !== null && modules[hoveredModule] && (
)}
>
)}
{shellOnlyMode && useModules && (
)}
{/* 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 (
{/* 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) => (
{
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);
}}
>
))}
{/* Highlight only on Colonnes. On Matériaux the block must not stay marked as selected. */}
{activeStep === 2 && isSelected && selectedBlock && (
null}
>
)}
{activeStep === 2 && needsMiddleFix && !hasShelfInStructuralWindow && (
)}
{/* 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(
);
}
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 (
{activeStep === 2 && (
{
e.stopPropagation();
setHoveredShelfKey(shelfKey);
gl.domElement.style.cursor = 'ns-resize';
}}
onPointerOut={(e) => {
e.stopPropagation();
setHoveredShelfKey((prev) => (prev === shelfKey ? null : prev));
if (!shelfDragState) gl.domElement.style.cursor = 'auto';
}}
onPointerDown={(e) => handleShelfPointerDown(moduleIndex, shelfIndex, isStructural, e)}
onClick={(e) => e.stopPropagation()}
>
)}
);
})}
>
);
})()}
{/* 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 (
);
})()
)}
{/* Drawers */}
{drawersCount > 0 && !spanCoversBlock(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 (
);
})()}
);
})}
{/* Portes coulissantes (sliding) */}
{showDoors && isSliding && (
resolvedHorizontalSpans.length === 0 ? (
) : (() => {
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) => (
));
})()
)}
{/* Module content is rendered above in the module loop */}
{/* Non-module door rendering is also above */}
{/* Step 1 dimension editor: bounding box + edge grips + rulers */}
{showDimensionHtml && (
<>
handleDimensionPointerDown('widthMm', e)}
/>
handleDimensionPointerDown('heightMm', e)}
/>
handleDimensionPointerDown('depthMm', e)}
/>
>
)}
{/* Pass interaction state to parent for OrbitControls toggling */}
);
}
function WardrobeOutline({ width, height, depth }) {
const geom = useMemo(() => {
const g = new THREE.EdgesGeometry(new THREE.BoxGeometry(width, height, depth), 18);
return g;
}, [width, height, depth]);
useEffect(() => () => geom.dispose(), [geom]);
return (
);
}
/* ===================================================================
InteractionBlocker - disables OrbitControls during drag/resize
Uses useThree to access the controls
=================================================================== */
function InteractionBlocker({ active }) {
const { controls } = useThree();
useEffect(() => {
if (controls) {
controls.enabled = !active;
}
}, [active, controls]);
return null;
}
/* ===================================================================
CameraController - exposes zoom/reset via ref
=================================================================== */
const CameraController = forwardRef(({ roomConfig, config, modules, activeStep, 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 (
);
});
/* ===================================================================
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 (
<>
{/* 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 && (
{/* floor */}
null}>
{/* back wall (niche sized) */}
null}>
{/* left wall (niche sized) */}
null}>
{/* right wall (niche sized) */}
null}>
{/* ceiling (niche sized) */}
null}>
{/* front wall: everywhere except the niche opening */}
)}
{/* Backdrop wall/floor for "Libre" (free-standing) view */}
{freeBackdrop && !nicheBackdrop && (
{/* floor */}
null}>
{/* wall behind wardrobe (Z-), flush to back */}
null}>
)}
{roomConfig && (
)}
>
);
}
function FrontWallWithOpening({ niche }) {
const shape = useMemo(() => {
// Outer wall (slightly larger than niche box so it reads as the room wall)
const outerW = Math.max(6, niche.outerWallW || niche.nicheW * 3);
const outerH = Math.max(3, niche.outerWallH || niche.nicheH * 2.2);
// Hole = niche opening (exact nicheW/nicheH), positioned around the wardrobe volume
const holeW = niche.nicheW;
const holeH = niche.nicheH;
const s = new THREE.Shape();
s.moveTo(-outerW / 2, -outerH / 2);
s.lineTo(outerW / 2, -outerH / 2);
s.lineTo(outerW / 2, outerH / 2);
s.lineTo(-outerW / 2, outerH / 2);
s.closePath();
const hole = new THREE.Path();
// Hole Y range: from floorY to floorY + holeH
const holeBottom = niche.floorY;
const holeTop = niche.floorY + holeH;
hole.moveTo(-holeW / 2, holeBottom);
hole.lineTo(holeW / 2, holeBottom);
hole.lineTo(holeW / 2, holeTop);
hole.lineTo(-holeW / 2, holeTop);
hole.closePath();
s.holes.push(hole);
return s;
}, [niche]);
return (
// raycast disabled: purely decorative wall must not intercept clicks on
// dimension grips that sit just outside the niche opening (e.g. width
// handle above the wardrobe top is behind the solid wall in 3D space).
null}>
);
}
/* ===================================================================
WardrobePreview - main exported component
=================================================================== */
export default function WardrobePreview({
config,
activeStep = 1,
roomConfig,
colors = {},
modules = [],
onElementClick,
onModuleClick,
onResizeModules,
onUpdateModule,
onSetDimension,
onBeginUndoableGesture,
canUndo3d = false,
canRedo3d = false,
onUndo3d,
onRedo3d,
selectedModuleIndex,
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 (
{selectedElement && !isDimensionStep && (
Élément sélectionné: {selectedElement.label}
)}
{/* Zoom Controls */}
{showReset && (
)}
{show3dUndo && (
<>
>
)}
{showDoorToggle && (
<>
>
)}
{showColorSelector && (
<>
>
)}
Chargement 3D… }>
);
}