From a9b07a07d8a71a9a8816bccbe73ae08a31679856 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:09:53 +0300 Subject: [PATCH] Fix --- src/App.tsx | 112 +++++++-- src/components/LayoutStep.tsx | 393 +++++++++++++++--------------- src/services/geometryGenerator.ts | 59 ++--- 3 files changed, 297 insertions(+), 267 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index cff9c35..b31f2b7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,7 +9,9 @@ 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, @@ -17,7 +19,7 @@ const App = () => { cornerRadius: 4, }); - // ИНИЦИАЛИЗАЦИЯ: partitions обязательно присутствует + // Инициализация с гарантированными пустыми массивами const [splits, setSplits] = useState({ x: [], y: [], @@ -25,26 +27,37 @@ const App = () => { }); useEffect(() => { - const sharedData = parseShareUrl(); - if (sharedData) { - setConfig(sharedData.config); - // Защита: если в ссылке старый формат, подставляем пустые partitions - setSplits({ - x: sharedData.splits.x || [], - y: sharedData.splits.y || [], - partitions: sharedData.splits.partitions || {} - }); - setStep(3); - window.history.replaceState({}, '', window.location.pathname); + 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); } }, []); + // ЗАЩИТА: Оборачиваем расчет геометрии, чтобы не ломать весь UI при ошибке const parts: GeneratedPart[] = useMemo(() => { - return calculateParts(config, splits); + try { + return calculateParts(config, splits); + } catch (e) { + console.error("Ошибка расчета деталей:", e); + return []; // Возвращаем пустой массив вместо краша + } }, [config, splits]); return (
+ {/* Header */}
@@ -56,39 +69,84 @@ const App = () => {

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

+
{[1, 2, 3].map((num) => ( -
- {num} - {num === 1 ? 'Настройки' : num === 2 ? 'Макет' : 'Экспорт'} -
+ +
+ + {num} + + + {num === 1 ? 'Настройки' : num === 2 ? 'Макет' : 'Экспорт'} + +
+ {num < 3 &&
} + ))}
+ {/* Main Content */}
- {step === 1 &&
} - - {/* ШАГ 2 */} + {step === 1 && ( +
+ +
+ )} + {step === 2 && (
- {/* Передаем key, чтобы React пересоздал компонент при смене шага (сброс ошибок) */} - + {/* Добавляем проверку на существование данных */} +
)} - {step === 3 &&
} + {step === 3 && ( +
+ +
+ )}
+ {/* Footer */}
- -
{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; };