first commit

This commit is contained in:
Oleg 2025-08-16 07:20:36 +00:00
commit af3c2bf481
9 changed files with 1777 additions and 0 deletions

73
furniture.js Normal file
View File

@ -0,0 +1,73 @@
// server.js
const express = require('express');
const path = require('path');
const app = express();
// --- ИЗМЕНЕНИЕ: Используйте порт 3201, как в вашем конфиге прокси ---
const PORT = 3201;
app.use(express.json());
// Раздаем ТОЛЬКО нашу папку 'public'
app.use(express.static(path.join(__dirname, 'public')));
// --- УДАЛЕНО: Раздача папок build и jsm больше не нужна ---
// app.use('/build', express.static(path.join(__dirname, 'node_modules/three/build')));
// app.use('/jsm', express.static(path.join(__dirname, 'node_modules/three/examples/jsm')));
// API эндпоинт для расчета цены (без изменений)
const PRICE_CONFIG = {
edgeBandingPerMeter: 50,
baseWorkCost: 3000,
};
const MATERIALS_PRICES = {
'Белый': 1500,
'Серый': 1650,
'Черный': 1800,
'Образец1': 2200,
'Образец2': 2500,
};
const PANEL_THICKNESS = 16;
app.post('/api/calculate-price', (req, res) => {
const params = req.body;
const pricePerM2 = MATERIALS_PRICES[params.materialName];
if (!pricePerM2) {
return res.status(400).json({ error: 'Invalid material name' });
}
const { width, height, depth, columns, rows } = params;
const M_IN_MM = 1000;
let totalAreaM2 = 0;
let totalEdgeLengthM = 0;
const innerWidth = width - 2 * PANEL_THICKNESS;
const innerHeight = height - 2 * PANEL_THICKNESS;
const shelfDepth = depth - PANEL_THICKNESS;
totalAreaM2 += (2 * height * depth) / (M_IN_MM * M_IN_MM);
totalAreaM2 += (2 * width * depth) / (M_IN_MM * M_IN_MM);
totalEdgeLengthM += (2 * height + 2 * width) / M_IN_MM;
totalAreaM2 += (innerWidth * innerHeight) / (M_IN_MM * M_IN_MM);
const numDividers = columns - 1;
if (numDividers > 0) {
totalAreaM2 += (numDividers * innerHeight * shelfDepth) / (M_IN_MM * M_IN_MM);
totalEdgeLengthM += (numDividers * innerHeight) / M_IN_MM;
}
const numShelves = rows - 1;
if (numShelves > 0) {
const sectionWidth = (innerWidth - (numDividers * PANEL_THICKNESS)) / columns;
const totalShelfArea = (columns * numShelves * sectionWidth * shelfDepth) / (M_IN_MM * M_IN_MM);
totalAreaM2 += totalShelfArea;
const totalShelfEdgeLength = (columns * numShelves * sectionWidth) / M_IN_MM;
totalEdgeLengthM += totalShelfEdgeLength;
}
const materialCost = totalAreaM2 * pricePerM2;
const edgeBandingCost = totalEdgeLengthM * PRICE_CONFIG.edgeBandingPerMeter;
const finalPrice = PRICE_CONFIG.baseWorkCost + materialCost + edgeBandingCost;
res.json({ price: Math.round(finalPrice) });
});
app.listen(PORT, () => {
console.log(`Сервер конструктора мебели запущен на порту: ${PORT}`);
});

1277
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

16
package.json Normal file
View File

@ -0,0 +1,16 @@
{
"name": "3deditor_ai",
"version": "1.0.0",
"description": "",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^5.1.0"
}
}

BIN
public/assets/model.glb Normal file

Binary file not shown.

BIN
public/assets/wood1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
public/assets/wood2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

59
public/css/style.css Normal file
View File

@ -0,0 +1,59 @@
html, body {
width: 100%;
height: 100%;
overflow: hidden;
}
#app-container {
width: 100%;
height: 100%;
position: relative;
}
#canvas-container {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
body { margin: 0; font-family: sans-serif; }
canvas { display: block; }
#ui-panel {
position: absolute;
top: 20px;
right: 20px;
padding: 20px;
background-color: rgba(255, 255, 255, 0.9);
border-radius: 10px;
width: 320px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
z-index: 10; /* Ставим панель поверх 3D сцены */
}
.control-group { margin-bottom: 20px; border-bottom: 1px solid #e0e0e0; padding-bottom: 20px; }
.control-group:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
.control-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
.control-row label { font-weight: bold; }
.control-row input[type="range"] { width: 160px; }
.control-row input[type="number"] { width: 60px; text-align: center; }
.control-row span { min-width: 40px; text-align: right; }
h3, h4 { margin-top: 0; margin-bottom: 15px; text-align: center; color: #333; }
#material-selector { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
.material-swatch { width: 40px; height: 40px; border-radius: 50%; border: 2px solid #ccc; cursor: pointer; transition: transform 0.2s, border-color 0.2s; background-size: cover; }
.material-swatch:hover { transform: scale(1.1); }
.material-swatch.selected { border-color: #007bff; border-width: 3px; }
#price-container {
text-align: center;
padding-top: 15px;
}
#price-container .price-label {
font-size: 16px;
color: #555;
margin-bottom: 5px;
}
#price-container .price-value {
font-size: 28px;
font-weight: bold;
color: #d9534f;
}

68
public/index.html Normal file
View File

@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D Конструктор Шкафа</title>
<!-- Путь к CSS оставляем относительным -->
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<!-- UI панель без изменений -->
<div id="ui-panel">
<div class="control-group">
<h3>Габариты (мм)</h3>
<div class="control-row">
<label for="width-slider">Ширина</label>
<input type="range" id="width-slider" min="600" max="2400" value="1200" step="10">
<span id="width-value">1200</span>
</div>
<div class="control-row">
<label for="height-slider">Высота</label>
<input type="range" id="height-slider" min="800" max="2700" value="2000" step="10">
<span id="height-value">2000</span>
</div>
<div class="control-row">
<label for="depth-slider">Глубина</label>
<input type="range" id="depth-slider" min="300" max="800" value="500" step="10">
<span id="depth-value">500</span>
</div>
</div>
<div class="control-group">
<h4>Наполнение (ячейки)</h4>
<div class="control-row">
<label for="columns-input">Колонки</label>
<input type="number" id="columns-input" min="1" max="10" value="3">
</div>
<div class="control-row">
<label for="rows-input">Ряды</label>
<input type="number" id="rows-input" min="1" max="10" value="4">
</div>
</div>
<div class="control-group">
<h4>Материал корпуса</h4>
<div id="material-selector"></div>
</div>
<div id="price-container">
<div class="price-label">Итоговая стоимость</div>
<div class="price-value" id="price-display">0 ₽</div>
</div>
</div>
<!--
### ИЗМЕНЕНИЕ: Возвращаем importmap с абсолютными ссылками на внешний CDN ###
-->
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.165.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.165.0/examples/jsm/"
}
}
</script>
<!-- Путь к main.js оставляем относительным -->
<script type="module" src="js/main.js"></script>
</body>
</html>

284
public/js/main.js Normal file
View File

@ -0,0 +1,284 @@
// 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);
});