From 8999d1f06ace61d8a756d208b5bd68010a2ef5f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Sat, 27 Dec 2025 22:59:31 +0300 Subject: [PATCH 01/32] Added split in to cell --- src/components/LayoutStep.tsx | 402 ++++++++++++++++++------------ src/services/geometryGenerator.ts | 110 ++++---- src/types.ts | 10 +- 3 files changed, 296 insertions(+), 226 deletions(-) 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 { From 6c6c859a3bcb57e2c06a1675f023b5333b7422ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Sat, 27 Dec 2025 23:06:22 +0300 Subject: [PATCH 02/32] Fixed layout step --- src/components/LayoutStep.tsx | 369 +++++++++++++----------------- src/services/geometryGenerator.ts | 25 +- src/types.ts | 8 +- 3 files changed, 169 insertions(+), 233 deletions(-) diff --git a/src/components/LayoutStep.tsx b/src/components/LayoutStep.tsx index c3f84f6..e963ce4 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, LayoutGrid, X } from 'lucide-react'; +import { Grid, MousePointer2, Trash2, RotateCcw, LayoutGrid, X, Plus, Minus, SplitSquareVertical, SplitSquareHorizontal } from 'lucide-react'; interface Props { config: AppConfig; @@ -8,54 +8,55 @@ interface Props { onChange: (splits: LayoutSplits) => void; } -type Axis = 'x' | 'y'; type EditMode = 'lines' | 'cells'; +type Axis = 'x' | 'y'; export const LayoutStep: React.FC = ({ config, splits, onChange }) => { const svgRef = useRef(null); - // State + // Режимы: 'lines' (двигать линии) или 'cells' (дробить ячейки) 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 updateSubdivision = (i: number, j: number, type: 'rows' | 'cols', delta: number) => { + const key = `${i}-${j}`; + const current = getSubdivision(i, j); + const newVal = Math.max(1, Math.min(10, current[type] + delta)); + + const newSubdivisions = { ...splits.subdivisions }; + + // Если вернулись к 1x1, удаляем запись для чистоты + if (newVal === 1 && (type === 'rows' ? current.cols : current.rows) === 1) { + delete newSubdivisions[key]; + } else { + newSubdivisions[key] = { ...current, [type]: newVal }; + } + + onChange({ ...splits, subdivisions: newSubdivisions }); + }; + + // --- Обработчики мыши --- const handleGlobalMouseMove = (e: React.MouseEvent) => { if (!svgRef.current) return; const rect = svgRef.current.getBoundingClientRect(); @@ -72,35 +73,31 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { onChange(newSplits); return; } + + if (isButtonHovered) return; setHoveredSplit(null); - // 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 SNAP = 0.02; + const closeToX = splits.x.some(val => Math.abs(nx - val) < SNAP); + const closeToY = splits.y.some(val => Math.abs(ny - val) < SNAP); + const closeToEdgeX = nx < SNAP || nx > (1 - SNAP); + const closeToEdgeY = ny < SNAP || ny > (1 - SNAP); - 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); - - 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 (!closeToX && !closeToY && !closeToEdgeX && !closeToEdgeY) { + const distRight = 1 - nx; const distBottom = 1 - ny; + const minXDist = Math.min(nx, distRight); + const minYDist = Math.min(ny, distBottom); + setPhantomAxis(minXDist < minYDist ? 'y' : 'x'); + } else { + setPhantomAxis(null); + } } }; const handleSplitHover = (e: React.MouseEvent, axis: Axis, index: number) => { - if (mode !== 'lines') return; - if (dragging) return; + if (mode !== 'lines' || dragging) return; e.stopPropagation(); setHoveredSplit({ axis, index }); setPhantomAxis(null); @@ -108,10 +105,10 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { const handleMouseDown = (e: React.MouseEvent) => { if (mode === 'lines') { - if (hoveredSplit) { + if (hoveredSplit && !isButtonHovered) { if (e.button === 0) setDragging(hoveredSplit); else if (e.button === 2) removeSplit(hoveredSplit.axis, hoveredSplit.index); - } else if (phantomAxis) { + } else if (phantomAxis && !isButtonHovered) { const val = phantomAxis === 'x' ? mousePos.x : mousePos.y; const newSplits = { ...splits }; newSplits[phantomAxis] = [...newSplits[phantomAxis], val]; @@ -119,16 +116,11 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { setDragging({ axis: phantomAxis, index: newSplits[phantomAxis].length - 1 }); } } else { - // Mode === 'cells' - // Клик обрабатывается в самом rect ячейки, а здесь можно сбрасывать выделение - if (e.target === svgRef.current) { - setSelectedCell(null); - } + // В режиме ячеек сбрасываем выделение при клике в пустоту + if (e.target === svgRef.current) setSelectedCell(null); } }; - const handleMouseUp = () => setDragging(null); - const removeSplit = (axis: Axis, index: number) => { const newSplits = { ...splits }; newSplits[axis] = newSplits[axis].filter((_, i) => i !== index); @@ -140,24 +132,25 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { return (
+ + {/* --- HEADER CONTROLS --- */}

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

- {/* --- TOGGLE MODE --- */}
@@ -165,73 +158,33 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { onClick={() => onChange({ x: [], y: [], subdivisions: {} })} className="px-3 py-1 text-xs bg-slate-800 text-red-400 hover:text-red-300 rounded hover:bg-slate-700 border border-slate-700 flex items-center gap-1 transition-colors" > - Сбросить + Сбросить всё
- {/* Панель настроек выбранной ячейки (Появляется только в режиме Cells) */} - {mode === 'cells' && selectedCell && ( -
-
- Настройка ячейки - -
- -
-
- КОЛОНКИ (X) -
- - {getSubdivision(selectedCell.i, selectedCell.j).cols} - -
-
-
- РЯДЫ (Y) -
- - {getSubdivision(selectedCell.i, selectedCell.j).rows} - -
-
-
-
- )} -
- {/* Instruction Box */} + {/* Instruction Overlay */}
- Инструкция + Режим: {mode === 'lines' ? 'Границы' : 'Ячейки'}
{mode === 'lines' ? ( -
    -
  • Клик у края: Новая линия
  • -
  • Драг: Переместить
  • -
  • ПКМ: Удалить
  • +
      +
    • Клик: Новая линия
    • +
    • Драг: Двигать линию
    • +
    • ПКМ: Удалить линию
    ) : ( -
      +
      • Клик по ячейке: Выбрать
      • -
      • Настрой деление в панели
      • +
      • Используй меню для деления внутри
      )}
+ {/* Rulers */}
0 {config.drawer.width} мм @@ -243,8 +196,9 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { {config.drawer.depth} мм
+ {/* SVG Container */}
= ({ config, splits, onChange }) => { className="w-full h-full touch-none" onMouseMove={handleGlobalMouseMove} onMouseDown={handleMouseDown} - onMouseUp={handleMouseUp} - onMouseLeave={handleMouseUp} + onMouseUp={() => setDragging(null)} onContextMenu={(e) => e.preventDefault()} > @@ -270,34 +223,27 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { - {/* --- CELLS & SUBDIVISIONS --- */} - {/* Рисуем ячейки ПЕРЕД линиями, чтобы ловить клики в режиме Cells */} + {/* --- РЕНДЕРИНГ ЯЧЕЕК --- */} {sortedX.slice(0, -1).map((x1, i) => { const x2 = sortedX[i + 1]; return sortedY.slice(0, -1).map((y1, j) => { - const y2 = sortedY[j + 1]; - const width = (x2 - x1) * config.drawer.width; - const depth = (y2 - y1) * config.drawer.depth; - const centerX = ((x1 + x2) / 2) * viewBoxW; - const centerY = ((y1 + y2) / 2) * viewBoxH; - const cellX = x1 * viewBoxW; const cellY = y1 * viewBoxH; const cellW = (x2 - x1) * viewBoxW; const cellH = (y2 - y1) * viewBoxH; - + const isSelected = selectedCell?.i === i && selectedCell?.j === j; const subdiv = getSubdivision(i, j); return ( - {/* Интерактивный прямоугольник ячейки */} + {/* Прямоугольник ячейки */} { if (mode === 'cells') { e.stopPropagation(); @@ -306,118 +252,111 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { }} /> - {/* Отрисовка внутренних разделителей (Визуализация) */} + {/* Внутренние пунктирные линии */} {subdiv.cols > 1 && Array.from({ length: subdiv.cols - 1 }).map((_, cI) => { const splitX = cellX + (cellW / subdiv.cols) * (cI + 1); - return ( - - ); + return ; })} {subdiv.rows > 1 && Array.from({ length: subdiv.rows - 1 }).map((_, rI) => { const splitY = cellY + (cellH / subdiv.rows) * (rI + 1); - return ( - - ); + return ; })} - {/* Текст размеров (Скрываем если ячейка разбита или слишком мелкая) */} + {/* РАЗМЕРЫ: показываем только если ячейка не разбита */} {subdiv.rows === 1 && subdiv.cols === 1 && ( - {width.toFixed(0)} × {depth.toFixed(0)} + {((x2 - x1) * config.drawer.width).toFixed(0)}×{((y2 - y1) * config.drawer.depth).toFixed(0)} )} ); }); })} - - {/* --- 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; - const color = isHovered || isDragging ? '#f59e0b' : '#64748b'; - const width = isHovered || isDragging ? 8 : 4; - return ( - removeSplit('x', i)} - onMouseMove={(e) => handleSplitHover(e, 'x', i)} - className={mode === 'lines' ? "cursor-col-resize" : ""} - > - - - {mode === 'lines' && (isHovered || isDragging) && ( - { e.stopPropagation(); removeSplit('x', i); }} - onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)} - > - - - - )} - - ); - })} + {/* --- ЛИНИИ СЕТКИ (Поверх ячеек) --- */} + {splits.x.map((x, i) => ( + handleSplitHover(e, 'x', i)}> + + + {mode === 'lines' && hoveredSplit?.axis === 'x' && hoveredSplit.index === i && ( + { e.stopPropagation(); removeSplit('x', i); }} + onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)} + > + + + + )} + + ))} - {/* --- 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; - const color = isHovered || isDragging ? '#f59e0b' : '#64748b'; - const width = isHovered || isDragging ? 8 : 4; + {splits.y.map((y, i) => ( + handleSplitHover(e, 'y', i)}> + + + {mode === 'lines' && hoveredSplit?.axis === 'y' && hoveredSplit.index === i && ( + { e.stopPropagation(); removeSplit('y', i); }} + onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)} + > + + + + )} + + ))} - return ( - removeSplit('y', i)} - onMouseMove={(e) => handleSplitHover(e, 'y', i)} - className={mode === 'lines' ? "cursor-row-resize" : ""} - > - - - {mode === 'lines' && (isHovered || isDragging) && ( - { e.stopPropagation(); removeSplit('y', i); }} - onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)} - > - - - - )} - - ); - })} - - {/* --- Phantom Lines --- */} - {mode === 'lines' && !hoveredSplit && !dragging && phantomAxis === 'x' && ( - - - - )} - {mode === 'lines' && !hoveredSplit && !dragging && phantomAxis === 'y' && ( - - - + {/* Фантомная линия */} + {mode === 'lines' && !hoveredSplit && !dragging && phantomAxis && ( + )} + + {/* --- КОНТРОЛЫ ЯЧЕЙКИ (Поверх SVG) --- */} + {mode === 'cells' && selectedCell && ( +
e.stopPropagation()} // Чтобы клик не снимал выделение + > + {/* Ряды (Горизонтально) */} +
+ + + {getSubdivision(selectedCell.i, selectedCell.j).cols} + +
+ + {/* Колонки (Вертикально) */} +
+ + + {getSubdivision(selectedCell.i, selectedCell.j).rows} + +
+ + +
+ )} +
diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index 23d5a4a..a792c78 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -16,17 +16,14 @@ export const calculateParts = ( for (let i = 0; i < xPoints.length - 1; i++) { for (let j = 0; j < yPoints.length - 1; j++) { - // Глобальные размеры ячейки сетки 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; - // Проверяем, есть ли разделение для этой ячейки + // Получаем настройки деления для этой ячейки const subdiv = splits.subdivisions?.[`${i}-${j}`] || { rows: 1, cols: 1 }; - // Вычисляем размер одной "под-ячейки" - // Делим общую ширину на кол-во колонок const subCellWidth = rawW / subdiv.cols; const subCellDepth = rawD / subdiv.rows; @@ -37,7 +34,6 @@ export const calculateParts = ( 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); @@ -45,9 +41,15 @@ export const calculateParts = ( if (realWidth < 5 || realDepth < 5) continue; + // Формируем имя: если ячейка поделена, добавляем индексы (1-1, 1-2...) + let partName = `Ячейка ${i+1}-${j+1}`; + if (subdiv.rows > 1 || subdiv.cols > 1) { + partName += ` (${r+1}-${c+1})`; + } + parts.push({ id: `part-${partCounter}`, - name: `Ячейка ${i+1}-${j+1}` + (subdiv.rows > 1 || subdiv.cols > 1 ? ` (${r+1}x${c+1})` : ''), + name: partName, width: realWidth, depth: realDepth, height: config.drawer.height, @@ -64,8 +66,7 @@ export const calculateParts = ( return parts; }; -// ... Остальной код (createBinGeometry, exportSTL) остается без изменений ... -// (Копируй функции createRoundedRectShape, createBinGeometry и прочие из предыдущего файла, они не менялись) +// ... Вспомогательные функции генерации геометрии (без изменений) ... const createRoundedRectShape = (width: number, height: number, radius: number): THREE.Shape => { const shape = new THREE.Shape(); const x = -width / 2; @@ -101,9 +102,7 @@ export const createBinGeometry = ( ): THREE.BufferGeometry => { const floorShape = createRoundedRectShape(width, depth, radius); const floorGeo = new THREE.ExtrudeGeometry(floorShape, { - depth: thickness, - bevelEnabled: false, - curveSegments: 16 + depth: thickness, bevelEnabled: false, curveSegments: 16 }); floorGeo.rotateX(-Math.PI / 2); @@ -119,9 +118,7 @@ export const createBinGeometry = ( const wallHeight = height - thickness; const wallGeo = new THREE.ExtrudeGeometry(outerShape, { - depth: wallHeight, - bevelEnabled: false, - curveSegments: 16 + depth: wallHeight, bevelEnabled: false, curveSegments: 16 }); wallGeo.rotateX(-Math.PI / 2); diff --git a/src/types.ts b/src/types.ts index f2a6a34..cc49616 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,16 +11,16 @@ export interface AppConfig { cornerRadius: number; } -// Конфигурация разделения одной ячейки +// Новая структура: сколько рядов и колонок внутри конкретной ячейки export interface CellSubdivision { - rows: number; // По умолчанию 1 - cols: number; // По умолчанию 1 + rows: number; // горизонтальные ряды + cols: number; // вертикальные колонки } export interface LayoutSplits { x: number[]; y: number[]; - // Ключ: "xIndex-yIndex" (например "0-0" для первой ячейки) + // Ключ - это индекс ячейки "xIndex-yIndex" (например "0-0") subdivisions: Record; } From 19754baaaecd083ad37bfb6c404f56a7077afc18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Sat, 27 Dec 2025 23:10:24 +0300 Subject: [PATCH 03/32] Fix --- src/App.tsx | 14 +++++--- src/components/LayoutStep.tsx | 68 +++++++++++++---------------------- src/types.ts | 7 ++-- 3 files changed, 37 insertions(+), 52 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 0120272..b1990e4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,12 +16,14 @@ const App = () => { drawer: { width: 300, depth: 400, height: 80 }, wallThickness: 1.2, printerTolerance: 0.5, - cornerRadius: 4, // <--- Дефолтное скругление (4мм) + cornerRadius: 4, }); + // ИСПРАВЛЕНИЕ: Добавлено поле subdivisions const [splits, setSplits] = useState({ x: [], - y: [] + y: [], + subdivisions: {} }); // --- ЛОГИКА ВОССТАНОВЛЕНИЯ ИЗ ССЫЛКИ --- @@ -29,7 +31,11 @@ const App = () => { const sharedData = parseShareUrl(); if (sharedData) { setConfig(sharedData.config); - setSplits(sharedData.splits); + // Если в старой ссылке нет subdivisions, добавляем пустое + setSplits({ + ...sharedData.splits, + subdivisions: sharedData.splits.subdivisions || {} + }); setStep(3); setIsLoadedFromUrl(true); window.history.replaceState({}, '', window.location.pathname); @@ -122,7 +128,7 @@ const App = () => { {getSubdivision(selectedCell.i, selectedCell.j).cols}
- {/* Колонки (Вертикально) */} + {/* Колонки */}
- + {getSubdivision(selectedCell.i, selectedCell.j).rows}
-
diff --git a/src/types.ts b/src/types.ts index cc49616..f7c9591 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,16 +11,15 @@ export interface AppConfig { cornerRadius: number; } -// Новая структура: сколько рядов и колонок внутри конкретной ячейки export interface CellSubdivision { - rows: number; // горизонтальные ряды - cols: number; // вертикальные колонки + rows: number; + cols: number; } export interface LayoutSplits { x: number[]; y: number[]; - // Ключ - это индекс ячейки "xIndex-yIndex" (например "0-0") + // Добавляем обязательное поле, но разрешаем ему быть пустым subdivisions: Record; } From 492585a2befb79b679bbcaf2506362fde3013553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Sat, 27 Dec 2025 23:20:04 +0300 Subject: [PATCH 04/32] Try fix 2 step --- src/components/LayoutStep.tsx | 41 ++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/src/components/LayoutStep.tsx b/src/components/LayoutStep.tsx index 00a620a..bee175d 100644 --- a/src/components/LayoutStep.tsx +++ b/src/components/LayoutStep.tsx @@ -1,7 +1,7 @@ import React, { useRef, useState, useMemo } from 'react'; import { AppConfig, LayoutSplits } from '../types'; -// ИСПРАВЛЕНИЕ: Используем безопасные иконки -import { Grid, MousePointer2, Trash2, RotateCcw, LayoutGrid, X, Plus, Minus, RectangleHorizontal, RectangleVertical } from 'lucide-react'; +// Заменили иконки на Columns/Rows (они есть везде) +import { Grid, MousePointer2, Trash2, RotateCcw, LayoutGrid, X, Plus, Minus, Columns, Rows } from 'lucide-react'; interface Props { config: AppConfig; @@ -23,16 +23,20 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { const [isButtonHovered, setIsButtonHovered] = useState(false); const [selectedCell, setSelectedCell] = useState<{ i: number, j: number } | null>(null); + // --- ЗАЩИТА ОТ СБОЕВ --- + // Если splits или массивы не инициализированы, используем пустые значения + const safeX = splits?.x || []; + const safeY = splits?.y || []; + const safeSubdivisions = splits?.subdivisions || {}; + const viewBoxW = 1000; - const aspectRatio = config.drawer.depth / config.drawer.width; + // Защита от деления на ноль + const aspectRatio = (config.drawer.depth || 1) / (config.drawer.width || 1); 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]); + const sortedX = useMemo(() => [0, ...safeX, 1].sort((a, b) => a - b), [safeX]); + const sortedY = useMemo(() => [0, ...safeY, 1].sort((a, b) => a - b), [safeY]); - // ИСПРАВЛЕНИЕ: Безопасное получение subdivisions (если вдруг undefined) - const safeSubdivisions = splits.subdivisions || {}; - const getSubdivision = (i: number, j: number) => { return safeSubdivisions[`${i}-${j}`] || { rows: 1, cols: 1 }; }; @@ -63,7 +67,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { if (mode === 'lines') { if (dragging) { - const newSplits = { ...splits, x: [...splits.x], y: [...splits.y] }; + const newSplits = { ...splits, x: [...safeX], y: [...safeY] }; const val = dragging.axis === 'x' ? nx : ny; newSplits[dragging.axis][dragging.index] = val; onChange(newSplits); @@ -75,8 +79,8 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { setHoveredSplit(null); const SNAP = 0.02; - const closeToX = splits.x.some(val => Math.abs(nx - val) < SNAP); - const closeToY = splits.y.some(val => Math.abs(ny - val) < SNAP); + const closeToX = safeX.some(val => Math.abs(nx - val) < SNAP); + const closeToY = safeY.some(val => Math.abs(ny - val) < SNAP); const closeToEdgeX = nx < SNAP || nx > (1 - SNAP); const closeToEdgeY = ny < SNAP || ny > (1 - SNAP); @@ -105,7 +109,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { else if (e.button === 2) removeSplit(hoveredSplit.axis, hoveredSplit.index); } else if (phantomAxis && !isButtonHovered) { const val = phantomAxis === 'x' ? mousePos.x : mousePos.y; - const newSplits = { ...splits }; + const newSplits = { ...splits, x: [...safeX], y: [...safeY] }; newSplits[phantomAxis] = [...newSplits[phantomAxis], val]; onChange(newSplits); setDragging({ axis: phantomAxis, index: newSplits[phantomAxis].length - 1 }); @@ -116,7 +120,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { }; const removeSplit = (axis: Axis, index: number) => { - const newSplits = { ...splits }; + const newSplits = { ...splits, x: [...safeX], y: [...safeY] }; newSplits[axis] = newSplits[axis].filter((_, i) => i !== index); onChange(newSplits); setHoveredSplit(null); @@ -261,7 +265,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { }); })} - {splits.x.map((x, i) => ( + {safeX.map((x, i) => ( handleSplitHover(e, 'x', i)}> @@ -276,7 +280,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { ))} - {splits.y.map((y, i) => ( + {safeY.map((y, i) => ( handleSplitHover(e, 'y', i)}> @@ -305,7 +309,6 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { )} - {/* --- МЕНЮ ДЛЯ ЯЧЕЙКИ (С ИСПРАВЛЕННЫМИ ИКОНКАМИ) --- */} {mode === 'cells' && selectedCell && (
= ({ config, splits, onChange }) => { }} onMouseDown={(e) => e.stopPropagation()} > - {/* Ряды */}
- + {getSubdivision(selectedCell.i, selectedCell.j).cols}
- {/* Колонки */}
- + {getSubdivision(selectedCell.i, selectedCell.j).rows} From ac00068175e0a23c70e05a8ecf6b510a7304d67d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Sat, 27 Dec 2025 23:33:57 +0300 Subject: [PATCH 05/32] Rollback --- src/App.tsx | 12 +- src/components/LayoutStep.tsx | 360 ++++++++++++------------------ src/services/geometryGenerator.ts | 77 +++---- src/types.ts | 7 - 4 files changed, 179 insertions(+), 277 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b1990e4..e65121c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,11 +19,9 @@ const App = () => { cornerRadius: 4, }); - // ИСПРАВЛЕНИЕ: Добавлено поле subdivisions const [splits, setSplits] = useState({ x: [], - y: [], - subdivisions: {} + y: [] }); // --- ЛОГИКА ВОССТАНОВЛЕНИЯ ИЗ ССЫЛКИ --- @@ -31,11 +29,7 @@ const App = () => { const sharedData = parseShareUrl(); if (sharedData) { setConfig(sharedData.config); - // Если в старой ссылке нет subdivisions, добавляем пустое - setSplits({ - ...sharedData.splits, - subdivisions: sharedData.splits.subdivisions || {} - }); + setSplits(sharedData.splits); setStep(3); setIsLoadedFromUrl(true); window.history.replaceState({}, '', window.location.pathname); @@ -128,7 +122,7 @@ const App = () => { - -
-
-
+
- Режим: {mode === 'lines' ? 'Границы' : 'Ячейки'} + Инструкция
- {mode === 'lines' ? ( -
    -
  • Клик: Новая линия
  • -
  • Драг: Двигать линию
  • -
  • ПКМ: Удалить линию
  • -
- ) : ( -
    -
  • Клик по ячейке: Выбрать
  • -
  • Используй меню для деления внутри
  • -
- )} +
    +
  • Клик у края: Новая линия
  • +
  • Перетаскивание: Изменить размер
  • +
  • Двойной клик/ПКМ: Удалить
  • +
@@ -191,12 +134,12 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => {
@@ -216,128 +159,111 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { + {/* --- Labels --- */} {sortedX.slice(0, -1).map((x1, i) => { const x2 = sortedX[i + 1]; return sortedY.slice(0, -1).map((y1, j) => { - const cellX = x1 * viewBoxW; - const cellY = y1 * viewBoxH; - const cellW = (x2 - x1) * viewBoxW; - const cellH = (y2 - y1) * viewBoxH; + const y2 = sortedY[j + 1]; + const width = (x2 - x1) * config.drawer.width; + const depth = (y2 - y1) * config.drawer.depth; + const centerX = ((x1 + x2) / 2) * viewBoxW; + const centerY = ((y1 + y2) / 2) * viewBoxH; - const isSelected = selectedCell?.i === i && selectedCell?.j === j; - const subdiv = getSubdivision(i, j); + const cellWidthSVG = (x2 - x1) * viewBoxW; + const cellHeightSVG = (y2 - y1) * viewBoxH; + + let fontSize = Math.min(36, cellHeightSVG * 0.6); + fontSize = Math.min(fontSize, cellWidthSVG * 0.25); + + if (fontSize < 10) return null; return ( - - { - 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 && ( - - {((x2 - x1) * config.drawer.width).toFixed(0)}×{((y2 - y1) * config.drawer.depth).toFixed(0)} - - )} - + + {width.toFixed(0)} × {depth.toFixed(0)} + ); }); })} + + {/* --- X Lines (Vertical) --- */} + {splits.x.map((x, i) => { + const isHovered = hoveredSplit?.axis === 'x' && hoveredSplit.index === i; + const isDragging = dragging?.axis === 'x' && dragging.index === i; + const color = isHovered || isDragging ? '#f59e0b' : '#64748b'; + const width = isHovered || isDragging ? 8 : 4; - {safeX.map((x, i) => ( - handleSplitHover(e, 'x', i)}> - - - {mode === 'lines' && hoveredSplit?.axis === 'x' && hoveredSplit.index === i && ( - { e.stopPropagation(); removeSplit('x', i); }} - onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)} - > - - - - )} - - ))} + return ( + removeSplit('x', i)} + onMouseMove={(e) => handleSplitHover(e, 'x', i)} + > + + + {(isHovered || isDragging) && ( + { e.stopPropagation(); removeSplit('x', i); }} + onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)} + > + + + + )} + + ); + })} - {safeY.map((y, i) => ( - handleSplitHover(e, 'y', i)}> - - - {mode === 'lines' && hoveredSplit?.axis === 'y' && hoveredSplit.index === i && ( - { e.stopPropagation(); removeSplit('y', i); }} - onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)} - > - - - - )} - - ))} + {/* --- Y Lines (Horizontal) --- */} + {splits.y.map((y, i) => { + const isHovered = hoveredSplit?.axis === 'y' && hoveredSplit.index === i; + const isDragging = dragging?.axis === 'y' && dragging.index === i; + const color = isHovered || isDragging ? '#f59e0b' : '#64748b'; + const width = isHovered || isDragging ? 8 : 4; - {mode === 'lines' && !hoveredSplit && !dragging && phantomAxis === 'x' && ( - + return ( + removeSplit('y', i)} + onMouseMove={(e) => handleSplitHover(e, 'y', i)} + > + + + {(isHovered || isDragging) && ( + { e.stopPropagation(); removeSplit('y', i); }} + onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)} + > + + + + )} + + ); + })} + + {/* --- Phantom Lines --- */} + {!hoveredSplit && !dragging && !isButtonHovered && phantomAxis === 'x' && ( + + + + )} - {mode === 'lines' && !hoveredSplit && !dragging && phantomAxis === 'y' && ( - + {!hoveredSplit && !dragging && !isButtonHovered && phantomAxis === 'y' && ( + + + + )} - - {mode === 'cells' && selectedCell && ( -
e.stopPropagation()} - > -
- - - {getSubdivision(selectedCell.i, selectedCell.j).cols} - -
- -
- - - {getSubdivision(selectedCell.i, selectedCell.j).rows} - -
- - -
- )} -
diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index a792c78..af173b0 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -16,57 +16,41 @@ export const calculateParts = ( for (let i = 0; i < xPoints.length - 1; i++) { for (let j = 0; j < yPoints.length - 1; j++) { - 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; + 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 subdiv = splits.subdivisions?.[`${i}-${j}`] || { rows: 1, cols: 1 }; - - const subCellWidth = rawW / subdiv.cols; - const subCellDepth = rawD / subdiv.rows; + const realWidth = segmentW - config.printerTolerance; + const realDepth = segmentD - config.printerTolerance; + const realX = segmentX + (config.printerTolerance / 2); + const realY = segmentY + (config.printerTolerance / 2); - // Генерируем под-ячейки - 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); - - 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; - - // Формируем имя: если ячейка поделена, добавляем индексы (1-1, 1-2...) - let partName = `Ячейка ${i+1}-${j+1}`; - if (subdiv.rows > 1 || subdiv.cols > 1) { - partName += ` (${r+1}-${c+1})`; - } - - parts.push({ - id: `part-${partCounter}`, - name: partName, - width: realWidth, - depth: realDepth, - height: config.drawer.height, - x: realX, - y: realY, - color: `hsl(${Math.random() * 360}, 70%, 50%)` - }); - partCounter++; - } + if (realWidth < 5 || realDepth < 5) { + continue; } + + 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; }; -// ... Вспомогательные функции генерации геометрии (без изменений) ... +// ... Вспомогательные функции (createRoundedRectShape, createBinGeometry) остаются ТЕМИ ЖЕ, +// что и в прошлой работающей версии (со скруглениями и Extrude). +// Они не менялись при внедрении subdivisions, но для целостности я их продублирую. + const createRoundedRectShape = (width: number, height: number, radius: number): THREE.Shape => { const shape = new THREE.Shape(); const x = -width / 2; @@ -100,9 +84,12 @@ export const createBinGeometry = ( thickness: number, radius: number = 0 ): THREE.BufferGeometry => { + const floorShape = createRoundedRectShape(width, depth, radius); const floorGeo = new THREE.ExtrudeGeometry(floorShape, { - depth: thickness, bevelEnabled: false, curveSegments: 16 + depth: thickness, + bevelEnabled: false, + curveSegments: 16 }); floorGeo.rotateX(-Math.PI / 2); @@ -118,7 +105,9 @@ export const createBinGeometry = ( const wallHeight = height - thickness; const wallGeo = new THREE.ExtrudeGeometry(outerShape, { - depth: wallHeight, bevelEnabled: false, curveSegments: 16 + depth: wallHeight, + bevelEnabled: false, + curveSegments: 16 }); wallGeo.rotateX(-Math.PI / 2); diff --git a/src/types.ts b/src/types.ts index f7c9591..1bdb5ff 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,16 +11,9 @@ export interface AppConfig { cornerRadius: number; } -export interface CellSubdivision { - rows: number; - cols: number; -} - export interface LayoutSplits { x: number[]; y: number[]; - // Добавляем обязательное поле, но разрешаем ему быть пустым - subdivisions: Record; } export interface GeneratedPart { From 929e071ea76312238c2cb04c7b1b072a1c4184e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Sat, 27 Dec 2025 23:46:13 +0300 Subject: [PATCH 06/32] Try repeat --- src/App.tsx | 12 +- src/components/LayoutStep.tsx | 373 ++++++++++++++++++------------ src/services/geometryGenerator.ts | 87 ++++--- src/types.ts | 8 + 4 files changed, 299 insertions(+), 181 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index e65121c..bdd5826 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,9 +19,11 @@ const App = () => { cornerRadius: 4, }); + // ВАЖНО: Инициализируем subdivisions пустым объектом const [splits, setSplits] = useState({ x: [], - y: [] + y: [], + subdivisions: {} }); // --- ЛОГИКА ВОССТАНОВЛЕНИЯ ИЗ ССЫЛКИ --- @@ -29,7 +31,11 @@ const App = () => { const sharedData = parseShareUrl(); if (sharedData) { setConfig(sharedData.config); - setSplits(sharedData.splits); + // При восстановлении тоже гарантируем наличие subdivisions + setSplits({ + ...sharedData.splits, + subdivisions: sharedData.splits.subdivisions || {} + }); setStep(3); setIsLoadedFromUrl(true); window.history.replaceState({}, '', window.location.pathname); @@ -122,7 +128,7 @@ const App = () => { + +
+
-
+
+ {/* Instruction Overlay */}
- Инструкция + + {mode === 'lines' ? 'Режим: Линии' : 'Режим: Ячейки'}
-
    -
  • Клик у края: Новая линия
  • -
  • Перетаскивание: Изменить размер
  • -
  • Двойной клик/ПКМ: Удалить
  • -
+ {mode === 'lines' ? ( +
    +
  • Клик: Новая линия
  • +
  • Драг: Двигать линию
  • +
  • ПКМ: Удалить линию
  • +
+ ) : ( +
    +
  • Клик по ячейке: Настройка
  • +
  • Добавляй ряды и колонки внутри
  • +
+ )}
+ {/* Rulers */}
0 {config.drawer.width} мм @@ -134,12 +198,12 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => {
@@ -159,111 +223,134 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { - {/* --- Labels --- */} + {/* --- Рендеринг ячеек --- */} {sortedX.slice(0, -1).map((x1, i) => { const x2 = sortedX[i + 1]; return sortedY.slice(0, -1).map((y1, j) => { - const y2 = sortedY[j + 1]; - const width = (x2 - x1) * config.drawer.width; - const depth = (y2 - y1) * config.drawer.depth; - const centerX = ((x1 + x2) / 2) * viewBoxW; - const centerY = ((y1 + y2) / 2) * viewBoxH; + const cellX = x1 * viewBoxW; + const cellY = y1 * viewBoxH; + const cellW = (x2 - x1) * viewBoxW; + const cellH = (y2 - y1) * viewBoxH; - const cellWidthSVG = (x2 - x1) * viewBoxW; - const cellHeightSVG = (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 && ( + + {((x2 - x1) * config.drawer.width).toFixed(0)}×{((y2 - y1) * config.drawer.depth).toFixed(0)} + + )} + ); }); })} - - {/* --- X Lines (Vertical) --- */} - {splits.x.map((x, i) => { - const isHovered = hoveredSplit?.axis === 'x' && hoveredSplit.index === i; - const isDragging = dragging?.axis === 'x' && dragging.index === i; - const color = isHovered || isDragging ? '#f59e0b' : '#64748b'; - const width = isHovered || isDragging ? 8 : 4; - return ( - removeSplit('x', i)} - onMouseMove={(e) => handleSplitHover(e, 'x', i)} - > - - - {(isHovered || isDragging) && ( - { e.stopPropagation(); removeSplit('x', i); }} - onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)} - > - - - - )} - - ); - })} + {/* --- Линии границ (X) --- */} + {safeX.map((x, i) => ( + handleSplitHover(e, 'x', i)}> + + + {mode === 'lines' && hoveredSplit?.axis === 'x' && hoveredSplit.index === i && ( + { e.stopPropagation(); removeSplit('x', i); }} + onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)} + > + + + + )} + + ))} - {/* --- Y Lines (Horizontal) --- */} - {splits.y.map((y, i) => { - const isHovered = hoveredSplit?.axis === 'y' && hoveredSplit.index === i; - const isDragging = dragging?.axis === 'y' && dragging.index === i; - const color = isHovered || isDragging ? '#f59e0b' : '#64748b'; - const width = isHovered || isDragging ? 8 : 4; + {/* --- Линии границ (Y) --- */} + {safeY.map((y, i) => ( + handleSplitHover(e, 'y', i)}> + + + {mode === 'lines' && hoveredSplit?.axis === 'y' && hoveredSplit.index === i && ( + { e.stopPropagation(); removeSplit('y', i); }} + onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)} + > + + + + )} + + ))} - return ( - removeSplit('y', i)} - onMouseMove={(e) => handleSplitHover(e, 'y', i)} - > - - - {(isHovered || isDragging) && ( - { e.stopPropagation(); removeSplit('y', i); }} - onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)} - > - - - - )} - - ); - })} - - {/* --- Phantom Lines --- */} - {!hoveredSplit && !dragging && !isButtonHovered && phantomAxis === 'x' && ( - - - - + {/* --- Фантомные линии --- */} + {mode === 'lines' && !hoveredSplit && !dragging && phantomAxis === 'x' && ( + )} - {!hoveredSplit && !dragging && !isButtonHovered && phantomAxis === 'y' && ( - - - - + {mode === 'lines' && !hoveredSplit && !dragging && phantomAxis === 'y' && ( + )} + + {/* --- Панель управления выбранной ячейкой (HTML) --- */} + {mode === 'cells' && selectedCell && ( +
e.stopPropagation()} + > + {/* Ряды */} +
+
X
+ + {getSubdivision(selectedCell.i, selectedCell.j).cols} + +
+ + {/* Колонки */} +
+
Y
+ + {getSubdivision(selectedCell.i, selectedCell.j).rows} + +
+ + +
+ )} +
diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index af173b0..a4ae8e2 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -8,49 +8,71 @@ export const calculateParts = ( ): 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]; + // Безопасное чтение данных + const safeX = splits?.x || []; + const safeY = splits?.y || []; + const safeSub = splits?.subdivisions || {}; + + const xPoints = [0, ...[...safeX].sort((a, b) => a - b), 1]; + const yPoints = [0, ...[...safeY].sort((a, b) => a - b), 1]; let partCounter = 1; 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; - const realWidth = segmentW - config.printerTolerance; - const realDepth = segmentD - config.printerTolerance; - const realX = segmentX + (config.printerTolerance / 2); - const realY = segmentY + (config.printerTolerance / 2); + // Получаем настройки деления + const subdiv = safeSub[`${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); + + 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; + + // Имя: если деление, добавляем суффикс + let partName = `Ячейка ${i+1}-${j+1}`; + if (subdiv.rows > 1 || subdiv.cols > 1) { + partName += ` (${r+1}-${c+1})`; + } + + parts.push({ + id: `part-${partCounter}`, + name: partName, + 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; }; -// ... Вспомогательные функции (createRoundedRectShape, createBinGeometry) остаются ТЕМИ ЖЕ, -// что и в прошлой работающей версии (со скруглениями и Extrude). -// Они не менялись при внедрении subdivisions, но для целостности я их продублирую. - +// ... Остальной код (createRoundedRectShape, createBinGeometry) как в работающей версии ... const createRoundedRectShape = (width: number, height: number, radius: number): THREE.Shape => { const shape = new THREE.Shape(); const x = -width / 2; @@ -84,12 +106,9 @@ export const createBinGeometry = ( thickness: number, radius: number = 0 ): THREE.BufferGeometry => { - const floorShape = createRoundedRectShape(width, depth, radius); const floorGeo = new THREE.ExtrudeGeometry(floorShape, { - depth: thickness, - bevelEnabled: false, - curveSegments: 16 + depth: thickness, bevelEnabled: false, curveSegments: 16 }); floorGeo.rotateX(-Math.PI / 2); @@ -105,9 +124,7 @@ export const createBinGeometry = ( const wallHeight = height - thickness; const wallGeo = new THREE.ExtrudeGeometry(outerShape, { - depth: wallHeight, - bevelEnabled: false, - curveSegments: 16 + depth: wallHeight, bevelEnabled: false, curveSegments: 16 }); wallGeo.rotateX(-Math.PI / 2); diff --git a/src/types.ts b/src/types.ts index 1bdb5ff..4008ee5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,9 +11,17 @@ export interface AppConfig { cornerRadius: number; } +// Новая структура: настройки деления внутри одной ячейки +export interface CellSubdivision { + rows: number; // горизонтальные ряды + cols: number; // вертикальные колонки +} + export interface LayoutSplits { x: number[]; y: number[]; + // Ключ: "indexX-indexY" (например "0-0"), Значение: {rows: 2, cols: 1} + subdivisions: Record; } export interface GeneratedPart { From 8789d820645160ec9e5d7ee38f4268dcaf8cc286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Sat, 27 Dec 2025 23:53:52 +0300 Subject: [PATCH 07/32] Fix --- src/App.tsx | 13 +- src/components/LayoutStep.tsx | 371 ++++++++++++------------------ src/services/geometryGenerator.ts | 72 ++---- src/types.ts | 8 - 4 files changed, 175 insertions(+), 289 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index bdd5826..225b87d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,11 +19,10 @@ const App = () => { cornerRadius: 4, }); - // ВАЖНО: Инициализируем subdivisions пустым объектом + // Чистый стейт без subdivisions const [splits, setSplits] = useState({ x: [], - y: [], - subdivisions: {} + y: [] }); // --- ЛОГИКА ВОССТАНОВЛЕНИЯ ИЗ ССЫЛКИ --- @@ -31,10 +30,10 @@ const App = () => { const sharedData = parseShareUrl(); if (sharedData) { setConfig(sharedData.config); - // При восстановлении тоже гарантируем наличие subdivisions + // Принудительно чистим объект от старых полей, если они были в ссылке setSplits({ - ...sharedData.splits, - subdivisions: sharedData.splits.subdivisions || {} + x: sharedData.splits.x || [], + y: sharedData.splits.y || [] }); setStep(3); setIsLoadedFromUrl(true); @@ -128,7 +127,7 @@ const App = () => { - -
-
-
+
- {/* Instruction Overlay */}
- - {mode === 'lines' ? 'Режим: Линии' : 'Режим: Ячейки'} + Инструкция
- {mode === 'lines' ? ( -
    -
  • Клик: Новая линия
  • -
  • Драг: Двигать линию
  • -
  • ПКМ: Удалить линию
  • -
- ) : ( -
    -
  • Клик по ячейке: Настройка
  • -
  • Добавляй ряды и колонки внутри
  • -
- )} +
    +
  • Клик у края: Новая линия
  • +
  • Перетаскивание: Изменить размер
  • +
  • Двойной клик/ПКМ: Удалить
  • +
- {/* Rulers */}
0 {config.drawer.width} мм @@ -198,12 +138,12 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => {
@@ -223,134 +163,111 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { - {/* --- Рендеринг ячеек --- */} + {/* --- Labels --- */} {sortedX.slice(0, -1).map((x1, i) => { const x2 = sortedX[i + 1]; return sortedY.slice(0, -1).map((y1, j) => { - const cellX = x1 * viewBoxW; - const cellY = y1 * viewBoxH; - const cellW = (x2 - x1) * viewBoxW; - const cellH = (y2 - y1) * viewBoxH; + const y2 = sortedY[j + 1]; + const width = (x2 - x1) * config.drawer.width; + const depth = (y2 - y1) * config.drawer.depth; + const centerX = ((x1 + x2) / 2) * viewBoxW; + const centerY = ((y1 + y2) / 2) * viewBoxH; - const isSelected = selectedCell?.i === i && selectedCell?.j === j; - const subdiv = getSubdivision(i, j); + const cellWidthSVG = (x2 - x1) * viewBoxW; + const cellHeightSVG = (y2 - y1) * viewBoxH; + + let fontSize = Math.min(36, cellHeightSVG * 0.6); + fontSize = Math.min(fontSize, cellWidthSVG * 0.25); + + if (fontSize < 10) return null; return ( - - {/* Прямоугольник ячейки */} - { - 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 && ( - - {((x2 - x1) * config.drawer.width).toFixed(0)}×{((y2 - y1) * config.drawer.depth).toFixed(0)} - - )} - + + {width.toFixed(0)} × {depth.toFixed(0)} + ); }); })} + + {/* --- X Lines (Vertical) --- */} + {safeX.map((x, i) => { + const isHovered = hoveredSplit?.axis === 'x' && hoveredSplit.index === i; + const isDragging = dragging?.axis === 'x' && dragging.index === i; + const color = isHovered || isDragging ? '#f59e0b' : '#64748b'; + const width = isHovered || isDragging ? 8 : 4; - {/* --- Линии границ (X) --- */} - {safeX.map((x, i) => ( - handleSplitHover(e, 'x', i)}> - - - {mode === 'lines' && hoveredSplit?.axis === 'x' && hoveredSplit.index === i && ( - { e.stopPropagation(); removeSplit('x', i); }} - onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)} - > - - - - )} - - ))} + return ( + removeSplit('x', i)} + onMouseMove={(e) => handleSplitHover(e, 'x', i)} + > + + + {(isHovered || isDragging) && ( + { e.stopPropagation(); removeSplit('x', i); }} + onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)} + > + + + + )} + + ); + })} - {/* --- Линии границ (Y) --- */} - {safeY.map((y, i) => ( - handleSplitHover(e, 'y', i)}> - - - {mode === 'lines' && hoveredSplit?.axis === 'y' && hoveredSplit.index === i && ( - { e.stopPropagation(); removeSplit('y', i); }} - onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)} - > - - - - )} - - ))} + {/* --- Y Lines (Horizontal) --- */} + {safeY.map((y, i) => { + const isHovered = hoveredSplit?.axis === 'y' && hoveredSplit.index === i; + const isDragging = dragging?.axis === 'y' && dragging.index === i; + const color = isHovered || isDragging ? '#f59e0b' : '#64748b'; + const width = isHovered || isDragging ? 8 : 4; - {/* --- Фантомные линии --- */} - {mode === 'lines' && !hoveredSplit && !dragging && phantomAxis === 'x' && ( - + return ( + removeSplit('y', i)} + onMouseMove={(e) => handleSplitHover(e, 'y', i)} + > + + + {(isHovered || isDragging) && ( + { e.stopPropagation(); removeSplit('y', i); }} + onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)} + > + + + + )} + + ); + })} + + {/* --- Phantom Lines --- */} + {!hoveredSplit && !dragging && !isButtonHovered && phantomAxis === 'x' && ( + + + + )} - {mode === 'lines' && !hoveredSplit && !dragging && phantomAxis === 'y' && ( - + {!hoveredSplit && !dragging && !isButtonHovered && phantomAxis === 'y' && ( + + + + )} - - {/* --- Панель управления выбранной ячейкой (HTML) --- */} - {mode === 'cells' && selectedCell && ( -
e.stopPropagation()} - > - {/* Ряды */} -
-
X
- - {getSubdivision(selectedCell.i, selectedCell.j).cols} - -
- - {/* Колонки */} -
-
Y
- - {getSubdivision(selectedCell.i, selectedCell.j).rows} - -
- - -
- )} -
diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index a4ae8e2..8ad4d65 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -8,10 +8,9 @@ export const calculateParts = ( ): GeneratedPart[] => { const parts: GeneratedPart[] = []; - // Безопасное чтение данных - const safeX = splits?.x || []; - const safeY = splits?.y || []; - const safeSub = splits?.subdivisions || {}; + // Safe Access + const safeX = splits.x || []; + const safeY = splits.y || []; const xPoints = [0, ...[...safeX].sort((a, b) => a - b), 1]; const yPoints = [0, ...[...safeY].sort((a, b) => a - b), 1]; @@ -21,58 +20,37 @@ export const calculateParts = ( for (let i = 0; i < xPoints.length - 1; i++) { for (let j = 0; j < yPoints.length - 1; j++) { - 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; + 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 subdiv = safeSub[`${i}-${j}`] || { rows: 1, cols: 1 }; - - // Размеры под-ячейки - const subCellWidth = rawW / subdiv.cols; - const subCellDepth = rawD / subdiv.rows; + const realWidth = segmentW - config.printerTolerance; + const realDepth = segmentD - config.printerTolerance; + const realX = segmentX + (config.printerTolerance / 2); + const realY = segmentY + (config.printerTolerance / 2); - // Генерируем под-ячейки - 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); - - 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; - - // Имя: если деление, добавляем суффикс - let partName = `Ячейка ${i+1}-${j+1}`; - if (subdiv.rows > 1 || subdiv.cols > 1) { - partName += ` (${r+1}-${c+1})`; - } - - parts.push({ - id: `part-${partCounter}`, - name: partName, - width: realWidth, - depth: realDepth, - height: config.drawer.height, - x: realX, - y: realY, - color: `hsl(${Math.random() * 360}, 70%, 50%)` - }); - partCounter++; - } + if (realWidth < 5 || realDepth < 5) { + continue; } + + 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; }; -// ... Остальной код (createRoundedRectShape, createBinGeometry) как в работающей версии ... const createRoundedRectShape = (width: number, height: number, radius: number): THREE.Shape => { const shape = new THREE.Shape(); const x = -width / 2; diff --git a/src/types.ts b/src/types.ts index 4008ee5..1bdb5ff 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,17 +11,9 @@ export interface AppConfig { cornerRadius: number; } -// Новая структура: настройки деления внутри одной ячейки -export interface CellSubdivision { - rows: number; // горизонтальные ряды - cols: number; // вертикальные колонки -} - export interface LayoutSplits { x: number[]; y: number[]; - // Ключ: "indexX-indexY" (например "0-0"), Значение: {rows: 2, cols: 1} - subdivisions: Record; } export interface GeneratedPart { From 4af73b00ebc8cad6ad460af941877ac9b70e8196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Sat, 10 Jan 2026 16:43:20 +0300 Subject: [PATCH 08/32] Add split --- src/App.tsx | 86 ++---- src/components/LayoutStep.tsx | 432 ++++++++++++++++++------------ src/components/PreviewStep.tsx | 11 +- src/services/geometryGenerator.ts | 115 +++++--- src/types.ts | 13 + 5 files changed, 379 insertions(+), 278 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 225b87d..8384063 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,9 +9,7 @@ import { ChevronRight, ChevronLeft, Box } from 'lucide-react'; const App = () => { const [step, setStep] = useState(1); - const [isLoadedFromUrl, setIsLoadedFromUrl] = useState(false); - // State const [config, setConfig] = useState({ drawer: { width: 300, depth: 400, height: 80 }, wallThickness: 1.2, @@ -19,36 +17,33 @@ const App = () => { cornerRadius: 4, }); - // Чистый стейт без subdivisions + // ВАЖНО: Инициализируем partitions const [splits, setSplits] = useState({ x: [], - y: [] + y: [], + partitions: {} }); - // --- ЛОГИКА ВОССТАНОВЛЕНИЯ ИЗ ССЫЛКИ --- useEffect(() => { const sharedData = parseShareUrl(); if (sharedData) { setConfig(sharedData.config); - // Принудительно чистим объект от старых полей, если они были в ссылке setSplits({ x: sharedData.splits.x || [], - y: sharedData.splits.y || [] + y: sharedData.splits.y || [], + partitions: sharedData.splits.partitions || {} }); setStep(3); - setIsLoadedFromUrl(true); window.history.replaceState({}, '', window.location.pathname); } }, []); - // Derived State: Parts const parts: GeneratedPart[] = useMemo(() => { return calculateParts(config, splits); }, [config, splits]); return (
- {/* Header */}
@@ -60,80 +55,31 @@ const App = () => {

Генератор органайзеров

- - {/* Progress Stepper */}
{[1, 2, 3].map((num) => ( - -
- - {num} - - - {num === 1 ? 'Настройки' : num === 2 ? 'Макет' : 'Экспорт'} - -
- {num < 3 &&
} - +
+ {num} + {num === 1 ? 'Настройки' : num === 2 ? 'Макет' : 'Экспорт'} +
))}
- {/* Main Content */}
- {step === 1 && ( -
- -
- )} - - {step === 2 && ( -
- -
- )} - - {step === 3 && ( -
- -
- )} + {step === 1 &&
} + {step === 2 &&
} + {step === 3 &&
}
- {/* Footer Navigation */}
- - -
- {step === 2 && Ячеек: {parts.length}} -
- + +
{step === 2 && Ячеек: {parts.length}}
{step < 3 ? ( - + ) : ( - + )}
diff --git a/src/components/LayoutStep.tsx b/src/components/LayoutStep.tsx index 3f82dd6..21db1d4 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 { AppConfig, LayoutSplits, Partition } from '../types'; +import { Grid, MousePointer2, Trash2, RotateCcw, LayoutGrid, X, Plus, Settings2, Sliders } from 'lucide-react'; interface Props { config: AppConfig; @@ -8,31 +8,84 @@ interface Props { onChange: (splits: LayoutSplits) => void; } +type EditMode = 'lines' | 'cells'; type Axis = 'x' | 'y'; export const LayoutStep: React.FC = ({ config, splits, onChange }) => { const svgRef = useRef(null); - // State + const [mode, setMode] = useState('lines'); + + // States for 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); - - // FIX: Кнопка удаления не пропадает под курсором const [isButtonHovered, setIsButtonHovered] = useState(false); - // SAFE ACCESS: Защита от undefined - const safeX = splits.x || []; - const safeY = splits.y || []; + // State for Cell Editor + const [editingCell, setEditingCell] = useState<{ i: number, j: number } | null>(null); + + // Safeties + const safeX = splits?.x || []; + const safeY = splits?.y || []; + const safePartitions = splits?.partitions || {}; const viewBoxW = 1000; - const aspectRatio = config.drawer.depth / config.drawer.width; + const aspectRatio = (config.drawer.depth || 1) / (config.drawer.width || 1); const viewBoxH = viewBoxW * aspectRatio; const sortedX = useMemo(() => [0, ...safeX, 1].sort((a, b) => a - b), [safeX]); const sortedY = useMemo(() => [0, ...safeY, 1].sort((a, b) => a - b), [safeY]); - + + // --- PARTITION LOGIC --- + const getCurrentPartitions = () => { + if (!editingCell) return []; + const key = `${editingCell.i}-${editingCell.j}`; + return safePartitions[key] || []; + }; + + const addPartition = (axis: 'x' | 'y') => { + if (!editingCell) return; + const key = `${editingCell.i}-${editingCell.j}`; + const current = safePartitions[key] || []; + const newPart: Partition = { + id: Date.now().toString(), + axis, + offset: 0.5, + height: config.drawer.height, // по умолчанию полная высота + rounded: false + }; + + onChange({ + ...splits, + partitions: { ...safePartitions, [key]: [...current, newPart] } + }); + }; + + const updatePartition = (id: string, updates: Partial) => { + if (!editingCell) return; + const key = `${editingCell.i}-${editingCell.j}`; + const current = safePartitions[key] || []; + const updated = current.map(p => p.id === id ? { ...p, ...updates } : p); + + onChange({ + ...splits, + partitions: { ...safePartitions, [key]: updated } + }); + }; + + const removePartition = (id: string) => { + if (!editingCell) return; + const key = `${editingCell.i}-${editingCell.j}`; + const current = safePartitions[key] || []; + onChange({ + ...splits, + partitions: { ...safePartitions, [key]: current.filter(p => p.id !== id) } + }); + }; + + // --- MOUSE HANDLERS (Global) --- const handleGlobalMouseMove = (e: React.MouseEvent) => { if (!svgRef.current) return; const rect = svgRef.current.getBoundingClientRect(); @@ -41,56 +94,51 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { setMousePos({ x: nx, y: ny }); - if (dragging) { - const newSplits = { x: [...safeX], y: [...safeY] }; - 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: [...safeX], y: [...safeY] }; + const val = dragging.axis === 'x' ? nx : ny; + newSplits[dragging.axis][dragging.index] = val; + onChange(newSplits); + return; + } + if (isButtonHovered) return; + setHoveredSplit(null); + + const SNAP = 0.02; + const closeToX = safeX.some(val => Math.abs(nx - val) < SNAP); + const closeToY = safeY.some(val => Math.abs(ny - val) < SNAP); + const closeToEdgeX = nx < SNAP || nx > (1 - SNAP); + const closeToEdgeY = ny < SNAP || ny > (1 - SNAP); + + if (!closeToX && !closeToY && !closeToEdgeX && !closeToEdgeY) { + const distRight = 1 - nx; const distBottom = 1 - ny; + setPhantomAxis(Math.min(nx, distRight) < Math.min(ny, distBottom) ? 'y' : 'x'); + } else { + setPhantomAxis(null); + } } - - if (isButtonHovered) return; - - setHoveredSplit(null); - - const SNAP = 0.02; - const closeToX = safeX.some(val => Math.abs(nx - val) < SNAP); - const closeToY = safeY.some(val => Math.abs(ny - val) < SNAP); - const closeToEdgeX = nx < SNAP || nx > (1 - SNAP); - const closeToEdgeY = ny < SNAP || ny > (1 - SNAP); - - if (!closeToX && !closeToY && !closeToEdgeX && !closeToEdgeY) { - const distRight = 1 - nx; const distBottom = 1 - ny; - const minXDist = Math.min(nx, distRight); - const minYDist = Math.min(ny, distBottom); - setPhantomAxis(minXDist < minYDist ? 'y' : 'x'); - } else { - setPhantomAxis(null); - } - }; - - const handleSplitHover = (e: React.MouseEvent, axis: Axis, index: number) => { - if (dragging) return; - e.stopPropagation(); - setHoveredSplit({ axis, index }); - setPhantomAxis(null); }; const handleMouseDown = (e: React.MouseEvent) => { - if (hoveredSplit && !isButtonHovered) { - if (e.button === 0) setDragging(hoveredSplit); - else if (e.button === 2) removeSplit(hoveredSplit.axis, hoveredSplit.index); - } else if (phantomAxis && !isButtonHovered) { - const val = phantomAxis === 'x' ? mousePos.x : mousePos.y; - const newSplits = { x: [...safeX], y: [...safeY] }; - newSplits[phantomAxis] = [...newSplits[phantomAxis], val]; - onChange(newSplits); - setDragging({ axis: phantomAxis, index: newSplits[phantomAxis].length - 1 }); + if (mode === 'lines') { + if (hoveredSplit && !isButtonHovered) { + if (e.button === 0) setDragging(hoveredSplit); + else if (e.button === 2) removeMainSplit(hoveredSplit.axis, hoveredSplit.index); + } else if (phantomAxis && !isButtonHovered) { + const val = phantomAxis === 'x' ? mousePos.x : mousePos.y; + const newSplits = { ...splits, x: [...safeX], y: [...safeY] }; + newSplits[phantomAxis] = [...newSplits[phantomAxis], val]; + onChange(newSplits); + setDragging({ axis: phantomAxis, index: newSplits[phantomAxis].length - 1 }); + } + } else { + if (e.target === svgRef.current) setEditingCell(null); } }; - const removeSplit = (axis: Axis, index: number) => { - const newSplits = { x: [...safeX], y: [...safeY] }; + const removeMainSplit = (axis: Axis, index: number) => { + const newSplits = { ...splits, x: [...safeX], y: [...safeY] }; newSplits[axis] = newSplits[axis].filter((_, i) => i !== index); onChange(newSplits); setHoveredSplit(null); @@ -99,62 +147,68 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { }; return ( -
-
+
+

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

- + +
+ +
-
+
-
+ {/* Instructions */} +
- Инструкция + {mode === 'lines' ? 'Режим: Границы' : 'Режим: Ячейки'}
-
    -
  • Клик у края: Новая линия
  • -
  • Перетаскивание: Изменить размер
  • -
  • Двойной клик/ПКМ: Удалить
  • -
+ {mode === 'lines' ? ( +
    +
  • Клик: Новая граница
  • +
  • Драг: Двигать
  • +
+ ) : ( +
    +
  • Клик по ячейке: Настройка стенок
  • +
+ )}
+ {/* Rulers */}
0 {config.drawer.width} мм
-
+
0 {config.drawer.depth} мм
-
- setDragging(null)} - onContextMenu={(e) => e.preventDefault()} + setDragging(null)} onContextMenu={(e) => e.preventDefault()} > @@ -163,113 +217,141 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { - {/* --- Labels --- */} + {/* --- Cells & Partitions --- */} {sortedX.slice(0, -1).map((x1, i) => { const x2 = sortedX[i + 1]; return sortedY.slice(0, -1).map((y1, j) => { - const y2 = sortedY[j + 1]; - const width = (x2 - x1) * config.drawer.width; - const depth = (y2 - y1) * config.drawer.depth; - const centerX = ((x1 + x2) / 2) * viewBoxW; - const centerY = ((y1 + y2) / 2) * viewBoxH; - - const cellWidthSVG = (x2 - x1) * viewBoxW; - const cellHeightSVG = (y2 - y1) * viewBoxH; - - let fontSize = Math.min(36, cellHeightSVG * 0.6); - fontSize = Math.min(fontSize, cellWidthSVG * 0.25); - - if (fontSize < 10) return null; + const cellX = x1 * viewBoxW; + const cellY = y1 * viewBoxH; + const cellW = (x2 - x1) * viewBoxW; + const cellH = (y2 - y1) * viewBoxH; + const isSelected = editingCell?.i === i && editingCell?.j === j; + const parts = safePartitions[`${i}-${j}`] || []; return ( - - {width.toFixed(0)} × {depth.toFixed(0)} - + + { if (mode === 'cells') { e.stopPropagation(); setEditingCell({ i, j }); } }} + /> + {/* Render Partitions */} + {parts.map(p => { + if (p.axis === 'x') { + const px = cellX + (cellW * p.offset); + return ; + } else { + const py = cellY + (cellH * p.offset); + return ; + } + })} + ); }); })} - - {/* --- X Lines (Vertical) --- */} - {safeX.map((x, i) => { - const isHovered = hoveredSplit?.axis === 'x' && hoveredSplit.index === i; - const isDragging = dragging?.axis === 'x' && dragging.index === i; - const color = isHovered || isDragging ? '#f59e0b' : '#64748b'; - const width = isHovered || isDragging ? 8 : 4; - return ( - removeSplit('x', i)} - onMouseMove={(e) => handleSplitHover(e, 'x', i)} - > - - - {(isHovered || isDragging) && ( - { e.stopPropagation(); removeSplit('x', i); }} - onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)} - > - - - - )} - - ); - })} + {/* --- Main Grid Lines --- */} + {safeX.map((x, i) => ( + { if (mode === 'lines' && !dragging) { e.stopPropagation(); setHoveredSplit({ axis: 'x', index: i }); setPhantomAxis(null); } }}> + + + {mode === 'lines' && hoveredSplit?.axis === 'x' && hoveredSplit.index === i && ( + { e.stopPropagation(); removeMainSplit('x', i); }} onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)}> + + + + )} + + ))} - {/* --- Y Lines (Horizontal) --- */} - {safeY.map((y, i) => { - const isHovered = hoveredSplit?.axis === 'y' && hoveredSplit.index === i; - const isDragging = dragging?.axis === 'y' && dragging.index === i; - const color = isHovered || isDragging ? '#f59e0b' : '#64748b'; - const width = isHovered || isDragging ? 8 : 4; + {safeY.map((y, i) => ( + { if (mode === 'lines' && !dragging) { e.stopPropagation(); setHoveredSplit({ axis: 'y', index: i }); setPhantomAxis(null); } }}> + + + {mode === 'lines' && hoveredSplit?.axis === 'y' && hoveredSplit.index === i && ( + { e.stopPropagation(); removeMainSplit('y', i); }} onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)}> + + + + )} + + ))} - return ( - removeSplit('y', i)} - onMouseMove={(e) => handleSplitHover(e, 'y', i)} - > - - - {(isHovered || isDragging) && ( - { e.stopPropagation(); removeSplit('y', i); }} - onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)} - > - - - - )} - - ); - })} - - {/* --- Phantom Lines --- */} - {!hoveredSplit && !dragging && !isButtonHovered && phantomAxis === 'x' && ( - - - - - )} - {!hoveredSplit && !dragging && !isButtonHovered && phantomAxis === 'y' && ( - - - - - )} + {mode === 'lines' && !hoveredSplit && !dragging && phantomAxis === 'x' && } + {mode === 'lines' && !hoveredSplit && !dragging && phantomAxis === 'y' && }
+ + {/* --- MODAL EDITOR FOR CELLS --- */} + {mode === 'cells' && editingCell && ( +
+
+

+ Редактор ячейки +

+ +
+ +
+ + +
+ +
+ {getCurrentPartitions().map((p, idx) => ( +
+
+ + #{idx+1} {p.axis === 'x' ? 'Вертикальная' : 'Горизонтальная'} + + +
+ +
+
+
+ Позиция {(p.offset * 100).toFixed(0)}% +
+ updatePartition(p.id, { offset: parseFloat(e.target.value) })} + className="w-full h-1 bg-slate-600 rounded-lg appearance-none cursor-pointer accent-purple-500" + /> +
+ +
+
+ Высота {p.height} мм +
+ updatePartition(p.id, { height: parseFloat(e.target.value) })} + className="w-full h-1 bg-slate-600 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ +
+ updatePartition(p.id, { rounded: e.target.checked })} + className="rounded bg-slate-700 border-slate-600 text-purple-500 focus:ring-0" + /> + +
+
+
+ ))} + {getCurrentPartitions().length === 0 && ( +
+ Нет перегородок +
+ )} +
+
+ )}
diff --git a/src/components/PreviewStep.tsx b/src/components/PreviewStep.tsx index c9f9aca..3faaeaf 100644 --- a/src/components/PreviewStep.tsx +++ b/src/components/PreviewStep.tsx @@ -33,8 +33,15 @@ interface BinMeshProps { const BinMesh: React.FC = ({ part, thickness, cornerRadius, isSelected, onClick }) => { const geometry = useMemo(() => { - return createBinGeometry(part.width, part.depth, part.height, thickness, cornerRadius); - }, [part, thickness, cornerRadius]); + return createBinGeometry( + part.width, + part.depth, + part.height, + thickness, + cornerRadius, + part.internalPartitions // <--- ВАЖНО: передаем перегородки + ); +}, [part, thickness, cornerRadius]); const edgesGeometry = useMemo(() => { return new THREE.EdgesGeometry(geometry, 20); diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index 8ad4d65..7d1c021 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -1,16 +1,12 @@ import * as THREE from 'three'; import { STLExporter, mergeBufferGeometries } from 'three-stdlib'; -import { AppConfig, LayoutSplits, GeneratedPart } from '../types'; +import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; -export const calculateParts = ( - config: AppConfig, - splits: LayoutSplits -): GeneratedPart[] => { +export const calculateParts = (config: AppConfig, splits: LayoutSplits): GeneratedPart[] => { const parts: GeneratedPart[] = []; - - // Safe Access const safeX = splits.x || []; const safeY = splits.y || []; + const safeParts = splits.partitions || {}; const xPoints = [0, ...[...safeX].sort((a, b) => a - b), 1]; const yPoints = [0, ...[...safeY].sort((a, b) => a - b), 1]; @@ -19,20 +15,21 @@ 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; - const realWidth = segmentW - config.printerTolerance; - const realDepth = segmentD - config.printerTolerance; - const realX = segmentX + (config.printerTolerance / 2); - const realY = segmentY + (config.printerTolerance / 2); + // Получаем перегородки для этой ячейки + const internalPartitions = safeParts[`${i}-${j}`] || []; - if (realWidth < 5 || realDepth < 5) { - continue; - } + // Рассчитываем реальные размеры с учетом допуска принтера + const realWidth = rawW - config.printerTolerance; + const realDepth = rawD - config.printerTolerance; + const realX = rawX + (config.printerTolerance / 2); + const realY = rawY + (config.printerTolerance / 2); + + if (realWidth < 5 || realDepth < 5) continue; parts.push({ id: `part-${partCounter}`, @@ -42,15 +39,18 @@ export const calculateParts = ( height: config.drawer.height, x: realX, y: realY, - color: `hsl(${Math.random() * 360}, 70%, 50%)` + color: `hsl(${Math.random() * 360}, 70%, 50%)`, + internalPartitions: internalPartitions }); partCounter++; } } - return parts; }; +// --- GEOMETRY GENERATION --- + +// Создает форму скругленного прямоугольника (или обычного) const createRoundedRectShape = (width: number, height: number, radius: number): THREE.Shape => { const shape = new THREE.Shape(); const x = -width / 2; @@ -75,20 +75,25 @@ const createRoundedRectShape = (width: number, height: number, radius: number): shape.quadraticCurveTo(x, y, x, y + r); } return shape; -} +}; +// Генерирует геометрию ячейки С ПЕРЕГОРОДКАМИ export const createBinGeometry = ( width: number, depth: number, height: number, thickness: number, - radius: number = 0 + radius: number = 0, + partitions: Partition[] = [] ): THREE.BufferGeometry => { + + const geometries: THREE.BufferGeometry[] = []; + + // 1. ОСНОВНАЯ КОРОБКА (Дно + Стенки) const floorShape = createRoundedRectShape(width, depth, radius); - const floorGeo = new THREE.ExtrudeGeometry(floorShape, { - depth: thickness, bevelEnabled: false, curveSegments: 16 - }); + const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false, curveSegments: 12 }); floorGeo.rotateX(-Math.PI / 2); + geometries.push(floorGeo); const outerShape = createRoundedRectShape(width, depth, radius); const innerRadius = Math.max(0, radius - thickness); @@ -101,19 +106,67 @@ export const createBinGeometry = ( } const wallHeight = height - thickness; - const wallGeo = new THREE.ExtrudeGeometry(outerShape, { - depth: wallHeight, bevelEnabled: false, curveSegments: 16 - }); - + const wallGeo = new THREE.ExtrudeGeometry(outerShape, { depth: wallHeight, bevelEnabled: false, curveSegments: 12 }); wallGeo.rotateX(-Math.PI / 2); wallGeo.translate(0, thickness, 0); + geometries.push(wallGeo); - const merged = mergeBufferGeometries([floorGeo, wallGeo]); + // 2. ВНУТРЕННИЕ ПЕРЕГОРОДКИ + // Мы создаем их внутри внутреннего пространства (innerWidth/innerDepth) + partitions.forEach(p => { + // Размеры перегородки + let pWidth = 0; + let pDepth = 0; + + // Позиция центра перегородки относительно центра ящика + let pX = 0; + let pY = 0; // (это Z в 3D) + + if (p.axis === 'x') { + // Вертикальная палка (делит ширину) + pWidth = thickness; + // Длина палки равна внутренней глубине ящика + pDepth = innerDepth; + + // Смещение: p.offset (0..1) переводим в координаты. + // innerLeft = -innerWidth/2. Position = innerLeft + (innerWidth * offset) + pX = (-innerWidth / 2) + (innerWidth * p.offset); + pY = 0; // По центру глубины + } else { + // Горизонтальная палка (делит глубину) + pWidth = innerWidth; + pDepth = thickness; + + pX = 0; // По центру ширины + pY = (-innerDepth / 2) + (innerDepth * p.offset); + } + + // Форма перегородки (скругленная или нет) + // Если скругленная, радиус берем такой же как у основной стенки, но не больше половины толщины + const pRadius = p.rounded ? Math.min(radius, thickness / 1.5) : 0; + + const partShape = createRoundedRectShape(pWidth, pDepth, pRadius); + const partGeo = new THREE.ExtrudeGeometry(partShape, { + depth: p.height, // Высота перегородки (может отличаться от основной) + bevelEnabled: false, + curveSegments: 8 + }); + + partGeo.rotateX(-Math.PI / 2); + // Поднимаем на толщину дна + partGeo.translate(pX, thickness, pY); + + geometries.push(partGeo); + }); + + // 3. СЛИЯНИЕ + const merged = mergeBufferGeometries(geometries); if (merged) merged.computeVertexNormals(); return merged || new THREE.BoxGeometry(1, 1, 1); }; +// ... Остальной код экспорта (без изменений) ... export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => { const exporter = new STLExporter(); const result = exporter.parse(mesh, { binary: true }); diff --git a/src/types.ts b/src/types.ts index 1bdb5ff..92c517a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,9 +11,20 @@ export interface AppConfig { cornerRadius: number; } +// Описание одной внутренней перегородки +export interface Partition { + id: string; + axis: 'x' | 'y'; // x - вертикальная палка, y - горизонтальная + offset: number; // позиция от 0 до 100% (0.5 = центр) + height: number; // высота стенки в мм + rounded: boolean; // скруглять ли края этой стенки +} + export interface LayoutSplits { x: number[]; y: number[]; + // Ключ: индекс ячейки "i-j", Значение: массив перегородок + partitions: Record; } export interface GeneratedPart { @@ -25,4 +36,6 @@ export interface GeneratedPart { x: number; y: number; color: string; + // Передаем перегородки в генератор + internalPartitions: Partition[]; } \ No newline at end of file From f7911e7147e3badf6f374fa4b794340b2be0979c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Sat, 10 Jan 2026 16:57:20 +0300 Subject: [PATCH 09/32] Try fix layout step --- src/App.tsx | 13 ++++++-- src/components/LayoutStep.tsx | 51 +++++++++++++++---------------- src/components/PreviewStep.tsx | 49 +++++++++++++++++++++-------- src/services/geometryGenerator.ts | 37 +++++----------------- src/types.ts | 11 +++---- 5 files changed, 86 insertions(+), 75 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 8384063..cff9c35 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,7 +17,7 @@ const App = () => { cornerRadius: 4, }); - // ВАЖНО: Инициализируем partitions + // ИНИЦИАЛИЗАЦИЯ: partitions обязательно присутствует const [splits, setSplits] = useState({ x: [], y: [], @@ -28,6 +28,7 @@ const App = () => { const sharedData = parseShareUrl(); if (sharedData) { setConfig(sharedData.config); + // Защита: если в ссылке старый формат, подставляем пустые partitions setSplits({ x: sharedData.splits.x || [], y: sharedData.splits.y || [], @@ -68,7 +69,15 @@ const App = () => {
{step === 1 &&
} - {step === 2 &&
} + + {/* ШАГ 2 */} + {step === 2 && ( +
+ {/* Передаем key, чтобы React пересоздал компонент при смене шага (сброс ошибок) */} + +
+ )} + {step === 3 &&
}
diff --git a/src/components/LayoutStep.tsx b/src/components/LayoutStep.tsx index 21db1d4..8b6cc6c 100644 --- a/src/components/LayoutStep.tsx +++ b/src/components/LayoutStep.tsx @@ -1,6 +1,7 @@ import React, { useRef, useState, useMemo } from 'react'; import { AppConfig, LayoutSplits, Partition } from '../types'; -import { Grid, MousePointer2, Trash2, RotateCcw, LayoutGrid, X, Plus, Settings2, Sliders } from 'lucide-react'; +// Используем только безопасные, стандартные иконки +import { Grid, MousePointer2, Trash2, RotateCcw, X, Plus, Move, Ban } from 'lucide-react'; interface Props { config: AppConfig; @@ -15,18 +16,16 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { const svgRef = useRef(null); const [mode, setMode] = useState('lines'); - - // States for 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); - - // State for Cell Editor + + // Редактор ячеек const [editingCell, setEditingCell] = useState<{ i: number, j: number } | null>(null); - // Safeties + // --- SAFETY FIRST --- const safeX = splits?.x || []; const safeY = splits?.y || []; const safePartitions = splits?.partitions || {}; @@ -38,7 +37,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]); - // --- PARTITION LOGIC --- + // --- Логика перегородок --- const getCurrentPartitions = () => { if (!editingCell) return []; const key = `${editingCell.i}-${editingCell.j}`; @@ -53,7 +52,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { id: Date.now().toString(), axis, offset: 0.5, - height: config.drawer.height, // по умолчанию полная высота + height: config.drawer.height, rounded: false }; @@ -85,7 +84,6 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { }); }; - // --- MOUSE HANDLERS (Global) --- const handleGlobalMouseMove = (e: React.MouseEvent) => { if (!svgRef.current) return; const rect = svgRef.current.getBoundingClientRect(); @@ -155,10 +153,10 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => {
@@ -173,21 +171,21 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { {/* Instructions */}
- {mode === 'lines' ? 'Режим: Границы' : 'Режим: Ячейки'} + + {mode === 'lines' ? 'Режим: Границы' : 'Режим: Ячейки'}
{mode === 'lines' ? (
    -
  • Клик: Новая граница
  • +
  • Клик: Новая линия
  • Драг: Двигать
) : (
    -
  • Клик по ячейке: Настройка стенок
  • +
  • Клик по ячейке: Настройка
)}
- {/* Rulers */}
0 {config.drawer.width} мм @@ -217,7 +215,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { - {/* --- Cells & Partitions --- */} + {/* --- Ячейки и перегородки --- */} {sortedX.slice(0, -1).map((x1, i) => { const x2 = sortedX[i + 1]; return sortedY.slice(0, -1).map((y1, j) => { @@ -236,14 +234,14 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { className={mode === 'cells' ? "cursor-pointer hover:fill-white/5 transition-all" : ""} onClick={(e) => { if (mode === 'cells') { e.stopPropagation(); setEditingCell({ i, j }); } }} /> - {/* Render Partitions */} + {/* Рисуем внутренние стенки */} {parts.map(p => { if (p.axis === 'x') { const px = cellX + (cellW * p.offset); - return ; + return ; } else { const py = cellY + (cellH * p.offset); - return ; + return ; } })} @@ -251,7 +249,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { }); })} - {/* --- Main Grid Lines --- */} + {/* --- Основные линии сетки (Границы) --- */} {safeX.map((x, i) => ( { if (mode === 'lines' && !dragging) { e.stopPropagation(); setHoveredSplit({ axis: 'x', index: i }); setPhantomAxis(null); } }}> @@ -284,22 +282,22 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => {
- {/* --- MODAL EDITOR FOR CELLS --- */} + {/* --- ПАНЕЛЬ РЕДАКТОРА (Справа) --- */} {mode === 'cells' && editingCell && ( -
+

- Редактор ячейки + Редактор ячейки

@@ -345,7 +343,8 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => {
))} {getCurrentPartitions().length === 0 && ( -
+
+ Нет перегородок
)} diff --git a/src/components/PreviewStep.tsx b/src/components/PreviewStep.tsx index 3faaeaf..8334b76 100644 --- a/src/components/PreviewStep.tsx +++ b/src/components/PreviewStep.tsx @@ -8,7 +8,7 @@ import { createBinGeometry, generateSTL, exportSTL } from '../services/geometryG import { Download, Package, Info, Loader2, Share2, Check, Ruler } from 'lucide-react'; import { generateShareUrl } from '../utils/share'; -// --- DrawerFrame (Каркас) --- +// --- DrawerFrame (Каркас ящика) --- const DrawerFrame = ({ config }: { config: AppConfig }) => { const { width, depth, height } = config.drawer; const offset = 0.5; @@ -32,32 +32,37 @@ interface BinMeshProps { } const BinMesh: React.FC = ({ part, thickness, cornerRadius, isSelected, onClick }) => { + // 1. Создаем геометрию, учитывая ВНУТРЕННИЕ ПЕРЕГОРОДКИ const geometry = useMemo(() => { return createBinGeometry( part.width, part.depth, part.height, thickness, - cornerRadius, - part.internalPartitions // <--- ВАЖНО: передаем перегородки + cornerRadius, + part.internalPartitions // <--- ВАЖНО: передаем перегородки в генератор ); -}, [part, thickness, cornerRadius]); + }, [part, thickness, cornerRadius]); + // 2. Создаем контур выделения (EdgesGeometry) + // Threshold 20 градусов скрывает линии на плавных скруглениях const edgesGeometry = useMemo(() => { return new THREE.EdgesGeometry(geometry, 20); }, [geometry]); return ( + {/* Сама модель */} { e.stopPropagation(); onClick(); }}> + {/* Белая подсветка при выборе */} {isSelected && ( @@ -67,7 +72,7 @@ const BinMesh: React.FC = ({ part, thickness, cornerRadius, isSele ); }; -// --- PreviewStep (Основной) --- +// --- PreviewStep (Основной компонент) --- interface Props { parts: GeneratedPart[]; config: AppConfig; @@ -80,25 +85,42 @@ export const PreviewStep: React.FC = ({ parts, config, splits }) => { const [shareUrlCopied, setShareUrlCopied] = useState(false); const itemRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}); + // Скролл к выбранной детали в списке useEffect(() => { if (selectedId && itemRefs.current[selectedId]) { itemRefs.current[selectedId]?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, [selectedId]); + // Скачивание одной детали const handleDownload = (part: GeneratedPart) => { - const geometry = createBinGeometry(part.width, part.depth, part.height, config.wallThickness, config.cornerRadius); + const geometry = createBinGeometry( + part.width, + part.depth, + part.height, + config.wallThickness, + config.cornerRadius, + part.internalPartitions // <--- ВАЖНО для STL + ); const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial()); exportSTL(mesh, `${part.name.replace(/\s+/g, '_')}.stl`); }; + // Скачивание всего архивом const handleDownloadAll = async () => { if (isZipping) return; setIsZipping(true); try { const zip = new JSZip(); parts.forEach(part => { - const geometry = createBinGeometry(part.width, part.depth, part.height, config.wallThickness, config.cornerRadius); + const geometry = createBinGeometry( + part.width, + part.depth, + part.height, + config.wallThickness, + config.cornerRadius, + part.internalPartitions // <--- ВАЖНО для STL + ); const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial()); const stlData = generateSTL(mesh); zip.file(`${part.name.replace(/\s+/g, '_')}.stl`, stlData); @@ -117,6 +139,7 @@ export const PreviewStep: React.FC = ({ parts, config, splits }) => { } }; + // Поделиться ссылкой const handleShare = async () => { const url = generateShareUrl(config, splits); let success = false; @@ -150,8 +173,9 @@ export const PreviewStep: React.FC = ({ parts, config, splits }) => { return (
- {/* Верхняя панель */} + {/* Верхняя панель: Размеры + Поделиться */}
+
@@ -173,6 +197,7 @@ export const PreviewStep: React.FC = ({ parts, config, splits }) => { мм
+ -
{step === 2 && Ячеек: {parts.length}}
+ + +
+ {step === 2 && Ячеек: {parts.length}} +
+ {step < 3 ? ( - + ) : ( - + )}
diff --git a/src/components/LayoutStep.tsx b/src/components/LayoutStep.tsx index 8b6cc6c..2e8904f 100644 --- a/src/components/LayoutStep.tsx +++ b/src/components/LayoutStep.tsx @@ -1,7 +1,7 @@ import React, { useRef, useState, useMemo } from 'react'; import { AppConfig, LayoutSplits, Partition } from '../types'; -// Используем только безопасные, стандартные иконки -import { Grid, MousePointer2, Trash2, RotateCcw, X, Plus, Move, Ban } from 'lucide-react'; +// ИСПОЛЬЗУЕМ ТОЛЬКО БАЗОВЫЕ ИКОНКИ (чтобы не крашилось из-за версий) +import { Grid, MousePointer2, Trash2, RotateCcw, X, Plus, Check } from 'lucide-react'; interface Props { config: AppConfig; @@ -16,40 +16,39 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { const svgRef = useRef(null); const [mode, setMode] = useState('lines'); + + // SVG State 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); - - // Редактор ячеек + + // Editor State const [editingCell, setEditingCell] = useState<{ i: number, j: number } | null>(null); - // --- SAFETY FIRST --- - const safeX = splits?.x || []; - const safeY = splits?.y || []; + // --- ЗАЩИТА ДАННЫХ (ОТ БЕЛОГО ЭКРАНА) --- + const safeX = Array.isArray(splits?.x) ? splits.x : []; + const safeY = Array.isArray(splits?.y) ? splits.y : []; const safePartitions = splits?.partitions || {}; + // Расчет размеров SVG с защитой от деления на ноль + const width = Math.max(1, config.drawer.width || 100); + const depth = Math.max(1, config.drawer.depth || 100); const viewBoxW = 1000; - const aspectRatio = (config.drawer.depth || 1) / (config.drawer.width || 1); + const aspectRatio = depth / width; const viewBoxH = viewBoxW * aspectRatio; const sortedX = useMemo(() => [0, ...safeX, 1].sort((a, b) => a - b), [safeX]); const sortedY = useMemo(() => [0, ...safeY, 1].sort((a, b) => a - b), [safeY]); - // --- Логика перегородок --- - const getCurrentPartitions = () => { - if (!editingCell) return []; - const key = `${editingCell.i}-${editingCell.j}`; - return safePartitions[key] || []; - }; - + // --- ЛОГИКА ПЕРЕГОРОДОК --- const addPartition = (axis: 'x' | 'y') => { if (!editingCell) return; const key = `${editingCell.i}-${editingCell.j}`; const current = safePartitions[key] || []; const newPart: Partition = { - id: Date.now().toString(), + id: Math.random().toString(36).substr(2, 9), axis, offset: 0.5, height: config.drawer.height, @@ -84,9 +83,12 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { }); }; + // --- UI HANDLERS --- const handleGlobalMouseMove = (e: React.MouseEvent) => { if (!svgRef.current) return; const rect = svgRef.current.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; // Защита + const nx = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const ny = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); @@ -100,16 +102,16 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { onChange(newSplits); return; } + if (isButtonHovered) return; setHoveredSplit(null); + // Фантомная линия const SNAP = 0.02; const closeToX = safeX.some(val => Math.abs(nx - val) < SNAP); const closeToY = safeY.some(val => Math.abs(ny - val) < SNAP); - const closeToEdgeX = nx < SNAP || nx > (1 - SNAP); - const closeToEdgeY = ny < SNAP || ny > (1 - SNAP); - - if (!closeToX && !closeToY && !closeToEdgeX && !closeToEdgeY) { + + if (!closeToX && !closeToY && nx > SNAP && nx < 1-SNAP && ny > SNAP && ny < 1-SNAP) { const distRight = 1 - nx; const distBottom = 1 - ny; setPhantomAxis(Math.min(nx, distRight) < Math.min(ny, distBottom) ? 'y' : 'x'); } else { @@ -131,6 +133,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { setDragging({ axis: phantomAxis, index: newSplits[phantomAxis].length - 1 }); } } else { + // Режим ячеек: клик обрабатывается на самих rect'ах, здесь только сброс if (e.target === svgRef.current) setEditingCell(null); } }; @@ -146,212 +149,202 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { return (
+ + {/* HEADER */}

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

- -
-
- {/* Instructions */} -
+ {/* Instruction Overlay */} +
{mode === 'lines' ? 'Режим: Границы' : 'Режим: Ячейки'}
- {mode === 'lines' ? ( -
    -
  • Клик: Новая линия
  • -
  • Драг: Двигать
  • -
- ) : ( -
    -
  • Клик по ячейке: Настройка
  • -
- )} +

+ {mode === 'lines' ? 'Клик: создать. Драг: двигать. ПКМ: удалить.' : 'Кликни по ячейке, чтобы добавить стенки внутри.'} +

-
- 0 - {config.drawer.width} мм -
+ {/* Canvas Container */} +
+ setDragging(null)} onContextMenu={(e) => e.preventDefault()} + > + + + + + + -
-
- 0 - {config.drawer.depth} мм -
+ {/* Ячейки и перегородки */} + {sortedX.slice(0, -1).map((x1, i) => { + const x2 = sortedX[i + 1]; + return sortedY.slice(0, -1).map((y1, j) => { + const cellX = x1 * viewBoxW; + const cellY = y1 * viewBoxH; + const cellW = (x2 - x1) * viewBoxW; + const cellH = (y2 - y1) * viewBoxH; + const isSelected = editingCell?.i === i && editingCell?.j === j; + const parts = safePartitions[`${i}-${j}`] || []; -
- setDragging(null)} onContextMenu={(e) => e.preventDefault()} - > - - - - - - + return ( + + {/* Прямоугольник для клика */} + { if (mode === 'cells') { e.stopPropagation(); setEditingCell({ i, j }); } }} + /> + {/* Перегородки */} + {parts.map(p => { + if (p.axis === 'x') { + const px = cellX + (cellW * p.offset); + return ; + } else { + const py = cellY + (cellH * p.offset); + return ; + } + })} + + ); + }); + })} - {/* --- Ячейки и перегородки --- */} - {sortedX.slice(0, -1).map((x1, i) => { - const x2 = sortedX[i + 1]; - return sortedY.slice(0, -1).map((y1, j) => { - const cellX = x1 * viewBoxW; - const cellY = y1 * viewBoxH; - const cellW = (x2 - x1) * viewBoxW; - const cellH = (y2 - y1) * viewBoxH; - const isSelected = editingCell?.i === i && editingCell?.j === j; - const parts = safePartitions[`${i}-${j}`] || []; - - return ( - - { if (mode === 'cells') { e.stopPropagation(); setEditingCell({ i, j }); } }} - /> - {/* Рисуем внутренние стенки */} - {parts.map(p => { - if (p.axis === 'x') { - const px = cellX + (cellW * p.offset); - return ; - } else { - const py = cellY + (cellH * p.offset); - return ; - } - })} - - ); - }); - })} - - {/* --- Основные линии сетки (Границы) --- */} - {safeX.map((x, i) => ( - { if (mode === 'lines' && !dragging) { e.stopPropagation(); setHoveredSplit({ axis: 'x', index: i }); setPhantomAxis(null); } }}> - - - {mode === 'lines' && hoveredSplit?.axis === 'x' && hoveredSplit.index === i && ( - { e.stopPropagation(); removeMainSplit('x', i); }} onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)}> - - - - )} - - ))} - - {safeY.map((y, i) => ( - { if (mode === 'lines' && !dragging) { e.stopPropagation(); setHoveredSplit({ axis: 'y', index: i }); setPhantomAxis(null); } }}> - - - {mode === 'lines' && hoveredSplit?.axis === 'y' && hoveredSplit.index === i && ( - { e.stopPropagation(); removeMainSplit('y', i); }} onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)}> - - - - )} - - ))} - - {mode === 'lines' && !hoveredSplit && !dragging && phantomAxis === 'x' && } - {mode === 'lines' && !hoveredSplit && !dragging && phantomAxis === 'y' && } - -
-
- - {/* --- ПАНЕЛЬ РЕДАКТОРА (Справа) --- */} - {mode === 'cells' && editingCell && ( -
-
-

- Редактор ячейки -

- -
- -
- - -
- -
- {getCurrentPartitions().map((p, idx) => ( -
-
- - #{idx+1} {p.axis === 'x' ? 'Вертикальная' : 'Горизонтальная'} - - -
- -
-
-
- Позиция {(p.offset * 100).toFixed(0)}% -
- updatePartition(p.id, { offset: parseFloat(e.target.value) })} - className="w-full h-1 bg-slate-600 rounded-lg appearance-none cursor-pointer accent-purple-500" - /> -
- -
-
- Высота {p.height} мм -
- updatePartition(p.id, { height: parseFloat(e.target.value) })} - className="w-full h-1 bg-slate-600 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
- -
- updatePartition(p.id, { rounded: e.target.checked })} - className="rounded bg-slate-700 border-slate-600 text-purple-500 focus:ring-0" - /> - -
-
-
- ))} - {getCurrentPartitions().length === 0 && ( -
- - Нет перегородок -
+ {/* Линии сетки (X) */} + {safeX.map((x, i) => ( + { if (mode === 'lines' && !dragging) { e.stopPropagation(); setHoveredSplit({ axis: 'x', index: i }); setPhantomAxis(null); } }}> + + + {mode === 'lines' && hoveredSplit?.axis === 'x' && hoveredSplit.index === i && ( + { e.stopPropagation(); removeMainSplit('x', i); }} onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)}> + + + )} -
-
- )} + + ))} + + {/* Линии сетки (Y) */} + {safeY.map((y, i) => ( + { if (mode === 'lines' && !dragging) { e.stopPropagation(); setHoveredSplit({ axis: 'y', index: i }); setPhantomAxis(null); } }}> + + + {mode === 'lines' && hoveredSplit?.axis === 'y' && hoveredSplit.index === i && ( + { e.stopPropagation(); removeMainSplit('y', i); }} onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)}> + + + + )} + + ))} + + {/* Фантомная линия */} + {mode === 'lines' && !hoveredSplit && !dragging && phantomAxis === 'x' && } + {mode === 'lines' && !hoveredSplit && !dragging && phantomAxis === 'y' && } + +
+ + {/* --- ПАНЕЛЬ РЕДАКТОРА (Справа) --- */} + {mode === 'cells' && editingCell && ( +
+
+

+ Редактор ячейки +

+ +
+ +
+ + +
+ +
+ {(safePartitions[`${editingCell.i}-${editingCell.j}`] || []).map((p, idx) => ( +
+
+ + #{idx+1} {p.axis === 'x' ? 'Вертикальная' : 'Горизонтальная'} + + +
+ +
+
+
+ Позиция {(p.offset * 100).toFixed(0)}% +
+ updatePartition(p.id, { offset: parseFloat(e.target.value) })} + className="w-full h-1 bg-slate-600 rounded-lg appearance-none cursor-pointer accent-purple-500" + /> +
+ +
+
+ Высота {p.height} мм +
+ updatePartition(p.id, { height: parseFloat(e.target.value) })} + className="w-full h-1 bg-slate-600 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ +
+ updatePartition(p.id, { rounded: e.target.checked })} + className="rounded bg-slate-700 border-slate-600 text-purple-500 focus:ring-0" + /> + +
+
+
+ ))} + {(safePartitions[`${editingCell.i}-${editingCell.j}`] || []).length === 0 && ( +
+ Нет перегородок +
+ )} +
+ + +
+ )}
); diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index 9ee9052..8aa57b9 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -4,9 +4,11 @@ import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; export const calculateParts = (config: AppConfig, splits: LayoutSplits): GeneratedPart[] => { const parts: GeneratedPart[] = []; - const safeX = splits.x || []; - const safeY = splits.y || []; - const safeParts = splits.partitions || {}; + + // ЗАЩИТА ОТ ОШИБОК ДАННЫХ + const safeX = Array.isArray(splits?.x) ? splits.x : []; + const safeY = Array.isArray(splits?.y) ? splits.y : []; + const safePartitions = splits?.partitions || {}; const xPoints = [0, ...[...safeX].sort((a, b) => a - b), 1]; const yPoints = [0, ...[...safeY].sort((a, b) => a - b), 1]; @@ -15,13 +17,16 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat for (let i = 0; i < xPoints.length - 1; i++) { for (let j = 0; j < yPoints.length - 1; j++) { + 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; - const internalPartitions = safeParts[`${i}-${j}`] || []; + // Получаем перегородки + const internalPartitions = safePartitions[`${i}-${j}`] || []; + // Допуски const realWidth = rawW - config.printerTolerance; const realDepth = rawD - config.printerTolerance; const realX = rawX + (config.printerTolerance / 2); @@ -46,7 +51,8 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat return parts; }; -// Геометрия +// ... Вспомогательные функции (createBinGeometry, exportSTL) ... +// (Они остаются без изменений из прошлого ответа, там всё верно) const createRoundedRectShape = (width: number, height: number, radius: number): THREE.Shape => { const shape = new THREE.Shape(); const x = -width / 2; @@ -74,23 +80,15 @@ const createRoundedRectShape = (width: number, height: number, radius: number): }; export const createBinGeometry = ( - width: number, - depth: number, - height: number, - thickness: number, - radius: number = 0, - partitions: Partition[] = [] + width: number, depth: number, height: number, thickness: number, radius: number = 0, partitions: Partition[] = [] ): THREE.BufferGeometry => { - const geometries: THREE.BufferGeometry[] = []; - // 1. ДНО const floorShape = createRoundedRectShape(width, depth, radius); const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false, curveSegments: 12 }); floorGeo.rotateX(-Math.PI / 2); geometries.push(floorGeo); - // 2. ВНЕШНИЕ СТЕНКИ const outerShape = createRoundedRectShape(width, depth, radius); const innerRadius = Math.max(0, radius - thickness); const innerWidth = width - (2 * thickness); @@ -107,51 +105,32 @@ export const createBinGeometry = ( wallGeo.translate(0, thickness, 0); geometries.push(wallGeo); - // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ partitions.forEach(p => { - let pWidth = 0; - let pDepth = 0; - let pX = 0; - let pY = 0; - + let pWidth = 0, pDepth = 0, pX = 0, pY = 0; if (p.axis === 'x') { - pWidth = thickness; - pDepth = innerDepth; - pX = (-innerWidth / 2) + (innerWidth * p.offset); - pY = 0; + pWidth = thickness; pDepth = innerDepth; + pX = (-innerWidth / 2) + (innerWidth * p.offset); pY = 0; } else { - pWidth = innerWidth; - pDepth = thickness; - pX = 0; - pY = (-innerDepth / 2) + (innerDepth * p.offset); + pWidth = innerWidth; pDepth = thickness; + pX = 0; pY = (-innerDepth / 2) + (innerDepth * p.offset); } - const pRadius = p.rounded ? Math.min(radius, thickness / 1.5) : 0; const partShape = createRoundedRectShape(pWidth, pDepth, pRadius); - const partGeo = new THREE.ExtrudeGeometry(partShape, { - depth: p.height, - bevelEnabled: false, - curveSegments: 8 - }); - + const partGeo = new THREE.ExtrudeGeometry(partShape, { depth: p.height, bevelEnabled: false, curveSegments: 8 }); partGeo.rotateX(-Math.PI / 2); partGeo.translate(pX, thickness, pY); - geometries.push(partGeo); }); const merged = mergeBufferGeometries(geometries); if (merged) merged.computeVertexNormals(); - return merged || new THREE.BoxGeometry(1, 1, 1); }; export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => { const exporter = new STLExporter(); const result = exporter.parse(mesh, { binary: true }); - if (result instanceof DataView) { - return new Uint8Array(result.buffer, result.byteOffset, result.byteLength); - } + if (result instanceof DataView) return new Uint8Array(result.buffer, result.byteOffset, result.byteLength); return result as string; }; From 3f527ab7e4f923f0d866a1641717d2c274a0ac77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Sat, 10 Jan 2026 17:16:36 +0300 Subject: [PATCH 11/32] 2 fix --- src/App.tsx | 101 +++------ src/components/LayoutStep.tsx | 409 ++++++++++++++++------------------ 2 files changed, 226 insertions(+), 284 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b31f2b7..7212f18 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,9 +9,8 @@ import { ChevronRight, ChevronLeft, Box } from 'lucide-react'; const App = () => { const [step, setStep] = useState(1); - const [isLoadedFromUrl, setIsLoadedFromUrl] = useState(false); - // State + // Инициализация конфига const [config, setConfig] = useState({ drawer: { width: 300, depth: 400, height: 80 }, wallThickness: 1.2, @@ -19,7 +18,7 @@ const App = () => { cornerRadius: 4, }); - // Инициализация с гарантированными пустыми массивами + // Инициализация splits с пустой структурой const [splits, setSplits] = useState({ x: [], y: [], @@ -28,36 +27,29 @@ const App = () => { useEffect(() => { try { - const sharedData = parseShareUrl(); - if (sharedData) { - setConfig(sharedData.config); - setSplits({ - x: Array.isArray(sharedData.splits.x) ? sharedData.splits.x : [], - y: Array.isArray(sharedData.splits.y) ? sharedData.splits.y : [], - partitions: sharedData.splits.partitions || {} - }); - setStep(3); - setIsLoadedFromUrl(true); - window.history.replaceState({}, '', window.location.pathname); - } - } catch (e) { - console.error("Ошибка при загрузке URL:", e); + const sharedData = parseShareUrl(); + if (sharedData) { + setConfig(sharedData.config); + // Жесткое приведение типов, чтобы избежать undefined + setSplits({ + x: Array.isArray(sharedData.splits.x) ? sharedData.splits.x : [], + y: Array.isArray(sharedData.splits.y) ? sharedData.splits.y : [], + partitions: sharedData.splits.partitions || {} + }); + setStep(3); + window.history.replaceState({}, '', window.location.pathname); + } + } catch(e) { + console.error("URL Error", e); } }, []); - // ЗАЩИТА: Оборачиваем расчет геометрии, чтобы не ломать весь UI при ошибке const parts: GeneratedPart[] = useMemo(() => { - try { - return calculateParts(config, splits); - } catch (e) { - console.error("Ошибка расчета деталей:", e); - return []; // Возвращаем пустой массив вместо краша - } + return calculateParts(config, splits); }, [config, splits]); return (
- {/* Header */}
@@ -69,26 +61,18 @@ const App = () => {

Генератор органайзеров

- + {/* Step Indicator */}
{[1, 2, 3].map((num) => ( - -
- - {num} - - - {num === 1 ? 'Настройки' : num === 2 ? 'Макет' : 'Экспорт'} - -
- {num < 3 &&
} - +
+ {num} + {num === 1 ? 'Настройки' : num === 2 ? 'Макет' : 'Экспорт'} +
))}
- {/* Main Content */}
{step === 1 && (
@@ -98,12 +82,8 @@ const App = () => { {step === 2 && (
- {/* Добавляем проверку на существование данных */} - + {/* Передаем key для принудительного пересоздания компонента */} +
)} @@ -114,39 +94,14 @@ const App = () => { )}
- {/* Footer */}
- - -
- {step === 2 && Ячеек: {parts.length}} -
- + + {step < 3 ? ( - + ) : ( - + )}
diff --git a/src/components/LayoutStep.tsx b/src/components/LayoutStep.tsx index 2e8904f..8c396eb 100644 --- a/src/components/LayoutStep.tsx +++ b/src/components/LayoutStep.tsx @@ -1,7 +1,7 @@ import React, { useRef, useState, useMemo } from 'react'; import { AppConfig, LayoutSplits, Partition } from '../types'; -// ИСПОЛЬЗУЕМ ТОЛЬКО БАЗОВЫЕ ИКОНКИ (чтобы не крашилось из-за версий) -import { Grid, MousePointer2, Trash2, RotateCcw, X, Plus, Check } from 'lucide-react'; +// ИСПОЛЬЗУЕМ ТОЛЬКО 4 БАЗОВЫЕ ИКОНКИ, ЧТОБЫ ИСКЛЮЧИТЬ ОШИБКИ +import { Grid, Trash2, X, Plus } from 'lucide-react'; interface Props { config: AppConfig; @@ -15,26 +15,29 @@ type Axis = 'x' | 'y'; export const LayoutStep: React.FC = ({ config, splits, onChange }) => { const svgRef = useRef(null); + // Режим работы const [mode, setMode] = useState('lines'); - // SVG State + // Состояния 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); - - // Editor State + + // Редактируемая ячейка const [editingCell, setEditingCell] = useState<{ i: number, j: number } | null>(null); - // --- ЗАЩИТА ДАННЫХ (ОТ БЕЛОГО ЭКРАНА) --- + // --- ЗАЩИТА ДАННЫХ --- + // Если что-то пришло undefined, подменяем на пустые значения const safeX = Array.isArray(splits?.x) ? splits.x : []; const safeY = Array.isArray(splits?.y) ? splits.y : []; const safePartitions = splits?.partitions || {}; - // Расчет размеров SVG с защитой от деления на ноль - const width = Math.max(1, config.drawer.width || 100); - const depth = Math.max(1, config.drawer.depth || 100); + // Защита от деления на ноль при расчете пропорций + const width = Math.max(1, config.drawer.width || 300); + const depth = Math.max(1, config.drawer.depth || 400); + const viewBoxW = 1000; const aspectRatio = depth / width; const viewBoxH = viewBoxW * aspectRatio; @@ -42,7 +45,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]); - // --- ЛОГИКА ПЕРЕГОРОДОК --- + // --- ЛОГИКА --- const addPartition = (axis: 'x' | 'y') => { if (!editingCell) return; const key = `${editingCell.i}-${editingCell.j}`; @@ -51,14 +54,10 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { id: Math.random().toString(36).substr(2, 9), axis, offset: 0.5, - height: config.drawer.height, + height: config.drawer.height || 80, rounded: false }; - - onChange({ - ...splits, - partitions: { ...safePartitions, [key]: [...current, newPart] } - }); + onChange({ ...splits, partitions: { ...safePartitions, [key]: [...current, newPart] } }); }; const updatePartition = (id: string, updates: Partial) => { @@ -66,28 +65,21 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { const key = `${editingCell.i}-${editingCell.j}`; const current = safePartitions[key] || []; const updated = current.map(p => p.id === id ? { ...p, ...updates } : p); - - onChange({ - ...splits, - partitions: { ...safePartitions, [key]: updated } - }); + onChange({ ...splits, partitions: { ...safePartitions, [key]: updated } }); }; const removePartition = (id: string) => { if (!editingCell) return; const key = `${editingCell.i}-${editingCell.j}`; const current = safePartitions[key] || []; - onChange({ - ...splits, - partitions: { ...safePartitions, [key]: current.filter(p => p.id !== id) } - }); + onChange({ ...splits, partitions: { ...safePartitions, [key]: current.filter(p => p.id !== id) } }); }; - // --- UI HANDLERS --- + // --- MOUSE HANDLERS --- const handleGlobalMouseMove = (e: React.MouseEvent) => { if (!svgRef.current) return; const rect = svgRef.current.getBoundingClientRect(); - if (rect.width === 0 || rect.height === 0) return; // Защита + if (rect.width === 0) return; const nx = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const ny = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); @@ -102,20 +94,22 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { onChange(newSplits); return; } - if (isButtonHovered) return; setHoveredSplit(null); // Фантомная линия - const SNAP = 0.02; - const closeToX = safeX.some(val => Math.abs(nx - val) < SNAP); - const closeToY = safeY.some(val => Math.abs(ny - val) < SNAP); - - if (!closeToX && !closeToY && nx > SNAP && nx < 1-SNAP && ny > SNAP && ny < 1-SNAP) { - const distRight = 1 - nx; const distBottom = 1 - ny; - setPhantomAxis(Math.min(nx, distRight) < Math.min(ny, distBottom) ? 'y' : 'x'); - } else { - setPhantomAxis(null); + const SNAP = 0.02; + if (nx > SNAP && nx < 1-SNAP && ny > SNAP && ny < 1-SNAP) { + // Простая проверка, не слишком ли близко к другим линиям + const closeToX = safeX.some(val => Math.abs(nx - val) < SNAP); + const closeToY = safeY.some(val => Math.abs(ny - val) < SNAP); + + if (!closeToX && !closeToY) { + const distRight = 1 - nx; const distBottom = 1 - ny; + setPhantomAxis(Math.min(nx, distRight) < Math.min(ny, distBottom) ? 'y' : 'x'); + } else { + setPhantomAxis(null); + } } } }; @@ -133,7 +127,6 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { setDragging({ axis: phantomAxis, index: newSplits[phantomAxis].length - 1 }); } } else { - // Режим ячеек: клик обрабатывается на самих rect'ах, здесь только сброс if (e.target === svgRef.current) setEditingCell(null); } }; @@ -153,198 +146,192 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { {/* HEADER */}

- 2. Редактор + 2. Макет

- -
-
- {/* Instruction Overlay */} -
-
- - {mode === 'lines' ? 'Режим: Границы' : 'Режим: Ячейки'} -
-

- {mode === 'lines' ? 'Клик: создать. Драг: двигать. ПКМ: удалить.' : 'Кликни по ячейке, чтобы добавить стенки внутри.'} -

+ {/* DEBUG INFO (Если вдруг снова пусто - увидим это) */} +
+ {width}x{depth} | X:{safeX.length} Y:{safeY.length}
- {/* Canvas Container */} -
- setDragging(null)} onContextMenu={(e) => e.preventDefault()} - > - - - - - - - - {/* Ячейки и перегородки */} - {sortedX.slice(0, -1).map((x1, i) => { - const x2 = sortedX[i + 1]; - return sortedY.slice(0, -1).map((y1, j) => { - const cellX = x1 * viewBoxW; - const cellY = y1 * viewBoxH; - const cellW = (x2 - x1) * viewBoxW; - const cellH = (y2 - y1) * viewBoxH; - const isSelected = editingCell?.i === i && editingCell?.j === j; - const parts = safePartitions[`${i}-${j}`] || []; - - return ( - - {/* Прямоугольник для клика */} - { if (mode === 'cells') { e.stopPropagation(); setEditingCell({ i, j }); } }} - /> - {/* Перегородки */} - {parts.map(p => { - if (p.axis === 'x') { - const px = cellX + (cellW * p.offset); - return ; - } else { - const py = cellY + (cellH * p.offset); - return ; - } - })} - - ); - }); - })} - - {/* Линии сетки (X) */} - {safeX.map((x, i) => ( - { if (mode === 'lines' && !dragging) { e.stopPropagation(); setHoveredSplit({ axis: 'x', index: i }); setPhantomAxis(null); } }}> - - - {mode === 'lines' && hoveredSplit?.axis === 'x' && hoveredSplit.index === i && ( - { e.stopPropagation(); removeMainSplit('x', i); }} onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)}> - - - - )} - - ))} - - {/* Линии сетки (Y) */} - {safeY.map((y, i) => ( - { if (mode === 'lines' && !dragging) { e.stopPropagation(); setHoveredSplit({ axis: 'y', index: i }); setPhantomAxis(null); } }}> - - - {mode === 'lines' && hoveredSplit?.axis === 'y' && hoveredSplit.index === i && ( - { e.stopPropagation(); removeMainSplit('y', i); }} onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)}> - - - - )} - - ))} - - {/* Фантомная линия */} - {mode === 'lines' && !hoveredSplit && !dragging && phantomAxis === 'x' && } - {mode === 'lines' && !hoveredSplit && !dragging && phantomAxis === 'y' && } - + {/* Rulers */} +
+ 0 + {width} мм
+ +
+
+ 0 + {depth} мм +
+ + {/* SVG */} +
+ setDragging(null)} onContextMenu={(e) => e.preventDefault()} + > + + + + + + + + {/* CELLS (Рисуем их первыми, чтобы ловить клики) */} + {sortedX.slice(0, -1).map((x1, i) => { + const x2 = sortedX[i + 1]; + return sortedY.slice(0, -1).map((y1, j) => { + const cellX = x1 * viewBoxW; + const cellY = y1 * viewBoxH; + const cellW = (x2 - x1) * viewBoxW; + const cellH = (y2 - y1) * viewBoxH; + const isSelected = editingCell?.i === i && editingCell?.j === j; + const parts = safePartitions[`${i}-${j}`] || []; + + return ( + + { if (mode === 'cells') { e.stopPropagation(); setEditingCell({ i, j }); } }} + /> + {parts.map(p => { + if (p.axis === 'x') { + const px = cellX + (cellW * p.offset); + return ; + } else { + const py = cellY + (cellH * p.offset); + return ; + } + })} + + ); + }); + })} + + {/* GRID LINES X */} + {safeX.map((x, i) => ( + { if (mode === 'lines' && !dragging) { e.stopPropagation(); setHoveredSplit({ axis: 'x', index: i }); setPhantomAxis(null); } }}> + + + {mode === 'lines' && hoveredSplit?.axis === 'x' && hoveredSplit.index === i && ( + { e.stopPropagation(); removeMainSplit('x', i); }} onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)}> + + + + )} + + ))} + + {/* GRID LINES Y */} + {safeY.map((y, i) => ( + { if (mode === 'lines' && !dragging) { e.stopPropagation(); setHoveredSplit({ axis: 'y', index: i }); setPhantomAxis(null); } }}> + + + {mode === 'lines' && hoveredSplit?.axis === 'y' && hoveredSplit.index === i && ( + { e.stopPropagation(); removeMainSplit('y', i); }} onMouseEnter={() => setIsButtonHovered(true)} onMouseLeave={() => setIsButtonHovered(false)}> + + + + )} + + ))} + + {/* PHANTOM LINES */} + {mode === 'lines' && !hoveredSplit && !dragging && phantomAxis === 'x' && } + {mode === 'lines' && !hoveredSplit && !dragging && phantomAxis === 'y' && } + +
+
+ + {/* --- EDITOR PANEL --- */} + {mode === 'cells' && editingCell && ( +
+
+

Редактор ячейки

+ +
+ +
+ + +
+ +
+ {(safePartitions[`${editingCell.i}-${editingCell.j}`] || []).map((p, idx) => ( +
+
+ + Стенка #{idx+1} ({p.axis === 'x' ? 'Верт' : 'Гориз'}) + + +
+
+
+
+ Позиция {(p.offset * 100).toFixed(0)}% +
+ updatePartition(p.id, { offset: parseFloat(e.target.value) })} + className="w-full h-1 bg-slate-600 rounded-lg appearance-none cursor-pointer accent-purple-500" + /> +
+
+
+ Высота {p.height} мм +
+ updatePartition(p.id, { height: parseFloat(e.target.value) })} + className="w-full h-1 bg-slate-600 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+
+ updatePartition(p.id, { rounded: e.target.checked })} + className="rounded bg-slate-700 border-slate-600 text-purple-500 focus:ring-0" + /> + +
+
+
+ ))} +
+
+ )}
- - {/* --- ПАНЕЛЬ РЕДАКТОРА (Справа) --- */} - {mode === 'cells' && editingCell && ( -
-
-

- Редактор ячейки -

- -
- -
- - -
- -
- {(safePartitions[`${editingCell.i}-${editingCell.j}`] || []).map((p, idx) => ( -
-
- - #{idx+1} {p.axis === 'x' ? 'Вертикальная' : 'Горизонтальная'} - - -
- -
-
-
- Позиция {(p.offset * 100).toFixed(0)}% -
- updatePartition(p.id, { offset: parseFloat(e.target.value) })} - className="w-full h-1 bg-slate-600 rounded-lg appearance-none cursor-pointer accent-purple-500" - /> -
- -
-
- Высота {p.height} мм -
- updatePartition(p.id, { height: parseFloat(e.target.value) })} - className="w-full h-1 bg-slate-600 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
- -
- updatePartition(p.id, { rounded: e.target.checked })} - className="rounded bg-slate-700 border-slate-600 text-purple-500 focus:ring-0" - /> - -
-
-
- ))} - {(safePartitions[`${editingCell.i}-${editingCell.j}`] || []).length === 0 && ( -
- Нет перегородок -
- )} -
- - -
- )}
); From cf09961807098ffcc684e0e0736ba0e7945938fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Sat, 10 Jan 2026 17:23:27 +0300 Subject: [PATCH 12/32] Add logs --- src/App.tsx | 98 ++++++++++++++++++------ src/components/LayoutStep.tsx | 138 +++++++++++----------------------- 2 files changed, 119 insertions(+), 117 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 7212f18..fb01df7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,12 +5,50 @@ import { ConfigStep } from './components/ConfigStep'; import { LayoutStep } from './components/LayoutStep'; import { PreviewStep } from './components/PreviewStep'; import { parseShareUrl } from './utils/share'; -import { ChevronRight, ChevronLeft, Box } from 'lucide-react'; +import { ChevronRight, ChevronLeft, Box, AlertTriangle } from 'lucide-react'; + +// --- ERROR BOUNDARY (Ловец ошибок) --- +class ErrorBoundary extends React.Component<{children: React.ReactNode}, {hasError: boolean, error: string}> { + constructor(props: any) { + super(props); + this.state = { hasError: false, error: '' }; + } + + static getDerivedStateFromError(error: any) { + return { hasError: true, error: error.toString() }; + } + + componentDidCatch(error: any, errorInfo: any) { + console.error("CRITICAL UI ERROR:", error, errorInfo); + } + + render() { + if (this.state.hasError) { + return ( +
+

