// public/js/main.js import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; // --- Базовая настройка сцены --- const scene = new THREE.Scene(); scene.background = new THREE.Color(0xd0d0d0); const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 1, 10000); camera.position.set(1500, 1400, 2500); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; document.body.appendChild(renderer.domElement); const hemiLight = new THREE.HemisphereLight( 0xffffff, 0x8d8d8d, 1.5 ); scene.add( hemiLight ); const dirLight = new THREE.DirectionalLight( 0xffffff, 1.5 ); dirLight.position.set( -1000, 2000, 1000 ); dirLight.castShadow = true; dirLight.shadow.camera.top = 2000; dirLight.shadow.camera.bottom = -2000; dirLight.shadow.camera.left = -2000; dirLight.shadow.camera.right = 2000; dirLight.shadow.camera.near = 0.1; dirLight.shadow.camera.far = 5000; scene.add( dirLight ); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.target.set(0, 500, 0); const ground = new THREE.Mesh(new THREE.PlaneGeometry(10000, 10000), new THREE.MeshLambertMaterial({ color: 0xbbbbbb })); ground.rotation.x = -Math.PI / 2; ground.position.y = 0; ground.receiveShadow = true; scene.add(ground); const textureLoader = new THREE.TextureLoader(); // --- Данные материалов --- const MATERIALS = [ { name: 'Белый', type: 'color', value: 0xffffff }, { name: 'Серый', type: 'color', value: 0x808080 }, { name: 'Черный', type: 'color', value: 0x333333 }, { name: 'Образец1', type: 'texture', value: 'assets/wood1.jpg' }, { name: 'Образец2', type: 'texture', value: 'assets/wood2.jpg' }, ]; let currentMaterial = new THREE.MeshStandardMaterial({ side: THREE.DoubleSide }); let currentMaterialData = MATERIALS[0]; // --- ИСПРАВЛЕНИЕ: Функция объявлена ОДИН раз --- function updateMaterial(materialData) { currentMaterialData = materialData; if (materialData.type === 'color') { currentMaterial.map = null; currentMaterial.color.set(materialData.value); currentMaterial.needsUpdate = true; } else if (materialData.type === 'texture') { textureLoader.load(materialData.value, (texture) => { currentMaterial.map = texture; currentMaterial.color.set(0xffffff); texture.wrapS = THREE.RepeatWrapping; texture.wrapT = THREE.RepeatWrapping; currentMaterial.needsUpdate = true; }); } } // --- Логика выделения ячеек --- const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); let currentlyIntersected = null; const highlighterMaterial = new THREE.MeshBasicMaterial({ color: 0xff8c00, transparent: true, opacity: 0.4, side: THREE.DoubleSide }); const highlighterGeometry = new THREE.PlaneGeometry(1, 1); const highlighterMesh = new THREE.Mesh(highlighterGeometry, highlighterMaterial); highlighterMesh.visible = false; scene.add(highlighterMesh); function onPointerMove(event) { const rect = renderer.domElement.getBoundingClientRect(); pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; } window.addEventListener('pointermove', onPointerMove); // --- Логика загрузки модели человека --- let humanModel = null; function loadHumanModel() { const loader = new GLTFLoader(); const stencilMaterial = new THREE.MeshStandardMaterial({ color: 0x4682B4, metalness: 0.1, roughness: 0.8, }); loader.load( 'assets/model.glb', (gltf) => { humanModel = gltf.scene; humanModel.traverse((child) => { if (child.isMesh) { child.material = stencilMaterial; child.castShadow = true; child.receiveShadow = false; } }); humanModel.scale.set(590, 590, 590); scene.add(humanModel); updateConfiguration(); }, undefined, (error) => { console.error('Ошибка при загрузке модели человека:', error); } ); } // --- Логика конструктора --- const PANEL_THICKNESS = 16; let wardrobeGroup = new THREE.Group(); let hitboxesGroup = new THREE.Group(); scene.add(wardrobeGroup); scene.add(hitboxesGroup); function createWardrobe(width, height, depth, columns, rows) { const modelGroup = new THREE.Group(); const cellHitboxesGroup = new THREE.Group(); modelGroup.position.y = height / 2; cellHitboxesGroup.position.y = height / 2; const createPanel = (geometry) => { const mesh = new THREE.Mesh(geometry, currentMaterial); mesh.castShadow = true; mesh.receiveShadow = true; return mesh; }; const innerWidth = width - 2 * PANEL_THICKNESS; const innerHeight = height - 2 * PANEL_THICKNESS; const shelfDepth = depth - PANEL_THICKNESS; modelGroup.add(createPanel(new THREE.BoxGeometry(width, PANEL_THICKNESS, depth)).translateY(-height / 2 + PANEL_THICKNESS / 2)); modelGroup.add(createPanel(new THREE.BoxGeometry(width, PANEL_THICKNESS, depth)).translateY(height / 2 - PANEL_THICKNESS / 2)); modelGroup.add(createPanel(new THREE.BoxGeometry(PANEL_THICKNESS, innerHeight, depth)).translateX(-width / 2 + PANEL_THICKNESS / 2)); modelGroup.add(createPanel(new THREE.BoxGeometry(PANEL_THICKNESS, innerHeight, depth)).translateX(width / 2 - PANEL_THICKNESS / 2)); modelGroup.add(createPanel(new THREE.BoxGeometry(innerWidth, innerHeight, PANEL_THICKNESS)).translateZ(-depth / 2 + PANEL_THICKNESS / 2)); const numDividers = columns - 1; const sectionWidth = (innerWidth - (numDividers * PANEL_THICKNESS)) / columns; if (numDividers > 0) { for (let i = 0; i < numDividers; i++) { const posX = -innerWidth / 2 + sectionWidth * (i + 1) + PANEL_THICKNESS * (i + 0.5); modelGroup.add(createPanel(new THREE.BoxGeometry(PANEL_THICKNESS, innerHeight, shelfDepth)).translateX(posX).translateZ(PANEL_THICKNESS/2)); } } const numShelves = rows - 1; const cellHeight = (innerHeight - (numShelves * PANEL_THICKNESS)) / rows; if (numShelves > 0) { for (let c = 0; c < columns; c++) { for (let r = 0; r < numShelves; r++) { const posX = -innerWidth / 2 + sectionWidth * (c + 0.5) + PANEL_THICKNESS * c; const posY = height / 2 - PANEL_THICKNESS - cellHeight * (r + 1) - PANEL_THICKNESS * r; const shelf = createPanel(new THREE.BoxGeometry(sectionWidth, PANEL_THICKNESS, shelfDepth)); shelf.position.set(posX, posY, PANEL_THICKNESS / 2); modelGroup.add(shelf); } } } const hitboxMaterial = new THREE.MeshBasicMaterial({visible: false}); for (let c = 0; c < columns; c++) { for (let r = 0; r < rows; r++) { const hitboxGeometry = new THREE.PlaneGeometry(sectionWidth, cellHeight); const hitbox = new THREE.Mesh(hitboxGeometry, hitboxMaterial); const cellCenterX = -innerWidth / 2 + sectionWidth * (c + 0.5) + PANEL_THICKNESS * c; const cellCenterY = height / 2 - PANEL_THICKNESS / 2 - cellHeight * (r + 0.5) - PANEL_THICKNESS * r; hitbox.position.set(cellCenterX, cellCenterY, depth / 2); cellHitboxesGroup.add(hitbox); } } return { model: modelGroup, hitboxes: cellHitboxesGroup }; } // --- Инициализация UI и связь с 3D --- (function init() { const selector = document.getElementById('material-selector'); const priceDisplay = document.getElementById('price-display'); MATERIALS.forEach((materialData) => { const swatch = document.createElement('div'); swatch.classList.add('material-swatch'); if (materialData.type === 'color') { swatch.style.backgroundColor = `#${materialData.value.toString(16).padStart(6, '0')}`; } else if (materialData.type === 'texture') { swatch.style.backgroundImage = `url(${materialData.value})`; } swatch.addEventListener('click', () => { updateMaterial(materialData); selector.querySelector('.selected')?.classList.remove('selected'); swatch.classList.add('selected'); updateConfiguration(); }); selector.appendChild(swatch); }); const uiControls = { width: document.getElementById('width-slider'), height: document.getElementById('height-slider'), depth: document.getElementById('depth-slider'), columns: document.getElementById('columns-input'), rows: document.getElementById('rows-input'), }; const uiValues = { width: document.getElementById('width-value'), height: document.getElementById('height-value'), depth: document.getElementById('depth-value'), }; window.updateConfiguration = async function() { priceDisplay.textContent = 'Расчет...'; const params = { width: parseFloat(uiControls.width.value), height: parseFloat(uiControls.height.value), depth: parseFloat(uiControls.depth.value), columns: parseInt(uiControls.columns.value), rows: parseInt(uiControls.rows.value), materialName: currentMaterialData.name, }; uiValues.width.textContent = params.width; uiValues.height.textContent = params.height; uiValues.depth.textContent = params.depth; controls.target.set(0, params.height / 2, 0); scene.remove(wardrobeGroup); scene.remove(hitboxesGroup); wardrobeGroup.traverse(child => { if (child.isMesh) child.geometry.dispose(); }); hitboxesGroup.traverse(child => { if (child.isMesh) child.geometry.dispose(); }); const result = createWardrobe(params.width, params.height, params.depth, params.columns, params.rows); wardrobeGroup = result.model; hitboxesGroup = result.hitboxes; scene.add(wardrobeGroup); scene.add(hitboxesGroup); if (humanModel) { const humanX = params.width / 2 + 550; const humanY = 0; const humanZ = params.depth / 2; humanModel.position.set(humanX, humanY, humanZ); } try { const response = await fetch('api/calculate-price', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(params), }); const data = await response.json(); if(response.ok) { priceDisplay.textContent = `${data.price.toLocaleString('ru-RU')} ₽`; } else { console.error(data.error); priceDisplay.textContent = 'Ошибка'; } } catch (error) { console.error('Ошибка сети при расчете цены:', error); priceDisplay.textContent = 'Ошибка сети'; } } Object.values(uiControls).forEach(control => control.addEventListener('input', window.updateConfiguration)); loadHumanModel(); selector.children[0].classList.add('selected'); updateMaterial(MATERIALS[0]); })(); // --- Рендер-цикл --- function animate() { requestAnimationFrame(animate); controls.update(); raycaster.setFromCamera(pointer, camera); const intersects = raycaster.intersectObjects(hitboxesGroup.children); if (intersects.length > 0) { const intersectedObject = intersects[0].object; if (currentlyIntersected !== intersectedObject) { currentlyIntersected = intersectedObject; highlighterMesh.position.copy(intersectedObject.position); highlighterMesh.quaternion.copy(intersectedObject.quaternion); highlighterMesh.position.add(hitboxesGroup.position); highlighterMesh.scale.set( intersectedObject.geometry.parameters.width, intersectedObject.geometry.parameters.height, 1 ); highlighterMesh.visible = true; } } else { if (currentlyIntersected) { highlighterMesh.visible = false; currentlyIntersected = null; } } renderer.render(scene, camera); } animate(); // --- Адаптивность --- window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); });