From 6c0d7e47b8061e361db3beef7ad2539e83c0d265 Mon Sep 17 00:00:00 2001 From: lejay Date: Thu, 28 May 2026 07:20:38 +0200 Subject: [PATCH] v2.0 Co-authored-by: Cursor --- .gitignore | 4 + client/package-lock.json | 12 +- client/src/App.jsx | 2 + client/src/api/client.js | 172 +- client/src/components/Cnc3DViewer.jsx | 1426 +++++++++ client/src/components/Cnc3DViewer.module.css | 55 + client/src/components/ColorPickerModal.jsx | 265 +- .../components/ColorPickerModal.module.css | 151 +- client/src/components/CuttingListTable.jsx | 593 +++- .../components/CuttingListTable.module.css | 489 ++- client/src/components/HardwareListTable.jsx | 91 + .../components/HardwareListTable.module.css | 156 + client/src/components/Layout.jsx | 1 + client/src/components/MaterialModal.jsx | 122 +- .../src/components/MaterialModal.module.css | 20 +- client/src/components/Plans2DView.jsx | 339 +- client/src/components/Plans2DView.module.css | 152 +- client/src/components/WardrobeElement.jsx | 4 + client/src/components/WardrobePreview.jsx | 1540 +++++++-- .../src/components/WardrobePreview.module.css | 31 + .../components/configurator/PreviewArea.jsx | 140 + .../configurator/PreviewArea.module.css | 102 + .../components/configurator/StepColumns.jsx | 443 +++ .../configurator/StepDimensions.jsx | 196 ++ .../src/components/configurator/StepDoors.jsx | 187 ++ .../components/configurator/StepFinalize.jsx | 239 ++ .../components/configurator/StepHardware.jsx | 110 + .../components/configurator/StepMaterials.jsx | 154 + .../components/configurator/StepperBar.jsx | 29 + .../configurator/StepperBar.module.css | 96 + .../components/configurator/steps.module.css | 987 ++++++ client/src/components/ui/Icon.jsx | 89 + client/src/constants/wardrobe.js | 228 ++ client/src/hooks/useConfiguratorUI.js | 126 + client/src/hooks/useWardrobeConfig.js | 676 ++++ client/src/pages/Admin.jsx | 754 ++++- client/src/pages/Admin.module.css | 390 ++- client/src/pages/Configurator.jsx | 1207 +------ client/src/pages/Configurator.module.css | 940 +----- client/src/pages/Materials.jsx | 66 +- client/src/pages/Materials.module.css | 66 + client/src/pages/Settings.jsx | 627 ++++ client/src/pages/Settings.module.css | 384 +++ client/vite.config.js | 9 + package-lock.json | 327 +- package.json | 7 +- server/.env.example | 17 + server/db/index.js | 50 + server/db/migrations/006_dimension_prices.sql | 2 + server/db/migrations/007_suppliers.sql | 16 + server/db/schema.sql | 14 + server/index.js | 238 +- server/package-lock.json | 266 ++ server/package.json | 6 +- server/routes/ai.js | 7 +- server/routes/engineeringRules.js | 384 +++ server/routes/orders.js | 323 +- server/routes/plans.js | 182 +- server/routes/settings.js | 34 +- server/routes/suppliers.js | 55 + server/routes/sync.js | 230 +- server/scripts/db-inspect.js | 24 + server/scripts/haefele-inspect.js | 38 + server/services/cncGenerator.js | 110 + server/services/configValidation.js | 97 + server/services/cuttingEngine.js | 2803 ++++++++++++++++- server/services/designAdvisor.js | 107 +- server/services/exportPdf.js | 101 + server/services/formexCrawler.js | 831 +++-- server/services/haefeleCrawler.js | 851 +++++ server/services/hardwareListEngine.js | 478 +++ server/services/holeSpecifications.js | 134 + server/services/mailer.js | 35 + server/services/mprGenerator.js | 522 +++ server/services/packingEngine.js | 219 ++ server/services/planGenerator.js | 766 +++-- server/services/priceEngine.js | 99 +- server/services/supplierVoucher.js | 282 ++ server/tests/rules.spec.mjs | 49 + shared/cncPanelHoles.js | 591 ++++ shared/manufacturing.js | 601 ++++ 81 files changed, 21780 insertions(+), 2986 deletions(-) create mode 100644 client/src/components/Cnc3DViewer.jsx create mode 100644 client/src/components/Cnc3DViewer.module.css create mode 100644 client/src/components/HardwareListTable.jsx create mode 100644 client/src/components/HardwareListTable.module.css create mode 100644 client/src/components/configurator/PreviewArea.jsx create mode 100644 client/src/components/configurator/PreviewArea.module.css create mode 100644 client/src/components/configurator/StepColumns.jsx create mode 100644 client/src/components/configurator/StepDimensions.jsx create mode 100644 client/src/components/configurator/StepDoors.jsx create mode 100644 client/src/components/configurator/StepFinalize.jsx create mode 100644 client/src/components/configurator/StepHardware.jsx create mode 100644 client/src/components/configurator/StepMaterials.jsx create mode 100644 client/src/components/configurator/StepperBar.jsx create mode 100644 client/src/components/configurator/StepperBar.module.css create mode 100644 client/src/components/configurator/steps.module.css create mode 100644 client/src/components/ui/Icon.jsx create mode 100644 client/src/constants/wardrobe.js create mode 100644 client/src/hooks/useConfiguratorUI.js create mode 100644 client/src/hooks/useWardrobeConfig.js create mode 100644 client/src/pages/Settings.jsx create mode 100644 client/src/pages/Settings.module.css create mode 100644 server/db/migrations/006_dimension_prices.sql create mode 100644 server/db/migrations/007_suppliers.sql create mode 100644 server/routes/engineeringRules.js create mode 100644 server/routes/suppliers.js create mode 100644 server/scripts/db-inspect.js create mode 100644 server/scripts/haefele-inspect.js create mode 100644 server/services/cncGenerator.js create mode 100644 server/services/configValidation.js create mode 100644 server/services/exportPdf.js create mode 100644 server/services/haefeleCrawler.js create mode 100644 server/services/hardwareListEngine.js create mode 100644 server/services/holeSpecifications.js create mode 100644 server/services/mailer.js create mode 100644 server/services/mprGenerator.js create mode 100644 server/services/packingEngine.js create mode 100644 server/services/supplierVoucher.js create mode 100644 server/tests/rules.spec.mjs create mode 100644 shared/cncPanelHoles.js create mode 100644 shared/manufacturing.js diff --git a/.gitignore b/.gitignore index c8c11cf..12b1435 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ node_modules/ server/data/ .env +.claude/ +*.docx +~$* +~WRL*.tmp *.db *.sqlite .DS_Store diff --git a/client/package-lock.json b/client/package-lock.json index 96687f1..a83c106 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -48,7 +48,6 @@ "version": "7.28.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -300,7 +299,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -903,7 +901,6 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.18.0.tgz", "integrity": "sha512-FYZZqD0UUHUswKz3LQl2Z7H24AhD14XGTsIRw3SJaXUxyfVMi+1yiZGmqTcPt/CkPpdU7rrxqcyQ1zJE5DjvIQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/react-reconciler": "^0.26.7", @@ -1397,7 +1394,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1422,7 +1418,6 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz", "integrity": "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==", "license": "MIT", - "peer": true, "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", @@ -1537,7 +1532,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2013,7 +2007,6 @@ "node_modules/react": { "version": "18.3.1", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2036,7 +2029,6 @@ "node_modules/react-dom": { "version": "18.3.1", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -2262,8 +2254,7 @@ "version": "0.169.0", "resolved": "https://registry.npmjs.org/three/-/three-0.169.0.tgz", "integrity": "sha512-Ed906MA3dR4TS5riErd4QBsRGPcx+HBDX2O5yYE5GqJeFQTPU+M56Va/f/Oph9X7uZo3W3o4l2ZhBZ6f6qUv0w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/three-mesh-bvh": { "version": "0.7.8", @@ -2422,7 +2413,6 @@ "version": "5.4.21", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/client/src/App.jsx b/client/src/App.jsx index dcb77e1..669ee31 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -7,6 +7,7 @@ import Configurator from './pages/Configurator'; import Login from './pages/Login'; import Admin from './pages/Admin'; import Materials from './pages/Materials'; +import Settings from './pages/Settings'; export default function App() { return ( @@ -19,6 +20,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index d920dec..e548767 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -30,6 +30,83 @@ export async function fetchSettings() { return res.json(); } +export async function updateSettings(data) { + const res = await fetch(`${API_BASE}/settings`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +// Engineering rules CRUD +export async function fetchEngineeringRules() { + const res = await fetch(`${API_BASE}/engineering-rules`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function createEngineeringRule(data) { + const res = await fetch(`${API_BASE}/engineering-rules`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function updateEngineeringRule(id, data) { + const res = await fetch(`${API_BASE}/engineering-rules/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function deleteEngineeringRule(id) { + const res = await fetch(`${API_BASE}/engineering-rules/${id}`, { + method: 'DELETE' + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +// Suppliers CRUD +export async function fetchSuppliers() { + const res = await fetch(`${API_BASE}/suppliers`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function createSupplier(data) { + const res = await fetch(`${API_BASE}/suppliers`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function updateSupplier(id, data) { + const res = await fetch(`${API_BASE}/suppliers/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function deleteSupplier(id) { + const res = await fetch(`${API_BASE}/suppliers/${id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error(await res.text()); +} + export async function calculatePrice(config) { const res = await fetch(`${API_BASE}/price`, { method: 'POST', @@ -46,6 +123,57 @@ export async function fetchOrders() { return res.json(); } +export async function fetchOrderSupplierVouchers(orderId) { + const res = await fetch(`${API_BASE}/orders/${orderId}/supplier-vouchers`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function fetchOrderPlanExports(orderId) { + const res = await fetch(`${API_BASE}/orders/${orderId}/plan-exports`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function saveOrderPlanExport(orderId, payload) { + const res = await fetch(`${API_BASE}/orders/${orderId}/plan-exports`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function generateOrderPlanExports(orderId) { + const res = await fetch(`${API_BASE}/orders/${orderId}/plan-exports/generate`, { + method: 'POST' + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function fetchOrderProductionDetails(orderId) { + const res = await fetch(`${API_BASE}/orders/${orderId}/production-details`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export function getOrderPlanExportDownloadUrl(orderId, exportId) { + return `${API_BASE}/orders/${orderId}/plan-exports/${exportId}/download`; +} + +export function getOrderPlanExportViewUrl(orderId, exportId) { + return `${API_BASE}/orders/${orderId}/plan-exports/${exportId}/download?disposition=inline`; +} + +export async function deleteOrderPlanExport(orderId, exportId) { + const res = await fetch(`${API_BASE}/orders/${orderId}/plan-exports/${exportId}`, { + method: 'DELETE' + }); + if (!res.ok) throw new Error(await res.text()); +} + export async function submitOrder(order) { const res = await fetch(`${API_BASE}/orders`, { method: 'POST', @@ -70,8 +198,27 @@ export async function triggerFormexSync() { /** * Get the SSE stream URL for real-time sync progress */ -export function getFormexSyncStreamUrl() { - return `${API_BASE}/sync/formex/stream`; +export function getFormexSyncStreamUrl(force = false) { + const url = `${API_BASE}/sync/formex/stream`; + return force ? `${url}?force=1` : url; +} + +export function getHaefeleSyncStreamUrl(urls = []) { + const cleaned = (urls || []).map((v) => (typeof v === 'string' ? v.trim() : '')).filter(Boolean); + const params = new URLSearchParams(); + for (const url of cleaned) params.append('url', url); + const query = params.toString(); + return query ? `${API_BASE}/sync/haefele/stream?${query}` : `${API_BASE}/sync/haefele/stream`; +} + +export async function triggerHaefeleSync(payload) { + const res = await fetch(`${API_BASE}/sync/haefele`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload || {}) + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); } export async function fetchSyncLogs(limit = 20) { @@ -86,13 +233,26 @@ export async function fetchSyncStatus() { return res.json(); } +export async function fetchHaefeleMaterials({ search = '', active = null, page = 1, limit = 25 } = {}) { + const params = new URLSearchParams(); + if (search) params.set('search', search); + if (active !== null) params.set('active', active ? '1' : '0'); + params.set('page', String(page)); + params.set('limit', String(limit)); + + const res = await fetch(`${API_BASE}/haefele/materials?${params.toString()}`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + // Formex Materials CRUD -export async function fetchFormexMaterials({ search = '', collection = '', category = '', active = null, page = 1, limit = 25 } = {}) { +export async function fetchFormexMaterials({ search = '', collection = '', category = '', supplier = '', active = null, page = 1, limit = 25 } = {}) { const params = new URLSearchParams(); if (search) params.set('search', search); if (collection) params.set('collection', collection); if (category) params.set('category', category); + if (supplier) params.set('supplier', supplier); if (active !== null) params.set('active', active ? '1' : '0'); params.set('page', String(page)); params.set('limit', String(limit)); @@ -102,6 +262,12 @@ export async function fetchFormexMaterials({ search = '', collection = '', categ return res.json(); } +export async function fetchMaterialSuppliers() { + const res = await fetch(`${API_BASE}/formex/suppliers`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + export async function fetchFormexMaterial(id) { const res = await fetch(`${API_BASE}/formex/materials/${id}`); if (!res.ok) throw new Error(await res.text()); diff --git a/client/src/components/Cnc3DViewer.jsx b/client/src/components/Cnc3DViewer.jsx new file mode 100644 index 0000000..f5e0227 --- /dev/null +++ b/client/src/components/Cnc3DViewer.jsx @@ -0,0 +1,1426 @@ +import { useMemo, Suspense, useRef, useEffect, useState } from 'react'; +import { Canvas, useThree, useFrame } from '@react-three/fiber'; +import { + Color, + DataTexture, + DoubleSide, + ExtrudeGeometry, + FloatType, + MeshStandardMaterial, + NearestFilter, + Quaternion, + RGBAFormat, + Shape, + Vector3 +} from 'three'; +import { OrbitControls } from '@react-three/drei'; +import { + buildTopBottomPlanHoleSpecsMm, + buildVerticalPanelIxconnectFaceHoleSpecsMm, + buildShelfMobileHoleSpecsMm, + buildFinishHabillageHoleSpecsMm, + buildDoorHingeCupHoleSpecsMm, + buildDrawerReinforcementHoleSpecsMm, + buildDrawerRenfortPanelIxconnectSpecsMm +} from '@shared/cncPanelHoles.js'; +import { GROOVE, getBackGroovePlanOrientation, getBackGrooveInnerEndInsetFromBackEdgeMm } from '@shared/manufacturing.js'; +import styles from './Cnc3DViewer.module.css'; + +/** Matches Canvas camera defaults so switching items without a saved view resets correctly. */ +const DEFAULT_ORBIT_POSITION = [1.6, 1.1, 1.6]; +const DEFAULT_ORBIT_TARGET = [0, 0, 0]; + +function orbitSessionKey(storageKey) { + const safe = String(storageKey || 'default').replace(/[^a-zA-Z0-9._:-]/g, '_') || 'default'; + return `apex.cnc3d.orbit.item:${safe}`; +} + +function PersistedOrbitControls({ storageKey }) { + const { camera } = useThree(); + const ctrlRef = useRef(null); + const restored = useRef(false); + const sessionKey = orbitSessionKey(storageKey); + + useEffect(() => { + restored.current = false; + }, [sessionKey]); + + useEffect(() => { + const tryRestore = () => { + const ctrl = ctrlRef.current; + if (!ctrl || restored.current) return; + try { + const raw = sessionStorage.getItem(sessionKey); + if (!raw) { + camera.position.fromArray(DEFAULT_ORBIT_POSITION); + ctrl.target.fromArray(DEFAULT_ORBIT_TARGET); + ctrl.update(); + restored.current = true; + return; + } + const data = JSON.parse(raw); + if (Array.isArray(data.position) && data.position.length === 3) { + camera.position.fromArray(data.position); + } + if (Array.isArray(data.target) && data.target.length === 3) { + ctrl.target.fromArray(data.target); + } + ctrl.update(); + } catch { + camera.position.fromArray(DEFAULT_ORBIT_POSITION); + ctrl.target.fromArray(DEFAULT_ORBIT_TARGET); + ctrl.update(); + } + restored.current = true; + }; + let innerId; + const outerId = requestAnimationFrame(() => { + innerId = requestAnimationFrame(tryRestore); + }); + return () => { + cancelAnimationFrame(outerId); + if (innerId != null) cancelAnimationFrame(innerId); + }; + }, [camera, sessionKey]); + + useEffect(() => { + const ctrl = ctrlRef.current; + if (!ctrl) return; + let debounce; + const persist = () => { + clearTimeout(debounce); + debounce = setTimeout(() => { + try { + sessionStorage.setItem( + sessionKey, + JSON.stringify({ + position: camera.position.toArray(), + target: ctrl.target.toArray() + }) + ); + } catch { + // quota / private mode + } + }, 120); + }; + ctrl.addEventListener('change', persist); + ctrl.addEventListener('end', persist); + return () => { + clearTimeout(debounce); + ctrl.removeEventListener('change', persist); + ctrl.removeEventListener('end', persist); + }; + }, [camera, sessionKey]); + + return ( + + ); +} + +/** World axes: X = largeur, Y = épaisseur, Z = profondeur (aligné sur le repère du viewer). */ +const VIEW_AXES = ['X', 'Y', 'Z']; + +const ALIGN_DURATION_MS = 520; + +const _alignUp = new Vector3(); + +/** +1 ou −1 : côté de la cible où se trouve déjà la caméra sur cet axe (défaut + si confondu). */ +function sideSign(cameraCoord, targetCoord) { + const d = cameraCoord - targetCoord; + if (Math.abs(d) < 1e-5) return 1; + return Math.sign(d); +} + +function easeInOutCubic(t) { + return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2; +} + +/** + * Distance actuelle + position finale + up final (vue ortho « depuis » l’axe, même côté qu’actuellement). + * Ne modifie pas le point visé (target). + */ +function getAxisAlignedEndPose(camera, controls, axis, fallbackDistance) { + if (!controls || !axis || !VIEW_AXES.includes(axis)) return null; + const t = controls.target; + const c = camera.position; + let d = c.distanceTo(t); + if (!Number.isFinite(d) || d < 1e-5) { + d = Math.max(0.15, fallbackDistance); + } + d = Math.max(0.05, Math.min(d, 200)); + const tx = t.x; + const ty = t.y; + const tz = t.z; + + switch (axis) { + case 'X': { + const sx = sideSign(c.x, tx); + return { + position: new Vector3(tx + sx * d, ty, tz), + up: new Vector3(0, 1, 0) + }; + } + case 'Z': { + const sz = sideSign(c.z, tz); + return { + position: new Vector3(tx, ty, tz + sz * d), + up: new Vector3(0, 1, 0) + }; + } + case 'Y': { + const sy = sideSign(c.y, ty); + return { + position: new Vector3(tx, ty + sy * d, tz), + up: new Vector3(0, 0, sy > 0 ? -1 : 1) + }; + } + default: + return null; + } +} + +function AxisAlignAnimator({ alignId, alignAxis, fallbackDistance }) { + const camera = useThree((s) => s.camera); + /** Abonnement explicite : `controls` n’existe qu’après le useEffect de OrbitControls (makeDefault). */ + const controls = useThree((s) => s.controls); + const get = useThree((s) => s.get); + const fallbackRef = useRef(fallbackDistance); + fallbackRef.current = fallbackDistance; + const animRef = useRef(null); + const alignRunId = useRef(0); + + useEffect(() => { + if (!alignAxis || alignId < 1) return undefined; + const ctrl = controls ?? get().controls; + if (!ctrl) return undefined; + + const myRun = ++alignRunId.current; + const endPose = getAxisAlignedEndPose(camera, ctrl, alignAxis, fallbackRef.current); + if (!endPose) return undefined; + + animRef.current = { + startPos: camera.position.clone(), + startUp: camera.up.clone(), + endPos: endPose.position, + endUp: endPose.up.clone(), + t0: performance.now(), + duration: ALIGN_DURATION_MS + }; + ctrl.enabled = false; + + return () => { + if (alignRunId.current !== myRun) return; + animRef.current = null; + const c = get().controls; + if (c) c.enabled = true; + }; + }, [alignId, alignAxis, camera, controls, get]); + + useFrame(() => { + const ctrl = get().controls; + const a = animRef.current; + if (!a || !ctrl) return; + + const elapsed = performance.now() - a.t0; + const u = Math.min(1, elapsed / a.duration); + const t = easeInOutCubic(u); + + camera.position.lerpVectors(a.startPos, a.endPos, t); + _alignUp.copy(a.startUp).lerp(a.endUp, t).normalize(); + camera.up.copy(_alignUp); + camera.lookAt(ctrl.target); + + if (u >= 1) { + camera.position.copy(a.endPos); + camera.up.copy(a.endUp); + camera.lookAt(ctrl.target); + animRef.current = null; + ctrl.enabled = true; + ctrl.update(); + } + }); + + return null; +} + +function parseRawSize(gcode) { + const lines = (gcode || '').split(/\r?\n/); + for (const raw of lines) { + const line = String(raw || ''); + if (!line.startsWith('; SIZE_RAW:')) continue; + const mx = line.match(/X=([0-9]+(\.[0-9]+)?)/i); + const my = line.match(/Y=([0-9]+(\.[0-9]+)?)/i); + const mt = line.match(/T=([0-9]+(\.[0-9]+)?)/i); + const widthM = (mx ? Number(mx[1]) : 0) / 1000; + const heightM = (my ? Number(my[1]) : 0) / 1000; + const thickM = (mt ? Number(mt[1]) : 16) / 1000; + if (widthM > 0 && heightM > 0) { + return { widthM, heightM, thickM }; + } + } + return null; +} + +function parseHoles(gcode) { + const lines = (gcode || '').split(/\r?\n/); + const holes = []; + for (const raw of lines) { + const line = String(raw || '').trim(); + if (!/^\s*;\s*HOLE\b/i.test(line)) continue; + const mx = line.match(/X=([0-9]+(\.[0-9]+)?)/i); + const my = line.match(/Y=([0-9]+(\.[0-9]+)?)/i); + const md = line.match(/D=([0-9]+(\.[0-9]+)?)/i); + const mz = line.match(/Z=([0-9]+(\.[0-9]+)?)/i); + const mt = line.match(/TYPE=([A-Z0-9_\-]+)/i); + const mface = line.match(/\bFACE=(\w+)/i); + const mend = line.match(/\bEND=(\w+)/i); + if (!mx || !my) continue; + holes.push({ + xM: Number(mx[1]) / 1000, + yM: Number(my[1]) / 1000, + dM: (md ? Number(md[1]) : 5) / 1000, + zM: (mz ? Number(mz[1]) : 10) / 1000, + type: mt ? mt[1].toLowerCase() : 'hole', + face: mface ? String(mface[1]).toLowerCase() : 'face', + end: mend ? String(mend[1]).toLowerCase() : null + }); + } + return holes; +} + +function planHoleSpecsToSceneHoles(piece, rawSize, partIndex = 0) { + if (!piece?.machining || !rawSize) return []; + const wMm = rawSize.widthM * 1000; + const hMm = rawSize.heightM * 1000; + const mapSpec = (spec) => ({ + xM: spec.xMm / 1000, + yM: spec.yMm / 1000, + dM: spec.diameterMm / 1000, + zM: spec.depthMm / 1000, + type: spec.type, + face: spec.face || 'face', + end: spec.end ?? null + }); + return [ + ...buildTopBottomPlanHoleSpecsMm(piece, wMm, hMm).map(mapSpec), + ...buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, wMm, hMm).map(mapSpec), + ...buildShelfMobileHoleSpecsMm(piece, wMm, hMm).map(mapSpec), + ...buildFinishHabillageHoleSpecsMm(piece, wMm, hMm).map(mapSpec), + ...buildDoorHingeCupHoleSpecsMm(piece, wMm, hMm).map(mapSpec), + ...buildDrawerReinforcementHoleSpecsMm(piece, wMm, hMm).map(mapSpec), + ...buildDrawerRenfortPanelIxconnectSpecsMm(piece, wMm, hMm, partIndex).map(mapSpec) + ]; +} + +/** @deprecated use `planHoleSpecsToSceneHoles` / `buildTopBottomPlanHoleSpecsMm` — kept for imports */ +export function holesFromMachiningHorizontalIxconnect(piece, rawSize) { + return planHoleSpecsToSceneHoles(piece, rawSize); +} + +function mergeHoleListsByPosition(a, b) { + const eps = 0.00035; + const near = (u, v) => Math.abs(u - v) < eps; + const out = [...a]; + for (const hb of b) { + const dup = out.some( + (ha) => + near(ha.xM, hb.xM) && + near(ha.yM, hb.yM) && + near(ha.dM, hb.dM) && + near(ha.zM ?? 0, hb.zM ?? 0) && + (ha.type || '') === (hb.type || '') && + (ha.face || '') === (hb.face || '') && + (ha.end ?? '') === (hb.end ?? '') + ); + if (!dup) out.push(hb); + } + return out; +} + +export function parseGcodeParts(gcode) { + const lines = (gcode || '').split(/\r?\n/); + const parts = []; + let currentLabel = ''; + let currentLines = []; + + const pushPart = () => { + if (!currentLines.length) return; + const label = currentLabel || `Part ${parts.length + 1}`; + parts.push({ + id: `part-${parts.length + 1}`, + label, + gcode: currentLines.join('\n') + }); + }; + + let seenFirstPart = false; + for (const raw of lines) { + const line = String(raw || ''); + if (line.trimStart().startsWith('; --- PART')) { + if (seenFirstPart) pushPart(); + seenFirstPart = true; + currentLabel = line.replace('; --- ', '').replace(' ---', '').trim(); + currentLines = []; + continue; + } + if (!seenFirstPart) continue; + currentLines.push(line); + } + if (seenFirstPart) pushPart(); + + if (!parts.length && gcode) { + parts.push({ id: 'part-1', label: 'Part 1', gcode }); + } + return parts; +} + +/** + * Slices CNC segments for one cutting-list row (same order as server cncGenerator). + */ +export function getCncPartsForPiece(cuttingList, piece, gcode) { + const all = parseGcodeParts(gcode); + if (!all.length || !cuttingList?.pieces?.length || !piece) return []; + const rowIndex = cuttingList.pieces.findIndex((p) => p.id === piece.id); + if (rowIndex < 0) return []; + let start = 0; + for (let i = 0; i < rowIndex; i++) { + start += Math.max(1, Math.round(Number(cuttingList.pieces[i].quantity) || 1)); + } + const qty = Math.max(1, Math.round(Number(piece.quantity) || 1)); + return all.slice(start, start + qty); +} + +/** Jonctions montants ext. (gauche/droite) : perçage depuis le chant 19×prof. — jonctions séparateur : grande face (non pivoté). */ +function isHorizontalPanelExteriorCoteJunction(xM, widthM) { + const w = widthM; + if (!(w > 0)) return false; + const edgeTol = Math.min(w * 0.12, 0.08); + return xM <= edgeTol || xM >= w - edgeTol; +} + +/** Séparateurs sur dessus / bandeau : perçage face depuis le bas en épaisseur (face caisse) vers l’autre face ; le fond reste depuis le haut. */ +function horizontalSeparatorFaceHoleFromBottomThick(elementType) { + return elementType === 'dessus' || elementType === 'bandeauHaut'; +} + +/** + * Trou Inbus (serrage Ixconnect) sur dessus/fond : toujours face intérieure caisse — même sens d’entrée en épaisseur + * que les perçages séparateurs (T–IX–T), pas le basculement « montants extrémité » des trous proches des chants X. + */ +function ixconnectInbusFaceFromBottomThick(etPiece, h) { + return ( + h.type === 'ixconnect_inbus' && + (etPiece === 'dessus' || etPiece === 'fond') && + h.face === 'face' + ); +} + +/** Couleur des faces usinées (rainure) — pas le bois apparent extérieur. */ +const GROOVE_CUT_FACE_COLOR = '#0d9488'; + +/** + * AABB monde + quads faces intérieures (aligné sur la même géométrie que GROOVE / BackGroove). + * @returns {null | { min: Vector3, max: Vector3, innerFaces: Array<{ pos: number[], rot: number[], size: [number, number] }> }} + */ +function getBackGrooveSceneParams(rawSize, piece) { + const m = piece?.machining?.groove; + if (!m || !rawSize) return null; + const orient = getBackGroovePlanOrientation(piece.elementType); + if (!orient) return null; + + const w = rawSize.widthM; + const t = rawSize.thickM; + const h = rawSize.heightM; + const gw = (m.width ?? GROOVE.back.width) / 1000; + const gd = (m.depth ?? GROOVE.back.depth) / 1000; + const axis = (m.axisFromPanelEdge ?? m.axisFromBackEdge ?? GROOVE.back.axisFromPanelEdge) / 1000; + const endInsetM = getBackGrooveInnerEndInsetFromBackEdgeMm() / 1000; + const eps = 0.00025; + + /* + * Horizontal : face caisse ≠ même côté en épaisseur (Y). + * — Dessus / bandeau haut : caisse en dessous de la pièce → ouverture bas en épaisseur (Y ≈ 0 → gd). + * — Fond : caisse au-dessus → ouverture haut en épaisseur (Y ≈ t−gd → t). + * Vertical : bande gw en X près du dos ; profondeur gd en Y depuis la face caisse (ici haut Y = t). + */ + if (orient === 'vertical') { + const xC = w - axis; + // Côtés extérieurs: la rainure ne doit pas aller sous/au-dessus du dessus+fond (19mm chacun) → -2×19mm. + const extraEndInsetM = piece?.elementType === 'cotes' ? (Number(piece?.thicknessMm) || 19) / 1000 : 0; + const zLen = Math.max(0.05, h - 2 * (endInsetM + extraEndInsetM)); + const yTop = t; + const yBot = t - gd; + const min = new Vector3(xC - gw / 2, yBot - eps, h / 2 - zLen / 2); + const max = new Vector3(xC + gw / 2, yTop + eps, h / 2 + zLen / 2); + const yMid = (yBot + yTop) / 2; + const innerFaces = [ + { pos: [xC, yBot + eps, h / 2], rot: [-Math.PI / 2, 0, 0], size: [gw, zLen] }, + { pos: [xC - gw / 2 + eps, yMid, h / 2], rot: [0, -Math.PI / 2, 0], size: [zLen, gd] }, + { pos: [xC + gw / 2 - eps, yMid, h / 2], rot: [0, Math.PI / 2, 0], size: [zLen, gd] }, + { pos: [xC, yMid, h / 2 - zLen / 2 + eps], rot: [0, 0, 0], size: [gw, gd] }, + { pos: [xC, yMid, h / 2 + zLen / 2 - eps], rot: [0, Math.PI, 0], size: [gw, gd] } + ]; + return { min, max, innerFaces }; + } + + const zC = h - axis; + const et = piece.elementType || ''; + const opensFromBottom = et === 'dessus' || et === 'bandeauHaut'; + const y0 = 0.0002; + + if (opensFromBottom) { + const yBot = y0; + const yTop = gd + y0; + const min = new Vector3(0, yBot - eps, zC - gw / 2); + const max = new Vector3(w, yTop + eps, zC + gw / 2); + const yMid = (yBot + yTop) / 2; + const innerFaces = [ + { pos: [w / 2, yTop - eps, zC], rot: [-Math.PI / 2, 0, 0], size: [w, gw] }, + { pos: [w / 2, yMid, zC - gw / 2 + eps], rot: [0, 0, 0], size: [w, gd] }, + { pos: [w / 2, yMid, zC + gw / 2 - eps], rot: [0, Math.PI, 0], size: [w, gd] } + ]; + return { min, max, innerFaces }; + } + + const yTop = t; + const yBot = t - gd; + const min = new Vector3(0, yBot - eps, zC - gw / 2); + const max = new Vector3(w, yTop + eps, zC + gw / 2); + const yMid = (yBot + yTop) / 2; + const innerFaces = [ + { pos: [w / 2, yBot + eps, zC], rot: [-Math.PI / 2, 0, 0], size: [w, gw] }, + { pos: [w / 2, yMid, zC - gw / 2 + eps], rot: [0, 0, 0], size: [w, gd] }, + { pos: [w / 2, yMid, zC + gw / 2 - eps], rot: [0, Math.PI, 0], size: [w, gd] } + ]; + return { min, max, innerFaces }; +} + +/** Max trous encodés dans la texture de perçages (sécurité perf). */ +const MAX_PANEL_HOLES_TEXTURE = 512; + +/** Épaisseur monde (m) dans laquelle on retire la matière sur la seule face d’entrée du perçage (axe Y). */ +const FACE_HOLE_SURFACE_CUT_DEPTH_M = 0.0018; +/** Épaisseur monde (m) du "trou" sur une coupe d'onglet (plan incliné). */ +const MITER_HOLE_SURFACE_CUT_BAND_M = 0.0016; + +function buildMiterHoleDiscardUniforms(holes, rawSize, piece) { + if (!holes?.length || !rawSize) return { count: 0, tex: null, texW: 0 }; + const w = rawSize.widthM; + const t = rawSize.thickM; + const H = rawSize.heightM; + const et = piece?.elementType || ''; + const miter = holes.filter((h) => h && h.face === 'end' && h.type === 'tourillon_miter'); + if (!miter.length) return { count: 0, tex: null, texW: 0 }; + + const max = Math.min(128, miter.length); + const texW = Math.max(1, max); + const data = new Float32Array(texW * 2 * 4); + const write = (i, px, py, pz, r, nx, ny, nz) => { + const o0 = i * 4; + data[o0 + 0] = px; + data[o0 + 1] = py; + data[o0 + 2] = pz; + data[o0 + 3] = r; + const o1 = texW * 4 + i * 4; + data[o1 + 0] = nx; + data[o1 + 1] = ny; + data[o1 + 2] = nz; + data[o1 + 3] = 0; + }; + + const yCenter = t / 2; + let count = 0; + for (let i = 0; i < miter.length && count < max; i++) { + const h = miter[i]; + const r = Math.max(0.0001, Number(h.dM) / 2); + let p = null; + let n = null; + + if (h.end === 'top' || h.end === 'bottom') { + // Side habillage: miter plane in (Y,Z). Use same bevel logic as geometry. + const bevel = Math.min(t, H * 0.35); + const zOnMiter = + h.end === 'top' ? H - Math.max(0, bevel - yCenter) : Math.max(0, bevel - yCenter); + p = new Vector3(h.xM, yCenter, zOnMiter); + n = new Vector3(0, 1, h.end === 'top' ? 1 : -1).normalize(); + } else if (h.end === 'left' || h.end === 'right') { + // Top habillage: left plane x+y=inset ; right plane (w-x)+y=inset. + const inset = Math.min(t, w * 0.25); + const xOnMiter = + h.end === 'left' + ? Math.max(0, inset - yCenter) + : Math.min(w, w - Math.max(0, inset - yCenter)); + p = new Vector3(xOnMiter, yCenter, h.yM); + n = new Vector3(h.end === 'left' ? 1 : -1, 1, 0).normalize(); + } else { + continue; + } + + write(count, p.x, p.y, p.z, r, n.x, n.y, n.z); + count++; + } + + if (count <= 0) return { count: 0, tex: null, texW: 0 }; + const tex = new DataTexture(data, texW, 2, RGBAFormat, FloatType); + tex.needsUpdate = true; + tex.magFilter = NearestFilter; + tex.minFilter = NearestFilter; + tex.generateMipmaps = false; + return { count, tex, texW }; +} + +/** + * Même géométrie que les cylindres dans CncScene : + * axe 0 = perçage face (Y épaisseur), axe 1 = chant profondeur (X), axe 2 = petits côtés haut/bas (Z longueur). + * faceCutBottom[ i ] : axe Y uniquement — 1 = découpe surface bas (Y≈0), 0 = découpe surface haut (Y≈t). + */ +function buildPanelHoleDiscardUniforms(holes, rawSize, piece) { + let count = 0; + if (!holes?.length || !rawSize) { + return { count, holeTex: null, holeTexW: 0 }; + } + + const w = rawSize.widthM; + const t = rawSize.thickM; + const hPanel = rawSize.heightM; + const etPiece = piece?.elementType || ''; + + const maxHoles = Math.min(MAX_PANEL_HOLES_TEXTURE, holes.length); + // 2 rows of texels per hole: + // row0: center.xyz + radius + // row1: halfLen + axis + faceCutBottom + faceThrough + const texW = Math.max(1, maxHoles); + const data = new Float32Array(texW * 2 * 4); + + const writeHole = (i, cx, cy, cz, r, hl, ax, cutBottom, through) => { + const o0 = i * 4; + data[o0 + 0] = cx; + data[o0 + 1] = cy; + data[o0 + 2] = cz; + data[o0 + 3] = r; + const o1 = texW * 4 + i * 4; + data[o1 + 0] = hl; + data[o1 + 1] = ax; + data[o1 + 2] = cutBottom; + data[o1 + 3] = through; + }; + + for (let hi = 0; hi < holes.length; hi++) { + if (count >= maxHoles) break; + const h = holes[hi]; + const r = Math.max(0.0001, Number(h.dM) / 2); + const i = count; + let cx = 0; + let cy = 0; + let cz = 0; + let hl = 0; + let ax = 0; + let cutBottom = 0; + let through = 0; + + if (h.face === 'end') { + const yCenter = t / 2; + const sepTopBottom = h.end === 'top' || h.end === 'bottom'; + if (sepTopBottom) { + const zDepth = Math.min(Number(h.zM), hPanel * 0.48, 0.08); + const zCenter = h.end === 'top' ? hPanel - zDepth / 2 : zDepth / 2; + cx = h.xM; + cy = yCenter; + cz = zCenter; + hl = zDepth / 2; + ax = 2; + writeHole(i, cx, cy, cz, r, hl, ax, cutBottom, through); + count++; + continue; + } + const isLeft = + h.end === 'right' ? false : h.end === 'left' ? true : h.xM < w * 0.5; + const zAlong = h.yM; + const depthAlong = Math.min(Number(h.zM), w * 0.48, 0.08); + const xCenter = isLeft ? depthAlong / 2 + 0.0001 : w - depthAlong / 2 - 0.0001; + cx = xCenter; + cy = yCenter; + cz = zAlong; + hl = depthAlong / 2; + ax = 1; + writeHole(i, cx, cy, cz, r, hl, ax, cutBottom, through); + count++; + continue; + } + + // NOTE: We do not auto-reinterpret FACE=face as edge drilling. + // The hole comments/specs already carry the intended face ("face" vs "end"). + + const rawDepth = Math.max(0, Number(h.zM) || 0); + let depth = Math.min(t * 0.999, Math.max(0.0008, rawDepth)); + if (h.type === 'tourillon') depth = Math.min(depth, 0.013); + const sepFromBottom = ixconnectInbusFaceFromBottomThick(etPiece, h) + ? horizontalSeparatorFaceHoleFromBottomThick(etPiece) + : (etPiece === 'dessus' || etPiece === 'fond' || etPiece === 'bandeauHaut') && + h.face === 'face' && + !isHorizontalPanelExteriorCoteJunction(h.xM, w) && + horizontalSeparatorFaceHoleFromBottomThick(etPiece); + // Rayons: perçage face toujours depuis le dessous (face non visible). + const shelfFromBottom = + (etPiece === 'etageres' || etPiece === 'rayonFixe') && + h.face === 'face' && + h.type === 'shelf_mobile_face'; + let fromBottom = sepFromBottom || shelfFromBottom; + // Habillage finition sur montant : lignes haut/bas 50 mm = entrée face extérieure (FACE=back), opposée à la face caisse. + // Séparateurs : renfort tiroir vers module supérieur (colonne droite) = FACE=back — grande face opposée à celle du module inférieur. + if ((etPiece === 'cotes' || etPiece === 'panneauxInterieurs') && h.face === 'back') fromBottom = true; + const y = fromBottom ? depth / 2 + 0.0002 : t - depth / 2 + 0.0002; + cx = h.xM; + cy = fromBottom ? depth / 2 + 0.0002 : t - depth / 2 + 0.0002; + cz = h.yM; + hl = depth / 2; + ax = 0; + // System32 is often emitted as 2 blind holes (one from each face). Don't force "through" visually for exterior sides. + const forceThroughSystem32 = h.type === 'system32' && etPiece !== 'cotes'; + // Ixconnect on `cotes` is drilled from the rainure side (same entry face as the groove opening) and should not + // appear as a through-hole on the opposite face in the viewer. + through = rawDepth >= t - 1e-5 || forceThroughSystem32 ? 1 : 0; + /* Perçage depuis le bas (dessus/bandeau séparateur) → découpe surface bas ; sinon (fond, montants, etc.) → surface haut. */ + cutBottom = fromBottom ? 1 : 0; + writeHole(i, cx, cy, cz, r, hl, ax, cutBottom, through); + count++; + } + + if (count <= 0) return { count: 0, holeTex: null, holeTexW: 0 }; + + const holeTex = new DataTexture(data, texW, 2, RGBAFormat, FloatType); + holeTex.needsUpdate = true; + holeTex.magFilter = NearestFilter; + holeTex.minFilter = NearestFilter; + holeTex.generateMipmaps = false; + + return { count, holeTex, holeTexW: texW }; +} + +function patchMaterialPanelDiscard(material, grooveParams, uMin, uMax, holeDiscard, panelSizeM) { + const grooveActive = grooveParams ? 1 : 0; + const hc = holeDiscard.count; + const mc = holeDiscard.miterCount || 0; + material.customProgramCacheKey = () => `apex-panel-cut-v12-g${grooveActive}-h${hc}-m${mc}`; + material.onBeforeCompile = (shader) => { + shader.uniforms.uGrooveActive = { value: grooveActive }; + shader.uniforms.uGrooveMin = { value: uMin }; + shader.uniforms.uGrooveMax = { value: uMax }; + shader.uniforms.uPanelThick = { value: panelSizeM?.t ?? 0.019 }; + shader.uniforms.uPanelW = { value: panelSizeM?.w ?? 1.0 }; + shader.uniforms.uPanelH = { value: panelSizeM?.h ?? 1.0 }; + shader.uniforms.uFaceCutBand = { value: FACE_HOLE_SURFACE_CUT_DEPTH_M }; + shader.uniforms.uHoleCount = { value: hc }; + shader.uniforms.uHoleTex = { value: holeDiscard.holeTex }; + shader.uniforms.uHoleTexW = { value: holeDiscard.holeTexW || 1 }; + shader.uniforms.uMiterCount = { value: mc }; + shader.uniforms.uMiterTex = { value: holeDiscard.miterTex }; + shader.uniforms.uMiterTexW = { value: holeDiscard.miterTexW || 1 }; + shader.uniforms.uMiterBand = { value: MITER_HOLE_SURFACE_CUT_BAND_M }; + shader.vertexShader = shader.vertexShader.replace( + '#include ', + `#include +varying vec3 vWpPanel;` + ); + shader.vertexShader = shader.vertexShader.replace( + '#include ', + `#include +{ + vec4 wpG = vec4( transformed, 1.0 ); + #ifdef USE_BATCHING + wpG = batchingMatrix * wpG; + #endif + #ifdef USE_INSTANCING + wpG = instanceMatrix * wpG; + #endif + vWpPanel = ( modelMatrix * wpG ).xyz; +} +` + ); + shader.fragmentShader = shader.fragmentShader.replace( + '#include ', + `#include +varying vec3 vWpPanel; +uniform int uGrooveActive; +uniform vec3 uGrooveMin; +uniform vec3 uGrooveMax; +uniform float uPanelThick; +uniform float uPanelW; +uniform float uPanelH; +uniform float uFaceCutBand; +uniform int uHoleCount; +uniform sampler2D uHoleTex; +uniform float uHoleTexW; +uniform int uMiterCount; +uniform sampler2D uMiterTex; +uniform float uMiterTexW; +uniform float uMiterBand;` + ); + shader.fragmentShader = shader.fragmentShader.replace( + '#include ', + `#include +{ + bool cutPanel = false; + if ( uGrooveActive > 0 && vWpPanel.x >= uGrooveMin.x && vWpPanel.x <= uGrooveMax.x && + vWpPanel.y >= uGrooveMin.y && vWpPanel.y <= uGrooveMax.y && + vWpPanel.z >= uGrooveMin.z && vWpPanel.z <= uGrooveMax.z ) { + cutPanel = true; + } + for ( int hi = 0; hi < ${MAX_PANEL_HOLES_TEXTURE}; hi ++ ) { + if ( hi >= uHoleCount ) break; + float u = ( float(hi) + 0.5 ) / uHoleTexW; + vec4 a0 = texture2D( uHoleTex, vec2( u, 0.25 ) ); + vec4 a1 = texture2D( uHoleTex, vec2( u, 0.75 ) ); + vec3 hcen = a0.xyz; + float rad = a0.w; + float hl = a1.x; + float ax = a1.y; + if ( ax < 0.5 ) { + float dx = vWpPanel.x - hcen.x; + float dz = vWpPanel.z - hcen.z; + if ( dx * dx + dz * dz <= rad * rad ) { + float throughFace = a1.w; + if ( throughFace > 0.5 ) { + if ( vWpPanel.y <= uFaceCutBand + 1e-5 || vWpPanel.y >= uPanelThick - uFaceCutBand - 1e-5 ) cutPanel = true; + continue; + } + float cutBottom = a1.z; + if ( cutBottom > 0.5 ) { + if ( vWpPanel.y <= uFaceCutBand + 1e-5 ) cutPanel = true; + } else { + if ( vWpPanel.y >= uPanelThick - uFaceCutBand - 1e-5 ) cutPanel = true; + } + } + } else if ( ax < 1.5 ) { + float dy = vWpPanel.y - hcen.y; + float dz = vWpPanel.z - hcen.z; + // Edge drilling (axis X): show the opening on the panel end face (X=0 or X=W). + // Use only the circular cross-section (dy/dz) to avoid missing the face due to axial tolerances. + if ( dy * dy + dz * dz <= rad * rad ) { + float band = max( uFaceCutBand, rad * 0.35 ); + if ( hcen.x < uPanelW * 0.5 ) { + if ( vWpPanel.x <= band + 1e-5 ) cutPanel = true; + } else { + if ( vWpPanel.x >= uPanelW - band - 1e-5 ) cutPanel = true; + } + } + } else { + float dx = vWpPanel.x - hcen.x; + float dy = vWpPanel.y - hcen.y; + // Edge drilling (axis Z): show the opening on the panel end face (Z=0 or Z=H). + if ( dx * dx + dy * dy <= rad * rad ) { + float band = max( uFaceCutBand, rad * 0.35 ); + if ( hcen.z < uPanelH * 0.5 ) { + if ( vWpPanel.z <= band + 1e-5 ) cutPanel = true; + } else { + if ( vWpPanel.z >= uPanelH - band - 1e-5 ) cutPanel = true; + } + } + } + } + for ( int mi = 0; mi < 128; mi ++ ) { + if ( mi >= uMiterCount ) break; + float u2 = ( float(mi) + 0.5 ) / uMiterTexW; + vec4 b0 = texture2D( uMiterTex, vec2( u2, 0.25 ) ); + vec4 b1 = texture2D( uMiterTex, vec2( u2, 0.75 ) ); + vec3 p0 = b0.xyz; + float rad2 = b0.w * b0.w; + vec3 n = normalize( b1.xyz ); + float dist = dot( vWpPanel - p0, n ); + if ( abs(dist) <= uMiterBand ) { + vec3 q = vWpPanel - n * dist; + vec3 d = q - p0; + if ( dot(d,d) <= rad2 ) { + cutPanel = true; + } + } + } + if ( cutPanel ) discard; +} +` + ); + }; +} + +/** Panneau avec volume rainure retiré (discard) + perçages (cylindres) + faces de coupe teal. */ +function GroovedPanelMesh({ rawSize, piece, materialColor, holes }) { + const w = rawSize.widthM; + const t = rawSize.thickM; + const h = rawSize.heightM; + const isHabillageDessus = (piece?.elementType || '') === 'habillageFinitionDessus'; + const isHabillageCote = + (piece?.elementType || '') === 'habillageFinitionGauche' || (piece?.elementType || '') === 'habillageFinitionDroite'; + const grooveParams = useMemo(() => getBackGrooveSceneParams(rawSize, piece), [rawSize, piece]); + const holeDiscard = useMemo(() => { + const base = buildPanelHoleDiscardUniforms(holes, rawSize, piece); + const miter = buildMiterHoleDiscardUniforms(holes, rawSize, piece); + return { + ...base, + miterCount: miter.count, + miterTex: miter.tex, + miterTexW: miter.texW + }; + }, [holes, rawSize, piece]); + + const grooveFaces = useMemo(() => { + const faces = grooveParams?.innerFaces || []; + if (!faces.length) return faces; + // Côtés extérieurs: montrer le fond + les 2 parois latérales, mais pas les faces d'extrémité + // (évite le "rainon" vertical d'une autre opération qui se confond visuellement aux bouts). + if (piece?.elementType === 'cotes') { + const eps = 1e-3; + return faces.filter((f) => { + const rx = Array.isArray(f?.rot) ? Number(f.rot[0]) : 0; + const ry = Array.isArray(f?.rot) ? Number(f.rot[1]) : 0; + const isBottom = Math.abs(Math.abs(rx) - Math.PI / 2) < eps; + const isSideWall = Math.abs(Math.abs(ry) - Math.PI / 2) < eps; + return isBottom || isSideWall; + }); + } + return faces; + }, [grooveParams, piece?.elementType]); + + const uMin = useMemo(() => new Vector3(), []); + const uMax = useMemo(() => new Vector3(), []); + + const panelMaterial = useMemo(() => { + const base = new Color(materialColor || '#c4a574'); + const m = new MeshStandardMaterial({ + color: base, + roughness: 0.72, + metalness: 0.05, + transparent: true, + opacity: 0.72, + depthWrite: false, + side: DoubleSide + }); + if (!grooveParams && holeDiscard.count === 0) return m; + if (grooveParams) { + uMin.copy(grooveParams.min); + uMax.copy(grooveParams.max); + } else { + uMin.set(0, 0, 0); + uMax.set(0, 0, 0); + } + patchMaterialPanelDiscard(m, grooveParams, uMin, uMax, holeDiscard, { w, h, t }); + return m; + }, [materialColor, grooveParams, holeDiscard, uMin, uMax, w, h, t]); + + useEffect(() => { + if (grooveParams) { + uMin.copy(grooveParams.min); + uMax.copy(grooveParams.max); + } + panelMaterial.color.set(materialColor || '#c4a574'); + }, [grooveParams, materialColor, panelMaterial, uMin, uMax]); + + useEffect( + () => () => { + panelMaterial.dispose(); + }, + [panelMaterial] + ); + + const panelGeometry = useMemo(() => { + if (!isHabillageDessus && !isHabillageCote) return null; + + if (isHabillageDessus) { + // 45° mitre on side edges where habillage latéral joint le dessus (sinon bord carré au montant). + const miterLeft = piece?.habillageTopMiterLeft !== false; + const miterRight = piece?.habillageTopMiterRight !== false; + const inset = Math.min(t, w * 0.25); + const shape = new Shape(); + + if (!miterLeft && !miterRight) { + return null; + } + + if (miterLeft && miterRight) { + const x0 = inset; + const x1 = Math.max(x0 + 1e-6, w - inset); + shape.moveTo(x0, 0); + shape.lineTo(x1, 0); + shape.lineTo(w, t); + shape.lineTo(0, t); + shape.lineTo(x0, 0); + } else if (miterLeft && !miterRight) { + const x0 = inset; + shape.moveTo(x0, 0); + shape.lineTo(w, 0); + shape.lineTo(w, t); + shape.lineTo(0, t); + shape.lineTo(x0, 0); + } else { + const x1 = Math.max(inset + 1e-6, w - inset); + shape.moveTo(0, 0); + shape.lineTo(x1, 0); + shape.lineTo(w, t); + shape.lineTo(0, t); + shape.lineTo(0, 0); + } + + const geom = new ExtrudeGeometry(shape, { + depth: h, + bevelEnabled: false, + curveSegments: 1, + steps: 1 + }); + // Center like `boxGeometry` so the mesh `position={[w/2,t/2,h/2]}` keeps the panel in +X/+Y/+Z. + geom.translate(-w / 2, -t / 2, -h / 2); + geom.computeVertexNormals(); + return geom; + } + + // Habillage finition gauche/droite: 45° mitre sur le haut seulement si joint avec habillage dessus. + // Sinon panneau rectangulaire (hauteur réduite, pas d’onglet vers le bandeau). + const topMiter = piece?.habillageSideTopMiter !== false; + const shape = new Shape(); + if (topMiter) { + const bevel = Math.min(t, h * 0.35); + shape.moveTo(0, 0); + shape.lineTo(0, t); + shape.lineTo(Math.max(1e-6, h - bevel), t); + shape.lineTo(h, 0); + shape.lineTo(0, 0); + } else { + shape.moveTo(0, 0); + shape.lineTo(0, t); + shape.lineTo(h, t); + shape.lineTo(h, 0); + shape.lineTo(0, 0); + } + const geom = new ExtrudeGeometry(shape, { + depth: w, + bevelEnabled: false, + curveSegments: 1, + steps: 1 + }); + geom.rotateY(-Math.PI / 2); + geom.computeBoundingBox(); + if (geom.boundingBox) { + const c = new Vector3(); + geom.boundingBox.getCenter(c); + geom.translate(-c.x, -c.y, -c.z); + } + geom.computeVertexNormals(); + return geom; + }, [h, isHabillageCote, isHabillageDessus, t, w, piece?.habillageTopMiterLeft, piece?.habillageTopMiterRight, piece?.habillageSideTopMiter]); + + useEffect( + () => () => { + if (panelGeometry) panelGeometry.dispose(); + }, + [panelGeometry] + ); + + return ( + <> + + {panelGeometry ? ( + + ) : ( + + )} + + + {grooveFaces.map((face, i) => ( + + + + + ))} + + ); +} + +function CncScene({ rawSize, materialColor, holes, piece, orbitStorageKey, alignId, alignAxis, fallbackDistance }) { + /** + * The panel mesh sits in +X/+Z (center at w/2, t/2, h/2). GridHelper is a square in XZ centered on its + * position; if we leave it at origin, a size S only covers [−S/2, S/2] and misses most of a long panel. + * Center the grid under the panel footprint and size it to max(w,h) plus margin. + */ + const [gridSize, gridDivisions, gridPos] = useMemo(() => { + if (!rawSize) return [2, 20, [0, 0, 0]]; + const w = rawSize.widthM; + const h = rawSize.heightM; + const spanM = Math.max(w, h); + const size = Math.max(2, spanM * 1.35); + const divisions = Math.max(20, Math.min(140, Math.round(size * 10))); + return [size, divisions, [w / 2, 0, h / 2]]; + }, [rawSize]); + + return ( + <> + + + + + + + + {rawSize && ( + + )} + {rawSize && holes.map((h, i) => { + const radius = Math.max(0.0001, Number(h.dM) / 2); + const etPiece = piece?.elementType || ''; + const wPanel = rawSize.widthM; + const color = + h.type === 'ixconnect' + ? '#7c3aed' + : String(h.type || '').startsWith('ixconnect_drawer') + ? '#ea580c' + : h.type === 'ixconnect_inbus' + ? '#a855f7' + : h.type === 'ferrure' + ? '#0f766e' + : h.type === 'tourillon_miter' + ? '#ff4f9a' + : h.type === 'tourillon' + ? '#64748b' + : '#ea580c'; + /* Dessus/fond : sur face (séparateurs), trio T–IX–T tout gris. Sur chant (montants), comme renfort : violet = Ixconnect seul au milieu, tourillons gris. */ + let holeColor = color; + if ((etPiece === 'dessus' || etPiece === 'fond') && h.face === 'face') { + if (!isHorizontalPanelExteriorCoteJunction(h.xM, wPanel) && h.type === 'ixconnect') { + holeColor = '#64748b'; + } + } + const lip = Math.min(0.00012, Math.max(0.00003, radius * 0.06)); + const innerR = Math.max(0.00005, radius - lip); + const outerR = radius + lip; + + if (h.face === 'end') { + const w = rawSize.widthM; + const t = rawSize.thickM; + const H = rawSize.heightM; + const yCenter = t / 2; + const depthAlong = Math.min(Number(h.zM), w * 0.48, 0.08); + const sepTopBottom = h.end === 'top' || h.end === 'bottom'; + if (sepTopBottom) { + const zDepth = Math.min(Number(h.zM), H * 0.48, 0.08); + const zCenter = h.end === 'top' ? H - zDepth / 2 : zDepth / 2; + const zSurface = h.end === 'top' ? H + 0.0004 : -0.0004; + const IX_VISUAL = '#7c3aed'; + const edgeHoleColor = h.type === 'ixconnect' ? IX_VISUAL : holeColor; + const isMiter = h.type === 'tourillon_miter'; + const dir = isMiter ? new Vector3(0, -1, h.end === 'top' ? -1 : 1).normalize() : null; + const q = isMiter ? new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), dir) : null; + const qRing = isMiter ? new Quaternion().setFromUnitVectors(new Vector3(0, 0, 1), dir) : null; + const visLen = isMiter ? zDepth * 0.5 : zDepth; + // Anchor miter dowel on the 45° cut plane (not on the square end). + // Side habillage top miter: plane in (Y,Z) goes from (y=0,z=H) to (y=bevel,z=H-bevel). + const bevel = Math.min(t, H * 0.35); + const zOnMiter = h.end === 'top' ? H - Math.max(0, bevel - yCenter) : Math.max(0, bevel - yCenter); + const entry = isMiter ? new Vector3(h.xM, yCenter, zOnMiter) : null; + const center = isMiter + ? entry.clone().addScaledVector(dir, visLen * 0.5) + : new Vector3(h.xM, yCenter, zCenter); + return ( + + + + + + {isMiter && entry && ( + + + + + )} + {isMiter && entry && ( + + + + + )} + {!isMiter && ( + <> + + + + + + + + + + )} + + ); + } + const isLeft = + h.end === 'right' ? false : h.end === 'left' ? true : h.xM < w * 0.5; + const zAlong = h.yM; + const xCenter = isLeft ? depthAlong / 2 + 0.0001 : w - depthAlong / 2 - 0.0001; + const xSurface = isLeft ? -0.0004 : w + 0.0004; + const rotYZ = [0, Math.PI / 2, 0]; + const isMiter = h.type === 'tourillon_miter' && (h.end === 'left' || h.end === 'right'); + // Perpendicular to the 45° cut plane: + // left plane: x+y=inset, solid is where x+y >= inset → drill inward increases (x+y): (+1,+1,0) + // right plane: (w-x)+y=inset, solid is where (w-x)+y >= inset → drill inward decreases x, increases y: (-1,+1,0) + const dir = isMiter ? new Vector3(h.end === 'left' ? 1 : -1, 1, 0).normalize() : null; + const q = isMiter ? new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), dir) : null; + const qRing = isMiter ? new Quaternion().setFromUnitVectors(new Vector3(0, 0, 1), dir) : null; + const visLen = isMiter ? depthAlong * 0.5 : depthAlong; + // Anchor miter dowel on the 45° cut plane (top habillage left/right miter): + // left plane: x+y=inset ; right plane: (w-x)+y=inset + const inset = Math.min(t, w * 0.25); + const xOnMiter = + h.end === 'left' + ? Math.max(0, inset - yCenter) + : Math.min(w, w - Math.max(0, inset - yCenter)); + const entry = isMiter ? new Vector3(xOnMiter, yCenter, zAlong) : null; + const center = isMiter + ? entry.clone().addScaledVector(dir, visLen * 0.5) + : new Vector3(xCenter, yCenter, zAlong); + return ( + + + + + + {isMiter && entry && ( + + + + + )} + {isMiter && entry && ( + + + + + )} + {!isMiter && ( + <> + + + + + + + + + + )} + + ); + } + + // NOTE: We do not auto-reinterpret FACE=face as edge drilling for dessus/fond. + + const rawDepth = Math.max(0, Number(h.zM) || 0); + let depth = Math.min(rawSize.thickM * 0.999, Math.max(0.0008, rawDepth)); + if (h.type === 'tourillon') depth = Math.min(depth, 0.013); + const forceThroughSystem32 = h.type === 'system32' && etPiece !== 'cotes'; + const throughFace = rawDepth >= rawSize.thickM - 1e-5 || forceThroughSystem32; + const sepFromBottom = ixconnectInbusFaceFromBottomThick(etPiece, h) + ? horizontalSeparatorFaceHoleFromBottomThick(etPiece) + : (etPiece === 'dessus' || etPiece === 'fond' || etPiece === 'bandeauHaut') && + h.face === 'face' && + !isHorizontalPanelExteriorCoteJunction(h.xM, wPanel) && + horizontalSeparatorFaceHoleFromBottomThick(etPiece); + const shelfFromBottom = + (etPiece === 'etageres' || etPiece === 'rayonFixe') && + h.face === 'face' && + h.type === 'shelf_mobile_face'; + let fromBottom = sepFromBottom || shelfFromBottom; + if ((etPiece === 'cotes' || etPiece === 'panneauxInterieurs') && h.face === 'back') fromBottom = true; + const y = throughFace + ? rawSize.thickM / 2 + : fromBottom + ? depth / 2 + 0.0002 + : rawSize.thickM - depth / 2 + 0.0002; + const ringY = fromBottom ? -0.00008 : rawSize.thickM + 0.00008; + const ringRot = fromBottom ? [Math.PI / 2, 0, 0] : [-Math.PI / 2, 0, 0]; + const ringOppY = fromBottom ? rawSize.thickM + 0.00008 : -0.00008; + const ringOppRot = fromBottom ? [-Math.PI / 2, 0, 0] : [Math.PI / 2, 0, 0]; + return ( + + + + + + + + + + {throughFace && ( + + + + + )} + + ); + })} + + + + ); +} + +export default function Cnc3DViewer({ + gcode, + piece = null, + materialColor = '#c4a574', + orbitStorageKey = 'default' +}) { + const rawSize = useMemo(() => parseRawSize(gcode), [gcode]); + const holes = useMemo(() => { + const fromG = parseHoles(gcode); + const fromPlan = planHoleSpecsToSceneHoles(piece, rawSize); + return mergeHoleListsByPosition(fromG, fromPlan); + }, [gcode, piece, rawSize]); + + /** Used only when current orbit distance cannot be read (e.g. first interaction). */ + const fallbackDistance = useMemo(() => { + if (!rawSize) return 1.6; + return Math.max(0.35, Math.max(rawSize.widthM, rawSize.heightM, rawSize.thickM) * 2.2); + }, [rawSize]); + + const [alignId, setAlignId] = useState(0); + const [alignAxis, setAlignAxis] = useState(null); + + const alignOnAxis = (axis) => { + setAlignAxis(axis); + setAlignId((n) => n + 1); + }; + + return ( +
+ + e.preventDefault()} + onWheel={(e) => e.stopPropagation()} + > + + + +
+ Axe + {VIEW_AXES.map((axis) => ( + + ))} +
+
+ ); +} diff --git a/client/src/components/Cnc3DViewer.module.css b/client/src/components/Cnc3DViewer.module.css new file mode 100644 index 0000000..707b68f --- /dev/null +++ b/client/src/components/Cnc3DViewer.module.css @@ -0,0 +1,55 @@ +.wrap { + position: relative; + width: 100%; + height: 360px; + border-radius: 8px; + overflow: hidden; + background: #f8fafc; + touch-action: none; +} + +.axisBar { + position: absolute; + left: 8px; + right: 8px; + bottom: 8px; + z-index: 2; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + pointer-events: auto; +} + +.axisLabel { + font-size: 0.65rem; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-right: 2px; +} + +.axisBtn { + padding: 4px 8px; + border: 1px solid #cbd5e1; + border-radius: 5px; + background: rgba(255, 255, 255, 0.92); + color: #334155; + font-size: 0.72rem; + font-weight: 600; + cursor: pointer; + font-variant-numeric: tabular-nums; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06); +} + +.axisBtn:hover { + background: #fff; + border-color: #94a3b8; + color: #0f172a; +} + +.axisBtn:focus-visible { + outline: 2px solid #6366f1; + outline-offset: 1px; +} diff --git a/client/src/components/ColorPickerModal.jsx b/client/src/components/ColorPickerModal.jsx index b032bba..0921dd5 100644 --- a/client/src/components/ColorPickerModal.jsx +++ b/client/src/components/ColorPickerModal.jsx @@ -7,6 +7,33 @@ import styles from './ColorPickerModal.module.css'; const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001'; +const PRIORITY_STORAGE_KEY = 'apex-ai-rec-priorities'; + +const DEFAULT_PRIORITIES = { price: 3, quality: 3, durability: 3, aesthetics: 3 }; + +function loadStoredPriorities() { + try { + const raw = localStorage.getItem(PRIORITY_STORAGE_KEY); + if (!raw) return { ...DEFAULT_PRIORITIES }; + const p = JSON.parse(raw); + return { + price: Math.min(5, Math.max(1, Number(p.price) || DEFAULT_PRIORITIES.price)), + quality: Math.min(5, Math.max(1, Number(p.quality) || DEFAULT_PRIORITIES.quality)), + durability: Math.min(5, Math.max(1, Number(p.durability) || DEFAULT_PRIORITIES.durability)), + aesthetics: Math.min(5, Math.max(1, Number(p.aesthetics) || DEFAULT_PRIORITIES.aesthetics)) + }; + } catch { + return { ...DEFAULT_PRIORITIES }; + } +} + +const PRIORITY_LABELS = { + price: 'Prix & rapport qualité-prix', + quality: 'Qualité & finition', + durability: 'Durabilité & entretien', + aesthetics: 'Esthétique & harmonie' +}; + const ELEMENT_LABELS = { plintheBas: 'Plinthe bas', fileur: 'Fileur', @@ -42,8 +69,17 @@ export default function ColorPickerModal({ const [hoveredFinish, setHoveredFinish] = useState(null); const [aiRecs, setAiRecs] = useState([]); const [aiLoading, setAiLoading] = useState(false); + const [priorities, setPriorities] = useState(loadStoredPriorities); const tooltipRef = useRef(null); + useEffect(() => { + try { + localStorage.setItem(PRIORITY_STORAGE_KEY, JSON.stringify(priorities)); + } catch { + /* ignore */ + } + }, [priorities]); + useEffect(() => { if (isOpen) { setSelectedFinish(currentFinish); @@ -51,42 +87,69 @@ export default function ColorPickerModal({ setSelectedCollection('all'); setHoveredFinish(null); setAiRecs([]); - // Fetch AI recommendations - if (config && finishes.length > 0) { - setAiLoading(true); - const controller = new AbortController(); - fetch(`${API_BASE}/api/ai/recommend-materials`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - config: { - width: config.totalWidth, - height: config.height, - depth: config.depth, - columns: config.modules?.map(m => ({ - type: m.type || m.moduleCode, - width: m.width - })), - materialName: config.finishLabel || null, - doorType: config.doorType - }, - finishes: finishes.map(f => ({ id: f.id, name: f.name || f.label, collection: f.collection, code: f.code })) - }), - signal: controller.signal - }) - .then(r => r.ok ? r.json() : null) - .then(data => { - if (data?.recommendations) { - setAiRecs(data.recommendations); - } - }) - .catch(() => {}) - .finally(() => setAiLoading(false)); - return () => controller.abort(); - } } }, [isOpen, currentFinish]); + useEffect(() => { + if (!isOpen) { + setAiLoading(false); + return undefined; + } + if (!config || finishes.length === 0) { + return undefined; + } + setAiLoading(true); + const controller = new AbortController(); + const t = setTimeout(() => { + fetch(`${API_BASE}/api/ai/recommend-materials`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + priorities, + config: { + width: config.totalWidth, + height: config.height, + depth: config.depth, + installationType: config.installationType, + columns: config.modules?.map(m => ({ + type: m.type || m.moduleCode, + width: m.width + })), + materialName: config.finishLabel || null, + doorType: config.doorType + }, + finishes: finishes.map(f => ({ + id: f.id, + name: f.name || f.label, + collection: f.collection, + code: f.code, + price_chf: f.price_chf != null ? Number(f.price_chf) : undefined + })) + }), + signal: controller.signal + }) + .then(r => (r.ok ? r.json() : null)) + .then(data => { + if (data?.recommendations) { + setAiRecs(data.recommendations); + } + }) + .catch(() => {}) + .finally(() => { + if (!controller.signal.aborted) setAiLoading(false); + }); + }, 320); + return () => { + clearTimeout(t); + controller.abort(); + }; + }, [isOpen, currentFinish, priorities, config, finishes]); + + const setPriority = useCallback((key, value) => { + const n = Math.min(5, Math.max(1, Number(value) || 3)); + setPriorities(prev => ({ ...prev, [key]: n })); + }, []); + const collections = useMemo(() => { const cols = [...new Set(finishes.map(f => f.collection).filter(Boolean))]; return cols.sort(); @@ -169,12 +232,41 @@ export default function ColorPickerModal({ {/* Swatch grid */}
- {/* AI Recommendations */} - {(aiRecs.length > 0 || aiLoading) && ( + {config && finishes.length > 0 && ( +
+
+ IA + Priorités de recommandation +
+

+ 1 = peu important, 5 = très important. L'IA pondère prix, qualité, durabilité et style + et explique chaque suggestion en fonction de vos réglages. +

+
+ {(['price', 'quality', 'durability', 'aesthetics']).map((key) => ( + + ))} +
+
+ )} + + {(aiRecs.length > 0 || aiLoading) && config && finishes.length > 0 && (
IA - Recommandations + Suggestions (justifiées selon vos priorités)
{aiLoading ? (
@@ -184,28 +276,37 @@ export default function ColorPickerModal({
) : (
- {aiRecs.map(rec => { + {aiRecs.map((rec) => { const finish = finishes.find(f => f.id === rec.finishId); if (!finish) return null; const isSelected = selectedFinish?.id === finish.id; + const summary = rec.summary || rec.reason || ''; + const detail = { + summary, + criteria: rec.criteria || {} + }; return ( - +
+ + {summary ? ( +

{summary}

+ ) : null} +
); })}
@@ -253,17 +354,55 @@ export default function ColorPickerModal({ {/* Hover detail strip -- shows info of hovered swatch */} {hoveredFinish && (
- {hoveredFinish.image_url && ( - - )} -
- {hoveredFinish.name || hoveredFinish.label} - {hoveredFinish.code && {hoveredFinish.code}} - {hoveredFinish.collection && {hoveredFinish.collection}} - {hoveredFinish.price_chf != null && ( - {hoveredFinish.price_chf.toFixed(2)} CHF +
+ {hoveredFinish.image_url && ( + )} +
+ {hoveredFinish.name || hoveredFinish.label} + {hoveredFinish.code && {hoveredFinish.code}} + {hoveredFinish.collection && {hoveredFinish.collection}} + {hoveredFinish.price_chf != null && ( + {hoveredFinish.price_chf.toFixed(2)} CHF + )} +
+ {hoveredFinish.aiDetail?.summary && ( +
+

{hoveredFinish.aiDetail.summary}

+ {(hoveredFinish.aiDetail.criteria?.price || + hoveredFinish.aiDetail.criteria?.quality || + hoveredFinish.aiDetail.criteria?.durability || + hoveredFinish.aiDetail.criteria?.aesthetics) && ( +
+ {hoveredFinish.aiDetail.criteria.price ? ( + <> +
Prix
+
{hoveredFinish.aiDetail.criteria.price}
+ + ) : null} + {hoveredFinish.aiDetail.criteria.quality ? ( + <> +
Qualité
+
{hoveredFinish.aiDetail.criteria.quality}
+ + ) : null} + {hoveredFinish.aiDetail.criteria.durability ? ( + <> +
Durabilité
+
{hoveredFinish.aiDetail.criteria.durability}
+ + ) : null} + {hoveredFinish.aiDetail.criteria.aesthetics ? ( + <> +
Style
+
{hoveredFinish.aiDetail.criteria.aesthetics}
+ + ) : null} +
+ )} +
+ )}
)}
diff --git a/client/src/components/ColorPickerModal.module.css b/client/src/components/ColorPickerModal.module.css index c7c88cc..48eaa53 100644 --- a/client/src/components/ColorPickerModal.module.css +++ b/client/src/components/ColorPickerModal.module.css @@ -220,8 +220,9 @@ /* --- Hover detail strip (bottom of swatch area) --- */ .hoverDetail { display: flex; - align-items: center; - gap: 10px; + flex-direction: column; + align-items: stretch; + gap: 0; padding: 8px 10px; margin-top: 8px; background: var(--bg-subtle); @@ -230,6 +231,12 @@ flex-shrink: 0; } +.hoverDetailTop { + display: flex; + align-items: center; + gap: 10px; +} + .hoverImg { width: 40px; height: 40px; @@ -411,9 +418,11 @@ } } -/* AI Recommendations section */ +/* AI Recommendations section — compact strip (swatches use fixed size, not grid) */ .aiSection { - padding: 8px 12px; + align-self: flex-start; + max-width: 100%; + padding: 6px 10px; margin-bottom: 8px; background: linear-gradient(135deg, #f8f7ff 0%, #eef2ff 100%); border-radius: 8px; @@ -446,12 +455,17 @@ .aiSwatches { display: flex; - gap: 6px; + gap: 10px; flex-wrap: wrap; + align-items: flex-start; } +/* Override .swatch { width: 100% } — in flex layout that made each rec span the full row */ .aiSwatch { position: relative; + width: 40px; + max-width: 40px; + flex: 0 0 40px; border-color: rgba(79, 70, 229, 0.3) !important; } @@ -477,8 +491,8 @@ } .aiShimmerDot { - width: 48px; - height: 48px; + width: 40px; + height: 40px; border-radius: 6px; background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%); background-size: 200% 100%; @@ -489,3 +503,126 @@ 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } + +/* IA — priorités utilisateur */ +.priorityPanel { + padding: 8px 10px 10px; + margin-bottom: 8px; + background: #fafafa; + border-radius: 8px; + border: 1px solid var(--border-light); + align-self: flex-start; + width: 100%; + max-width: 100%; +} + +.priorityTitleRow { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; +} + +.priorityTitle { + font-size: 0.78rem; + font-weight: 600; + color: var(--text); +} + +.priorityHint { + font-size: 0.7rem; + color: var(--text-muted); + margin: 0 0 8px; + line-height: 1.35; +} + +.priorityGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 14px; +} + +@media (max-width: 720px) { + .priorityGrid { + grid-template-columns: 1fr; + } +} + +.priorityRow { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(60px, 1fr) 22px; + align-items: center; + gap: 6px; + font-size: 0.72rem; + color: var(--text); +} + +.priorityLabel { + min-width: 0; +} + +.priorityRange { + width: 100%; + min-width: 0; + accent-color: var(--action, #4f46e5); +} + +.priorityValue { + font-weight: 600; + font-size: 0.75rem; + color: var(--action, #4f46e5); + text-align: right; +} + +.aiSwatchCard { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + max-width: 152px; +} + +.aiCardSummary { + margin: 0; + font-size: 0.65rem; + line-height: 1.3; + color: var(--text-muted); + text-align: center; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.aiExplain { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--border-light); +} + +.aiExplainLead { + margin: 0 0 8px; + font-size: 0.78rem; + color: var(--text); + line-height: 1.4; +} + +.aiExplainDl { + margin: 0; + display: grid; + grid-template-columns: minmax(52px, auto) 1fr; + gap: 4px 10px; + font-size: 0.72rem; +} + +.aiExplainDl dt { + margin: 0; + font-weight: 600; + color: var(--text-muted); +} + +.aiExplainDl dd { + margin: 0; + color: var(--text); + line-height: 1.35; +} diff --git a/client/src/components/CuttingListTable.jsx b/client/src/components/CuttingListTable.jsx index d113583..89d02f6 100644 --- a/client/src/components/CuttingListTable.jsx +++ b/client/src/components/CuttingListTable.jsx @@ -2,10 +2,334 @@ * CuttingListTable - Display cutting list in a table format * Shows pieces grouped by finish with dimensions and quantities */ -import { useMemo } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { + CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM, + CARCASS_FASTENERS, + CONNECTORS, + tourillonFaceDrillDepthMm +} from '@shared/manufacturing.js'; +import Cnc3DViewer, { getCncPartsForPiece } from './Cnc3DViewer'; import styles from './CuttingListTable.module.css'; -export default function CuttingListTable({ cuttingList, onExport }) { +const CNC_MODAL_STORAGE_KEY = 'apex.cnc3d.modal'; + +/** Must match server `generatePiecePlanSvg` in cuttingEngine.js (multi-view row layout). */ +const PIECE_PLAN_LAYOUT = { + margin: 28, + viewW: 330, + viewH: 220, + panelGap: 24, + yA: 42 +}; + +function getPieceDepthMm(piece, cuttingListConfigDepthMm) { + const t = piece?.elementType; + const width = piece?.widthMm; + const height = piece?.heightMm; + + // For readability in the cutting list, "Profondeur" is the dimension along wardrobe depth. + // Depending on the panel type, this maps to either width or height. + const depthIsWidth = new Set(['cotes', 'cotesDoublage', 'fileur', 'habillageFinitionGauche', 'habillageFinitionDroite']); + const depthIsHeight = new Set(['dessus', 'fond', 'habillageFinitionDessus']); + + if (depthIsWidth.has(t)) return width ?? null; + if (depthIsHeight.has(t)) return height ?? null; + + const configDepth = Number(cuttingListConfigDepthMm); + if (Number.isFinite(configDepth) && configDepth > 0) { + if (Number(width) === configDepth) return width; + if (Number(height) === configDepth) return height; + } + + return null; +} + +/** Nombre d'exemplaires MPR pour une ligne (aligné sur le serveur). */ +function countMprInstancesForPiece(piece) { + const qty = Math.max(1, Math.round(Number(piece?.quantity) || 1)); + if ( + piece?.elementType === 'cotes' && + qty === 2 && + piece.id === 'piece-cotes' && + String(piece.description || '').includes('gauche et droit') + ) { + return 2; + } + return qty; +} + +function getPiecePlanPanelPercents(svgEl) { + let vbW = PIECE_PLAN_LAYOUT.margin + 3 * PIECE_PLAN_LAYOUT.viewW + 2 * PIECE_PLAN_LAYOUT.panelGap + PIECE_PLAN_LAYOUT.margin; + let vbH = 478; + if (svgEl) { + if (svgEl.viewBox && svgEl.viewBox.baseVal.width > 0) { + vbW = svgEl.viewBox.baseVal.width; + vbH = svgEl.viewBox.baseVal.height; + } else if (svgEl.width && svgEl.width.baseVal) { + vbW = svgEl.width.baseVal.value; + vbH = svgEl.height?.baseVal?.value || vbH; + } + } + const { margin, viewW, panelGap, yA, viewH } = PIECE_PLAN_LAYOUT; + const xA = margin; + const xB = xA + viewW + panelGap; + const xC = xB + viewW + panelGap; + return { + vbW, + vbH, + panels: [ + { leftPct: (xA / vbW) * 100, widthPct: (viewW / vbW) * 100, topPct: (yA / vbH) * 100, heightPct: (viewH / vbH) * 100 }, + { leftPct: (xB / vbW) * 100, widthPct: (viewW / vbW) * 100, topPct: (yA / vbH) * 100, heightPct: (viewH / vbH) * 100 }, + { leftPct: (xC / vbW) * 100, widthPct: (viewW / vbW) * 100, topPct: (yA / vbH) * 100, heightPct: (viewH / vbH) * 100 } + ] + }; +} + +export default function CuttingListTable({ cuttingList, onExportMprPiece, cncProgram = '', planRestoreKey = '' }) { + const [previewPiece, setPreviewPiece] = useState(null); + const [cncModal, setCncModal] = useState(null); // { piece, segments, instanceIndex } + const cncRestoreDone = useRef(false); + const [lightbox, setLightbox] = useState(null); // { views, index, title } + const piecePlanHostRef = useRef(null); + const [planPanelLayout, setPlanPanelLayout] = useState(() => getPiecePlanPanelPercents(null)); + const [mprInstanceByPieceId, setMprInstanceByPieceId] = useState({}); + const [mprExportingPieceId, setMprExportingPieceId] = useState(null); + + function openPiecePlan(piece) { + if (!piece?.piecePlanSvg) return; + setPreviewPiece(piece); + } + + function openCnc3d(piece) { + const segments = getCncPartsForPiece(cuttingList, piece, cncProgram); + if (!segments.length) return; + setCncModal({ piece, segments, instanceIndex: 0 }); + } + + async function handleExportMprRow(piece) { + if (!onExportMprPiece || !piece?.id) return; + const instChoice = mprInstanceByPieceId[piece.id] ?? 'all'; + const instanceIndex = instChoice === 'all' ? null : Number(instChoice); + setMprExportingPieceId(piece.id); + try { + await onExportMprPiece(piece, instanceIndex); + } finally { + setMprExportingPieceId(null); + } + } + + function closeCncModal() { + setCncModal(null); + try { + sessionStorage.removeItem(CNC_MODAL_STORAGE_KEY); + } catch { + // ignore + } + } + + useEffect(() => { + if (!planRestoreKey || !cuttingList?.pieces?.length || !cncProgram) return; + if (cncRestoreDone.current) return; + cncRestoreDone.current = true; + try { + const raw = sessionStorage.getItem(CNC_MODAL_STORAGE_KEY); + if (!raw) return; + const data = JSON.parse(raw); + if (data.configKey !== planRestoreKey) { + sessionStorage.removeItem(CNC_MODAL_STORAGE_KEY); + return; + } + const piece = cuttingList.pieces.find((p) => p.id === data.pieceId); + if (!piece) return; + const segments = getCncPartsForPiece(cuttingList, piece, cncProgram); + if (!segments.length) return; + const idx = Math.min(Math.max(0, Number(data.instanceIndex) || 0), segments.length - 1); + setCncModal({ piece, segments, instanceIndex: idx }); + } catch { + // ignore + } + }, [cuttingList, cncProgram, planRestoreKey]); + + useEffect(() => { + if (!planRestoreKey || !cncModal) return; + try { + sessionStorage.setItem( + CNC_MODAL_STORAGE_KEY, + JSON.stringify({ + pieceId: cncModal.piece.id, + instanceIndex: cncModal.instanceIndex, + configKey: planRestoreKey + }) + ); + } catch { + // ignore + } + }, [cncModal, planRestoreKey]); + + function getPieceMaterialColor(piece) { + const explicit = + piece?.hexColor || + piece?.colorHex || + piece?.finishHexColor || + piece?.finishColor || + piece?.finish?.hexColor || + piece?.finish?.colorHex; + if (typeof explicit === 'string' && explicit.trim()) return explicit.trim(); + + const key = String(piece?.finishLabel || piece?.finishId || piece?.elementType || 'material'); + let hash = 0; + for (let i = 0; i < key.length; i++) hash = ((hash << 5) - hash) + key.charCodeAt(i); + const palette = ['#c4a574', '#b89a6a', '#a98a5e', '#d4c4a4', '#b09060', '#8b7355']; + return palette[Math.abs(hash) % palette.length]; + } + + function buildManufacturingRows(piece) { + const m = piece?.machining || {}; + const rows = [ + ['Dimensions finies', `${piece.widthMm} x ${piece.heightMm} x ${piece.thicknessMm} mm`], + ['Dimensions brutes CNC', `${piece.rawCutWidthMm} x ${piece.rawCutHeightMm} mm`], + ['Chants', `${piece.edgeBanding?.sides || 0} côté(s) · épaisseur ${piece.edgeBanding?.thickness || 0} mm`] + ]; + + if (m.system32) { + rows.push([ + 'System 32', + `Ø${m.system32.holeDiameter} · ${m.system32.throughHole ? 'traversant' : `prof. ${m.system32.holeDepth || 12} mm`} · pas ${m.system32.pitch || 32} mm · rangées ${m.system32.rows || 2}` + ]); + } + + if (m.hingeCups) { + rows.push([ + 'Cuvettes charnières', + `Ø${m.hingeCups.diameter} · prof. ${m.hingeCups.cupDepth} mm · positions ${m.hingeCups.positionsFromTop?.map(v => `${Math.round(v)}mm`).join(', ') || '-'}` + ]); + } + + if (m.carcassFasteners?.positionsAlongJointMm?.length) { + const cf = m.carcassFasteners; + const axes = (cf.positionsAlongJointMm || []).map((v) => `${Math.round(v)}`).join(', '); + const off = cf.dowelSideOffset ?? CARCASS_FASTENERS.dowel.sideOffset; + const nF = cf.positionsAlongJointMm.length; + const pat = + nF >= 3 + ? '3 ferrures/jonction (50 / milieu / 50)' + : nF === 2 + ? `2 ferrures/jonction (50/50, sans milieu si arête ≤ ${CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM} mm)` + : `${nF} ferrure(s)/jonction`; + rows.push([ + 'Ferrures corps (jonctions côtés/séparateurs)', + `${cf.jointCount || 1} jonctions · ${pat} · centres ${axes} mm · perçage ferrure ${cf.ferrureDepthAlongLengthMm ?? 46}/${cf.ferrureDepthInThicknessMm ?? 12} mm · +2 tourillons Ø${cf.dowelDiameter || 8}×${tourillonFaceDrillDepthMm(piece.thicknessMm)} (±${off} mm) par ferrure` + ]); + } + + if (m.connectorHoles) { + const edge = CONNECTORS.jointPatternEdge || m.connectorHoles.jointPattern; + const offMid = CONNECTORS.jointPatternMiddleTourillonOffsetFromIxMm ?? 30; + let patternNote = ''; + const ixJ = m.connectorHoles.ixconnectJointsAlongDepth; + if (ixJ?.length && ['dessus', 'fond'].includes(piece.elementType)) { + patternNote = ` · Axes (bord avant profondeur): extrémités T@${edge?.tourillon1Axis}·IX@${edge?.ixconnectAxis}·T@${edge?.tourillon2Axis} · internes IX milieu + T ±${offMid} mm (${ixJ.length} jonction(s))`; + } else if (m.connectorHoles.jointPattern && edge) { + patternNote = ` · Pattern arête T@${edge.tourillon1Axis}·IX@${edge.ixconnectAxis}·T@${edge.tourillon2Axis} mm (depuis bord avant)`; + } + rows.push([ + 'Connecteurs Ixconnect', + `About Ø${m.connectorHoles.edgeHole?.diameter || 8} prof ${m.connectorHoles.edgeHole?.depth || 45} mm · Face Ø${m.connectorHoles.faceHole?.diameter || 8} prof ${m.connectorHoles.faceHole?.depth || 12} mm${patternNote}` + ]); + } + + if (m.groove) { + const groovePos = m.groove.axisFromPanelEdge || m.groove.axisFromBackEdge || 16; + rows.push([ + 'Rainure', + `${m.groove.width || 9} x ${m.groove.depth || 10.5} mm · position à ${groovePos} mm du bord` + ]); + } + + if (m.fullHeightHandleGroove) { + const off = m.fullHeightHandleGroove.topBottomOffsetMm ?? 2; + const gw = m.fullHeightHandleGroove.grooveWidthMm ?? 9; + const gd = m.fullHeightHandleGroove.grooveDepthMm ?? 10.5; + const useful = Math.max(0, (piece.heightMm || 0) - 2 * off); + rows.push([ + 'Rainure poignée TH', + `${gw} × ${gd} mm (larg. × prof.) · retraits ${off} mm haut/bas · longueur utile rainure ${Math.round(useful)} mm` + ]); + } + + if (m.finishConnectors) { + const fc = m.finishConnectors; + rows.push([ + 'SC 8/25 (finition)', + `Face Ø${fc.faceHole?.diameter || 8} · prof. ${fc.faceHole?.depth || 12.5} mm · axes arrière ${fc.axes?.back ?? 30} / avant ${fc.axes?.front ?? 43} mm (depuis face intérieure)` + ]); + } + + if (m.reinforcementFixing) { + const rf = m.reinforcementFixing; + const ix = rf.ixconnectAxisFromTopMm != null ? ` · Ixconnect ${rf.connectorModel || 'SC 8/60'} axe ${rf.ixconnectAxisFromTopMm} mm (depuis haut)` : ''; + const axes = rf.tourillonAxesFromTopMm || rf.tourillonAxes || []; + const sides = rf.sideCount || 2; + rows.push([ + 'Fixation renfort', + `${sides} chants extrémité · par chant: ${rf.connectorModel || 'SC 8/60'}${ix} + ${rf.tourillonsPerSide || rf.tourillonCount || 2} tourillons Ø${rf.tourillonDiameter || 8} · axes ${axes.join(', ')} mm` + ]); + } + + return rows; + } + + function openViewLightbox(piece, index = 0) { + const views = Array.isArray(piece?.piecePlanViews) ? piece.piecePlanViews : []; + if (!views.length) return; + setLightbox({ views, index, title: piece.description }); + } + + function shiftLightbox(step) { + if (!lightbox) return; + const total = lightbox.views.length; + const next = (lightbox.index + step + total) % total; + setLightbox({ ...lightbox, index: next }); + } + + useEffect(() => { + if (!lightbox) return undefined; + const onKeyDown = (event) => { + if (event.key === 'Escape') { + setLightbox(null); + } else if (event.key === 'ArrowRight') { + setLightbox((prev) => { + if (!prev) return prev; + const total = prev.views.length; + return { ...prev, index: (prev.index + 1) % total }; + }); + } else if (event.key === 'ArrowLeft') { + setLightbox((prev) => { + if (!prev) return prev; + const total = prev.views.length; + return { ...prev, index: (prev.index - 1 + total) % total }; + }); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [lightbox]); + + useLayoutEffect(() => { + if (!previewPiece?.piecePlanSvg) return undefined; + const host = piecePlanHostRef.current; + if (!host) return undefined; + const sync = () => { + const svg = host.querySelector('svg'); + setPlanPanelLayout(getPiecePlanPanelPercents(svg)); + }; + sync(); + const ro = new ResizeObserver(sync); + ro.observe(host); + return () => ro.disconnect(); + }, [previewPiece]); + if (!cuttingList || !cuttingList.pieces) { return (
@@ -41,7 +365,7 @@ export default function CuttingListTable({ cuttingList, onExport }) {
- {/* Tables by finish */} +
{byFinish.map((group, groupIndex) => (

@@ -50,25 +374,44 @@ export default function CuttingListTable({ cuttingList, onExport }) { {group.pieceCount} pièces • {group.totalSurfaceM2.toFixed(2)} m²

- + + + + + {onExportMprPiece && } - {group.pieces.map((piece, pieceIndex) => ( + {group.pieces.map((piece, pieceIndex) => { + const cncSegments = cncProgram ? getCncPartsForPiece(cuttingList, piece, cncProgram) : []; + const depthMm = getPieceDepthMm(piece, cuttingList?.config?.depthMm); + const widthMm = piece?.widthMm ?? null; + const heightMm = piece?.heightMm ?? null; + const depthNum = depthMm == null ? null : Number(depthMm); + const widthNum = widthMm == null ? null : Number(widthMm); + const heightNum = heightMm == null ? null : Number(heightMm); + const hideWidth = depthNum != null && widthNum != null && depthNum === widthNum; + const hideHeight = depthNum != null && heightNum != null && depthNum === heightNum; + const mprInstanceCount = countMprInstancesForPiece(piece); + const mprBusy = mprExportingPieceId === piece.id; + return ( - - + + + @@ -79,28 +422,232 @@ export default function CuttingListTable({ cuttingList, onExport }) { )} + + + {onExportMprPiece && ( + + )} - ))} + ); + })}
Description Largeur HauteurProfondeur Ép. Qté Surface ChantPerçages + 3D + MPR
{piece.description}{piece.widthMm}{piece.heightMm}{hideWidth ? '—' : (widthMm ?? '—')}{hideHeight ? '—' : (heightMm ?? '—')}{depthMm ?? '—'} {piece.thicknessMm} {piece.quantity} {piece.surfaceM2.toFixed(3)} + {piece.piecePlanSvg ? ( + + ) : '—'} + + {cncSegments.length > 0 ? ( + + ) : ( + '—' + )} + +
+ {mprInstanceCount > 1 && ( + + )} + +
+
))} +
- {/* Export buttons */} - {onExport && ( -
- - + {cncModal && ( +
+
e.stopPropagation()}> +
+ +

{cncModal.piece.description}

+
+ Parcours outil (G-code) · Qté {cncModal.piece.quantity} + {cncModal.segments.length > 1 && ( + + {' · '} + + + + )} +
+
+
+
+ +

+ Prévisualisation 3D du parcours pour cette pièce. Glisser pour tourner, molette pour zoomer, clic droit + pour déplacer. +

+
+
+                {cncModal.segments[cncModal.instanceIndex]?.gcode || '; —'}
+              
+
+
+
+ )} + + {previewPiece && ( +
setPreviewPiece(null)}> +
e.stopPropagation()}> +
+ +

{previewPiece.description}

+
+ Fini: {previewPiece.widthMm} x {previewPiece.heightMm} x {previewPiece.thicknessMm} mm + {' · '} + Brut CNC: {previewPiece.rawCutWidthMm} x {previewPiece.rawCutHeightMm} mm +
+
+
+
+
+
+ {!!previewPiece?.piecePlanViews?.length && + previewPiece.piecePlanViews.map((view, idx) => { + const p = planPanelLayout.panels[idx]; + if (!p) return null; + return ( +
openViewLightbox(previewPiece, idx)} + title={`Agrandir ${view.label || `Vue ${idx + 1}`}`} + > + +
+ ); + })} +
+
+
+
+
Informations fabrication
+ + + {buildManufacturingRows(previewPiece).map(([label, value]) => ( + + + + + ))} + +
{label}{value}
+
+
+
+ )} + + {lightbox && ( +
setLightbox(null)}> +
e.stopPropagation()}> +
+
+ {lightbox.title} +
+ {lightbox.views[lightbox.index]?.label} - {lightbox.views[lightbox.index]?.title} +
+
+ +
+
+
+ + {lightbox.index + 1} / {lightbox.views.length} + +
+
)}
diff --git a/client/src/components/CuttingListTable.module.css b/client/src/components/CuttingListTable.module.css index 1aecab5..4b2c078 100644 --- a/client/src/components/CuttingListTable.module.css +++ b/client/src/components/CuttingListTable.module.css @@ -2,6 +2,11 @@ background: #fff; border-radius: 8px; overflow: hidden; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + height: 100%; } .empty { @@ -14,6 +19,15 @@ background: #f0f7ff; padding: 16px; border-bottom: 1px solid #cce0ff; + flex-shrink: 0; +} + +.tableScrollArea { + flex: 1; + min-height: 0; + overflow: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; } .summaryTitle { @@ -51,7 +65,7 @@ border-bottom: 1px solid #e5e7eb; } -.finishGroup:last-of-type { +.finishGroup:last-child { border-bottom: none; } @@ -80,10 +94,18 @@ .table { width: 100%; + min-width: 820px; border-collapse: collapse; font-size: 0.85rem; } +.table thead th { + position: sticky; + top: 0; + z-index: 2; + background: #f9fafb; +} + .table th { padding: 8px 10px; background: #f9fafb; @@ -127,6 +149,419 @@ text-align: center; } +.planBtn { + padding: 4px 8px; + border: 1px solid #cbd5e1; + border-radius: 4px; + background: #fff; + color: #1e3a8a; + cursor: pointer; + font-size: 0.75rem; +} + +.planBtn:hover { + background: #eff6ff; +} + +.cncCell { + text-align: center; + vertical-align: middle; + width: 2.75rem; + max-width: 2.75rem; + padding: 4px 2px; +} + +.table th.cncHeader { + text-align: center; + vertical-align: middle; + width: 2.75rem; + max-width: 2.75rem; + white-space: nowrap; + z-index: 3; + padding: 6px 2px !important; + font-size: 0.65rem; + letter-spacing: 0.02em; +} + +.cncBtn { + display: block; + width: 100%; + max-width: 2.5rem; + margin: 0 auto; + padding: 2px 0; + border: 1px solid #c4b5fd; + border-radius: 3px; + background: #f5f3ff; + color: #5b21b6; + cursor: pointer; + font-size: 0.62rem; + font-weight: 700; + line-height: 1.25; + letter-spacing: 0.03em; +} + +.cncBtn:hover { + background: #ede9fe; +} + +.table th.mprHeader { + text-align: center; + vertical-align: middle; + width: 2.75rem; + max-width: 2.75rem; + white-space: nowrap; + z-index: 3; + padding: 6px 2px !important; + font-size: 0.65rem; + letter-spacing: 0.02em; +} + +.mprCell { + text-align: center; + vertical-align: middle; + width: 2.75rem; + max-width: 2.75rem; + padding: 4px 2px; +} + +.mprCellInner { + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: center; + gap: 2px; + width: 100%; + max-width: 2.5rem; + margin: 0 auto; +} + +.mprInstanceSelect { + width: 100%; + padding: 1px 0; + border: 1px solid #cbd5e1; + border-radius: 3px; + font-size: 0.62rem; + line-height: 1.2; + color: #374151; + background: #fff; + text-align: center; +} + +.mprBtn { + width: 100%; + padding: 2px 0; + border: 1px solid #86efac; + border-radius: 3px; + background: #f0fdf4; + color: #166534; + cursor: pointer; + font-size: 0.62rem; + font-weight: 700; + line-height: 1.25; + letter-spacing: 0.03em; +} + +.mprBtn:hover:not(:disabled) { + background: #dcfce7; +} + +.mprBtn:disabled { + opacity: 0.6; + cursor: wait; +} + +@media (min-width: 768px) { + .table th.cncHeader, + .table th.mprHeader, + .cncCell, + .mprCell { + width: 3rem; + max-width: 3rem; + } + + .cncBtn { + max-width: 2.75rem; + } + + .mprCellInner { + max-width: 2.75rem; + } +} + +.cncModal { + width: min(95vw, 960px); + max-height: 90vh; + background: #fff; + border-radius: 10px; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25); +} + +.cncModalBody { + display: grid; + grid-template-columns: 1.15fr 1fr; + gap: 12px; + padding: 12px 16px 16px; + overflow: auto; + max-height: min(72vh, 640px); +} + +.cncViewerCol { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.cncModalHint { + margin: 0; + font-size: 0.78rem; + color: #64748b; +} + +.cncCodeBlock { + margin: 0; + min-height: 200px; + max-height: 420px; + overflow: auto; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid #e2e8f0; + background: #0f172a; + color: #e2e8f0; + font-size: 0.72rem; + line-height: 1.45; +} + +.cncInstanceWrap { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.cncInstanceLabel { + font-size: 0.85rem; + color: #64748b; + font-weight: 500; +} + +.cncInstanceSelect { + padding: 4px 8px; + border-radius: 6px; + border: 1px solid #cbd5e1; + font-size: 0.82rem; + background: #fff; +} + +@media (max-width: 768px) { + .cncModalBody { + grid-template-columns: 1fr; + } +} + +.previewOverlay { + position: fixed; + inset: 0; + background: rgba(17, 24, 39, 0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 1200; + padding: 20px; +} + +.previewModal { + width: min(95vw, 1100px); + max-height: 90vh; + background: #fff; + border-radius: 10px; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25); +} + +.previewHeader { + position: relative; + padding: 12px 16px; + border-bottom: 1px solid #e5e7eb; + padding-right: 44px; +} + +.previewHeader h4 { + margin: 0 0 6px; + font-size: 1rem; +} + +.previewMeta { + color: #6b7280; + font-size: 0.85rem; +} + +.previewClose { + position: absolute; + top: 10px; + right: 10px; + width: 28px; + height: 28px; + border: 1px solid #cbd5e1; + border-radius: 999px; + background: #fff; + color: #334155; + font-size: 20px; + line-height: 1; + cursor: pointer; +} + +.previewClose:hover { + background: #eff6ff; +} + +.previewCanvasWrap { + position: relative; +} + +.previewContent { + overflow: auto; + padding: 12px; + background: #f8fafc; +} + +.previewContentClickable { + cursor: default; +} + +.piecePlanHost { + position: relative; + display: block; + width: 100%; +} + +.piecePlanHost :global(svg) { + display: block; + max-width: 100%; + height: auto; +} + +.viewOverlayPanel { + position: absolute; + z-index: 2; + cursor: zoom-in; + box-sizing: border-box; +} + +.viewIconBtn { + position: absolute; + top: 8px; + right: 8px; + border: 1px solid #cbd5e1; + background: rgba(255, 255, 255, 0.92); + border-radius: 999px; + width: 34px; + height: 34px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: #1e3a8a; + z-index: 3; +} + +.viewIconBtn:hover { + background: #eff6ff; +} + +.viewIconGlyph { + font-size: 14px; + line-height: 1; +} + +.previewTech { + border-top: 1px solid #e5e7eb; + padding: 12px 16px 16px; + background: #fff; +} + +.previewTech h5 { + margin: 0 0 8px; + font-size: 0.95rem; +} + +.previewTable { + width: 100%; + border-collapse: collapse; + font-size: 0.84rem; +} + +.previewTable th, +.previewTable td { + border: 1px solid #e5e7eb; + padding: 6px 8px; + text-align: left; + vertical-align: top; +} + +.previewTable th { + width: 220px; + background: #f8fafc; + color: #334155; + font-weight: 600; +} + +.lightboxOverlay { + position: fixed; + inset: 0; + background: rgba(2, 6, 23, 0.72); + display: flex; + align-items: center; + justify-content: center; + z-index: 1300; + padding: 22px; +} + +.lightboxModal { + width: min(96vw, 1280px); + max-height: 94vh; + background: #fff; + border-radius: 10px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.lightboxHeader { + border-bottom: 1px solid #e5e7eb; + padding: 10px 16px; + position: relative; + padding-right: 44px; +} + +.lightboxContent { + overflow: auto; + background: #f8fafc; + padding: 10px; + min-height: 420px; +} + +.lightboxFooter { + border-top: 1px solid #e5e7eb; + padding: 10px 16px; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; +} + +.lightboxCounter { + font-size: 0.85rem; + color: #475569; + min-width: 54px; + text-align: center; +} + .edgeBadge { display: inline-block; padding: 2px 6px; @@ -145,45 +580,27 @@ color: #92400e; } -.exportActions { - display: flex; - justify-content: flex-end; - gap: 10px; - padding: 16px; - border-top: 1px solid #e5e7eb; - background: #f9fafb; -} - -.exportBtn { - padding: 10px 20px; - border-radius: 6px; - font-size: 0.9rem; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - background: #fff; - border: 1px solid #d1d5db; - color: #374151; -} - -.exportBtn:hover { - background: #f3f4f6; -} - -.exportBtn.primary { - background: #4f46e5; - border-color: #4f46e5; - color: #fff; -} - -.exportBtn.primary:hover { - background: #4338ca; -} - @media (max-width: 640px) { .table { font-size: 0.75rem; } + + .table th.mprHeader, + .mprCell { + position: sticky; + right: 0; + z-index: 3; + box-shadow: -4px 0 8px -4px rgba(15, 23, 42, 0.12); + } + + .table th.mprHeader { + background: #f9fafb; + } + + .mprCell { + background: #fff; + z-index: 2; + } .descCell { min-width: 100px; diff --git a/client/src/components/HardwareListTable.jsx b/client/src/components/HardwareListTable.jsx new file mode 100644 index 0000000..c3fd545 --- /dev/null +++ b/client/src/components/HardwareListTable.jsx @@ -0,0 +1,91 @@ +import styles from './HardwareListTable.module.css'; + +function formatChf(value) { + const n = Number(value); + if (!Number.isFinite(n)) return '—'; + return `CHF ${n.toLocaleString('fr-CH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + +function formatUnitPrice({ unitPriceChf, unitLabel, unit }) { + const n = Number(unitPriceChf); + if (!Number.isFinite(n)) return '—'; + const money = `CHF ${n.toLocaleString('fr-CH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + const u = unitLabel || unit || null; + return u ? `${money} / ${u}` : money; +} + +export default function HardwareListTable({ hardwareList, error = null }) { + if (error) { + return
{error}
; + } + + if (!hardwareList) { + return
Aucune donnée de quincaillerie disponible
; + } + + const items = Array.isArray(hardwareList.items) ? hardwareList.items : []; + + return ( +
+
+

Résumé

+
+
+ Articles + {items.length} +
+
+ Total + {formatChf(hardwareList.totalChf)} +
+
+
+ + + + + + + + + + + + + + {items.map((it) => ( + + + + + + + + + ))} + + + + + + + +
MatérielCodeQtéPrix unitaireTotal
+ {it.imageUrl ? ( + + ) : ( + — + )} + + {it.productUrl ? ( + + {it.label} + + ) : ( + it.label + )} + {it.code || '—'}{it.quantity}{formatUnitPrice(it)}{formatChf(it.lineTotalChf)}
Total{formatChf(hardwareList.totalChf)}
+
+ ); +} + diff --git a/client/src/components/HardwareListTable.module.css b/client/src/components/HardwareListTable.module.css new file mode 100644 index 0000000..607f1cc --- /dev/null +++ b/client/src/components/HardwareListTable.module.css @@ -0,0 +1,156 @@ +.container { + display: flex; + flex-direction: column; + gap: 16px; +} + +.empty { + padding: 18px; + border: 1px dashed var(--border); + border-radius: 10px; + background: var(--bg-subtle); + color: var(--text-muted); + font-size: 0.9rem; +} + +.summary { + background: #f0f7ff; + border-radius: 10px; + padding: 18px; + border: 1px solid rgba(59, 130, 246, 0.15); +} + +.summaryTitle { + margin: 0 0 12px; + font-size: 1.05rem; + font-weight: 700; + color: var(--text); +} + +.summaryGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.summaryItem { + display: flex; + flex-direction: column; + gap: 4px; +} + +.summaryLabel { + font-size: 0.78rem; + color: var(--text-muted); + font-weight: 600; +} + +.summaryValue { + font-size: 1.25rem; + font-weight: 800; + color: #1d4ed8; +} + +.table { + width: 100%; + border-collapse: collapse; + background: #fff; + border: 1px solid var(--border-light); + border-radius: 10px; + overflow: hidden; +} + +.table th, +.table td { + padding: 10px 12px; + border-bottom: 1px solid var(--border-light); + vertical-align: middle; +} + +.table thead th { + background: var(--bg-subtle); + font-size: 0.82rem; + letter-spacing: 0.01em; + text-transform: uppercase; + color: var(--text-muted); + font-weight: 700; +} + +.thumbCol { + width: 52px; +} + +.thumbCell { + width: 52px; + text-align: center; +} + +.thumbImg { + width: 38px; + height: 38px; + object-fit: cover; + border-radius: 8px; + border: 1px solid var(--border-light); + background: #fff; +} + +.thumbEmpty { + color: var(--text-muted); +} + +.labelCell { + font-weight: 600; + color: var(--text); +} + +.codeCell { + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +.productLink { + color: var(--action); + text-decoration: none; + font-weight: 700; +} + +.productLink:hover { + text-decoration: underline; +} + +.num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.qty { + font-weight: 700; +} + +.table tfoot th, +.table tfoot td { + background: #fff7ed; + border-top: 1px solid rgba(245, 158, 11, 0.25); + border-bottom: none; +} + +.totalLabel { + text-align: right; + font-weight: 800; + color: #7c2d12; +} + +.totalValue { + font-weight: 900; + color: #7c2d12; +} + +@media (max-width: 768px) { + .summaryGrid { + grid-template-columns: 1fr; + } + .table { + font-size: 0.9rem; + } +} + diff --git a/client/src/components/Layout.jsx b/client/src/components/Layout.jsx index 62e4781..3900367 100644 --- a/client/src/components/Layout.jsx +++ b/client/src/components/Layout.jsx @@ -22,6 +22,7 @@ export default function Layout({ children }) { Configurateur Matériaux Admin + Paramètres ) : ( diff --git a/client/src/components/MaterialModal.jsx b/client/src/components/MaterialModal.jsx index cf5a05b..11ab463 100644 --- a/client/src/components/MaterialModal.jsx +++ b/client/src/components/MaterialModal.jsx @@ -12,6 +12,13 @@ export default function MaterialModal({ material, collections, onClose, onSave } width_mm: '', height_mm: '', thickness_mm: '', + diameter_mm: '', + length_mm: '', + bore_diameter_mm: '', + bore_depth_base_mm: '', + bore_depth_piece_mm: '', + price_chf: '', + price_unit: '', formex_url: '' }); @@ -27,6 +34,13 @@ export default function MaterialModal({ material, collections, onClose, onSave } width_mm: material.width_mm?.toString() || '', height_mm: material.height_mm?.toString() || '', thickness_mm: material.thickness_mm?.toString() || '', + diameter_mm: material.diameter_mm?.toString() || '', + length_mm: material.length_mm?.toString() || '', + bore_diameter_mm: material.bore_diameter_mm?.toString() || '', + bore_depth_base_mm: material.bore_depth_base_mm?.toString() || '', + bore_depth_piece_mm: material.bore_depth_piece_mm?.toString() || '', + price_chf: material.price_chf?.toString() || '', + price_unit: material.price_unit || '', formex_url: material.formex_url || '' }); } @@ -56,7 +70,14 @@ export default function MaterialModal({ material, collections, onClose, onSave } collection: formData.collection.trim(), width_mm: formData.width_mm ? parseInt(formData.width_mm, 10) : null, height_mm: formData.height_mm ? parseInt(formData.height_mm, 10) : null, - thickness_mm: formData.thickness_mm ? parseInt(formData.thickness_mm, 10) : null, + thickness_mm: formData.thickness_mm ? parseFloat(formData.thickness_mm) : null, + diameter_mm: formData.diameter_mm ? parseFloat(formData.diameter_mm) : null, + length_mm: formData.length_mm ? parseFloat(formData.length_mm) : null, + bore_diameter_mm: formData.bore_diameter_mm ? parseFloat(formData.bore_diameter_mm) : null, + bore_depth_base_mm: formData.bore_depth_base_mm ? parseFloat(formData.bore_depth_base_mm) : null, + bore_depth_piece_mm: formData.bore_depth_piece_mm ? parseFloat(formData.bore_depth_piece_mm) : null, + price_chf: formData.price_chf ? parseFloat(formData.price_chf) : null, + price_unit: formData.price_unit.trim() || null, formex_url: formData.formex_url.trim() || null }; @@ -174,15 +195,110 @@ export default function MaterialModal({ material, collections, onClose, onSave }
+
+
+ + +
+
+ + +
+
+ +
+ Perçage +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ + +
+
+ + +
+
+
- +
diff --git a/client/src/components/MaterialModal.module.css b/client/src/components/MaterialModal.module.css index b30e61c..6d6de02 100644 --- a/client/src/components/MaterialModal.module.css +++ b/client/src/components/MaterialModal.module.css @@ -12,7 +12,7 @@ .modal { background: #fff; border-radius: 8px; - max-width: 500px; + max-width: 560px; width: 100%; max-height: 90vh; overflow-y: auto; @@ -92,6 +92,24 @@ gap: 1rem; } +.row:has(> :nth-child(2):last-child) { + grid-template-columns: repeat(2, 1fr); +} + +.fieldset { + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.75rem 1rem 0.5rem; + margin-bottom: 1rem; +} + +.fieldset legend { + font-size: 0.8125rem; + font-weight: 600; + color: var(--accent); + padding: 0 0.375rem; +} + .actions { display: flex; gap: 0.75rem; diff --git a/client/src/components/Plans2DView.jsx b/client/src/components/Plans2DView.jsx index 43ab886..e11c4b7 100644 --- a/client/src/components/Plans2DView.jsx +++ b/client/src/components/Plans2DView.jsx @@ -4,22 +4,74 @@ */ import { useState, useEffect, useCallback } from 'react'; import CuttingListTable from './CuttingListTable'; +import HardwareListTable from './HardwareListTable'; import styles from './Plans2DView.module.css'; +import { saveOrderPlanExport, generateOrderPlanExports } from '../api/client'; +import Icon from './ui/Icon'; const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001'; -export default function Plans2DView({ config, modules = [], colors = {}, onClose }) { - const [activeTab, setActiveTab] = useState('assembly'); +const PLANS_TAB_KEY = 'apex.plans.tab'; +const VALID_PLAN_TABS = ['assembly', 'side', 'top', 'cutting', 'hardware']; + +function readSavedPlanTab() { + try { + let t = localStorage.getItem(PLANS_TAB_KEY); + if (VALID_PLAN_TABS.includes(t)) return t; + t = sessionStorage.getItem(PLANS_TAB_KEY); + if (VALID_PLAN_TABS.includes(t)) { + localStorage.setItem(PLANS_TAB_KEY, t); + sessionStorage.removeItem(PLANS_TAB_KEY); + return t; + } + } catch { + // ignore + } + return 'assembly'; +} + +export default function Plans2DView({ config, modules = [], colors = {}, orderId = null, onClose }) { + const [activeTab, setActiveTab] = useState(readSavedPlanTab); const [plans, setPlans] = useState(null); const [cuttingList, setCuttingList] = useState(null); + const [hardwareList, setHardwareList] = useState(null); + const [hardwareError, setHardwareError] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + /** Erreur API plans 2D seulement — n’empêche pas liste débitage / quincaillerie */ + const [planViewsError, setPlanViewsError] = useState(null); + const [exportStatus, setExportStatus] = useState(''); + const [cncProgram, setCncProgram] = useState(''); + + useEffect(() => { + try { + localStorage.setItem(PLANS_TAB_KEY, activeTab); + sessionStorage.removeItem(PLANS_TAB_KEY); + } catch { + // ignore + } + }, [activeTab]); + + const planRestoreKey = `${config.widthMm}|${config.heightMm}|${config.depthMm}`; + + const handleClose = useCallback(() => { + try { + localStorage.removeItem(PLANS_TAB_KEY); + sessionStorage.removeItem(PLANS_TAB_KEY); + sessionStorage.removeItem('apex.cnc3d.modal'); + } catch { + // ignore + } + onClose?.(); + }, [onClose]); // Fetch plans and cutting list useEffect(() => { const fetchData = async () => { setLoading(true); setError(null); + setPlanViewsError(null); + setHardwareError(null); try { // Fetch plans @@ -28,10 +80,24 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ config, modules, colors }) }); - const plansData = await plansRes.json(); - - if (plansData.success) { + let plansData = {}; + try { + plansData = await plansRes.json(); + } catch { + plansData = {}; + } + + if (plansRes.ok && plansData.success && plansData.plans) { setPlans(plansData.plans); + setPlanViewsError(null); + } else { + setPlans(null); + const detail = Array.isArray(plansData.details) ? plansData.details[0]?.message : undefined; + const msg = + plansData.error || + detail || + (!plansRes.ok ? `Plans 2D indisponibles (HTTP ${plansRes.status}).` : 'Réponse serveur invalide pour les plans 2D.'); + setPlanViewsError(msg); } // Fetch cutting list @@ -42,8 +108,34 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose }); const cutData = await cutRes.json(); - if (cutData.success) { + if (cutRes.ok && cutData.success) { setCuttingList(cutData.cuttingList); + } else { + setCuttingList(null); + } + + // Fetch hardware list + const hwRes = await fetch(`${API_BASE}/api/plans/hardware-list`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config, modules, colors }) + }); + const hwData = await hwRes.json(); + if (hwData.success) { + setHardwareList({ items: hwData.items || [], totalChf: hwData.totalChf ?? 0 }); + } else { + setHardwareList(null); + setHardwareError('Erreur lors de la génération de la liste de quincaillerie'); + } + + const cncRes = await fetch(`${API_BASE}/api/plans/export-cnc`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config, modules, colors }) + }); + if (cncRes.ok) { + const cncText = await cncRes.text(); + setCncProgram(cncText); } } catch (err) { @@ -60,19 +152,34 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose // Export handlers const handleExport = useCallback(async (format) => { try { + setExportStatus(''); if (format === 'text') { const res = await fetch(`${API_BASE}/api/plans/export-text`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ config, modules, colors }) }); + if (!res.ok) throw new Error(await res.text()); const blob = await res.blob(); + const textContent = await blob.text(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'liste-debitage.txt'; a.click(); URL.revokeObjectURL(url); + + if (orderId) { + await saveOrderPlanExport(orderId, { + exportType: 'cutting_list_text', + content: textContent, + mimeType: 'text/plain; charset=utf-8', + fileExtension: 'txt' + }); + setExportStatus(`Export enregistré pour la commande #${orderId}.`); + } else { + setExportStatus('Export téléchargé localement (non archivé: commande non envoyée).'); + } } else if (format === 'pdf') { const res = await fetch(`${API_BASE}/api/plans/export-pdf`, { @@ -80,27 +187,123 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ config, modules, colors }) }); + if (!res.ok) throw new Error(await res.text()); const html = await res.text(); // Open in new window for printing const printWindow = window.open('', '_blank'); - printWindow.document.write(html); - printWindow.document.close(); - printWindow.onload = () => { - printWindow.print(); - }; + if (printWindow) { + printWindow.document.write(html); + printWindow.document.close(); + printWindow.onload = () => { + printWindow.print(); + }; + } + + if (orderId) { + await generateOrderPlanExports(orderId); + setExportStatus(`PDF dossier enregistré pour la commande #${orderId}.`); + } else { + setExportStatus('Version imprimable ouverte (non archivée: commande non envoyée).'); + } + } else if (format === 'cnc') { + const res = await fetch(`${API_BASE}/api/plans/export-cnc`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config, modules, colors }) + }); + if (!res.ok) throw new Error(await res.text()); + const gcode = await res.text(); + setCncProgram(gcode); + + const blob = new Blob([gcode], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'apex-program.nc'; + a.click(); + URL.revokeObjectURL(url); + setExportStatus('Programme CNC (.nc) téléchargé.'); + } else if (format === 'mpr') { + const res = await fetch(`${API_BASE}/api/plans/export-mpr`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + config, + modules, + colors, + orderReference: orderId ? `COMMANDE_${orderId}` : '', + stackListKm: '01', + mprGroupIndex: 1 + }) + }); + if (!res.ok) throw new Error(await res.text()); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'apex-mpr-g1.zip'; + a.click(); + URL.revokeObjectURL(url); + setExportStatus('Archive Homag MPR (.zip) téléchargée.'); } } catch (err) { console.error('Export error:', err); alert('Erreur lors de l\'export'); } - }, [config, modules, colors]); + }, [config, modules, colors, orderId]); + + const handleExportMprPiece = useCallback(async (piece, instanceIndex = null) => { + try { + setExportStatus(''); + if (!piece?.id) return; + const res = await fetch(`${API_BASE}/api/plans/export-mpr-piece`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + config, + modules, + colors, + pieceId: piece.id, + ...(instanceIndex != null && Number.isFinite(instanceIndex) ? { instanceIndex } : {}), + orderReference: orderId ? `COMMANDE_${orderId}` : '', + stackListKm: '01', + mprGroupIndex: 1 + }) + }); + if (!res.ok) { + let msg = `HTTP ${res.status}`; + try { + const err = await res.json(); + msg = err.error || msg; + } catch { + msg = (await res.text()) || msg; + } + throw new Error(msg); + } + const blob = await res.blob(); + const disposition = res.headers.get('Content-Disposition') || ''; + const match = disposition.match(/filename="?([^";\n]+)"?/i); + const downloadName = match?.[1] || 'piece.mpr'; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = downloadName; + a.click(); + URL.revokeObjectURL(url); + setExportStatus(`MPR téléchargé : ${piece.description}`); + } catch (err) { + console.error('Export MPR piece error:', err); + alert(err.message || 'Erreur lors de l\'export MPR'); + } + }, [config, modules, colors, orderId]); const tabs = [ { id: 'assembly', label: 'Vue de face' }, { id: 'side', label: 'Vue de côté' }, { id: 'top', label: 'Vue de dessus' }, - { id: 'cutting', label: 'Liste de débitage' } + { id: 'cutting', label: 'Liste de débitage' }, + { id: 'hardware', label: 'Quincaillerie' } ]; return ( @@ -108,7 +311,10 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose

Plans de fabrication

- +
@@ -123,38 +329,65 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose ))}
-
+
{loading ? (
Génération des plans...
) : error ? (
{error}
) : ( <> - {activeTab === 'assembly' && plans?.assembly && ( -
+ {activeTab === 'assembly' && ( + planViewsError ? ( +
{planViewsError}
+ ) : plans?.assembly ? ( +
+ ) : ( +
Aucun plan de face disponible.
+ ) )} - {activeTab === 'side' && plans?.side && ( -
+ {activeTab === 'side' && ( + planViewsError ? ( +
{planViewsError}
+ ) : plans?.side ? ( +
+ ) : ( +
Aucun plan de côté disponible.
+ ) )} - {activeTab === 'top' && plans?.top && ( -
+ {activeTab === 'top' && ( + planViewsError ? ( +
{planViewsError}
+ ) : plans?.top ? ( +
+ ) : ( +
Aucun plan de dessus disponible.
+ ) )} {activeTab === 'cutting' && ( - + )} + + {activeTab === 'hardware' && ( + )} @@ -162,20 +395,50 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose
-
- {config.widthMm} × {config.heightMm} × {config.depthMm} mm - • - {config.installationType === 'niche' ? 'Encastrée' : 'Libre'} - • - {config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'} +
+
+ + {config.widthMm} × {config.heightMm} × {config.depthMm} mm + + + {config.installationType === 'niche' ? 'Encastrée' : 'Libre'} + + {config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'} +
+ {exportStatus &&
{exportStatus}
}
+ {activeTab === 'cutting' && ( + + )} + {activeTab === 'cutting' && ( + <> + + + + )}
diff --git a/client/src/components/Plans2DView.module.css b/client/src/components/Plans2DView.module.css index 8a6df7b..f7e811e 100644 --- a/client/src/components/Plans2DView.module.css +++ b/client/src/components/Plans2DView.module.css @@ -17,10 +17,11 @@ border-radius: 12px; width: 100%; max-width: 1000px; - max-height: 90vh; + max-height: min(90vh, calc(100dvh - 40px)); display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + overflow: hidden; } .header { @@ -39,17 +40,26 @@ } .closeBtn { - background: none; - border: none; - font-size: 1.5rem; + display: inline-flex; + align-items: center; + gap: 0.35rem; + background: #fff; + border: 1px solid var(--border); + border-radius: 9px; color: var(--text-muted); cursor: pointer; - padding: 4px 8px; - line-height: 1; + padding: 0.35rem 0.6rem; + font-size: 0.8rem; + font-weight: 600; + line-height: 1.2; + transition: all 0.15s ease; } .closeBtn:hover { - color: var(--text); + border-color: var(--action); + color: var(--action); + box-shadow: 0 6px 16px rgba(79, 70, 229, 0.15); + transform: translateY(-1px); } .tabs { @@ -97,6 +107,15 @@ min-height: 400px; } +.contentCutting { + overflow: hidden; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + padding: 20px; +} + .loading, .error { display: flex; @@ -111,6 +130,20 @@ color: var(--danger); } +.empty { + display: flex; + align-items: center; + justify-content: center; + min-height: 280px; + padding: 24px; + text-align: center; + color: var(--text-muted); + font-size: 0.9rem; + line-height: 1.45; + max-width: 420px; + margin: 0 auto; +} + .planContainer { display: flex; align-items: center; @@ -128,45 +161,107 @@ .footer { display: flex; - align-items: center; + flex-wrap: wrap; + align-items: flex-start; justify-content: space-between; - padding: 16px 20px; + gap: 12px 16px; + padding: 14px 16px 16px; border-top: 1px solid var(--border-light); background: var(--bg-subtle); + flex-shrink: 0; +} + +.footerInfo { + flex: 1 1 12rem; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 8px; } .specs { display: flex; - align-items: center; - gap: 8px; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 8px; font-size: 0.85rem; + line-height: 1.45; color: var(--text-muted); } +.specsDims { + white-space: nowrap; +} + +.specsSep { + opacity: 0.55; +} + +.exportStatus { + font-size: 0.8rem; + line-height: 1.4; + color: #166534; +} + .actions { display: flex; - gap: 10px; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; + flex: 1 1 16rem; + max-width: 100%; } .exportBtn { - padding: 10px 20px; - background: var(--action); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 9px 14px; + background: linear-gradient(135deg, var(--action), var(--action-hover)); border: none; - border-radius: 6px; + border-radius: 10px; color: #fff; - font-size: 0.9rem; - font-weight: 500; + font-size: 0.85rem; + font-weight: 600; cursor: pointer; transition: all 0.2s; + white-space: nowrap; + line-height: 1.25; +} + +.exportBtnSecondary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 9px 14px; + background: #fff; + border: 1px solid var(--border); + border-radius: 10px; + color: var(--text); + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; + line-height: 1.25; +} + +.exportBtnSecondary:hover { + border-color: var(--action); + color: var(--action); } .exportBtn:hover { - background: var(--action-hover); + box-shadow: 0 10px 22px rgba(79, 70, 229, 0.3); + transform: translateY(-1px); } @media (max-width: 768px) { .modal { - max-height: 95vh; + max-height: min(95vh, calc(100dvh - 24px)); } .tabs { @@ -182,11 +277,26 @@ .footer { flex-direction: column; + align-items: stretch; gap: 12px; + padding: 12px 14px 14px; + } + + .footerInfo { + padding-right: 0; } .specs { - flex-wrap: wrap; - justify-content: center; + justify-content: flex-start; + } + + .actions { + justify-content: stretch; + } + + .exportBtn, + .exportBtnSecondary { + flex: 1 1 calc(50% - 4px); + min-width: 9rem; } } diff --git a/client/src/components/WardrobeElement.jsx b/client/src/components/WardrobeElement.jsx index 387693b..46f45da 100644 --- a/client/src/components/WardrobeElement.jsx +++ b/client/src/components/WardrobeElement.jsx @@ -23,6 +23,8 @@ const DEFAULT_COLORS = { panneauxInterieurs: '#d4c4a4', separateurs: '#6b5344', etageres: '#d4c4a4', + rayonFixe: '#d4c4a4', + renfortsTiroir: '#c4a574', tiroirs: '#c4a574', penderie: '#8b7355' }; @@ -43,6 +45,8 @@ const ELEMENT_LABELS = { panneauxInterieurs: 'Panneaux intérieurs', separateurs: 'Séparateurs', etageres: 'Étagères', + rayonFixe: 'Rayon fixe', + renfortsTiroir: 'Renforts tiroir', tiroirs: 'Tiroirs', penderie: 'Tringle penderie' }; diff --git a/client/src/components/WardrobePreview.jsx b/client/src/components/WardrobePreview.jsx index b0c195f..b9e64f9 100644 --- a/client/src/components/WardrobePreview.jsx +++ b/client/src/components/WardrobePreview.jsx @@ -1,13 +1,47 @@ -import { useRef, Suspense, useMemo, useState, useCallback, forwardRef, useImperativeHandle } from 'react'; +import { useRef, Suspense, useMemo, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'; import { Canvas, useThree } from '@react-three/fiber'; import { OrbitControls, Html } from '@react-three/drei'; import * as THREE from 'three'; import Room3D, { calculateWardrobeTransform } from './Room3D'; import WardrobeElement from './WardrobeElement'; import styles from './WardrobePreview.module.css'; +import { GROOVE, PANEL_M, getSeparatorDepthMm, getDoorHingeSide } from '@shared/manufacturing.js'; +import { shelfCenterYsMeters, minShelvesForStructuralHeight, resolveShelfPositionsMm, system32ShelfPositionsMm } from '../constants/wardrobe'; const SCALE = 1 / 1000; -const PANEL_THICKNESS = 0.019; // 19mm in meters +const PANEL_THICKNESS = PANEL_M.structure; // 0.019 m (19 mm) +const BACK_PANEL_THICKNESS = PANEL_M.back; // 0.008 m (8 mm) +const REINFORCEMENT_THICKNESS = PANEL_M.reinforcement; // 0.016 m (16 mm) +const REINFORCEMENT_WIDTH = PANEL_M.reinforcementWidth; // 0.100 m (100 mm) +const DIMENSION_RULES = { + widthMm: { min: 400, max: 5000, step: 50 }, + heightMm: { min: 1800, max: 2800, step: 50 }, + depthMm: { min: 400, max: 800, step: 10 }, +}; + +/** Edges shorter than this (m) get a grip that extends past the geometry for easier grabs. */ +const THIN_EDGE_M = 0.45; +const MIN_EDGE_GRIP_M = 0.2; +const GRIP_OVERFLOW_WHEN_THIN_M = 0.08; +const STRUCTURAL_WINDOW_HALF_MM = 200; + +/** + * Visible grip length along an edge (width, height, or depth of the carcass). + * When the edge is narrow, the grip is at least slightly longer than the element. + */ +function gripSpanAlongEdge(edgeLenM) { + if (!(edgeLenM > 0)) return MIN_EDGE_GRIP_M; + const inset = edgeLenM * 0.96; + if (edgeLenM < THIN_EDGE_M) { + return Math.max(MIN_EDGE_GRIP_M, edgeLenM + GRIP_OVERFLOW_WHEN_THIN_M, inset); + } + return Math.max(0.18, inset); +} + +/** Perpendicular thickness for DimensionAxisGrip hit boxes; grows when the main span is thin. */ +function gripHitCrossAxis(mainSpanM) { + return Math.max(0.1, 0.06 + mainSpanM * 0.12); +} /* =================================================================== ResizableSeparator - a draggable handle between two modules @@ -16,6 +50,7 @@ function ResizableSeparator({ position, height, depth, + zCenter, separatorIndex, colors, onElementClick, @@ -49,31 +84,37 @@ function ResizableSeparator({ 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 ( - {/* Visual panel (thin) */} - - {/* Invisible wider hit area for grabbing */} + {/* Séparateur : plaqué au dos, sans rainure ; repère Z aligné arrière avec dessus/fond */} + + + + {/* Invisible hit area: wider/taller/deeper than the panel when the carcass is shallow or short */} - + {/* Highlight overlay when hovered/resizing */} {(hovered || isResizing) && ( - + )} @@ -81,6 +122,71 @@ function ResizableSeparator({ ); } +/** + * 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 =================================================================== */ @@ -103,15 +209,124 @@ function WidthLabel({ position, widthMm }) { ); } +/* =================================================================== + 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, colors, onElementClick }) { +function DrawerBox({ + moduleCenter, + moduleWidth, + bodyH, + plinthH, + drawersCount, + depth, + behindDoor, + drawerDepthMm, + colors, + onElementClick +}) { const drawerHeight = 0.15 * SCALE * 1000; // ~150mm in scene units const gap = 0.008; // gap between drawers const totalDrawerZone = Math.min(bodyH * 0.6, drawersCount * (drawerHeight + gap)); const actualDrawerH = (totalDrawerZone - gap * (drawersCount - 1)) / drawersCount; const startY = -bodyH / 2 + PANEL_THICKNESS; + /** Profondeur utile tiroir (mm) — réduite en mode caché. */ + const effMm = Math.max(350, Number(drawerDepthMm) || 550); + const drawerBodyDepthM = Math.max( + PANEL_THICKNESS * 2, + Math.min(effMm * SCALE, Math.max(0.08, depth - PANEL_THICKNESS * 3)) + ); + /** Face avant des tiroirs (côté porte) : +Z vers l’avant du caisson. */ + const frontFaceZ = behindDoor + ? depth / 2 - PANEL_THICKNESS - 0.15 + : depth / 2 - PANEL_THICKNESS - 0.002; + const centerZ = frontFaceZ - drawerBodyDepthM / 2; return ( <> @@ -122,8 +337,8 @@ function DrawerBox({ moduleCenter, moduleWidth, bodyH, plinthH, drawersCount, co key={`drawer-${di}`} elementType="tiroirs" geometryType="box" - geometryArgs={[moduleWidth * 0.92, actualDrawerH, PANEL_THICKNESS]} - position={[moduleCenter, plinthH / 2 + y, moduleWidth > 0 ? 0.28 : 0]} + geometryArgs={[moduleWidth * 0.92, actualDrawerH, drawerBodyDepthM]} + position={[moduleCenter, plinthH / 2 + y, moduleWidth > 0 ? centerZ : 0]} colors={colors} onElementClick={onElementClick} materialProps={{ roughness: 0.5, metalness: 0.05 }} @@ -146,9 +361,15 @@ function WardrobeMesh({ modules = [], showDoors = true, onResizeModules, + onUpdateModule, + onSetDimension, + onBeginUndoableGesture, + activeStep = 1, + suppressHtmlOverlays = false, }) { const { gl, camera, raycaster } = useThree(); const useModules = modules && modules.length > 0; + const shellOnlyMode = activeStep === 1; // --- Dimensions --- const totalWidth = useModules @@ -160,8 +381,56 @@ function WardrobeMesh({ const depth = (config.depthMm || 600) * SCALE; const plinthH = (config.plinthHeightMm || 100) * SCALE; const bodyH = height - plinthH; + const exteriorFinishesEnabled = !!config.exteriorFinishesEnabled; + const sideDefaultsEnabled = config?.sideFinishesEnabled !== false; + const sideFinishLeftEnabled = config?.sideFinishLeftEnabled ?? sideDefaultsEnabled; + const sideFinishRightEnabled = config?.sideFinishRightEnabled ?? sideDefaultsEnabled; + const topFinishEnabled = config?.topFinishEnabled !== false; + const isBlockMode = config.columnMode === 'block'; + /** Gap between double montants (assemblage block) */ + const BLOCK_JOINT_GAP_M = 0.003; + const separatorDepthM = getSeparatorDepthMm(config.depthMm || 600) * SCALE; + const separatorZ = PANEL_THICKNESS - depth / 2 + separatorDepthM / 2; const isNiche = config.installationType === 'niche'; const isSliding = config.doorType === 'sliding'; + const hw = config.hardware || {}; + const doorOpenAngle = 0.32; // radians (~18°) - visible “ajar” preview + + /** Séparateurs: en block, montants alignés sur la hauteur habillage (body + bandeau si habillage dessus). */ + const sepCenterY = isBlockMode + ? (topFinishEnabled ? plinthH / 2 + PANEL_THICKNESS / 2 : plinthH / 2) + : plinthH / 2; + const sepHeight = isBlockMode + ? (topFinishEnabled ? bodyH + PANEL_THICKNESS : bodyH) + : bodyH - PANEL_THICKNESS * 2; + /** Single full-width top on step 1 (dimensions); per-block split from step 2 when separators matter. */ + const splitTopPerBlock = + isBlockMode && useModules && modules.length > 1 && !shellOnlyMode; + + const dimGrip = useMemo(() => { + const w = gripSpanAlongEdge(width); + const h = gripSpanAlongEdge(bodyH); + const d = gripSpanAlongEdge(depth); + const gripOutset = 0.03; + const rightExteriorX = + width / 2 + + (isNiche ? PANEL_THICKNESS * 0.6 : 0) + + ((!isNiche && exteriorFinishesEnabled && sideFinishRightEnabled) ? PANEL_THICKNESS : 0) + + gripOutset; + const frontExteriorZ = depth / 2 + gripOutset; + const bottomY = -height / 2 + plinthH + 0.02; + return { + w, + h, + d, + crossW: gripHitCrossAxis(width), + crossH: gripHitCrossAxis(bodyH), + crossD: gripHitCrossAxis(depth), + rightExteriorX, + frontExteriorZ, + bottomY, + }; + }, [width, bodyH, depth, height, plinthH, isNiche, exteriorFinishesEnabled, sideFinishRightEnabled]); // --- Module positions --- const modulePositions = useMemo(() => { @@ -187,10 +456,28 @@ function WardrobeMesh({ // --- Hover state for width labels --- const [hoveredModule, setHoveredModule] = useState(null); + const [hoveredShelfKey, setHoveredShelfKey] = useState(null); const [resizeState, setResizeState] = useState(null); // { separatorIndex, startX } + const [shelfDragState, setShelfDragState] = useState(null); // { moduleIndex, shelfIndex, startClientY, startPosMm, shelfPositionsMm, slotsMm } + const [dimensionResizeState, setDimensionResizeState] = useState(null); // { type, startClientX, startClientY, startValue } + const dimensionResizeStateRef = useRef(null); const planeRef = useRef(new THREE.Plane(new THREE.Vector3(0, 1, 0), 0)); const intersectPoint = useRef(new THREE.Vector3()); + const dimensionEditMode = activeStep === 1 && typeof onSetDimension === 'function'; + /** Drei Html portals use a high z-index and can paint above fixed modals (e.g. plans). */ + const showDimensionHtml = dimensionEditMode && !suppressHtmlOverlays; + const applyDimensionValue = useCallback((type, rawValue) => { + if (!dimensionEditMode) return; + const rules = DIMENSION_RULES[type]; + if (!rules) return; + const parsed = Number(rawValue); + if (!Number.isFinite(parsed)) return; + const clamped = Math.min(rules.max, Math.max(rules.min, parsed)); + const stepped = Math.round(clamped / rules.step) * rules.step; + onSetDimension?.(type, stepped); + }, [dimensionEditMode, onSetDimension]); + // --- Helpers to project pointer to world X --- const getWorldX = useCallback((e) => { const rect = gl.domElement.getBoundingClientRect(); @@ -209,16 +496,64 @@ function WardrobeMesh({ // --- Resize pointer down --- const handleResizePointerDown = useCallback((separatorIndex, e) => { e.stopPropagation(); + e.target?.setPointerCapture?.(e.pointerId); const worldX = getWorldX(e); if (worldX === null) return; + onBeginUndoableGesture?.(); setResizeState({ separatorIndex, startX: worldX }); - }, [getWorldX]); + gl.domElement.style.cursor = 'col-resize'; + }, [getWorldX, onBeginUndoableGesture]); + + const handleDimensionPointerDown = useCallback((type, e) => { + if (!dimensionEditMode) return; + onBeginUndoableGesture?.(); + const nextState = { + type, + startClientX: e.clientX, + startClientY: e.clientY, + startValue: Number(config[type]) || 0, + }; + dimensionResizeStateRef.current = nextState; + setDimensionResizeState(nextState); + if (type === 'heightMm') gl.domElement.style.cursor = 'ns-resize'; + if (type === 'widthMm') gl.domElement.style.cursor = 'ew-resize'; + if (type === 'depthMm') gl.domElement.style.cursor = 'ew-resize'; + }, [dimensionEditMode, gl, config, onBeginUndoableGesture]); + + const handleShelfPointerDown = useCallback((moduleIndex, shelfIndex, isStructural, e) => { + if (activeStep !== 2 || typeof onUpdateModule !== 'function') return; + const mod = modules[moduleIndex]; + const shelfCount = Math.max(0, Number(mod?.shelves) || 0); + if (shelfCount <= 0) return; + const slotsMm = system32ShelfPositionsMm(config.heightMm, config.plinthHeightMm ?? 100); + if (!slotsMm.length) return; + const shelfPositionsMm = resolveShelfPositionsMm({ + shelfCount, + heightMm: config.heightMm, + plinthHeightMm: config.plinthHeightMm ?? 100, + shelfPositionsMm: mod?.shelfPositionsMm + }); + const startPosMm = shelfPositionsMm[shelfIndex]; + if (!Number.isFinite(startPosMm)) return; + e.stopPropagation(); + e.target?.setPointerCapture?.(e.pointerId); + onBeginUndoableGesture?.(); + setShelfDragState({ + moduleIndex, + shelfIndex, + startClientY: e.clientY, + startPosMm, + shelfPositionsMm, + slotsMm + }); + gl.domElement.style.cursor = 'ns-resize'; + }, [activeStep, onUpdateModule, modules, config.heightMm, config.plinthHeightMm, onBeginUndoableGesture, gl]); // Attach window-level pointer events for resize const onPointerMoveRef = useRef(null); const onPointerUpRef = useRef(null); - useMemo(() => { + useEffect(() => { const handleMove = (e) => { if (resizeState) { const worldX = getWorldX(e); @@ -231,6 +566,38 @@ function WardrobeMesh({ } } } + if (dimensionResizeStateRef.current) { + const { type, startClientX, startClientY, startValue } = dimensionResizeStateRef.current; + const mmPerPx = e.shiftKey ? 0.5 : 2; + const deltaPx = type === 'heightMm' + ? startClientY - e.clientY + : type === 'depthMm' + ? startClientX - e.clientX + : e.clientX - startClientX; + const nextValue = startValue + deltaPx * mmPerPx; + applyDimensionValue(type, nextValue); + } + if (shelfDragState) { + const mmPerPx = e.shiftKey ? 0.6 : 1.5; + const rawMm = shelfDragState.startPosMm + (shelfDragState.startClientY - e.clientY) * mmPerPx; + const slots = shelfDragState.slotsMm; + let nearestIdx = 0; + let best = Infinity; + for (let i = 0; i < slots.length; i++) { + const d = Math.abs(slots[i] - rawMm); + if (d < best) { + best = d; + nearestIdx = i; + } + } + const n = shelfDragState.shelfPositionsMm.length; + const minIdx = shelfDragState.shelfIndex; + const maxIdx = slots.length - (n - shelfDragState.shelfIndex); + nearestIdx = Math.max(minIdx, Math.min(maxIdx, nearestIdx)); + const nextShelfPositions = [...shelfDragState.shelfPositionsMm]; + nextShelfPositions[shelfDragState.shelfIndex] = slots[nearestIdx]; + onUpdateModule?.(shelfDragState.moduleIndex, { shelfPositionsMm: nextShelfPositions }); + } }; const handleUp = () => { @@ -238,29 +605,41 @@ function WardrobeMesh({ setResizeState(null); gl.domElement.style.cursor = 'auto'; } + if (dimensionResizeStateRef.current) { + dimensionResizeStateRef.current = null; + setDimensionResizeState(null); + gl.domElement.style.cursor = 'auto'; + } + if (shelfDragState) { + setShelfDragState(null); + setHoveredShelfKey(null); + gl.domElement.style.cursor = 'auto'; + } }; onPointerMoveRef.current = handleMove; onPointerUpRef.current = handleUp; - }, [resizeState, getWorldX, onResizeModules, gl]); + }, [resizeState, dimensionResizeState, shelfDragState, getWorldX, onResizeModules, applyDimensionValue, onUpdateModule, gl]); // Attach/detach event listeners - useMemo(() => { - const el = gl.domElement; + useEffect(() => { const move = (e) => onPointerMoveRef.current?.(e); const up = (e) => onPointerUpRef.current?.(e); - el.addEventListener('pointermove', move); - el.addEventListener('pointerup', up); + // Use window-level listeners so resize remains live even if pointer leaves canvas. + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); return () => { - el.removeEventListener('pointermove', move); - el.removeEventListener('pointerup', up); + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); }; - }, [gl]); + }, []); // Disable OrbitControls during resize - const interactionActive = resizeState !== null; + const interactionActive = resizeState !== null || dimensionResizeState !== null || shelfDragState !== null; + const elementClickHandler = dimensionEditMode ? undefined : onElementClick; + const moduleClickHandler = dimensionEditMode ? null : onModuleClick; return ( @@ -271,71 +650,209 @@ function WardrobeMesh({ geometryArgs={[width, plinthH, depth]} position={[0, -height / 2 + plinthH / 2, 0]} colors={colors} - onElementClick={onElementClick} + onElementClick={elementClickHandler} materialProps={{ roughness: 0.7, metalness: 0.1 }} /> - {/* Bandeau haut */} - - - {/* Dessus (top panel) */} - + {/* Dessus (top panel) — 1 pièce (séparations) ou 1 par caisson (block) */} + {splitTopPerBlock ? ( + modulePositions.map((mp, i) => { + const segW = Math.max(0.02, mp.width - 2 * PANEL_THICKNESS - (isBlockMode ? BLOCK_JOINT_GAP_M : 0)); + return ( + + + + + ); + }) + ) : ( + + + + + )} {/* Fond (bottom panel) */} - + + + + {/* Côté gauche */} - + + + + {/* Côté droit */} - + + + + - {/* Dos (back panel) */} + {/* Habillages de finition (Libre + Premium) */} + {!isNiche && exteriorFinishesEnabled && ( + <> + {/* Habillage gauche */} + {sideFinishLeftEnabled && ( + + )} + {/* Habillage droite */} + {sideFinishRightEnabled && ( + + )} + {/* Habillage dessus — 1 bandeau ou 1 par caisson (block) */} + {topFinishEnabled && ( + splitTopPerBlock ? ( + modulePositions.map((mp, i) => { + const n = modules.length; + const tl = i === 0 ? (sideFinishLeftEnabled ? PANEL_THICKNESS : 0) : PANEL_THICKNESS; + const tr = i === n - 1 ? (sideFinishRightEnabled ? PANEL_THICKNESS : 0) : PANEL_THICKNESS; + const habitW = Math.max(0.001, mp.width + tl + tr); + const cx = mp.center + (tr - tl) / 2; + return ( + + ); + }) + ) : ( + (() => { + const tl = sideFinishLeftEnabled ? PANEL_THICKNESS : 0; + const tr = sideFinishRightEnabled ? PANEL_THICKNESS : 0; + const habitW = Math.max(0.001, width + tl + tr); + const cx = (tr - tl) / 2; + return ( + + ); + })() + ) + )} + + )} + + {/* Dos (back panel) — 8 mm per APEX 4.0 spec */} + {/* Outline to keep wardrobe edges readable through niche wall */} + {config?.installationType === 'niche' && ( + + )} + {/* Fileurs (si encastré) */} {isNiche && ( <> @@ -345,7 +862,7 @@ function WardrobeMesh({ geometryArgs={[PANEL_THICKNESS / 2, bodyH, depth]} position={[-width / 2 - PANEL_THICKNESS / 4, plinthH / 2, 0]} colors={colors} - onElementClick={onElementClick} + onElementClick={elementClickHandler} /> )} - {/* Separators between modules (resizable) */} - {useModules && onResizeModules - ? separatorPositions.map((pos, i) => ( - - )) - : separatorPositions.map((pos, i) => ( - - )) + {/* Separators between modules — en block: double montant + hauteur habillage */} + {!shellOnlyMode && useModules && onResizeModules + ? separatorPositions.flatMap((pos, i) => { + const jointHalf = (BLOCK_JOINT_GAP_M + PANEL_THICKNESS) / 2; + const xs = isBlockMode + ? [pos.x - jointHalf, pos.x + jointHalf] + : [pos.x]; + return xs.map((xSep, j) => ( + + )); + }) + : !shellOnlyMode && separatorPositions.flatMap((pos, i) => { + const jointHalf = (BLOCK_JOINT_GAP_M + PANEL_THICKNESS) / 2; + const xs = isBlockMode + ? [pos.x - jointHalf, pos.x + jointHalf] + : [pos.x]; + return xs.map((xSep, j) => ( + + + + )); + }) } {/* Width labels -- show during resize or hover */} - {modules.length > 0 && (resizeState !== null || hoveredModule !== null) && ( + {!shellOnlyMode && !suppressHtmlOverlays && modules.length > 0 && (resizeState !== null || hoveredModule !== null) && ( <> {resizeState !== null && ( <> @@ -415,7 +946,7 @@ function WardrobeMesh({ )} {/* Module contents: doors, shelves, rods, drawers + click-to-select */} - {useModules && modules.map((module, moduleIndex) => { + {!shellOnlyMode && useModules && modules.map((module, moduleIndex) => { const pos = modulePositions[moduleIndex]; if (!pos) return null; const moduleCenter = pos.center; @@ -423,22 +954,50 @@ function WardrobeMesh({ const shelfCount = module.shelves || 0; const drawersCount = module.drawers || 0; const isSelected = selectedModuleIndex === moduleIndex; + const needsMiddleFix = minShelvesForStructuralHeight(config.heightMm || 2400) > 0; + const bodyHmm = (Number(config.heightMm) || 0) - (Number(config.plinthHeightMm) || 0); + const bodyMidMm = bodyHmm / 2; + const structuralMinMm = bodyMidMm - STRUCTURAL_WINDOW_HALF_MM; + const structuralMaxMm = bodyMidMm + STRUCTURAL_WINDOW_HALF_MM; + const resolvedShelfPositionsMm = resolveShelfPositionsMm({ + shelfCount, + heightMm: config.heightMm, + plinthHeightMm: config.plinthHeightMm ?? 100, + shelfPositionsMm: module.shelfPositionsMm + }); + const hasShelfInStructuralWindow = + !needsMiddleFix || + resolvedShelfPositionsMm.some((p) => p >= structuralMinMm && p <= structuralMaxMm); + const structuralZoneCenterY = + -height / 2 + plinthH + (bodyMidMm / 1000); + const structuralZoneHeight = (STRUCTURAL_WINDOW_HALF_MM * 2) / 1000; + const moduleSpanForZone = Math.max(0.02, moduleWidth - PANEL_THICKNESS * 2); + const depthSpanForZone = Math.max(0.02, depth * 0.9); + const shelfCenterYs = shelfCenterYsMeters({ + shelfCount, + heightM: height, + plinthHM: plinthH, + shelfGapMm: module.shelfGapMm, + shelfPositionsMm: module.shelfPositionsMm, + }); return ( {/* Invisible click/hover area for module selection */} - setHoveredModule(moduleIndex)} - onPointerOut={() => setHoveredModule(prev => prev === moduleIndex ? null : prev)} - onClick={(e) => { - e.stopPropagation(); - if (onModuleClick) onModuleClick(moduleIndex); - }} - > - - - + {activeStep !== 2 && ( + setHoveredModule(moduleIndex)} + onPointerOut={() => setHoveredModule(prev => prev === moduleIndex ? null : prev)} + onClick={(e) => { + e.stopPropagation(); + if (moduleClickHandler) moduleClickHandler(moduleIndex); + }} + > + + + + )} {/* Selection highlight */} {isSelected && ( @@ -448,37 +1007,179 @@ function WardrobeMesh({ )} + {activeStep === 2 && needsMiddleFix && !hasShelfInStructuralWindow && ( + + + + + + + + + + + )} + {/* Portes battantes per module */} {showDoors && !isSliding && ( - + (() => { + const doorW = moduleWidth * 0.96; + const drawerCount = Math.max(0, Number(module?.drawers) || 0); + const drawerHeightsMm = Array.isArray(hw.drawerHeightsMm) ? hw.drawerHeightsMm : [90, 128, 177, 240]; + const drawerGapMm = 8; + const drawerStackMmRaw = + drawerCount > 0 + ? drawerHeightsMm + .slice(0, Math.min(drawerCount, drawerHeightsMm.length)) + .reduce((s, v) => s + (Number(v) || 0), 0) + + Math.max(0, drawerCount - 1) * drawerGapMm + : 0; + const drawerStackM = Math.min(bodyH * 0.78, Math.max(0, drawerStackMmRaw) * SCALE); + /** Caché : porte pleine hauteur ; Visible : porte au-dessus du bandeau tiroirs. */ + const drawerHidden = ['hidden', 'hidden_with_handle'].includes(hw.drawerType || 'visible'); + const splitDoorOverDrawers = drawerCount > 0 && !drawerHidden; + const drawerStackForDoor = splitDoorOverDrawers ? drawerStackM : 0; + const doorH = Math.max(0.12, bodyH * 0.96 - drawerStackForDoor); + const doorCenterY = splitDoorOverDrawers + ? plinthH / 2 + drawerStackM / 2 + : plinthH / 2; + const doorCount = Math.max(1, modules.length); + const overrideSides = + Array.isArray(hw.doorHingeSides) && hw.doorHingeSides.length === doorCount + ? hw.doorHingeSides + : null; + const hingeSide = + overrideSides + ? (overrideSides[moduleIndex] === 'right' ? 'right' : 'left') + : getDoorHingeSide(moduleIndex, doorCount, hw); + // Rotate so the free edge swings toward the front (+Z), i.e. "outside" the carcass. + const angle = hingeSide === 'left' ? -doorOpenAngle : doorOpenAngle; + const hingeX = hingeSide === 'left' + ? moduleCenter - doorW / 2 + : moduleCenter + doorW / 2; + const localX = hingeSide === 'left' ? doorW / 2 : -doorW / 2; + return ( + + + + ); + })() )} {/* Shelves */} - {Array.from({ length: shelfCount }).map((_, shelfIndex) => { - const shelfY = -height / 2 + plinthH + PANEL_THICKNESS + - ((bodyH - PANEL_THICKNESS * 2) / (shelfCount + 1)) * (shelfIndex + 1); + {(() => { + const bottomInside = -height / 2 + plinthH + PANEL_THICKNESS; + const innerSpan = bodyH - 2 * PANEL_THICKNESS; + const yMiddle = bottomInside + innerSpan / 2; + const tolM = 0.2; // ±20 cm tolerance around middle + let structuralIdx = null; + let best = Infinity; + for (let i = 0; i < shelfCount; i++) { + const y = shelfCenterYs[i]; + if (y == null) continue; + const d = Math.abs(y - yMiddle); + if (d < best) { + best = d; + structuralIdx = i; + } + } + return ( - + <> + {Array.from({ length: shelfCount }).map((_, shelfIndex) => { + const shelfY = shelfCenterYs[shelfIndex]; + const isStructural = needsMiddleFix && shelfIndex === structuralIdx; + const shelfKey = `${moduleIndex}-${shelfIndex}`; + const isShelfHovered = hoveredShelfKey === shelfKey; + const isShelfDragging = + shelfDragState?.moduleIndex === moduleIndex && shelfDragState?.shelfIndex === shelfIndex; + return ( + + + {activeStep === 2 && ( + { + e.stopPropagation(); + setHoveredShelfKey(shelfKey); + gl.domElement.style.cursor = 'ns-resize'; + }} + onPointerOut={(e) => { + e.stopPropagation(); + setHoveredShelfKey((prev) => (prev === shelfKey ? null : prev)); + if (!shelfDragState) gl.domElement.style.cursor = 'auto'; + }} + onPointerDown={(e) => handleShelfPointerDown(moduleIndex, shelfIndex, isStructural, e)} + > + + + + )} + + ); + })} + ); - })} + })()} + + {/* Fixed structural shelf above drawers */} + {drawersCount > 0 && ( + (() => { + const drawerHeightsMm = Array.isArray(hw.drawerHeightsMm) ? hw.drawerHeightsMm : [90, 128, 177, 240]; + const drawerGapMm = 8; + const drawerStackMmRaw = + drawersCount > 0 + ? drawerHeightsMm + .slice(0, Math.min(drawersCount, drawerHeightsMm.length)) + .reduce((s, v) => s + (Number(v) || 0), 0) + + Math.max(0, drawersCount - 1) * drawerGapMm + : 0; + const drawerStackM = Math.min(bodyH * 0.78, Math.max(0, drawerStackMmRaw) * SCALE); + const yFixed = (-height / 2 + plinthH + PANEL_THICKNESS) + drawerStackM + PANEL_THICKNESS / 2; + return ( + + ); + })() + )} {/* Drawers */} {drawersCount > 0 && ( @@ -488,8 +1189,14 @@ function WardrobeMesh({ bodyH={bodyH} plinthH={plinthH} drawersCount={drawersCount} + depth={depth} + behindDoor={['hidden', 'hidden_with_handle'].includes(hw.drawerType || 'visible')} + drawerDepthMm={ + config.effectiveDrawerDepthMm ?? + Math.max(350, (config.depthMm || 600) - 10) + } colors={colors} - onElementClick={onElementClick} + onElementClick={elementClickHandler} /> )} @@ -502,7 +1209,7 @@ function WardrobeMesh({ position={[moduleCenter, height / 2 - plinthH / 2 - 0.15, 0]} rotation={[0, 0, Math.PI / 2]} colors={colors} - onElementClick={onElementClick} + onElementClick={elementClickHandler} materialProps={{ roughness: 0.3, metalness: 0.8 }} /> )} @@ -518,7 +1225,7 @@ function WardrobeMesh({ geometryArgs={[width * 0.98, bodyH * 0.98]} position={[0, plinthH / 2, depth / 2 + 0.002]} colors={colors} - onElementClick={onElementClick} + onElementClick={elementClickHandler} transparent={true} opacity={0.85} materialProps={{ roughness: 0.5 }} @@ -528,19 +1235,99 @@ function WardrobeMesh({ {/* 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(); - useMemo(() => { + useEffect(() => { if (controls) { controls.enabled = !active; } @@ -551,12 +1338,11 @@ function InteractionBlocker({ active }) { /* =================================================================== CameraController - exposes zoom/reset via ref =================================================================== */ -const CameraController = forwardRef(({ roomConfig, config }, ref) => { +const CameraController = forwardRef(({ roomConfig, config, modules, activeStep }, ref) => { const { camera, gl } = useThree(); const controlsRef = useRef(); - - const minDistance = roomConfig ? 1 : 0.5; - const maxDistance = roomConfig ? 15 : 8; + const isDimensionStep = activeStep === 1; + const snapAnimRef = useRef(null); const cameraTarget = useMemo(() => { if (roomConfig) { @@ -565,42 +1351,118 @@ const CameraController = forwardRef(({ roomConfig, config }, ref) => { return [0, (config.heightMm || 2400) * SCALE * 0.5, 0]; }, [roomConfig, config]); + // Keep OrbitControls target in sync as dimensions/modules change. + useEffect(() => { + if (!controlsRef.current) return; + const [x, y, z] = cameraTarget; + controlsRef.current.target.set(x, y, z); + controlsRef.current.update(); + }, [cameraTarget]); + + const totalWidthMm = useMemo(() => { + if (Array.isArray(modules) && modules.length > 0) { + const sum = modules.reduce((s, m) => s + (Number(m?.widthMm) || 0), 0); + return sum > 0 ? sum : (config.widthMm || 2400); + } + return config.widthMm || 2400; + }, [modules, config.widthMm]); + + const fitDistance = useMemo(() => { + if (roomConfig) { + return Math.max(roomConfig.width, roomConfig.length) * 1.2; + } + const w = totalWidthMm * SCALE; + const h = (config.heightMm || 2400) * SCALE; + const d = (config.depthMm || 600) * SCALE; + const maxDim = Math.max(w, h, d); + const fov = THREE.MathUtils.degToRad(45); + const dist = (maxDim / 2) / Math.tan(fov / 2); + return Math.max(1.2, dist * 1.25); + }, [roomConfig, totalWidthMm, config.heightMm, config.depthMm]); + + const getDefaultDistance = useCallback( + () => fitDistance, + [fitDistance] + ); + + const setView = useCallback((view) => { + if (!controlsRef.current) return; + const target = controlsRef.current.target; + const dist = getDefaultDistance(); + + if (view === 'front') camera.position.set(0, target.y, dist); + if (view === 'side') camera.position.set(dist, target.y, 0); + if (view === 'top') camera.position.set(0.001, target.y + dist, 0.001); + if (view === 'iso') camera.position.set(dist * 0.75, target.y + dist * 0.35, dist * 0.75); + if (view === 'inside') { + // View from slightly in front of the wardrobe, looking into it. + const depth = (config.depthMm || 600) * SCALE; + camera.position.set(0, target.y, Math.max(0.25, depth * 0.35)); + } + controlsRef.current.update(); + }, [camera, getDefaultDistance, config]); + + const cancelSnap = useCallback(() => { + if (snapAnimRef.current) { + cancelAnimationFrame(snapAnimRef.current.raf); + snapAnimRef.current = null; + } + }, []); + + // Intentionally no snapping rules: keep mouse movement direct. + useImperativeHandle(ref, () => ({ - zoomIn: () => { - if (controlsRef.current) { - const currentDistance = camera.position.distanceTo(controlsRef.current.target); - const newDistance = Math.max(minDistance, currentDistance * 0.8); - const direction = camera.position.clone().sub(controlsRef.current.target).normalize(); - camera.position.copy(controlsRef.current.target).add(direction.multiplyScalar(newDistance)); - controlsRef.current.update(); - } - }, - zoomOut: () => { - if (controlsRef.current) { - const currentDistance = camera.position.distanceTo(controlsRef.current.target); - const newDistance = Math.min(maxDistance, currentDistance * 1.25); - const direction = camera.position.clone().sub(controlsRef.current.target).normalize(); - camera.position.copy(controlsRef.current.target).add(direction.multiplyScalar(newDistance)); - controlsRef.current.update(); - } - }, resetView: () => { if (controlsRef.current) { - const defaultDist = roomConfig ? Math.max(roomConfig.width, roomConfig.length) * 1.2 : 3; - camera.position.set(defaultDist * 0.7, defaultDist * 0.5, defaultDist * 0.7); + cancelSnap(); + const defaultDist = getDefaultDistance(); + if (isDimensionStep) { + camera.position.set(defaultDist * 0.75, defaultDist * 0.7, defaultDist * 0.9); + if (camera.isOrthographicCamera) { + camera.zoom = 85; + camera.updateProjectionMatrix(); + } + } else { + // Default to a consistent 3/4 view + camera.position.set(defaultDist * 0.75, defaultDist * 0.55, defaultDist * 0.75); + } controlsRef.current.update(); } + }, + }), [camera, cancelSnap, getDefaultDistance, isDimensionStep]); + + useEffect(() => { + if (controlsRef.current) { + controlsRef.current.minPolarAngle = isDimensionStep ? 0 : 0; + controlsRef.current.maxPolarAngle = Math.PI; } - }), [camera, minDistance, maxDistance, roomConfig]); + }, [isDimensionStep]); + + const handleControlStart = useCallback(() => { + cancelSnap(); + gl.domElement.style.cursor = 'grabbing'; + }, [cancelSnap, gl]); + + const handleControlEnd = useCallback(() => { + gl.domElement.style.cursor = 'auto'; + }, [gl]); return ( ); @@ -609,7 +1471,23 @@ const CameraController = forwardRef(({ roomConfig, config }, ref) => { /* =================================================================== Scene - assembles everything =================================================================== */ -function Scene({ config, roomConfig, controllerRef, colors, modules, onElementClick, onModuleClick, selectedModuleIndex, showDoors, onResizeModules }) { +function Scene({ + config, + roomConfig, + controllerRef, + colors, + modules, + onElementClick, + onModuleClick, + selectedModuleIndex, + showDoors, + onResizeModules, + onUpdateModule, + onSetDimension, + onBeginUndoableGesture, + activeStep, + suppressHtmlOverlays, +}) { const wardrobeTransform = useMemo(() => { if (roomConfig) { return calculateWardrobeTransform(roomConfig, config); @@ -617,6 +1495,60 @@ function Scene({ config, roomConfig, controllerRef, colors, modules, onElementCl return { position: [0, 0, 0], rotation: [0, 0, 0] }; }, [roomConfig, config]); + const nicheBackdrop = useMemo(() => { + if (roomConfig) return null; + if (config?.installationType !== 'niche') return null; + const OUTER_WALL_EXTENT_M = 200; // effectively infinite wall planes + const gapSideM = 0.05; // 5cm clearance on sides + const gapTopM = 0; // no clearance on top + const gapBottomM = 0; // no clearance under (sit on floor) + const gapDepthM = 0.05; // 5cm clearance front/back + const widthMmRaw = Array.isArray(modules) && modules.length > 0 + ? modules.reduce((s, m) => s + (Number(m?.widthMm) || 0), 0) + : (config.widthMm || 2400); + const widthM = Math.max(0.4, (widthMmRaw || 2400) * SCALE); + const heightM = Math.max(1.8, (config.heightMm || 2400) * SCALE); + const depthM = Math.max(0.4, (config.depthMm || 600) * SCALE); + + const nicheW = widthM + gapSideM * 2; + const nicheH = heightM + gapTopM + gapBottomM; + const nicheD = depthM + gapDepthM * 2; + + return { + outerWallW: OUTER_WALL_EXTENT_M, + outerWallH: OUTER_WALL_EXTENT_M, + outerWallD: OUTER_WALL_EXTENT_M, + gapSideM, + gapTopM, + gapBottomM, + gapDepthM, + widthM, + heightM, + depthM, + nicheW, + nicheH, + nicheD, + // wardrobe spans y=[0..heightM], z=[-depthM/2..+depthM/2] + floorY: -gapBottomM, + ceilY: heightM + gapTopM, + backZ: -depthM / 2 - gapDepthM, + frontZ: depthM / 2 + gapDepthM, + leftX: -widthM / 2 - gapSideM, + rightX: widthM / 2 + gapSideM, + }; + }, [roomConfig, config?.installationType, config?.widthMm, config?.heightMm, config?.depthMm, modules]); + + const freeBackdrop = useMemo(() => { + if (roomConfig) return null; + if (config?.installationType !== 'free') return null; + const depthM = (config.depthMm || 600) * SCALE; + const heightM = (config.heightMm || 2400) * SCALE; + // Place the wall right behind the wardrobe back face. + const wallZ = -depthM / 2 - 0.01; + const wallY = heightM * 0.6; + return { wallZ, wallY }; + }, [roomConfig, config?.installationType, config?.depthMm, config?.heightMm]); + return ( <> @@ -633,6 +1565,62 @@ function Scene({ config, roomConfig, controllerRef, colors, modules, onElementCl /> + {/* Niche "alcove" backdrop with 5cm clearance */} + {/* All backdrop meshes opt out of raycasting: they are decorative only and + must never intercept pointer events meant for wardrobe handles. */} + {nicheBackdrop && ( + + {/* floor */} + null}> + + + + + {/* back wall (niche sized) */} + null}> + + + + + {/* left wall (niche sized) */} + null}> + + + + + {/* right wall (niche sized) */} + null}> + + + + + {/* ceiling (niche sized) */} + null}> + + + + + {/* front wall: everywhere except the niche opening */} + + + )} + + {/* Backdrop wall/floor for "Libre" (free-standing) view */} + {freeBackdrop && !nicheBackdrop && ( + + {/* floor */} + null}> + + + + {/* wall behind wardrobe (Z-), flush to back */} + null}> + + + + + )} + {roomConfig && ( )} @@ -650,33 +1638,97 @@ function Scene({ config, roomConfig, controllerRef, colors, modules, onElementCl selectedModuleIndex={selectedModuleIndex} showDoors={showDoors} onResizeModules={onResizeModules} + onUpdateModule={onUpdateModule} + onSetDimension={onSetDimension} + onBeginUndoableGesture={onBeginUndoableGesture} + activeStep={activeStep} + suppressHtmlOverlays={suppressHtmlOverlays} /> - + ); } +function FrontWallWithOpening({ niche }) { + const shape = useMemo(() => { + // Outer wall (slightly larger than niche box so it reads as the room wall) + const outerW = Math.max(6, niche.outerWallW || niche.nicheW * 3); + const outerH = Math.max(3, niche.outerWallH || niche.nicheH * 2.2); + + // Hole = niche opening (exact nicheW/nicheH), positioned around the wardrobe volume + const holeW = niche.nicheW; + const holeH = niche.nicheH; + + const s = new THREE.Shape(); + s.moveTo(-outerW / 2, -outerH / 2); + s.lineTo(outerW / 2, -outerH / 2); + s.lineTo(outerW / 2, outerH / 2); + s.lineTo(-outerW / 2, outerH / 2); + s.closePath(); + + const hole = new THREE.Path(); + // Hole Y range: from floorY to floorY + holeH + const holeBottom = niche.floorY; + const holeTop = niche.floorY + holeH; + hole.moveTo(-holeW / 2, holeBottom); + hole.lineTo(holeW / 2, holeBottom); + hole.lineTo(holeW / 2, holeTop); + hole.lineTo(-holeW / 2, holeTop); + hole.closePath(); + s.holes.push(hole); + return s; + }, [niche]); + + return ( + // raycast disabled: purely decorative wall must not intercept clicks on + // dimension grips that sit just outside the niche opening (e.g. width + // handle above the wardrobe top is behind the solid wall in 3D space). + null}> + + + + ); +} + /* =================================================================== WardrobePreview - main exported component =================================================================== */ export default function WardrobePreview({ config, + activeStep = 1, roomConfig, colors = {}, modules = [], onElementClick, onModuleClick, onResizeModules, + onUpdateModule, + onSetDimension, + onBeginUndoableGesture, + canUndo3d = false, + canRedo3d = false, + onUndo3d, + onRedo3d, selectedModuleIndex, + suppressHtmlOverlays = false, }) { const containerRef = useRef(null); const controllerRef = useRef(null); const [selectedElement, setSelectedElement] = useState(null); const [showDoors, setShowDoors] = useState(true); + const isDimensionStep = activeStep === 1; - const cameraPosition = useMemo(() => { + const perspectiveCameraPosition = useMemo(() => { if (roomConfig) { const roomSize = Math.max(roomConfig.width, roomConfig.length); return [roomSize * 0.8, roomSize * 0.5, roomSize * 0.8]; @@ -684,18 +1736,15 @@ export default function WardrobePreview({ return [2.5, 1.5, 2.5]; }, [roomConfig]); - const handleZoomIn = useCallback(() => { - controllerRef.current?.zoomIn(); - }, []); - - const handleZoomOut = useCallback(() => { - controllerRef.current?.zoomOut(); - }, []); - const handleResetView = useCallback(() => { controllerRef.current?.resetView(); }, []); + useEffect(() => { + // Re-anchor camera when entering/leaving the dedicated dimension mode. + controllerRef.current?.resetView(); + }, [isDimensionStep]); + const handleToggleDoors = useCallback(() => { setShowDoors(prev => !prev); }, []); @@ -719,6 +1768,8 @@ export default function WardrobePreview({ { 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' }, ], []); @@ -734,48 +1785,98 @@ export default function WardrobePreview({ e.target.value = ''; }, [PICKABLE_ELEMENTS, handleElementClick]); + const showDoorToggle = activeStep === 2 || activeStep === 3 || activeStep === 6; + const showColorSelector = activeStep === 4 || activeStep === 6; + const showReset = isDimensionStep || activeStep === 2 || activeStep === 6; + const show3dUndo = typeof onUndo3d === 'function'; + const effectiveShowDoors = isDimensionStep ? true : showDoors; + return ( -
- {selectedElement && ( +
+ {selectedElement && !isDimensionStep && (
Élément sélectionné: {selectedElement.label}
)} - {/* Zoom Controls */}
- - - -
- -
- + {showReset && ( + + )} + + {show3dUndo && ( + <> +
+ + + + )} + + {showDoorToggle && ( + <> +
+ + + )} + + {showColorSelector && ( + <> +
+ + + )}
Chargement 3D…
}> { + gl.setClearColor(0x000000, 0); + }} > - {/* Hint */} -
- Cliquer un module = configurer {'\u2022'} Cliquer un {'\u00e9'}l{'\u00e9'}ment = couleur {'\u2022'} Tirer s{'\u00e9'}parateurs = redimensionner -
); } diff --git a/client/src/components/WardrobePreview.module.css b/client/src/components/WardrobePreview.module.css index d4e98a1..a8dc066 100644 --- a/client/src/components/WardrobePreview.module.css +++ b/client/src/components/WardrobePreview.module.css @@ -5,6 +5,9 @@ min-height: 280px; border-radius: 8px; overflow: hidden; + background: var(--bg); + border: 1px solid var(--border); + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06); } .controls { @@ -17,6 +20,19 @@ z-index: 10; } +.stepBadge { + padding: 4px 8px; + border-radius: 999px; + background: rgba(79, 70, 229, 0.92); + color: #fff; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.01em; + text-transform: uppercase; + text-align: center; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + .controlBtn { width: 36px; height: 36px; @@ -44,6 +60,20 @@ transform: scale(0.95); } +.controlBtnDisabled, +.controlBtn:disabled { + opacity: 0.42; + cursor: not-allowed; + transform: none; + pointer-events: none; +} + +.controlBtnDisabled:hover, +.controlBtn:disabled:hover { + transform: none; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + .loading { display: flex; align-items: center; @@ -121,3 +151,4 @@ border-color: var(--action); box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2); } + diff --git a/client/src/components/configurator/PreviewArea.jsx b/client/src/components/configurator/PreviewArea.jsx new file mode 100644 index 0000000..12bce6b --- /dev/null +++ b/client/src/components/configurator/PreviewArea.jsx @@ -0,0 +1,140 @@ +import { useCallback, useEffect } from 'react'; +import WardrobePreview from '../WardrobePreview'; +import DesignTip from '../DesignTip'; +import { resolveShelfPositionsMm, minShelvesForStructuralHeight } from '../../constants/wardrobe'; +import styles from './PreviewArea.module.css'; + +export default function PreviewArea({ wc, ui }) { + const colorPickEnabled = ui.activeStep === 4 || ui.activeStep === 6; + const infoByStep = { + 1: 'Survolez les zones colorees du cadre puis glissez pour regler L/H/P.', + 2: 'Tirez les separateurs pour redimensionner et glissez les rayonnages par la tranche avant.', + 3: 'Activez ou masquez les portes pour verifier l ouverture.', + 4: 'Choisissez un element puis appliquez sa couleur.', + 5: 'Verifiez les volumes interieurs pour valider la quincaillerie.', + 6: 'Controle final: zoom, rotation et verification globale.' + }; + const structuralWarnings = (() => { + if (ui.activeStep !== 2) return []; + if (minShelvesForStructuralHeight(wc.config.heightMm) <= 0) return []; + const heightMm = Number(wc.config.heightMm) || 0; + const plinthMm = Number(wc.config.plinthHeightMm) || 0; + const midMm = (heightMm - plinthMm) / 2; + const mods = Array.isArray(wc.config.modules) ? wc.config.modules : []; + const out = []; + for (let i = 0; i < mods.length; i++) { + const mod = mods[i]; + const shelfCount = Math.max(0, Number(mod?.shelves) || 0); + const positions = resolveShelfPositionsMm({ + shelfCount, + heightMm, + plinthHeightMm: plinthMm, + shelfPositionsMm: mod?.shelfPositionsMm + }); + const hasStructural = positions.some((p) => p >= midMm - 200 && p <= midMm + 200); + if (!hasStructural) out.push(`Colonne ${i + 1}: placer un rayon dans la zone structurelle (±20 cm).`); + } + return out; + })(); + const handleSetDimension = useCallback((key, valueMm) => { + wc.setDimension(key, valueMm); + }, [wc.setDimension]); + + useEffect(() => { + const onKeyDown = (e) => { + if (!e.ctrlKey && !e.metaKey) return; + const el = e.target; + if (el && (el.closest?.('input, textarea, select') || el.isContentEditable)) return; + + if (e.key === 'z' && !e.shiftKey) { + e.preventDefault(); + wc.undo(); + return; + } + if (e.key === 'z' && e.shiftKey) { + e.preventDefault(); + wc.redo(); + return; + } + if (e.key === 'y' && e.ctrlKey && !e.metaKey) { + e.preventDefault(); + wc.redo(); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [wc.undo, wc.redo]); + + return ( +
+
+ {ui.roomViewActive && ui.roomConfig ? ( + <> + + Vue : Ma pièce ({ui.roomAnalysis?.roomDimensions?.widthM}m × {ui.roomAnalysis?.roomDimensions?.lengthM}m) + +
+ + +
+ + ) : ( + <> + Vue : Simple +
+ {ui.roomConfig && ( + + )} + + +
+ + )} +
+ +
+ ui.openColorPicker(elementType, label) : undefined} + onModuleClick={ui.handleModuleClick} + onUpdateModule={wc.handleColumnUpdate} + onResizeModules={wc.handleResizeModules} + selectedModuleIndex={ui.expandedColumn} + onSetDimension={handleSetDimension} + onBeginUndoableGesture={wc.pushUndoSnapshot} + canUndo3d={wc.canUndo} + canRedo3d={wc.canRedo} + onUndo3d={wc.undo} + onRedo3d={wc.redo} + suppressHtmlOverlays={ui.showPlansView || ui.showAIRender || ui.showRoomUpload} + /> +
+
+
{infoByStep[ui.activeStep] || 'Manipulez la vue pour controler la configuration.'}
+ {structuralWarnings.map((msg) => ( +
{msg}
+ ))} +
+ +
+ ); +} diff --git a/client/src/components/configurator/PreviewArea.module.css b/client/src/components/configurator/PreviewArea.module.css new file mode 100644 index 0000000..1fb57f1 --- /dev/null +++ b/client/src/components/configurator/PreviewArea.module.css @@ -0,0 +1,102 @@ +.previewArea { + position: relative; + width: 100%; + max-width: 100%; + margin: 0; +} + +.roomControls { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.75rem; + font-size: 0.8125rem; + gap: 0.5rem; + flex-wrap: wrap; +} + +.roomLabel { + color: var(--text-muted); + font-size: 0.8125rem; +} + +.roomBtnGroup { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; +} + +.roomBtn, .roomBtnSecondary, .aiRenderBtn { + padding: 0.3rem 0.65rem; + border-radius: 5px; + font-size: 0.75rem; + font-weight: 500; + border: 1px solid var(--border); + cursor: pointer; + background: var(--bg-card); + color: var(--text); + transition: all 0.15s; +} +.roomBtn:hover, .roomBtnSecondary:hover, .aiRenderBtn:hover { + background: var(--bg); +} + +.aiRenderBtn { + background: var(--action); + color: #fff; + border-color: var(--action); +} +.aiRenderBtn:hover { opacity: 0.9; } + +.aiBadgeSmall { + display: inline-block; + background: rgba(255,255,255,0.2); + color: #fff; + font-size: 0.6rem; + font-weight: 700; + padding: 0.1rem 0.3rem; + border-radius: 3px; + margin-right: 0.25rem; + vertical-align: middle; +} + +.preview { + width: 100%; + aspect-ratio: 16 / 10; + max-height: 50vh; + background: var(--bg); + border-radius: 8px; + overflow: hidden; +} + +.infoZone { + margin-top: 0.5rem; + border: 1px solid var(--border); + background: var(--bg-card); + border-radius: 8px; + padding: 0.45rem 0.6rem; + display: grid; + gap: 0.35rem; +} + +.infoLine { + font-size: 0.78rem; + color: var(--text-muted); +} + +.warnLine { + font-size: 0.78rem; + color: #b91c1c; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 6px; + padding: 0.25rem 0.45rem; +} + +@media (max-width: 700px) { + .preview { + aspect-ratio: 16 / 9; + max-height: 40vh; + } + .roomControls { padding: 0.35rem 0.5rem; } +} diff --git a/client/src/components/configurator/StepColumns.jsx b/client/src/components/configurator/StepColumns.jsx new file mode 100644 index 0000000..6f9997c --- /dev/null +++ b/client/src/components/configurator/StepColumns.jsx @@ -0,0 +1,443 @@ +import { useState, useRef, useCallback } from 'react'; +import { + COLUMN_TYPES, + maxShelfGapMm, + SHELF_GAP_MM, + minShelvesForStructuralHeight, + resolveShelfPositionsMm, + system32ShelfPositionsMm, + SYSTEM32_PITCH_MM +} from '../../constants/wardrobe'; +import s from './steps.module.css'; + +/** After moving array item from -> to, map a selected index to the new position. */ +function mapExpandedIndexAfterReorder(expanded, from, to) { + if (expanded === null || expanded === undefined) return expanded; + if (expanded === from) return to; + if (from < to) { + if (expanded > from && expanded <= to) return expanded - 1; + } else if (from > to) { + if (expanded >= to && expanded < from) return expanded + 1; + } + return expanded; +} + +export default function StepColumns({ wc, ui }) { + const { + config, numColumns, + handleNumColumnsChange, handleAIOptimize, handleColumnTypeChange, + handleColumnLock, handleColumnUpdate, + aiOptimizing, aiOptimizeMsg, setAiOptimizeMsg, totalModuleWidth, + reorderModules, pushUndoSnapshot, + } = wc; + const { expandedColumn, setExpandedColumn } = ui; + + const [draggingIndex, setDraggingIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + const dragSourceRef = useRef(null); + const suppressCardClickRef = useRef(false); + const canReorderColumns = config.modules.length > 1; + + const handleColumnDragStart = useCallback((index, e) => { + if (!canReorderColumns) { + e.preventDefault(); + return; + } + dragSourceRef.current = index; + setDraggingIndex(index); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(index)); + }, [canReorderColumns]); + + const handleColumnDragOver = useCallback((index, e) => { + if (!canReorderColumns || dragSourceRef.current === null) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDragOverIndex(index); + }, [canReorderColumns]); + + const handleColumnDrop = useCallback((toIndex, e) => { + e.preventDefault(); + const raw = e.dataTransfer.getData('text/plain'); + const fromIndex = Number.parseInt(raw, 10); + dragSourceRef.current = null; + setDragOverIndex(null); + setDraggingIndex(null); + if (!Number.isFinite(fromIndex) || fromIndex === toIndex) return; + suppressCardClickRef.current = true; + pushUndoSnapshot(); + reorderModules(fromIndex, toIndex); + setExpandedColumn((prev) => mapExpandedIndexAfterReorder(prev, fromIndex, toIndex)); + }, [pushUndoSnapshot, reorderModules, setExpandedColumn]); + + const handleColumnDragEnd = useCallback(() => { + dragSourceRef.current = null; + setDraggingIndex(null); + setDragOverIndex(null); + }, []); + + const handleColCardClick = useCallback((i, isActive) => { + if (suppressCardClickRef.current) { + suppressCardClickRef.current = false; + return; + } + setExpandedColumn(isActive ? null : i); + }, [setExpandedColumn]); + + const minShelvesStructural = minShelvesForStructuralHeight(config.heightMm); + const hasTallCabinet = minShelvesStructural > 0; + const hw = config.hardware || {}; + const drawerDepth = config.effectiveDrawerDepthMm || Math.max(400, Math.min(650, config.depthMm - 10)); + + return ( +
+

Configuration intérieure

+

+ Choisissez le nombre de colonnes et personnalisez chacune. Cliquez sur un module dans la vue 3D pour le sélectionner. + {canReorderColumns && ( + Glissez-déposez les cartes ci-dessous pour changer l'ordre des colonnes (gauche → droite = avant → arrière dans la vue 3D). + )} +

+ +
+ +
+ {[ + { key: 'separations', label: 'Séparations' }, + { key: 'block', label: 'Block (x caisses)' } + ].map((m) => ( + + ))} +
+ {config.columnMode === 'block' && ( +

Assemblage block: vis de jonction + perçages 3 milieux et +100/-100 mm (30 mm face/dos).

+ )} +
+ + {/* Column count */} +
+ Nombre de colonnes +
+ {[1, 2, 3, 4, 5, 6].map(n => ( + + ))} +
+ +
+ + {aiOptimizeMsg && ( +
+ IA + {aiOptimizeMsg} + +
+ )} + + {/* Horizontal column strip */} + {config.modules.length > 0 && ( + <> +

+ Gauche → droite : colonne 1, 2, 3… (identique à la vue 3D). +

+
+ {config.modules.map((mod, i) => { + const typeDef = COLUMN_TYPES.find(t => t.key === mod.type) || COLUMN_TYPES[0]; + const isActive = expandedColumn === i; + const isDragging = draggingIndex === i; + const isOver = dragOverIndex === i && draggingIndex !== null && draggingIndex !== i; + return ( + + ); + })} +
+ + {/* Detail tray for expanded column */} + {expandedColumn !== null && config.modules[expandedColumn] && (() => { + const i = expandedColumn; + const mod = config.modules[i]; + const typeDef = COLUMN_TYPES.find(t => t.key === mod.type) || COLUMN_TYPES[0]; + const shelfN = mod.shelves || 0; + const shelfPositions = resolveShelfPositionsMm({ + shelfCount: shelfN, + heightMm: config.heightMm, + plinthHeightMm: config.plinthHeightMm ?? 100, + shelfPositionsMm: mod.shelfPositionsMm + }); + const minShelves = minShelvesStructural; + const bodyHmm = (Number(config.heightMm) || 0) - (Number(config.plinthHeightMm) || 0); + const bodyMidMm = bodyHmm / 2; + const shelfSlots = system32ShelfPositionsMm(config.heightMm, config.plinthHeightMm ?? 100); + const structuralIdx = + minShelves > 0 && shelfPositions.length > 0 + ? shelfPositions.reduce((bestIdx, posMm, idx, arr) => { + const d = Math.abs(posMm - bodyMidMm); + const best = Math.abs(arr[bestIdx] - bodyMidMm); + return d < best ? idx : bestIdx; + }, 0) + : null; + const hasShelfInStructuralWindow = + minShelves <= 0 || + shelfPositions.some((p) => p >= bodyMidMm - 200 && p <= bodyMidMm + 200); + const gapMax = Math.min( + SHELF_GAP_MM.max, + maxShelfGapMm(config.heightMm, config.plinthHeightMm ?? 100, shelfN) + ); + const gapMin = SHELF_GAP_MM.min; + const canCustomGap = shelfN >= 2 && gapMax >= gapMin; + const handleShelfPosChange = (shelfIndex, rawValue) => { + if (!shelfPositions.length) return; + const next = [...shelfPositions]; + next[shelfIndex] = Number(rawValue); + handleColumnUpdate(i, { shelfPositionsMm: next }); + }; + return ( +
+
+ Colonne {i + 1} — {typeDef.icon} {typeDef.label} + +
+ + {/* Type tiles */} +
+ {COLUMN_TYPES.map(t => ( + + ))} +
+ + {/* Shelves */} +
+ Étagères +
+ + {mod.shelves || 0} + +
+
+ {minShelves >= 1 && (mod.shelves || 0) <= minShelves && ( +

+ Hauteur > 2 m : au moins 1 étagère par colonne (rigidité, assemblage RAZENTZAB). Le compteur ne peut pas descendre en dessous. +

+ )} + + {shelfN >= 2 && ( +
+ Entre rayons (clair) + {mod.shelfGapMm == null ? ( +
+ Automatique + +
+ ) : ( +
+ {mod.shelfGapMm} mm + +
+ )} +
+ )} + {shelfN >= 2 && mod.shelfGapMm != null && canCustomGap && ( +
+ handleColumnUpdate(i, { shelfGapMm: Number(e.target.value) })} + /> +
+ )} + {shelfN >= 2 && !canCustomGap && ( +

Hauteur insuffisante pour fixer l'espacement entre rayons (répartition auto).

+ )} + {shelfN > 0 && shelfPositions.length > 0 && ( +
+ Positions rayons +
+ {shelfPositions.map((posMm, shelfIdx) => { + let minMm = shelfSlots[shelfIdx] ?? shelfSlots[0]; + let maxMm = shelfSlots[shelfSlots.length - (shelfPositions.length - shelfIdx)] ?? shelfSlots[shelfSlots.length - 1]; + return ( +
+ + R{String(shelfIdx + 1)}{structuralIdx === shelfIdx ? '*' : ''} + + handleShelfPosChange(shelfIdx, e.target.value)} + className={s.gapRange} + /> + mm +
+ ); + })} +
+
+ )} + {structuralIdx !== null && ( +

* Rayon structurel recommandé autour du milieu (zone ±200 mm).

+ )} + {minShelves > 0 && !hasShelfInStructuralWindow && ( +

Aucun rayon dans la zone structurelle ±200 mm autour du milieu. Placez-en un pour la rigidité.

+ )} + + {/* Drawers */} +
+ Tiroirs +
+ + {mod.drawers || 0} + +
+
+ {(mod.drawers || 0) > 0 && ( + <> +
+ Type tiroirs +
+ {[ + { key: 'visible', label: 'Visible' }, + { key: 'hidden', label: 'Caché' } + ].map((item) => ( + + ))} +
+
+

+ Manufacture depuis le bas. Profondeur utile: {drawerDepth} mm (réglage global). +

+ + )} + + {/* Rod */} +
+ Tringle + +
+
+ ); + })()} + +

+ {config.modules.length} colonne{config.modules.length > 1 ? 's' : ''} — Largeur totale : {totalModuleWidth} mm +

+ {hasTallCabinet &&

Armoire > 2m: rayon au milieu obligatoire avec assemblage RAZENTZAB.

} + {config.modules.some((m) => (m.drawers || 0) > 0 && (m.widthMm || 0) > 1000) && ( +

Une colonne avec tiroirs dépasse 1000 mm. Limite max appliquée.

+ )} + {config.modules.some((m) => m.hasRod) && config.heightMm < 1200 && ( +

Rayon avec tringle: hauteur minimale 1200 mm.

+ )} +

Tirez les séparateurs dans la vue 3D pour redimensionner

+ + )} +
+ ); +} diff --git a/client/src/components/configurator/StepDimensions.jsx b/client/src/components/configurator/StepDimensions.jsx new file mode 100644 index 0000000..1e7e04f --- /dev/null +++ b/client/src/components/configurator/StepDimensions.jsx @@ -0,0 +1,196 @@ +import s from './steps.module.css'; + +const DIMS = [ + { key: 'widthMm', label: 'Largeur totale', min: 400, max: 5000, step: 50, unit: 'mm' }, + { key: 'heightMm', label: 'Hauteur', min: 1800, max: 2800, step: 50, unit: 'mm' }, + { key: 'depthMm', label: 'Profondeur', min: 400, max: 800, step: 10, unit: 'mm' }, + { key: 'plinthHeightMm', label: 'Plinthe', min: 60, max: 160, step: 10, unit: 'mm' }, +]; + +export default function StepDimensions({ wc }) { + const { config, set } = wc; + const niche = config.nicheMeasurements || {}; + const sideDefaultsEnabled = config.sideFinishesEnabled !== false; + const sideLeftEnabled = config.sideFinishLeftEnabled ?? sideDefaultsEnabled; + const sideRightEnabled = config.sideFinishRightEnabled ?? sideDefaultsEnabled; + const topFinishEnabled = config.topFinishEnabled !== false; + + const minNicheWidth = Math.min(niche.widthTopMm || config.widthMm, niche.widthMidMm || config.widthMm, niche.widthBottomMm || config.widthMm); + const minNicheDepth = Math.min(niche.depthTopMm || config.depthMm, niche.depthMidMm || config.depthMm, niche.depthBottomMm || config.depthMm); + const minNicheHeight = Math.min(niche.heightLeftMm || config.heightMm, niche.heightRightMm || config.heightMm); + const gap = Number(config.sanitaryGapMm || 30); + const usableWidth = Math.max(400, minNicheWidth - gap * 2); + const usableDepth = Math.max(400, minNicheDepth - gap * 2); + const usableHeight = Math.max(1800, minNicheHeight - gap * 2); + + return ( +
+

Espace & Dimensions

+ + {/* Installation type */} +
+ + +
+ +
+ +
+ {[ + { key: 'other', label: 'Autre' }, + { key: 'bathroom', label: 'Salle de bain' }, + { key: 'laundry', label: 'Buanderie' }, + { key: 'technical', label: 'Local technique' } + ].map((r) => ( + + ))} +
+
+ + {/* Dimension sliders */} +
+ {DIMS.map(({ key, label, min, max, step, unit }) => ( +
+ {label} +
+ set(key, Number(e.target.value))} + /> + set(key, Number(e.target.value) || min)} + /> + {unit} +
+
+ ))} +
+ + {config.installationType === 'free' && ( +
+ +
+ + + +
+
+ )} + + {config.installationType === 'niche' && ( +
+
Mesures niche complètes
+
+ + + + + + + + + +
+
Plus petite largeur:{minNicheWidth} mm
+
Plus petite profondeur:{minNicheDepth} mm
+
Plus petite hauteur:{minNicheHeight} mm
+
Cote utile retenue:{usableWidth} × {usableHeight} × {usableDepth} mm
+
+ )} +
+ ); +} diff --git a/client/src/components/configurator/StepDoors.jsx b/client/src/components/configurator/StepDoors.jsx new file mode 100644 index 0000000..e602dfa --- /dev/null +++ b/client/src/components/configurator/StepDoors.jsx @@ -0,0 +1,187 @@ +import s from './steps.module.css'; +import { getDoorHingeSide } from '@shared/manufacturing.js'; + +export default function StepDoors({ wc }) { + const { config, set } = wc; + const hw = config.hardware || {}; + const handleHeight = Number(hw.handleBoreHeightMm || 1000); + const isNonStandardHandleHeight = handleHeight < 800 || handleHeight > 1200; + const doorCount = Math.max(1, (config.modules?.length || 0) || (config.blocks?.length || 0) || 2); + const hingeSides = Array.isArray(hw.doorHingeSides) ? hw.doorHingeSides : null; + + const getHingeSideForDoor = (i) => { + if (hingeSides && hingeSides.length === doorCount) { + return hingeSides[i] === 'right' ? 'right' : 'left'; + } + return getDoorHingeSide(i, doorCount, hw); + }; + + const setDoorHingeSideAt = (i, side) => { + const next = Array.from({ length: doorCount }, (_, idx) => getHingeSideForDoor(idx)); + next[i] = side === 'right' ? 'right' : 'left'; + wc.setHardware('doorHingeSides', next); + }; + + return ( +
+

Type de portes

+

Portes battantes uniquement pour le moment. Configurez charnières et poignées.

+ +
+ +
+ +
+ +
+ {Array.from({ length: doorCount }).map((_, i) => { + const active = getHingeSideForDoor(i); + return ( +
+ + Porte {i + 1} + +
+ {[ + { key: 'left', label: 'Gauche' }, + { key: 'right', label: 'Droite' } + ].map((item) => ( + + ))} +
+
+ ); + })} +
+
+ +
+ +
+ + Si vous définissez « par porte » ci-dessus, ces réglages ne servent que de fallback. + +
+ +
+ {[ + { key: 'left', label: 'Gauche' }, + { key: 'right', label: 'Droite' } + ].map((item) => ( + + ))} +
+ + +
+ {[ + { key: 'left', label: 'Fiche gauche' }, + { key: 'right', label: 'Fiche droite' } + ].map((item) => ( + + ))} +
+
+ +
+ +
+ wc.setHardware('handleBoreHeightMm', Number(e.target.value))} + /> + wc.setHardware('handleBoreHeightMm', Number(e.target.value) || 1000)} + /> + mm +
+

Plage standard visible sur plan: 1000 mm ± 200 mm.

+ {isNonStandardHandleHeight && ( +

Configuration non standard: hauteur hors plage standard.

+ )} +
+ +
+ +
+ {[ + { key: 'standard', label: 'Standard' }, + { key: 'full_height', label: 'Toute hauteur' } + ].map((item) => ( + + ))} +
+
+ +
+ Portes coulissantes + Indisponible pour le moment (support backend conservé). +
+ + {config.doorType === 'sliding' && ( +
+ Nombre de vantaux (legacy) +
+ {[2, 3].map((n) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/client/src/components/configurator/StepFinalize.jsx b/client/src/components/configurator/StepFinalize.jsx new file mode 100644 index 0000000..027aeae --- /dev/null +++ b/client/src/components/configurator/StepFinalize.jsx @@ -0,0 +1,239 @@ +import { COLUMN_TYPES } from '../../constants/wardrobe'; +import { submitOrder } from '../../api/client'; +import Icon from '../ui/Icon'; +import s from './steps.module.css'; + +export default function StepFinalize({ wc, ui }) { + const { config, priceResult, serviceOptions, selectedFinish, totalModuleWidth } = wc; + const { orderForm, setOrderForm, orderSent, setOrderSent, setActiveStep } = ui; + + const errors = priceResult?.errors || []; + const totalChf = priceResult?.totalChf ?? 0; + + const handleSubmitOrder = (e) => { + e.preventDefault(); + submitOrder({ config, totalChf: priceResult?.totalChf, ...orderForm }) + .then((order) => { + setOrderSent(true); + ui.setLastOrderId(order?.id || null); + }) + .catch(alert); + }; + + // Build summary lines + const installLabel = config.installationType === 'free' ? 'Libre' : 'Niche'; + const doorLabel = config.doorType === 'hinged' ? 'Battantes' : `Coulissantes (${config.slidingPanelsCount} vantaux) [legacy]`; + const roomLabel = { + other: 'Autre', + bathroom: 'Salle de bain', + laundry: 'Buanderie', + technical: 'Local technique' + }[config.roomType || 'other']; + const columnsDesc = config.modules.map((m, i) => { + const t = COLUMN_TYPES.find(ct => ct.key === m.type) || COLUMN_TYPES[0]; + return `${t.icon} ${t.label} (${m.widthMm}mm)`; + }).join(', '); + + return ( +
+

Récapitulatif & Commande

+ + {errors.length > 0 && ( +
    + {errors.map((err, i) =>
  • {err.message}
  • )} +
+ )} + + {/* Configuration summary */} +
+
+ Installation + + {installLabel} · {roomLabel} + + +
+
+ Dimensions + + {config.widthMm} × {config.heightMm} × {config.depthMm} mm + + +
+
+ Colonnes ({config.modules.length}) + + {totalModuleWidth} mm + + +
+
+ Portes + + {doorLabel} + + +
+
+ Matériau + + {selectedFinish?.label || 'Non s\u00e9lectionn\u00e9'} + + +
+
+ + {/* Panel optimization */} + {priceResult?.details?.panelOptimization && ( +
+
Optimisation panneaux
+
+ Format optimal : + + {priceResult.details.panelOptimization.selectedDimension.width_mm} ×{' '} + {priceResult.details.panelOptimization.selectedDimension.height_mm} mm + +
+
+ Panneaux nécessaires : + {priceResult.details.panelOptimization.panelsNeeded} +
+
+ Surface requise : + {priceResult.surfaceM2?.toFixed(2)} m² +
+
+ Chute estimée : + {priceResult.details.panelOptimization.wasteM2?.toFixed(2)} m² ({priceResult.details.panelOptimization.wastePercent}%) +
+
+ )} + + {/* Manufacturing specs (APEX 4.0) */} +
+
Fabrication APEX 4.0
+
+ Structure / Renforts / Dos : + 19 mm / 16 mm / 8 mm +
+
+ System 32 : + Ø5 mm, prof. 12 mm, pas 32 mm +
+
+ Rainure dos : + 9×10.5 mm, à 16 mm du bord +
+
+ Connecteurs structure : + SC 8/60 (extrémités T@20 → IX@50 → T@80 · milieu IX + T ±30 mm) +
+
+ Connecteurs finition : + SC 8/25 (axes 30/43 mm) +
+
+ Plinthe : + AXILO™ 78 +
+ {priceResult?.details?.reinforcementCount > 0 && ( +
+ Renforts Forteresse : + {priceResult.details.reinforcementCount} pcs (1× SC 8/60 + 2× tourillons) +
+ )} + {priceResult?.details?.reinforcementChf > 0 && ( +
+ Coût renforts : + CHF {priceResult.details.reinforcementChf.toFixed(2)} +
+ )} + {priceResult?.details?.blockExtraStructureChf > 0 && ( +
+ Surplus structure (assemblage block, montants suppl.) : + CHF {priceResult.details.blockExtraStructureChf.toFixed(2)} +
+ )} +
+ + {/* Action buttons */} +
+ + + +
+ + {/* Service options */} +
+ + {serviceOptions.map((opt) => ( + + ))} +
+ + {/* Price */} +
+
+ Total + CHF {totalChf.toLocaleString('fr-CH', { minimumFractionDigits: 2 })}.– +
+
+ + {/* Order form */} + {!orderSent ? ( +
+ setOrderForm((f) => ({ ...f, email: e.target.value }))} + required + /> + setOrderForm((f) => ({ ...f, name: e.target.value }))} + /> + setOrderForm((f) => ({ ...f, phone: e.target.value }))} + /> +