/* ===== Three.js 3D scene for container loading ===== */ const Scene3D = ({ items, container, colorMode, selectedId, onSelect, viewMode = '3d', xray = false, layerSlice = 1.0 }) => { const mountRef = React.useRef(null); const stateRef = React.useRef({}); const [hover, setHover] = React.useState(null); // build / rebuild scene React.useEffect(() => { if (!window.THREE || !mountRef.current) return; const THREE = window.THREE; const mount = mountRef.current; const w = mount.clientWidth, h = mount.clientHeight; const scene = new THREE.Scene(); scene.background = new THREE.Color('#ECE6DF'); const camera = new THREE.PerspectiveCamera(38, w/h, 0.1, 200); const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(w, h); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; mount.appendChild(renderer.domElement); // lights scene.add(new THREE.AmbientLight(0xffffff, 0.55)); const dir = new THREE.DirectionalLight(0xffffff, 0.85); dir.position.set(8, 14, 6); dir.castShadow = true; dir.shadow.mapSize.set(1024, 1024); dir.shadow.camera.left = -15; dir.shadow.camera.right = 15; dir.shadow.camera.top = 15; dir.shadow.camera.bottom = -15; scene.add(dir); const fill = new THREE.DirectionalLight(0xffe2c2, 0.20); fill.position.set(-6, 5, -4); scene.add(fill); // ground const ground = new THREE.Mesh( new THREE.PlaneGeometry(80, 60), new THREE.MeshStandardMaterial({ color: 0xd9d0c4, roughness: 0.95 }) ); ground.rotation.x = -Math.PI/2; ground.position.y = -0.001; ground.receiveShadow = true; scene.add(ground); // grid const grid = new THREE.GridHelper(60, 60, 0xb0a497, 0xc6bdb0); grid.position.y = 0.002; scene.add(grid); stateRef.current = { THREE, scene, camera, renderer, mount, items: new Map(), raycaster: new THREE.Raycaster(), mouse: new THREE.Vector2() }; // camera target — fit container const cL = container.L/1000, cW = container.W/1000, cH = container.H/1000 || 0.05; const camTarget = new THREE.Vector3(cL/2, cH*0.35, cW/2); const fitR = Math.max(cL, cW * 2.5, 6) * 1.35; const orbit = { az: -0.95, el: 0.55, r: fitR }; function applyCam() { const x = camTarget.x + orbit.r * Math.cos(orbit.el) * Math.sin(orbit.az); const y = camTarget.y + orbit.r * Math.sin(orbit.el); const z = camTarget.z + orbit.r * Math.cos(orbit.el) * Math.cos(orbit.az); camera.position.set(x, y, z); camera.lookAt(camTarget); } if (viewMode === 'top') { orbit.az = 0; orbit.el = 1.40; orbit.r = fitR; camTarget.set(cL/2, 0, cW/2); } if (viewMode === 'side') { orbit.az = Math.PI/2; orbit.el = 0.02; orbit.r = fitR; camTarget.set(cL/2, cH/2, cW/2); } applyCam(); stateRef.current.applyCam = applyCam; stateRef.current.orbit = orbit; stateRef.current.camTarget = camTarget; // controls let dragging = false, mid = false, last = {x:0,y:0}; const dom = renderer.domElement; dom.style.cursor = 'grab'; dom.addEventListener('mousedown', e => { dragging = true; mid = e.button === 1 || e.shiftKey; last = {x:e.clientX, y:e.clientY}; dom.style.cursor='grabbing'; }); window.addEventListener('mouseup', () => { dragging = false; dom.style.cursor='grab'; }); window.addEventListener('mousemove', e => { if (!dragging) return; const dx = e.clientX - last.x, dy = e.clientY - last.y; last = {x:e.clientX, y:e.clientY}; if (mid) { const f = orbit.r * 0.002; camTarget.x -= dx * f * Math.cos(orbit.az); camTarget.z += dx * f * Math.sin(orbit.az); camTarget.y += dy * f; } else { orbit.az -= dx * 0.005; orbit.el = Math.max(0.02, Math.min(1.5, orbit.el + dy * 0.005)); } applyCam(); }); dom.addEventListener('wheel', e => { e.preventDefault(); orbit.r = Math.max(2, Math.min(50, orbit.r * (1 + e.deltaY * 0.001))); applyCam(); }, { passive: false }); // pick handling dom.addEventListener('mousemove', (e) => { const r = dom.getBoundingClientRect(); stateRef.current.mouse.x = ((e.clientX - r.left) / r.width) * 2 - 1; stateRef.current.mouse.y = -((e.clientY - r.top) / r.height) * 2 + 1; }); dom.addEventListener('click', (e) => { const { raycaster, mouse, camera, scene } = stateRef.current; raycaster.setFromCamera(mouse, camera); const hits = raycaster.intersectObjects(scene.children, true).filter(h => h.object.userData?.itemId); if (hits.length) onSelect && onSelect(hits[0].object.userData.itemId, { x: e.clientX, y: e.clientY }); }); // animate let alive = true; function frame() { if (!alive) return; renderer.render(scene, camera); requestAnimationFrame(frame); } frame(); function onResize() { const w = mount.clientWidth, h = mount.clientHeight; camera.aspect = w/h; camera.updateProjectionMatrix(); renderer.setSize(w, h); } const ro = new ResizeObserver(onResize); ro.observe(mount); return () => { alive = false; ro.disconnect(); mount.removeChild(renderer.domElement); renderer.dispose(); }; }, [container.id]); // (re)build items + container whenever data changes React.useEffect(() => { const s = stateRef.current; if (!s?.scene) return; const THREE = s.THREE; // remove old item meshes & container [...s.scene.children].forEach(c => { if (c.userData?.role === 'cargo' || c.userData?.role === 'container') { s.scene.remove(c); c.geometry?.dispose?.(); if (Array.isArray(c.material)) c.material.forEach(m=>m.dispose()); else c.material?.dispose?.(); } }); const cL = container.L/1000, cW = container.W/1000, cH = container.H/1000 || 0.05; // container wireframe const cgeo = new THREE.BoxGeometry(cL, cH, cW); const cmat = new THREE.MeshStandardMaterial({ color: 0x2c1010, transparent: true, opacity: xray ? 0.04 : 0.10, side: THREE.DoubleSide }); const cbox = new THREE.Mesh(cgeo, cmat); cbox.position.set(cL/2, cH/2, cW/2); cbox.userData.role = 'container'; s.scene.add(cbox); // edges const edges = new THREE.LineSegments( new THREE.EdgesGeometry(cgeo), new THREE.LineBasicMaterial({ color: 0x2c1010, transparent: true, opacity: 0.85 }) ); edges.position.copy(cbox.position); edges.userData.role = 'container'; s.scene.add(edges); // floor of container const cfloor = new THREE.Mesh( new THREE.PlaneGeometry(cL, cW), new THREE.MeshStandardMaterial({ color: 0xf0e8de, roughness: 0.9 }) ); cfloor.rotation.x = -Math.PI/2; cfloor.position.set(cL/2, 0.003, cW/2); cfloor.userData.role = 'container'; cfloor.receiveShadow = true; s.scene.add(cfloor); // colors helper function colorFor(it) { if (colorMode === 'category') { const cat = (window.Mojro.CATEGORIES.find(c=>c.id===it.category) || {}); return cat.color || '#7a6a64'; } if (colorMode === 'weight') { const w = (it.weight || 0) * (it.qty||1); const t = Math.min(1, w / 800); // blue→amber→red if (t < 0.5) { const k = t / 0.5; return mixHex('#2E5FA8', '#F0B830', k); } else { const k = (t - 0.5) / 0.5; return mixHex('#F0B830', '#C13333', k); } } if (colorMode === 'stop') { const palette = ['#C13333','#E8893C','#F0B830','#1F8A5B','#2E5FA8']; return palette[(it.stop||1) - 1] || '#7a6a64'; } return it.color || '#C13333'; } // pack items as rows along container length (simple visual layout) let cx = 0.1, cz = 0.05, cy = 0; let rowH = 0; items.forEach((it, idx) => { const L = it.L/1000, W = it.W/1000, H = it.H/1000; const stack = Math.min(it.qty || 1, 4); const acrossW = Math.max(1, Math.floor((cW - 0.1) / Math.max(W, 0.4))); const groupL = L; for (let i=0; i<(it.qty||1); i++) { const idxInLayer = i % (acrossW * stack); const layer = Math.floor(idxInLayer / acrossW); const acrossI = idxInLayer % acrossW; // new row? if (i % (acrossW * stack) === 0 && i !== 0) { cx += groupL + 0.08; } const x = cx + L/2; const y = H/2 + layer * (H + 0.005); const z = 0.05 + acrossI * (W + 0.04) + W/2; if (x + L/2 > cL - 0.05) break; const slice = layerSlice * cH; if (y - H/2 > slice) continue; const color = colorFor(it); const opacity = xray ? 0.28 : 1; const mat = new THREE.MeshStandardMaterial({ color, transparent: xray, opacity, roughness: 0.6, metalness: 0.05, emissive: selectedId === it.id ? new THREE.Color(color).multiplyScalar(0.35) : 0x000000, }); let geo; if (it.type === 'barrel') geo = new THREE.CylinderGeometry(L/2, L/2, H, 16); else if (it.type === 'roll' || it.type === 'pipe') { geo = new THREE.CylinderGeometry(W/2, W/2, L, 14); geo.rotateZ(Math.PI/2); } else geo = new THREE.BoxGeometry(L, H, W); const m = new THREE.Mesh(geo, mat); m.position.set(x, y, z); m.castShadow = true; m.receiveShadow = true; m.userData.itemId = it.id; m.userData.role = 'cargo'; s.scene.add(m); // edges const ge = new THREE.LineSegments( new THREE.EdgesGeometry(geo), new THREE.LineBasicMaterial({ color: 0x2c1010, transparent: true, opacity: xray ? 0.5 : 0.5 }) ); ge.position.copy(m.position); ge.userData.role = 'cargo'; s.scene.add(ge); // selection halo if (selectedId === it.id) { const halo = new THREE.LineSegments( new THREE.EdgesGeometry(new THREE.BoxGeometry(L*1.04, H*1.04, W*1.04)), new THREE.LineBasicMaterial({ color: 0xc13333, linewidth: 2 }) ); halo.position.copy(m.position); halo.userData.role = 'cargo'; s.scene.add(halo); } // violation pulse if (it.violation) { const vh = new THREE.LineSegments( new THREE.EdgesGeometry(new THREE.BoxGeometry(L*1.06, H*1.06, W*1.06)), new THREE.LineBasicMaterial({ color: it.violation === 'bad' ? 0xc13333 : 0xc98a1b }) ); vh.position.copy(m.position); vh.userData.role = 'cargo'; s.scene.add(vh); } } cx += groupL + 0.08; }); }, [items, colorMode, selectedId, xray, layerSlice, container.id]); // recompute view mode without rebuild React.useEffect(() => { const s = stateRef.current; if (!s?.applyCam) return; const cL = container.L/1000, cW = container.W/1000, cH = container.H/1000 || 0.05; const fitR = Math.max(cL, cW * 2.5, 6) * 1.35; if (viewMode === 'top') { s.orbit.az = 0; s.orbit.el = 1.40; s.orbit.r = fitR; s.camTarget.set(cL/2, 0, cW/2); } else if (viewMode === 'side'){ s.orbit.az = Math.PI/2; s.orbit.el = 0.02; s.orbit.r = fitR; s.camTarget.set(cL/2, cH/2, cW/2); } else { s.orbit.az = -0.95; s.orbit.el = 0.55; s.orbit.r = fitR; s.camTarget.set(cL/2, cH*0.35, cW/2); } s.applyCam(); }, [viewMode]); return
; }; // hex mixer function mixHex(a, b, t) { const pa = a.match(/\w\w/g).map(x=>parseInt(x,16)); const pb = b.match(/\w\w/g).map(x=>parseInt(x,16)); const r = pa.map((c,i) => Math.round(c + (pb[i]-c)*t)); return '#' + r.map(c => c.toString(16).padStart(2,'0')).join(''); } window.Scene3D = Scene3D; window.mixHex = mixHex;