From 6cd7a5194d679290b46448080fffeed0454de6f0 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: Sun, 11 Jan 2026 13:54:10 +0300 Subject: [PATCH] 3 --- src/components/LayoutStep.tsx | 312 ++++++++++++------------------ src/services/geometryGenerator.ts | 97 +++++----- 2 files changed, 174 insertions(+), 235 deletions(-) diff --git a/src/components/LayoutStep.tsx b/src/components/LayoutStep.tsx index 65f1872..a2d44b2 100644 --- a/src/components/LayoutStep.tsx +++ b/src/components/LayoutStep.tsx @@ -18,23 +18,21 @@ type DragTarget = export const LayoutStep: React.FC = ({ config, splits, onChange }) => { const svgRef = useRef(null); - // --- STATE --- + // -- STATE -- const [mode, setMode] = useState('lines'); const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); const [dragging, setDragging] = useState(null); const [isButtonHovered, setIsButtonHovered] = useState(false); - // Main Grid const [phantomMainAxis, setPhantomMainAxis] = useState(null); const [hoveredMainSplit, setHoveredMainSplit] = useState<{ axis: Axis; index: number } | null>(null); - // Partitions const [hoveredCell, setHoveredCell] = useState<{ i: number; j: number } | null>(null); const [hoveredPartition, setHoveredPartition] = useState<{ id: string; cellKey: string } | null>(null); const [phantomPartition, setPhantomPartition] = useState<{ axis: Axis; offset: number; min: number; max: number } | null>(null); const [selectedPartitionId, setSelectedPartitionId] = useState(null); - // --- SAFE DATA --- + // -- DATA -- const safeX = Array.isArray(splits?.x) ? splits.x : []; const safeY = Array.isArray(splits?.y) ? splits.y : []; const safePartitions = splits?.partitions || {}; @@ -50,55 +48,48 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { const sortedX = useMemo(() => [0, ...safeX, 1].sort((a, b) => a - b), [safeX]); const sortedY = useMemo(() => [0, ...safeY, 1].sort((a, b) => a - b), [safeY]); - // --- HELPERS --- - const getSelectedPartition = () => { - if (!selectedPartitionId) return null; - for (const key in safePartitions) { - const part = safePartitions[key].find(p => p.id === selectedPartitionId); - if (part) return { key, part }; - } - return null; - }; - const selectedData = getSelectedPartition(); - - // Поиск ближайших соседей для расчета размеров - const getNeighborOffsets = (currentOffset: number, crossPos: number, axis: Axis, parts: Partition[]) => { - let minLimit = 0; - let maxLimit = 1; + // -- LOGIC: Dynamic Neighbors Calculation -- + // Эта функция пересчитывает реальные границы стенки на лету + const calculateDynamicLimits = (target: { axis: Axis, offset: number, min?: number, max?: number }, parts: Partition[]) => { + let min = 0; + let max = 1; + // Используем сохраненные min/max только как "подсказку" где центр стенки + const mid = ((target.min ?? 0) + (target.max ?? 1)) / 2; parts.forEach(p => { - // Если стенка параллельна нашей - if (p.axis === axis) { - const pMin = p.min ?? 0; - const pMax = p.max ?? 1; - - // Проверяем пересечение проекций - if (crossPos > pMin && crossPos < pMax) { - if (p.offset < currentOffset) minLimit = Math.max(minLimit, p.offset); - if (p.offset > currentOffset) maxLimit = Math.min(maxLimit, p.offset); - } + if (p.axis === target.axis) return; // Игнорируем параллельные + + // Границы соседки (статические, но для соседки они тоже могут быть динамическими - тут упрощение для производительности) + // В идеале нужен рекурсивный солвер, но для 2D UI достаточно проверить попадание + const pMin = p.min ?? 0; + const pMax = p.max ?? 1; + + if (target.offset > pMin && target.offset < pMax) { + if (p.offset < mid) min = Math.max(min, p.offset); + else if (p.offset > mid) max = Math.min(max, p.offset); } }); - return { min: minLimit, max: maxLimit }; + return { min, max }; }; - // Поиск границ для НОВОЙ стенки (T-соединения) + // Поиск границ для НОВОЙ стенки (под курсором) const getHoveredBoundaries = (lx: number, ly: number, parts: Partition[]) => { + return calculateDynamicLimits({ axis: 'x', offset: lx, min: ly, max: ly }, parts); // Hack: передаем ly как min/max чтобы найти соседей по Y для X-стенки? + // Нет, для новой стенки логика чуть другая - мы ищем ближайшие стенки вокруг точки (lx, ly) + let minX = 0, maxX = 1; let minY = 0, maxY = 1; parts.forEach(p => { - const pMin = p.min ?? 0; - const pMax = p.max ?? 1; + // Вычисляем ДИНАМИЧЕСКИЕ границы для соседки, чтобы знать её реальную длину + const { min: pMin, max: pMax } = calculateDynamicLimits(p, parts); if (p.axis === 'x') { - // Вертикальная стенка. Ограничивает по X. if (ly >= pMin && ly <= pMax) { if (p.offset < lx) minX = Math.max(minX, p.offset); if (p.offset > lx) maxX = Math.min(maxX, p.offset); } } else { - // Горизонтальная стенка. Ограничивает по Y. if (lx >= pMin && lx <= pMax) { if (p.offset < ly) minY = Math.max(minY, p.offset); if (p.offset > ly) maxY = Math.min(maxY, p.offset); @@ -108,18 +99,14 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { return { minX, maxX, minY, maxY }; }; - // --- ACTIONS --- + // -- ACTIONS -- const createPartition = (i: number, j: number, axis: Axis, offset: number, min: number, max: number) => { const key = `${i}-${j}`; const current = safePartitions[key] || []; const newPart: Partition = { id: Math.random().toString(36).substr(2, 9), - axis, - offset, - min, - max, - height: config.drawer.height || 80, - rounded: false + axis, offset, min, max, + height: config.drawer.height || 80, rounded: false }; onChange({ ...splits, partitions: { ...safePartitions, [key]: [...current, newPart] } }); setSelectedPartitionId(newPart.id); @@ -146,12 +133,21 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { setDragging(null); }; - // --- MOUSE HANDLER --- + const getSelectedPartition = () => { + if (!selectedPartitionId) return null; + for (const key in safePartitions) { + const part = safePartitions[key].find(p => p.id === selectedPartitionId); + if (part) return { key, part }; + } + return null; + }; + const selectedData = getSelectedPartition(); + + // -- MOUSE -- const handleMouseMove = (e: React.MouseEvent) => { if (!svgRef.current) return; const rect = svgRef.current.getBoundingClientRect(); 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)); setMousePos({ x: nx, y: ny }); @@ -163,26 +159,17 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { newSplits[dragging.axis][dragging.index] = val; onChange(newSplits); } else { - // Dragging Partition const [iStr, jStr] = dragging.cellKey.split('-'); const i = parseInt(iStr); const j = parseInt(jStr); const cellX1 = sortedX[i]; const cellX2 = sortedX[i+1]; const cellY1 = sortedY[j]; const cellY2 = sortedY[j+1]; let newOffset = 0; - if (dragging.axis === 'x') { - newOffset = (nx - cellX1) / (cellX2 - cellX1); - } else { - newOffset = (ny - cellY1) / (cellY2 - cellY1); - } + if (dragging.axis === 'x') newOffset = (nx - cellX1) / (cellX2 - cellX1); + else newOffset = (ny - cellY1) / (cellY2 - cellY1); - // Ограничиваем перетаскивание. - // В идеале нужно динамически считать соседей, но для скорости пока ограничим ячейкой (2%-98%) newOffset = Math.max(0.02, Math.min(0.98, newOffset)); - - if (!isNaN(newOffset)) { - updatePartition(dragging.cellKey, dragging.id, { offset: newOffset }); - } + if (!isNaN(newOffset)) updatePartition(dragging.cellKey, dragging.id, { offset: newOffset }); } return; } @@ -195,13 +182,10 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { 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; - setPhantomMainAxis(Math.min(nx, distRight) < Math.min(ny, distBottom) ? 'y' : 'x'); - } else { setPhantomMainAxis(null); } + if (!closeToX && !closeToY) setPhantomMainAxis(Math.min(nx, 1-nx) < Math.min(ny, 1-ny) ? 'y' : 'x'); + else setPhantomMainAxis(null); } } else { - // Cells Mode setHoveredPartition(null); setPhantomPartition(null); setHoveredCell(null); @@ -211,8 +195,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { if (nx >= sortedX[i] && nx <= sortedX[i+1]) { for (let j = 0; j < sortedY.length - 1; j++) { if (ny >= sortedY[j] && ny <= sortedY[j+1]) { - cellIdx = { i, j }; - break; + cellIdx = { i, j }; break; } } } @@ -229,11 +212,11 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { const lx = (nx - cx1) / cw; const ly = (ny - cy1) / ch; - // Check Existing Partitions Hover let found = null; const SNAP = 0.05; for (const p of parts) { - const pMin = p.min ?? 0; const pMax = p.max ?? 1; + // Для проверки наведения тоже используем динамические границы, чтобы не кликать в пустоту + const { min: pMin, max: pMax } = calculateDynamicLimits(p, parts); if (p.axis === 'x') { if (ly >= pMin && ly <= pMax && Math.abs(lx - p.offset) < SNAP * (aspectRatio > 1 ? 1 : aspectRatio)) found = p; } else { @@ -244,23 +227,16 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { if (found) { setHoveredPartition({ id: found.id, cellKey: key }); } else { - // Calculate Phantom Partition with T-junctions const bounds = getHoveredBoundaries(lx, ly, parts); - - const distL = lx - bounds.minX; - const distR = bounds.maxX - lx; - const distT = ly - bounds.minY; - const distB = bounds.maxY - ly; - + const distL = lx - bounds.minX; const distR = bounds.maxX - lx; + const distT = ly - bounds.minY; const distB = bounds.maxY - ly; const minX = Math.min(distL, distR); const minY = Math.min(distT, distB); const axis = minX < minY ? 'y' : 'x'; - const width = bounds.maxX - bounds.minX; const height = bounds.maxY - bounds.minY; - // Рисуем фантом только если есть место (>10% ячейки) if ((axis === 'y' && height > 0.1) || (axis === 'x' && width > 0.1)) { const offset = axis === 'x' ? lx : ly; const min = axis === 'x' ? bounds.minY : bounds.minX; @@ -274,7 +250,6 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { const handleMouseDown = (e: React.MouseEvent) => { if (isButtonHovered) return; - if (mode === 'lines') { if (hoveredMainSplit) { if (e.button === 0) setDragging({ type: 'main', ...hoveredMainSplit }); @@ -309,14 +284,12 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { } }; - // --- RENDER --- const renderCellsAndPartitions = () => { const elements = []; for (let i = 0; i < sortedX.length - 1; i++) { for (let j = 0; j < sortedY.length - 1; j++) { const x1 = sortedX[i]; const x2 = sortedX[i + 1]; const y1 = sortedY[j]; const y2 = sortedY[j + 1]; - if (y2 === undefined || x2 === undefined) continue; const cellX = x1 * viewBoxW; const cellY = y1 * viewBoxH; @@ -326,17 +299,14 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { const isHovered = hoveredCell?.i === i && hoveredCell?.j === j && mode === 'cells'; const parts = safePartitions[key] || []; const isAnySelected = parts.some(p => p.id === selectedPartitionId); - const realW = (x2 - x1) * drawerW; const realD = (y2 - y1) * drawerD; - // --- LABEL FOR EMPTY CELL --- if (parts.length === 0) { const labelX = cellX + cellW / 2; const labelY = cellY + cellH / 2; const textW = Math.max(0, realW - wallThick).toFixed(0); const textD = Math.max(0, realD - wallThick).toFixed(0); - // Увеличен шрифт до 16px if (cellH > 40 && cellW > 60) { elements.push( @@ -349,38 +319,79 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { elements.push( {isHovered && } - {isAnySelected && mode === 'cells' && } {parts.map(p => { - const pMin = p.min ?? 0; const pMax = p.max ?? 1; - let lx1, ly1, lx2, ly2; // Координаты линии - let dist1 = 0, dist2 = 0; // Расстояния + // ИСПОЛЬЗУЕМ ДИНАМИЧЕСКИЙ РАСЧЕТ ГРАНИЦ ДЛЯ ОТРИСОВКИ + const { min: pMin, max: pMax } = calculateDynamicLimits(p, parts); - // Ищем соседей для расчета размеров - const neighbors = getNeighborOffsets(p.offset, (pMin + pMax)/2, p.axis, parts); + let lx1, ly1, lx2, ly2; + let dist1 = 0, dist2 = 0; + let midX, midY; + const isVertical = p.axis === 'x'; if (isVertical) { const px = cellX + (cellW * p.offset); lx1 = px; lx2 = px; ly1 = cellY + (cellH * pMin); ly2 = cellY + (cellH * pMax); - // Расчет расстояний по горизонтали - dist1 = Math.abs((p.offset - neighbors.min) * realW) - wallThick; - dist2 = Math.abs((neighbors.max - p.offset) * realW) - wallThick; + + // Для X-стенки, p.offset - это X координата. + // pMin/pMax - это границы по Y. + // Чтобы найти расстояние по бокам, нам нужны границы по X. + // Мы ищем ВЕРТИКАЛЬНЫХ соседей в диапазоне Y [pMin, pMax] + // calculateDynamicLimits дает границы ВДОЛЬ самой стенки. Это нам дало высоту. + + // Теперь найдем ширину (слева/справа). + // Мы берем точку в центре стенки и ищем ближайших вертикальных соседей + const { min: leftLim, max: rightLim } = calculateDynamicLimits({ axis: 'y', offset: (pMin + pMax)/2, min: 0, max: 1 }, parts.filter(pp => pp.axis === 'x')); + // Это хак. Правильнее: + let left = 0, right = 1; + const cy = (pMin + pMax)/2; + parts.forEach(n => { + if (n.axis === 'x') { + // Соседка перекрывает нас по высоте? + // Нужно найти её реальные границы + const { min: nMin, max: nMax } = calculateDynamicLimits(n, parts); + if (cy > nMin && cy < nMax) { + if (n.offset < p.offset) left = Math.max(left, n.offset); + if (n.offset > p.offset) right = Math.min(right, n.offset); + } + } + }); + + dist1 = (p.offset - left) * realW - wallThick; + dist2 = (right - p.offset) * realW - wallThick; + + midX = px; + midY = (ly1 + ly2) / 2; + } else { const py = cellY + (cellH * p.offset); ly1 = py; ly2 = py; lx1 = cellX + (cellW * pMin); lx2 = cellX + (cellW * pMax); - // Расчет расстояний по вертикали - dist1 = Math.abs((p.offset - neighbors.min) * realD) - wallThick; - dist2 = Math.abs((neighbors.max - p.offset) * realD) - wallThick; + + let top = 0, bot = 1; + const cx = (pMin + pMax)/2; + parts.forEach(n => { + if (n.axis === 'y') { + const { min: nMin, max: nMax } = calculateDynamicLimits(n, parts); + if (cx > nMin && cx < nMax) { + if (n.offset < p.offset) top = Math.max(top, n.offset); + if (n.offset > p.offset) bot = Math.min(bot, n.offset); + } + } + }); + + dist1 = (p.offset - top) * realD - wallThick; + dist2 = (bot - p.offset) * realD - wallThick; + + midX = (lx1 + lx2) / 2; + midY = py; } const isSel = selectedPartitionId === p.id; const isHov = hoveredPartition?.id === p.id; - const midX = (lx1 + lx2) / 2; - const midY = (ly1 + ly2) / 2; - const textOffset = 8; // Отступ текста от линии + const textOffset = 10; return ( @@ -389,35 +400,27 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { - {/* DIMENSIONS - Увеличен шрифт до 14px */} {isVertical ? ( - // Для вертикальных линий: два отдельных текста слева и справа, выровненных по центру высоты <> - - {Math.max(0, dist1).toFixed(0)} - - - {Math.max(0, dist2).toFixed(0)} - + {Math.max(0, dist1).toFixed(0)} + {Math.max(0, dist2).toFixed(0)} ) : ( - // Для горизонтальных линий: один текст по центру с tspan сверху и снизу - - {Math.max(0, dist1).toFixed(0)} - {Math.max(0, dist2).toFixed(0)} - + <> + {Math.max(0, dist1).toFixed(0)} + {Math.max(0, dist2).toFixed(0)} + )} ); })} - {/* Phantom Line */} {isHovered && phantomPartition && !hoveredPartition && !dragging && ( {(() => { - const pMin = phantomPartition.min; const pMax = phantomPartition.max; + const { min: pMin, max: pMax } = phantomPartition; let fx1, fy1, fx2, fy2; if (phantomPartition.axis === 'x') { const px = cellX + (cellW * phantomPartition.offset); @@ -439,115 +442,58 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { return (
- - {/* HEADER */}
-

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

- +

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

- - + +
- - +
- {/* INFO */}
{mode === 'lines' ? ( -
- ЛКМ: Линия - ПКМ: Удалить -
+
ЛКМ: ЛинияПКМ: Удалить
) : ( -
- ЛКМ в ячейке: Стенка - Драг: Двигать - 2xЛКМ: Удалить -
+
ЛКМ в ячейке: СтенкаДраг: Двигать2xЛКМ: Удалить
)}
- {/* CANVAS */}
-
1 ? 'auto' : '100%', - height: aspectRatio > 1 ? '100%' : 'auto', - aspectRatio: `${1/aspectRatio}`, - maxHeight: '100%', maxWidth: '100%', - cursor: mode === 'lines' ? (dragging ? 'grabbing' : hoveredMainSplit ? 'col-resize' : 'crosshair') - : (dragging ? 'grabbing' : hoveredPartition ? 'grab' : hoveredCell ? 'crosshair' : 'default'), - }} - > - setDragging(null)} onMouseLeave={() => setDragging(null)} onContextMenu={(e) => e.preventDefault()} - > - - - - - +
1 ? 'auto' : '100%', height: aspectRatio > 1 ? '100%' : 'auto', aspectRatio: `${1/aspectRatio}`, maxHeight: '100%', maxWidth: '100%', cursor: mode === 'lines' ? (dragging ? 'grabbing' : hoveredMainSplit ? 'col-resize' : 'crosshair') : (dragging ? 'grabbing' : hoveredPartition ? 'grab' : hoveredCell ? 'crosshair' : 'default') }}> + setDragging(null)} onMouseLeave={() => setDragging(null)} onContextMenu={(e) => e.preventDefault()}> + - {renderCellsAndPartitions()} - - {/* MAIN GRID X */} {safeX.map((x, i) => ( { if(mode === 'lines' && !dragging) { e.stopPropagation(); setHoveredMainSplit({axis: 'x', index: i}); setPhantomMainAxis(null); }}} onDoubleClick={(e) => { e.stopPropagation(); removeMainSplit('x', i); }}> ))} - - {/* MAIN GRID Y */} {safeY.map((y, i) => ( { if(mode === 'lines' && !dragging) { e.stopPropagation(); setHoveredMainSplit({axis: 'y', index: i}); setPhantomMainAxis(null); }}} onDoubleClick={(e) => { e.stopPropagation(); removeMainSplit('y', i); }}> ))} - - {/* PHANTOMS */} - {mode === 'lines' && !hoveredMainSplit && !dragging && phantomMainAxis === 'x' && } - {mode === 'lines' && !hoveredMainSplit && !dragging && phantomMainAxis === 'y' && } + {mode === 'lines' && !hoveredMainSplit && !dragging && phantomMainAxis && ( + + )}
- {/* SIDEBAR */} {mode === 'cells' && selectedData && (
-
-

Настройки стенки

- -
+

Настройки стенки

-
-
Высота {selectedData.part.height} мм
- updatePartition(selectedData.key, selectedData.part.id, { height: parseFloat(e.target.value) })} className="w-full h-1 bg-slate-600 rounded-lg appearance-none cursor-pointer accent-purple-500"/> -
-
- - updatePartition(selectedData.key, selectedData.part.id, { rounded: e.target.checked })} className="w-4 h-4 rounded bg-slate-700 border-slate-600 text-purple-500 focus:ring-0 cursor-pointer"/> -
- +
Высота {selectedData.part.height} мм
updatePartition(selectedData.key, selectedData.part.id, { height: parseFloat(e.target.value) })} className="w-full h-1 bg-slate-600 rounded-lg appearance-none cursor-pointer accent-purple-500"/>
+
updatePartition(selectedData.key, selectedData.part.id, { rounded: e.target.checked })} className="w-4 h-4 rounded bg-slate-700 border-slate-600 text-purple-500 focus:ring-0 cursor-pointer"/>
+
-
Выделите стенку для настройки.
Двойной клик удаляет её.
)}
diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index 354166f..96daedc 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -48,7 +48,6 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat // --- ГЕОМЕТРИЯ --- -// 1. Форма скругленного прямоугольника (для дна и внешних стенок) const createRoundedRectShape = (width: number, height: number, radius: number): THREE.Shape => { const shape = new THREE.Shape(); const x = -width / 2; @@ -75,35 +74,60 @@ const createRoundedRectShape = (width: number, height: number, radius: number): return shape; }; -// 2. Форма галтели (вогнутого треугольника) для углов const createFilletShape = (radius: number): THREE.Shape => { const shape = new THREE.Shape(); - // Начинаем из угла (0,0) shape.moveTo(0, 0); - // Линия вдоль одной стенки shape.lineTo(radius, 0); - // Вогнутая дуга к другой стенке - // Центр окружности (radius, radius), радиус radius. - // Рисуем дугу от 270 (-PI/2) до 180 (PI) градусов по часовой стрелке shape.absarc(radius, radius, radius, 1.5 * Math.PI, Math.PI, true); - // Замыкаем в угол shape.lineTo(0, 0); - return shape; }; +// --- УМНЫЙ РАСЧЕТ ГРАНИЦ (Fix overlapping walls) --- +const calculateDynamicLimits = (target: Partition, allParts: Partition[]) => { + let min = 0; + let max = 1; + + // Центр текущей стенки (чтобы понять, в каком мы сегменте) + const mid = ((target.min ?? 0) + (target.max ?? 1)) / 2; + + allParts.forEach(p => { + // Нас интересуют только ПЕРПЕНДИКУЛЯРНЫЕ стенки + if (p.axis === target.axis) return; + + // Определяем, пересекает ли соседка путь нашей стенки + // Для этого соседка должна "покрывать" нашу координату offset + const pMin = p.min ?? 0; + const pMax = p.max ?? 1; + + // p.offset - это позиция соседки по нашей оси движения + // target.offset - это наша позиция по оси соседки + + if (target.offset > pMin && target.offset < pMax) { + // Соседка стоит на пути. Где она? Сверху или снизу (слева или справа)? + if (p.offset < mid) { + // Соседка "перед" нами, это новая нижняя граница + min = Math.max(min, p.offset); + } else if (p.offset > mid) { + // Соседка "после" нас, это новая верхняя граница + max = Math.min(max, p.offset); + } + } + }); + + return { min, max }; +}; + export const createBinGeometry = ( width: number, depth: number, height: number, thickness: number, radius: number = 0, partitions: Partition[] = [] ): THREE.BufferGeometry => { const geometries: THREE.BufferGeometry[] = []; - // ДНО 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); - // ВНЕШНИЕ СТЕНКИ const outerShape = createRoundedRectShape(width, depth, radius); const innerRadius = Math.max(0, radius - thickness); const innerWidth = width - (2 * thickness); @@ -120,16 +144,15 @@ export const createBinGeometry = ( wallGeo.translate(0, thickness, 0); geometries.push(wallGeo); - // ВНУТРЕННИЕ ПЕРЕГОРОДКИ И СКРУГЛЕНИЯ partitions.forEach(p => { - const pMin = p.min ?? 0; - const pMax = p.max ?? 1; + // ИСПОЛЬЗУЕМ ДИНАМИЧЕСКИЙ РАСЧЕТ ВМЕСТО p.min/p.max + const { min: pMin, max: pMax } = calculateDynamicLimits(p, partitions); + const lengthRatio = pMax - pMin; const midRatio = pMin + (lengthRatio / 2); let pWidth = 0, pDepth = 0, pX = 0, pY = 0; - // Размеры самой стенки if (p.axis === 'x') { pWidth = thickness; pDepth = lengthRatio * innerDepth; @@ -142,28 +165,21 @@ export const createBinGeometry = ( pY = (-innerDepth / 2) + (innerDepth * p.offset); } - // Создаем стенку - const partShape = createRoundedRectShape(pWidth, pDepth, 0.1); // Чуть-чуть скругляем саму стенку, чтобы не была острой + const partShape = createRoundedRectShape(pWidth, pDepth, 0.1); const partGeo = new THREE.ExtrudeGeometry(partShape, { depth: p.height, bevelEnabled: false, curveSegments: 2 }); partGeo.rotateX(-Math.PI / 2); partGeo.translate(pX, thickness, pY); geometries.push(partGeo); - // --- ДОБАВЛЕНИЕ ГАЛТЕЛЕЙ (FILLETS) --- if (p.rounded && radius > 0.5) { - // Радиус скругления такой же, как у углов ящика, но не больше разумного предела const filletR = Math.min(radius, 6); const filletShape = createFilletShape(filletR); const filletExtrudeSettings = { depth: p.height, bevelEnabled: false, curveSegments: 8 }; - // Функция для создания и позиционирования одной галтели const addFillet = (x: number, y: number, rotation: number) => { const geo = new THREE.ExtrudeGeometry(filletShape, filletExtrudeSettings); - geo.rotateX(-Math.PI / 2); // Положить на пол - geo.rotateY(rotation); // Повернуть в нужный угол - - // Корректировка позиции после вращения вокруг (0,0) - // Нам нужно сместить так, чтобы угол (0,0) галтели совпал с углом стыка + geo.rotateX(-Math.PI / 2); + geo.rotateY(rotation); geo.translate(x, thickness, y); geometries.push(geo); }; @@ -171,40 +187,17 @@ export const createBinGeometry = ( const halfThick = thickness / 2; if (p.axis === 'x') { - // Вертикальная стенка. Концы: Top (pMin) и Bottom (pMax) (в 2D координатах Y) - // Y координата начала: -innerDepth/2 + innerDepth * pMin - // Y координата конца: -innerDepth/2 + innerDepth * pMax - // X координата центра: pX - const startY = (-innerDepth / 2) + (innerDepth * pMin); const endY = (-innerDepth / 2) + (innerDepth * pMax); - - // 4 Угла: - // 1. Start Left: X = pX - halfThick, Y = startY. Rotation: 0 (смотрит вправо-вверх? нет) - // Галтель рисуется в +X, +Y квадранте от 0,0. - // Нам нужно заполнить угол между стенкой (идет вниз) и перпендикуляром. - - // Start (Top in 2D view, actually Min Y in 3D logic here usually means "Back") - // Let's assume standard plan view: - // Min Y is "Top" edge visually in SVG usually 0. In 3D Z is Y. - - // Min End (Start of wall): - addFillet(pX - halfThick, startY, 0); // Left side, pointing towards +Z (down in visual) - addFillet(pX + halfThick, startY, -Math.PI/2); // Right side - - // Max End (End of wall): - addFillet(pX - halfThick, endY, Math.PI/2); // Left side - addFillet(pX + halfThick, endY, Math.PI); // Right side + addFillet(pX - halfThick, startY, 0); + addFillet(pX + halfThick, startY, -Math.PI/2); + addFillet(pX - halfThick, endY, Math.PI/2); + addFillet(pX + halfThick, endY, Math.PI); } else { - // Горизонтальная стенка const startX = (-innerWidth / 2) + (innerWidth * pMin); const endX = (-innerWidth / 2) + (innerWidth * pMax); - - // Min End (Left side): addFillet(startX, pY + halfThick, -Math.PI/2); addFillet(startX, pY - halfThick, 0); - - // Max End (Right side): addFillet(endX, pY + halfThick, Math.PI); addFillet(endX, pY - halfThick, Math.PI/2); }