diff --git a/src/components/LayoutStep.tsx b/src/components/LayoutStep.tsx index 80a3ef8..ffd94b8 100644 --- a/src/components/LayoutStep.tsx +++ b/src/components/LayoutStep.tsx @@ -50,7 +50,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { const sortedX = useMemo(() => [0, ...safeX, 1].sort((a, b) => a - b), [safeX]); const sortedY = useMemo(() => [0, ...safeY, 1].sort((a, b) => a - b), [safeY]); - // -- HELPERS -- + // -- HELPER: Get Selected -- const getSelectedPartition = () => { if (!selectedPartitionId) return null; for (const key in safePartitions) { @@ -61,13 +61,11 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { }; const selectedData = getSelectedPartition(); - // --- SOLVER (Пересчет границ для корректного 3D и UI) --- + // --- SOLVER (Копия из geometryGenerator) --- const solveWallLimits = (parts: Partition[]): LimitMap => { const limits: LimitMap = {}; - // Изначально считаем, что все стенки от края до края (0-1) parts.forEach(p => { limits[p.id] = { min: 0, max: 1 }; }); - // Несколько проходов для решения вложенных зависимостей for (let pass = 0; pass < 4; pass++) { parts.forEach(target => { let newMin = 0; @@ -77,12 +75,11 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { parts.forEach(obstacle => { if (target.id === obstacle.id || target.axis === obstacle.axis) return; - // Берем границы препятствия с предыдущего шага const obsMin = limits[obstacle.id].min; const obsMax = limits[obstacle.id].max; - // Если препятствие пересекает наш путь - if (target.offset > obsMin && target.offset < obsMax) { + // Используем >= и <= для надежности + if (target.offset >= obsMin - 0.001 && target.offset <= obsMax + 0.001) { if (obstacle.offset < center) newMin = Math.max(newMin, obstacle.offset); else if (obstacle.offset > center) newMax = Math.min(newMax, obstacle.offset); } @@ -93,59 +90,47 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { return limits; }; - // --- RAYCASTING: Поиск свободного места под курсором --- - // Это исправляет проблему, когда линия рисуется сквозь стенки + // --- RAYCASTING (Исправленный поиск свободного места) --- const getCursorBox = (lx: number, ly: number, parts: Partition[], limitMap: LimitMap) => { - // Начинаем с границ всей ячейки let minX = 0, maxX = 1; let minY = 0, maxY = 1; - // Проверяем каждую стенку как потенциальное препятствие parts.forEach(p => { - // Берем "решенные" границы стенки, чтобы знать, где она реально находится const { min: pMin, max: pMax } = limitMap[p.id]; - - // Если стенка схлопнулась (ошибка расчета), игнорируем её if (pMax - pMin < 0.001) return; + // Используем те же допуски, что и в Solver + const EPSILON = 0.001; + if (p.axis === 'x') { - // Вертикальная стенка. Это препятствие по оси X. - // Проверяем, находится ли наш курсор (ly) в диапазоне высоты этой стенки? - if (ly >= pMin && ly <= pMax) { - // Стенка на нашем уровне по Y. Она слева или справа? - if (p.offset < lx) { - minX = Math.max(minX, p.offset); // Ближайшая стенка слева - } else { - maxX = Math.min(maxX, p.offset); // Ближайшая стенка справа - } + // Вертикальная преграда (X) + // Проверяем, перекрывает ли она Y курсора + if (ly >= pMin - EPSILON && ly <= pMax + EPSILON) { + if (p.offset < lx) minX = Math.max(minX, p.offset); + if (p.offset > lx) maxX = Math.min(maxX, p.offset); } } else { - // Горизонтальная стенка. Препятствие по оси Y. - // Проверяем, находится ли наш курсор (lx) в диапазоне ширины этой стенки? - if (lx >= pMin && lx <= pMax) { - // Стенка на нашем уровне по X. Она сверху или снизу? - if (p.offset < ly) { - minY = Math.max(minY, p.offset); // Ближайшая стенка сверху - } else { - maxY = Math.min(maxY, p.offset); // Ближайшая стенка снизу - } + // Горизонтальная преграда (Y) + // Проверяем, перекрывает ли она X курсора + if (lx >= pMin - EPSILON && lx <= pMax + EPSILON) { + if (p.offset < ly) minY = Math.max(minY, p.offset); + if (p.offset > ly) maxY = Math.min(maxY, p.offset); } } }); - return { minX, maxX, minY, maxY }; }; - // Поиск соседей для отображения размеров (аналогичная логика) - const getNeighborOffsets = (offset: number, crossCenter: number, axis: Axis, parts: Partition[], limitMap: LimitMap) => { + // Поиск соседей для отображения размеров + const getNeighborOffsets = (offset: number, crossPos: number, axis: Axis, parts: Partition[], limitMap: LimitMap) => { let min = 0; let max = 1; + const EPSILON = 0.001; parts.forEach(p => { - if (p.axis === axis) { // Ищем параллельные стенки + if (p.axis === axis) { const { min: pMin, max: pMax } = limitMap[p.id]; - // Если проекции пересекаются - if (crossCenter > pMin && crossCenter < pMax) { + if (crossPos >= pMin - EPSILON && crossPos <= pMax + EPSILON) { if (p.offset < offset) min = Math.max(min, p.offset); if (p.offset > offset) max = Math.min(max, p.offset); } @@ -250,7 +235,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { setHoveredCell(cellIdx); const key = `${cellIdx.i}-${cellIdx.j}`; const parts = safePartitions[key] || []; - const limitMap = solveWallLimits(parts); // Сначала решаем геометрию + const limitMap = solveWallLimits(parts); const cx1 = sortedX[cellIdx.i]; const cx2 = sortedX[cellIdx.i+1]; const cy1 = sortedY[cellIdx.j]; const cy2 = sortedY[cellIdx.j+1]; @@ -260,7 +245,6 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { let found = null; const SNAP = 0.05; - // Проверяем наведение на существующие стенки for (const p of parts) { const { min, max } = limitMap[p.id]; if (p.axis === 'x') { @@ -273,25 +257,24 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { if (found) { setHoveredPartition({ id: found.id, cellKey: key }); } else { - // --- КЛЮЧЕВОЙ МОМЕНТ: Raycasting для фантома --- + // --- ВАЖНО: Получаем корректные границы с учетом Solver --- const box = getCursorBox(lx, ly, parts, limitMap); + const distL = lx - box.minX; const distR = box.maxX - lx; + const distT = ly - box.minY; const distB = box.maxY - ly; + + // Выбираем ось перпендикулярно ближайшей стороне + const minD = Math.min(distL, distR, distT, distB); + const newAxis = (minD === distL || minD === distR) ? 'x' : 'y'; + const width = box.maxX - box.minX; const height = box.maxY - box.minY; - // Логика авто-выбора оси: делим более длинную сторону "комнаты" - // Если комната широкая -> ставим вертикальную (X) - // Если комната высокая -> ставим горизонтальную (Y) - const axis = width > height ? 'x' : 'y'; - - // Разрешаем рисовать, если есть хоть немного места (>5%) - if ((axis === 'x' && width > 0.05) || (axis === 'y' && height > 0.05)) { - const offset = axis === 'x' ? lx : ly; - // Границы новой стенки — это перпендикулярные стенки коробки - const min = axis === 'x' ? box.minY : box.minX; - const max = axis === 'x' ? box.maxY : box.maxX; - - setPhantomPartition({ axis, offset, min, max }); + if ((newAxis === 'y' && height > 0.05) || (newAxis === 'x' && width > 0.05)) { + const offset = newAxis === 'x' ? lx : ly; + const min = newAxis === 'x' ? box.minY : box.minX; + const max = newAxis === 'x' ? box.maxY : box.maxX; + setPhantomPartition({ axis: newAxis, offset, min, max }); } } } @@ -353,6 +336,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { const realW = (x2 - x1) * drawerW; const realD = (y2 - y1) * drawerD; + // ВАЖНО: Используем solver для отрисовки const limitMap = solveWallLimits(parts); // Label @@ -389,19 +373,20 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { lx1 = px; lx2 = px; ly1 = cellY + (cellH * min); ly2 = cellY + (cellH * max); - // Для размеров ищем границы относительно центра отрисованной линии - const neighbors = getNeighborOffsets(p.offset, (min + max)/2, p.axis, parts, limitMap); - dist1 = Math.abs((p.offset - neighbors.min) * realW) - wallThick; - dist2 = Math.abs((neighbors.max - p.offset) * realW) - wallThick; + const cy = (min + max) / 2; + const box = getCursorBox(p.offset, cy, parts, limitMap); + dist1 = Math.abs((p.offset - box.minX) * realW) - wallThick; + dist2 = Math.abs((box.maxX - p.offset) * realW) - wallThick; midX = px; midY = (ly1 + ly2) / 2; } else { const py = cellY + (cellH * p.offset); ly1 = py; ly2 = py; lx1 = cellX + (cellW * min); lx2 = cellX + (cellW * max); - const neighbors = getNeighborOffsets(p.offset, (min + max)/2, p.axis, parts, limitMap); - dist1 = Math.abs((p.offset - neighbors.min) * realD) - wallThick; - dist2 = Math.abs((neighbors.max - p.offset) * realD) - wallThick; + const cx = (min + max) / 2; + const box = getCursorBox(cx, p.offset, parts, limitMap); + dist1 = Math.abs((p.offset - box.minY) * realD) - wallThick; + dist2 = Math.abs((box.maxY - p.offset) * realD) - wallThick; midX = (lx1 + lx2) / 2; midY = py; } diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index 9365a35..affa06a 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -5,29 +5,39 @@ import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; type Limits = { min: number; max: number }; type LimitMap = Record; -// --- SOLVER (Устраняет пересечения стенок) --- +// --- SOLVER: Итеративный расчет границ (Улучшенный) --- const solveWallLimits = (partitions: Partition[]): LimitMap => { const limits: LimitMap = {}; + // 1. Инициализация partitions.forEach(p => { limits[p.id] = { min: 0, max: 1 }; }); - // 3 прохода для надежности вложенных структур - for (let pass = 0; pass < 3; pass++) { + // 2. Итерации (4 прохода для стабилизации сложных вложений) + for (let pass = 0; pass < 4; pass++) { partitions.forEach(target => { let newMin = 0; let newMax = 1; const center = target.offset; partitions.forEach(obstacle => { - if (target.id === obstacle.id || target.axis === obstacle.axis) return; + if (target.id === obstacle.id) return; + if (target.axis === obstacle.axis) return; // Параллельные не влияют + // Берем границы препятствия с предыдущего шага const obsMin = limits[obstacle.id].min; const obsMax = limits[obstacle.id].max; - if (target.offset > obsMin && target.offset < obsMax) { - if (obstacle.offset < center) newMin = Math.max(newMin, obstacle.offset); - else if (obstacle.offset > center) newMax = Math.min(newMax, obstacle.offset); + // Важно: используем >= и <= с небольшим запасом для надежности + // Пересекает ли препятствие линию нашей стенки? + if (target.offset >= obsMin - 0.001 && target.offset <= obsMax + 0.001) { + // Препятствие на пути. Где оно относительно центра нашей стенки? + if (obstacle.offset < center) { + newMin = Math.max(newMin, obstacle.offset); + } else if (obstacle.offset > center) { + newMax = Math.min(newMax, obstacle.offset); + } } }); + limits[target.id] = { min: newMin, max: newMax }; }); } @@ -106,13 +116,10 @@ const createRoundedRectShape = (width: number, height: number, radius: number): return shape; }; -// Правильная форма "обратного" скругления (вогнутая) const createConcaveFilletShape = (radius: number): THREE.Shape => { const shape = new THREE.Shape(); - // Рисуем квадрат (0,0) -> (r,r), но вырезаем из него круг shape.moveTo(0, 0); shape.lineTo(radius, 0); - // Дуга: центр (r,r), радиус r, от 270 (-PI/2) до 180 (-PI) градусов shape.absarc(radius, radius, radius, -Math.PI / 2, -Math.PI, true); shape.lineTo(0, 0); return shape; @@ -123,7 +130,6 @@ export const createBinGeometry = ( ): THREE.BufferGeometry => { const geometries: THREE.BufferGeometry[] = []; - // ДНО И ВНЕШНИЕ СТЕНКИ const floorShape = createRoundedRectShape(width, depth, radius); const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); floorGeo.rotateX(-Math.PI / 2); @@ -145,7 +151,7 @@ export const createBinGeometry = ( wallGeo.translate(0, thickness, 0); geometries.push(wallGeo); - // ВНУТРЕННИЕ ПЕРЕГОРОДКИ + // Используем солвер для расчета реальных границ const limitMap = solveWallLimits(partitions); partitions.forEach(p => { @@ -175,22 +181,15 @@ export const createBinGeometry = ( partGeo.translate(pX, thickness, pY); geometries.push(partGeo); - // --- ДОБАВЛЕНИЕ ГАЛТЕЛЕЙ (FILLETS) --- if (p.rounded && radius > 1) { const filletR = Math.min(radius, 5); const filletShape = createConcaveFilletShape(filletR); const filletExtrude = { depth: p.height, bevelEnabled: false }; - const addFillet = (x: number, y: number, rotationY: number) => { + const addFillet = (x: number, y: number, rotY: number) => { const geo = new THREE.ExtrudeGeometry(filletShape, filletExtrude); - // Сначала кладем на пол (-PI/2 по X) - // Потом вращаем вокруг оси Y (которая теперь смотрит вверх) geo.rotateX(-Math.PI / 2); - - // ВАЖНО: Вращение геометрии происходит вокруг (0,0,0). - // Наша форма галтели имеет угол в (0,0). Это идеально. - geo.rotateY(rotationY); - + geo.rotateY(rotY); geo.translate(x, thickness, y); geometries.push(geo); }; @@ -198,44 +197,20 @@ export const createBinGeometry = ( const h = thickness / 2; if (p.axis === 'x') { - // Вертикальная стенка (вдоль Z) - // Top (Min Y) const topY = (-innerDepth / 2) + (innerDepth * pMin); - // Углы: Слева (-X) и Справа (+X) - - // Left-Top: смотрит в (-X, +Z). Угол PI (180) addFillet(pX - h, topY, Math.PI); - - // Right-Top: смотрит в (+X, +Z). Угол -PI/2 (-90) addFillet(pX + h, topY, -Math.PI / 2); - // Bottom (Max Y) const botY = (-innerDepth / 2) + (innerDepth * pMax); - - // Left-Bottom: смотрит в (-X, -Z). Угол PI/2 (90) addFillet(pX - h, botY, Math.PI / 2); - - // Right-Bottom: смотрит в (+X, -Z). Угол 0 addFillet(pX + h, botY, 0); - } else { - // Горизонтальная стенка (вдоль X) - // Left (Min X) const leftX = (-innerWidth / 2) + (innerWidth * pMin); - - // Top-Left: смотрит в (+X, -Z). Угол 0 addFillet(leftX, pY - h, 0); - - // Bottom-Left: смотрит в (+X, +Z). Угол -PI/2 (-90) addFillet(leftX, pY + h, -Math.PI / 2); - // Right (Max X) const rightX = (-innerWidth / 2) + (innerWidth * pMax); - - // Top-Right: смотрит в (-X, -Z). Угол PI/2 (90) addFillet(rightX, pY - h, Math.PI / 2); - - // Bottom-Right: смотрит в (-X, +Z). Угол PI (180) addFillet(rightX, pY + h, Math.PI); } }