+ Что-то сломалось в этом компоненте +

+
+            {this.state.error}
+          
+ +
+ ); + } + return this.props.children; + } +} const App = () => { const [step, setStep] = useState(1); + const [isLoadedFromUrl, setIsLoadedFromUrl] = useState(false); - // Инициализация конфига const [config, setConfig] = useState({ drawer: { width: 300, depth: 400, height: 80 }, wallThickness: 1.2, @@ -18,34 +56,44 @@ const App = () => { cornerRadius: 4, }); - // Инициализация splits с пустой структурой const [splits, setSplits] = useState({ x: [], y: [], partitions: {} }); + // Логируем состояние при каждом изменении + useEffect(() => { + console.log("APP STATE UPDATE:", { step, splits, config }); + }, [step, splits, config]); + useEffect(() => { try { - const sharedData = parseShareUrl(); - if (sharedData) { - setConfig(sharedData.config); - // Жесткое приведение типов, чтобы избежать undefined - setSplits({ - x: Array.isArray(sharedData.splits.x) ? sharedData.splits.x : [], - y: Array.isArray(sharedData.splits.y) ? sharedData.splits.y : [], - partitions: sharedData.splits.partitions || {} - }); - setStep(3); - window.history.replaceState({}, '', window.location.pathname); - } - } catch(e) { - console.error("URL Error", e); + const sharedData = parseShareUrl(); + if (sharedData) { + console.log("Loaded from URL:", sharedData); + setConfig(sharedData.config); + setSplits({ + x: Array.isArray(sharedData.splits.x) ? sharedData.splits.x : [], + y: Array.isArray(sharedData.splits.y) ? sharedData.splits.y : [], + partitions: sharedData.splits.partitions || {} + }); + setStep(3); + setIsLoadedFromUrl(true); + window.history.replaceState({}, '', window.location.pathname); + } + } catch (e) { + console.error("Url Parse Error", e); } }, []); const parts: GeneratedPart[] = useMemo(() => { - return calculateParts(config, splits); + try { + return calculateParts(config, splits); + } catch(e) { + console.error("Geometry Calc Error:", e); + return []; + } }, [config, splits]); return ( @@ -61,7 +109,6 @@ const App = () => {

Генератор органайзеров

- {/* Step Indicator */}
{[1, 2, 3].map((num) => (
@@ -82,8 +129,15 @@ const App = () => { {step === 2 && (
- {/* Передаем key для принудительного пересоздания компонента */} - + {/* Оборачиваем LayoutStep в ErrorBoundary */} + + +
)} @@ -97,7 +151,7 @@ const App = () => {