diff --git a/src/components/LayoutStep.tsx b/src/components/LayoutStep.tsx index 2fab6ad..c3f84f6 100644 --- a/src/components/LayoutStep.tsx +++ b/src/components/LayoutStep.tsx @@ -1,6 +1,6 @@ import React, { useRef, useState, useMemo } from 'react'; import { AppConfig, LayoutSplits } from '../types'; -import { Grid, MousePointer2, Trash2, RotateCcw } from 'lucide-react'; +import { Grid, MousePointer2, Trash2, RotateCcw, LayoutGrid, X } from 'lucide-react'; interface Props { config: AppConfig; @@ -9,24 +9,53 @@ interface Props { } type Axis = 'x' | 'y'; +type EditMode = 'lines' | 'cells'; export const LayoutStep: React.FC = ({ config, splits, onChange }) => { const svgRef = useRef(null); // State + const [mode, setMode] = useState('lines'); const [phantomAxis, setPhantomAxis] = useState(null); const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); const [hoveredSplit, setHoveredSplit] = useState<{ axis: Axis; index: number } | null>(null); const [dragging, setDragging] = useState<{ axis: Axis; index: number } | null>(null); + const [isButtonHovered, setIsButtonHovered] = useState(false); + + // New State for Cells + const [selectedCell, setSelectedCell] = useState<{ i: number, j: number } | null>(null); const viewBoxW = 1000; const aspectRatio = config.drawer.depth / config.drawer.width; const viewBoxH = viewBoxW * aspectRatio; + // Сортируем линии, чтобы понимать границы ячеек const sortedX = useMemo(() => [0, ...splits.x, 1].sort((a, b) => a - b), [splits.x]); const sortedY = useMemo(() => [0, ...splits.y, 1].sort((a, b) => a - b), [splits.y]); - // --- Глобальный обработчик (для пустого места и перетаскивания) --- + // --- Helpers for Subdivision --- + const updateSubdivision = (i: number, j: number, field: 'rows' | 'cols', delta: number) => { + const key = `${i}-${j}`; + const current = splits.subdivisions?.[key] || { rows: 1, cols: 1 }; + const newVal = Math.max(1, Math.min(10, current[field] + delta)); + + // Если 1x1, удаляем запись, чтобы не засорять + const newSubdivisions = { ...splits.subdivisions }; + + if (newVal === 1 && (field === 'rows' ? current.cols : current.rows) === 1) { + delete newSubdivisions[key]; + } else { + newSubdivisions[key] = { ...current, [field]: newVal }; + } + + onChange({ ...splits, subdivisions: newSubdivisions }); + }; + + const getSubdivision = (i: number, j: number) => { + return splits.subdivisions?.[`${i}-${j}`] || { rows: 1, cols: 1 }; + }; + + // --- Handlers --- const handleGlobalMouseMove = (e: React.MouseEvent) => { if (!svgRef.current) return; const rect = svgRef.current.getBoundingClientRect(); @@ -35,92 +64,70 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { setMousePos({ x: nx, y: ny }); - // 1. Если тащим - обновляем позицию - if (dragging) { - const newSplits = { - x: [...splits.x], - y: [...splits.y] - }; - const val = dragging.axis === 'x' ? nx : ny; - newSplits[dragging.axis][dragging.index] = val; - onChange(newSplits); - return; - } + if (mode === 'lines') { + if (dragging) { + const newSplits = { ...splits, x: [...splits.x], y: [...splits.y] }; + const val = dragging.axis === 'x' ? nx : ny; + newSplits[dragging.axis][dragging.index] = val; + onChange(newSplits); + return; + } - // 2. Если мы здесь, значит мышка НЕ на существующей линии - // (иначе событие было бы перехвачено в handleSplitHover) - setHoveredSplit(null); + setHoveredSplit(null); - // 3. Логика Фантомной линии (создание новой) - const SNAP_THRESHOLD = 0.02; - - // Проверяем, не слишком ли мы близко к краям или другим линиям (чтобы не спамить) - const closeToX = splits.x.some(val => Math.abs(nx - val) < SNAP_THRESHOLD); - const closeToY = splits.y.some(val => Math.abs(ny - val) < SNAP_THRESHOLD); - const closeToEdgeX = nx < SNAP_THRESHOLD || nx > (1 - SNAP_THRESHOLD); - const closeToEdgeY = ny < SNAP_THRESHOLD || ny > (1 - SNAP_THRESHOLD); + // Phantom Logic (Creation) + const SNAP_THRESHOLD = 0.02; + const closeToX = splits.x.some(val => Math.abs(nx - val) < SNAP_THRESHOLD); + const closeToY = splits.y.some(val => Math.abs(ny - val) < SNAP_THRESHOLD); + const closeToEdgeX = nx < SNAP_THRESHOLD || nx > (1 - SNAP_THRESHOLD); + const closeToEdgeY = ny < SNAP_THRESHOLD || ny > (1 - SNAP_THRESHOLD); - const distLeft = nx; - const distRight = 1 - nx; - const distTop = ny; - const distBottom = 1 - ny; + const distLeft = nx; const distRight = 1 - nx; + const distTop = ny; const distBottom = 1 - ny; + const minXDist = Math.min(distLeft, distRight); + const minYDist = Math.min(distTop, distBottom); - const minXDist = Math.min(distLeft, distRight); - const minYDist = Math.min(distTop, distBottom); + let potentialAxis: Axis = minXDist < minYDist ? 'y' : 'x'; + + let valid = true; + if (potentialAxis === 'x') { if (closeToX || closeToEdgeX) valid = false; } + else { if (closeToY || closeToEdgeY) valid = false; } - // Определяем ось по близости к краю - let potentialAxis: Axis = minXDist < minYDist ? 'y' : 'x'; - - let valid = true; - if (potentialAxis === 'x') { - if (closeToX || closeToEdgeX) valid = false; - } else { - if (closeToY || closeToEdgeY) valid = false; - } - - if (valid) { - setPhantomAxis(potentialAxis); - } else { - setPhantomAxis(null); + if (valid) setPhantomAxis(potentialAxis); + else setPhantomAxis(null); } }; - // --- Обработчик наведения на КОНКРЕТНУЮ линию --- const handleSplitHover = (e: React.MouseEvent, axis: Axis, index: number) => { - // Если мы тащим линию, позволяем событию всплыть до глобального обработчика, - // чтобы он посчитал координаты. + if (mode !== 'lines') return; if (dragging) return; - - // Если просто водим мышкой - блокируем всплытие, - // чтобы глобальный обработчик не думал, что мы в пустоте. e.stopPropagation(); - setHoveredSplit({ axis, index }); - setPhantomAxis(null); // Убираем фантом, раз мы на линии + setPhantomAxis(null); }; const handleMouseDown = (e: React.MouseEvent) => { - // Клик по существующей линии (hoveredSplit уже установлен через onMouseMove линии) - if (hoveredSplit) { - if (e.button === 0) { - setDragging(hoveredSplit); - } else if (e.button === 2) { - removeSplit(hoveredSplit.axis, hoveredSplit.index); - } - } - // Клик по пустому месту (создание) - else if (phantomAxis) { - const val = phantomAxis === 'x' ? mousePos.x : mousePos.y; - const newSplits = { ...splits }; - newSplits[phantomAxis] = [...newSplits[phantomAxis], val]; - onChange(newSplits); - setDragging({ axis: phantomAxis, index: newSplits[phantomAxis].length - 1 }); + if (mode === 'lines') { + if (hoveredSplit) { + if (e.button === 0) setDragging(hoveredSplit); + else if (e.button === 2) removeSplit(hoveredSplit.axis, hoveredSplit.index); + } else if (phantomAxis) { + const val = phantomAxis === 'x' ? mousePos.x : mousePos.y; + const newSplits = { ...splits }; + newSplits[phantomAxis] = [...newSplits[phantomAxis], val]; + onChange(newSplits); + setDragging({ axis: phantomAxis, index: newSplits[phantomAxis].length - 1 }); + } + } else { + // Mode === 'cells' + // Клик обрабатывается в самом rect ячейки, а здесь можно сбрасывать выделение + if (e.target === svgRef.current) { + setSelectedCell(null); + } } }; - const handleMouseUp = () => { - setDragging(null); - }; + const handleMouseUp = () => setDragging(null); const removeSplit = (axis: Axis, index: number) => { const newSplits = { ...splits }; @@ -128,34 +135,101 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { onChange(newSplits); setHoveredSplit(null); setDragging(null); + setIsButtonHovered(false); }; return ( -
-
+
+

2. Редактор макета

+ + {/* --- TOGGLE MODE --- */} +
+ + +
+
-
+
+ {/* Панель настроек выбранной ячейки (Появляется только в режиме Cells) */} + {mode === 'cells' && selectedCell && ( +
+
+ Настройка ячейки + +
+ +
+
+ КОЛОНКИ (X) +
+ + {getSubdivision(selectedCell.i, selectedCell.j).cols} + +
+
+
+ РЯДЫ (Y) +
+ + {getSubdivision(selectedCell.i, selectedCell.j).rows} + +
+
+
+
+ )} +
+ {/* Instruction Box */}
Инструкция
-
    -
  • Клик у края: Новая линия
  • -
  • Перетаскивание: Изменить размер
  • -
  • Двойной клик/ПКМ: Удалить
  • -
+ {mode === 'lines' ? ( +
    +
  • Клик у края: Новая линия
  • +
  • Драг: Переместить
  • +
  • ПКМ: Удалить
  • +
+ ) : ( +
    +
  • Клик по ячейке: Выбрать
  • +
  • Настрой деление в панели
  • +
+ )}
@@ -175,7 +249,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { width: '100%', maxWidth: '900px', aspectRatio: `${1/aspectRatio}`, - cursor: dragging ? 'grabbing' : hoveredSplit ? 'grab' : 'crosshair', + cursor: mode === 'lines' ? (dragging ? 'grabbing' : hoveredSplit ? 'grab' : 'crosshair') : 'default', maxHeight: '75vh' }} > @@ -196,7 +270,8 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { - {/* --- Labels --- */} + {/* --- CELLS & SUBDIVISIONS --- */} + {/* Рисуем ячейки ПЕРЕД линиями, чтобы ловить клики в режиме Cells */} {sortedX.slice(0, -1).map((x1, i) => { const x2 = sortedX[i + 1]; return sortedY.slice(0, -1).map((y1, j) => { @@ -206,34 +281,76 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { const centerX = ((x1 + x2) / 2) * viewBoxW; const centerY = ((y1 + y2) / 2) * viewBoxH; - const cellWidthSVG = (x2 - x1) * viewBoxW; - const cellHeightSVG = (y2 - y1) * viewBoxH; + const cellX = x1 * viewBoxW; + const cellY = y1 * viewBoxH; + const cellW = (x2 - x1) * viewBoxW; + const cellH = (y2 - y1) * viewBoxH; - let fontSize = Math.min(36, cellHeightSVG * 0.6); - fontSize = Math.min(fontSize, cellWidthSVG * 0.25); - - if (fontSize < 10) return null; + const isSelected = selectedCell?.i === i && selectedCell?.j === j; + const subdiv = getSubdivision(i, j); return ( - - {width.toFixed(0)} × {depth.toFixed(0)} - + + {/* Интерактивный прямоугольник ячейки */} + { + if (mode === 'cells') { + e.stopPropagation(); + setSelectedCell({ i, j }); + } + }} + /> + + {/* Отрисовка внутренних разделителей (Визуализация) */} + {subdiv.cols > 1 && Array.from({ length: subdiv.cols - 1 }).map((_, cI) => { + const splitX = cellX + (cellW / subdiv.cols) * (cI + 1); + return ( + + ); + })} + {subdiv.rows > 1 && Array.from({ length: subdiv.rows - 1 }).map((_, rI) => { + const splitY = cellY + (cellH / subdiv.rows) * (rI + 1); + return ( + + ); + })} + + {/* Текст размеров (Скрываем если ячейка разбита или слишком мелкая) */} + {subdiv.rows === 1 && subdiv.cols === 1 && ( + + {width.toFixed(0)} × {depth.toFixed(0)} + + )} + ); }); })} - {/* --- X Lines (Vertical) --- */} + {/* --- Main Grid Lines (X) --- */} {splits.x.map((x, i) => { const isHovered = hoveredSplit?.axis === 'x' && hoveredSplit.index === i; const isDragging = dragging?.axis === 'x' && dragging.index === i; @@ -244,26 +361,16 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { removeSplit('x', i)} - // ВАЖНО: Событие вешаем на группу onMouseMove={(e) => handleSplitHover(e, 'x', i)} + className={mode === 'lines' ? "cursor-col-resize" : ""} > - {/* Невидимая широкая зона захвата (80 единиц!) */} - - {/* Видимая линия */} - - {(isHovered || isDragging) && ( - { e.stopPropagation(); removeSplit('x', i); }}> - + + + {mode === 'lines' && (isHovered || isDragging) && ( + { e.stopPropagation(); removeSplit('x', i); }} + onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)} + > + )} @@ -271,7 +378,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { ); })} - {/* --- Y Lines (Horizontal) --- */} + {/* --- Main Grid Lines (Y) --- */} {splits.y.map((y, i) => { const isHovered = hoveredSplit?.axis === 'y' && hoveredSplit.index === i; const isDragging = dragging?.axis === 'y' && dragging.index === i; @@ -283,22 +390,15 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { key={`y-${i}`} onDoubleClick={() => removeSplit('y', i)} onMouseMove={(e) => handleSplitHover(e, 'y', i)} + className={mode === 'lines' ? "cursor-row-resize" : ""} > - - - {(isHovered || isDragging) && ( - { e.stopPropagation(); removeSplit('y', i); }}> - + + + {mode === 'lines' && (isHovered || isDragging) && ( + { e.stopPropagation(); removeSplit('y', i); }} + onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)} + > + )} @@ -307,30 +407,14 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { })} {/* --- Phantom Lines --- */} - {!hoveredSplit && !dragging && phantomAxis === 'x' && ( - - - - - + {mode === 'lines' && !hoveredSplit && !dragging && phantomAxis === 'x' && ( + + )} - {!hoveredSplit && !dragging && phantomAxis === 'y' && ( - - - - - + {mode === 'lines' && !hoveredSplit && !dragging && phantomAxis === 'y' && ( + + )} diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index 7dd5e51..23d5a4a 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -2,16 +2,12 @@ import * as THREE from 'three'; import { STLExporter, mergeBufferGeometries } from 'three-stdlib'; import { AppConfig, LayoutSplits, GeneratedPart } from '../types'; -/** - * Calculates the final list of bins based on layout. - */ export const calculateParts = ( config: AppConfig, splits: LayoutSplits ): GeneratedPart[] => { const parts: GeneratedPart[] = []; - // Сортируем линии разреза const xPoints = [0, ...[...splits.x].sort((a, b) => a - b), 1]; const yPoints = [0, ...[...splits.y].sort((a, b) => a - b), 1]; @@ -20,59 +16,69 @@ export const calculateParts = ( for (let i = 0; i < xPoints.length - 1; i++) { for (let j = 0; j < yPoints.length - 1; j++) { - const segmentX = xPoints[i] * config.drawer.width; - const segmentY = yPoints[j] * config.drawer.depth; - const segmentW = (xPoints[i + 1] - xPoints[i]) * config.drawer.width; - const segmentD = (yPoints[j + 1] - yPoints[j]) * config.drawer.depth; + // Глобальные размеры ячейки сетки + const rawX = xPoints[i] * config.drawer.width; + const rawY = yPoints[j] * config.drawer.depth; + const rawW = (xPoints[i + 1] - xPoints[i]) * config.drawer.width; + const rawD = (yPoints[j + 1] - yPoints[j]) * config.drawer.depth; - // Применяем зазор (Tolerance) - const realWidth = segmentW - config.printerTolerance; - const realDepth = segmentD - config.printerTolerance; - const realX = segmentX + (config.printerTolerance / 2); - const realY = segmentY + (config.printerTolerance / 2); + // Проверяем, есть ли разделение для этой ячейки + const subdiv = splits.subdivisions?.[`${i}-${j}`] || { rows: 1, cols: 1 }; + + // Вычисляем размер одной "под-ячейки" + // Делим общую ширину на кол-во колонок + const subCellWidth = rawW / subdiv.cols; + const subCellDepth = rawD / subdiv.rows; - // Игнорируем слишком мелкие детали - if (realWidth < 5 || realDepth < 5) { - continue; + // Генерируем под-ячейки + for (let r = 0; r < subdiv.rows; r++) { + for (let c = 0; c < subdiv.cols; c++) { + + const subX = rawX + (c * subCellWidth); + const subY = rawY + (r * subCellDepth); + + // Применяем Tolerance (зазор) к каждой микро-ячейке + const realWidth = subCellWidth - config.printerTolerance; + const realDepth = subCellDepth - config.printerTolerance; + const realX = subX + (config.printerTolerance / 2); + const realY = subY + (config.printerTolerance / 2); + + if (realWidth < 5 || realDepth < 5) continue; + + parts.push({ + id: `part-${partCounter}`, + name: `Ячейка ${i+1}-${j+1}` + (subdiv.rows > 1 || subdiv.cols > 1 ? ` (${r+1}x${c+1})` : ''), + width: realWidth, + depth: realDepth, + height: config.drawer.height, + x: realX, + y: realY, + color: `hsl(${Math.random() * 360}, 70%, 50%)` + }); + partCounter++; + } } - - parts.push({ - id: `part-${partCounter}`, - name: `Ячейка ${i+1}-${j+1}`, - width: realWidth, - depth: realDepth, - height: config.drawer.height, - x: realX, - y: realY, - color: `hsl(${Math.random() * 360}, 70%, 50%)` - }); - partCounter++; } } return parts; }; -/** - * Создает 2D форму прямоугольника со скругленными краями - */ +// ... Остальной код (createBinGeometry, exportSTL) остается без изменений ... +// (Копируй функции createRoundedRectShape, createBinGeometry и прочие из предыдущего файла, они не менялись) const createRoundedRectShape = (width: number, height: number, radius: number): THREE.Shape => { const shape = new THREE.Shape(); const x = -width / 2; const y = -height / 2; - - // Ограничиваем радиус, чтобы он не сломал геометрию (не больше половины стороны) const r = Math.min(radius, width / 2, height / 2); if (r <= 0.1) { - // Обычный прямоугольник (если радиус 0) shape.moveTo(x, y); shape.lineTo(x + width, y); shape.lineTo(x + width, y + height); shape.lineTo(x, y + height); shape.lineTo(x, y); } else { - // Прямоугольник со скруглениями shape.moveTo(x, y + r); shape.lineTo(x, y + height - r); shape.quadraticCurveTo(x, y + height, x + r, y + height); @@ -83,13 +89,9 @@ const createRoundedRectShape = (width: number, height: number, radius: number): shape.lineTo(x + r, y); shape.quadraticCurveTo(x, y, x, y + r); } - return shape; } -/** - * Генерирует 3D геометрию ящика - */ export const createBinGeometry = ( width: number, depth: number, @@ -97,25 +99,15 @@ export const createBinGeometry = ( thickness: number, radius: number = 0 ): THREE.BufferGeometry => { - - // 1. ГЕОМЕТРИЯ ДНА (Сплошная) const floorShape = createRoundedRectShape(width, depth, radius); - const floorGeo = new THREE.ExtrudeGeometry(floorShape, { - depth: thickness, // Выдавливаем на толщину дна + depth: thickness, bevelEnabled: false, - curveSegments: 16 // Количество сегментов на скруглениях + curveSegments: 16 }); - - // Extrude выдавливает по оси Z. Нам нужно повернуть, чтобы "глубина" стала "высотой" (Y). - // Поворот на -90 градусов вокруг X кладет Z на Y. floorGeo.rotateX(-Math.PI / 2); - // Теперь дно занимает пространство от Y=0 до Y=thickness. - // 2. ГЕОМЕТРИЯ СТЕНОК (С дыркой) const outerShape = createRoundedRectShape(width, depth, radius); - - // Вырезаем внутреннюю часть const innerRadius = Math.max(0, radius - thickness); const innerWidth = width - (2 * thickness); const innerDepth = depth - (2 * thickness); @@ -125,32 +117,18 @@ export const createBinGeometry = ( outerShape.holes.push(innerHole); } - // Высота стенок = общая высота минус толщина дна const wallHeight = height - thickness; - const wallGeo = new THREE.ExtrudeGeometry(outerShape, { depth: wallHeight, bevelEnabled: false, curveSegments: 16 }); - // Поворачиваем стенки так же, как дно wallGeo.rotateX(-Math.PI / 2); - - // Сейчас стенки тоже начинаются с Y=0. - // Нам нужно поднять их НАД дном. wallGeo.translate(0, thickness, 0); - - // Теперь стенки занимают пространство от Y=thickness до Y=height. - // 3. ОБЪЕДИНЕНИЕ - // Сливаем две геометрии в одну. Слайсеры поймут это как единый объект, - // так как поверхности идеально соприкасаются. const merged = mergeBufferGeometries([floorGeo, wallGeo]); - - // Центрирование не нужно, так как createRoundedRectShape строит форму вокруг (0,0) по X и Z. - // А по Y мы выстроили от 0 вверх. - // Pivot point (опорная точка) осталась внизу в центре (0,0,0), что идеально для позиционирования. + if (merged) merged.computeVertexNormals(); return merged || new THREE.BoxGeometry(1, 1, 1); }; diff --git a/src/types.ts b/src/types.ts index d3c2012..f2a6a34 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,12 +8,20 @@ export interface AppConfig { drawer: DrawerDimensions; wallThickness: number; printerTolerance: number; - cornerRadius: number; // <--- Новое свойство + cornerRadius: number; +} + +// Конфигурация разделения одной ячейки +export interface CellSubdivision { + rows: number; // По умолчанию 1 + cols: number; // По умолчанию 1 } export interface LayoutSplits { x: number[]; y: number[]; + // Ключ: "xIndex-yIndex" (например "0-0" для первой ячейки) + subdivisions: Record; } export interface GeneratedPart {