diff --git a/src/components/LayoutStep.tsx b/src/components/LayoutStep.tsx index 45aa85c..d58feaa 100644 --- a/src/components/LayoutStep.tsx +++ b/src/components/LayoutStep.tsx @@ -19,6 +19,7 @@ type DragTarget = export const LayoutStep: React.FC = ({ config, splits, onChange }) => { const svgRef = useRef(null); + const containerRef = useRef(null); // -- STATE -- const [mode, setMode] = useState('lines'); @@ -50,7 +51,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { const sortedX = useMemo(() => [0, ...safeX, 1].sort((a, b) => a - b), [safeX]); const sortedY = useMemo(() => [0, ...safeY, 1].sort((a, b) => a - b), [safeY]); - // -- HELPERS -- + // -- HELPER: Get Selected -- const getSelectedPartition = () => { if (!selectedPartitionId) return null; for (const key in safePartitions) { @@ -61,7 +62,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { }; const selectedData = getSelectedPartition(); - // --- SOLVER (Копия из geometryGenerator для синхронизации) --- + // --- SOLVER (Синхронизирован с 3D) --- const solveWallLimits = (parts: Partition[]): LimitMap => { const limits: LimitMap = {}; parts.forEach(p => { limits[p.id] = { min: 0, max: 1 }; }); @@ -73,8 +74,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { const center = target.offset; parts.forEach(obstacle => { - if (target.id === obstacle.id) return; - if (target.axis === obstacle.axis) return; + if (target.id === obstacle.id || target.axis === obstacle.axis) return; const obsMin = limits[obstacle.id].min; const obsMax = limits[obstacle.id].max; @@ -90,22 +90,42 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { return limits; }; - // Расчет границ для НОВОЙ стенки (используем уже решенные границы существующих) + // Поиск ближайших соседей для текста размеров + const getNeighborOffsets = (currentOffset: number, crossPos: number, axis: Axis, parts: Partition[]) => { + let minLimit = 0; + let maxLimit = 1; + const limitMap = solveWallLimits(parts); // Используем решенные границы для соседей + + parts.forEach(p => { + if (p.axis === axis) { // Параллельные + const { min: pMin, max: pMax } = limitMap[p.id]; + // Пересекаются ли проекции? + 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); + } + } + }); + return { min: minLimit, max: maxLimit }; + }; + + // Границы для курсора (Raycasting) const getCursorBox = (lx: number, ly: number, parts: Partition[], limitMap: LimitMap) => { let minX = 0, maxX = 1; let minY = 0, maxY = 1; parts.forEach(p => { - // Берем актуальные границы const { min: pMin, max: pMax } = limitMap[p.id]; if (pMax - pMin < 0.001) return; if (p.axis === '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 { + // Горизонтальная преграда 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); @@ -196,6 +216,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { setPhantomPartition(null); setHoveredCell(null); + // 1. Находим ячейку let cellIdx = null; for (let i = 0; i < sortedX.length - 1; i++) { if (nx >= sortedX[i] && nx <= sortedX[i+1]) { @@ -211,7 +232,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { setHoveredCell(cellIdx); const key = `${cellIdx.i}-${cellIdx.j}`; const parts = safePartitions[key] || []; - const limitMap = solveWallLimits(parts); // Вызываем солвер + const limitMap = solveWallLimits(parts); const cx1 = sortedX[cellIdx.i]; const cx2 = sortedX[cellIdx.i+1]; const cy1 = sortedY[cellIdx.j]; const cy2 = sortedY[cellIdx.j+1]; @@ -233,17 +254,14 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { if (found) { setHoveredPartition({ id: found.id, cellKey: key }); } else { + // Вычисляем бокс под курсором const box = getCursorBox(lx, ly, parts, limitMap); - const axis = (lx - box.minX < box.maxX - lx) && (lx - box.minX < Math.min(ly - box.minY, box.maxY - ly)) ? 'x' : - (box.maxX - lx < lx - box.minX) && (box.maxX - lx < Math.min(ly - box.minY, box.maxY - ly)) ? 'x' : - Math.min(lx - box.minX, box.maxX - lx) < Math.min(ly - box.minY, box.maxY - ly) ? 'x' : 'y'; - - // Лучше: перпендикулярно ближайшей стороне const distL = lx - box.minX; const distR = box.maxX - lx; const distT = ly - box.minY; const distB = box.maxY - ly; - const minD = Math.min(distL, distR, distT, distB); + // Выбираем ось перпендикулярно ближайшей границе + const minD = Math.min(distL, distR, distT, distB); const newAxis = (minD === distL || minD === distR) ? 'x' : 'y'; const width = box.maxX - box.minX; @@ -296,7 +314,6 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { } }; - // --- RENDER --- const renderCellsAndPartitions = () => { const elements = []; for (let i = 0; i < sortedX.length - 1; i++) { @@ -315,7 +332,8 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { const realW = (x2 - x1) * drawerW; const realD = (y2 - y1) * drawerD; - const limitMap = solveWallLimits(parts); // SOLVER для отрисовки + // ВАЖНО: Вычисляем границы для отображения + const limitMap = solveWallLimits(parts); if (parts.length === 0) { const labelX = cellX + cellW / 2; @@ -350,20 +368,21 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { lx1 = px; lx2 = px; ly1 = cellY + (cellH * min); ly2 = cellY + (cellH * max); - const cy = (min + max) / 2; - const box = getCursorBox(p.offset, cy, parts, limitMap); - dist1 = Math.abs((p.offset - box.minX) * realW) - wallThick; - dist2 = Math.abs((box.maxX - p.offset) * realW) - wallThick; + // Ищем соседей относительно текущей длины + const neighbors = getNeighborOffsets(p.offset, (min + max)/2, p.axis, parts); + + dist1 = Math.abs((p.offset - neighbors.min) * realW) - wallThick; + dist2 = Math.abs((neighbors.max - p.offset) * realW) - wallThick; midX = px; midY = (ly1 + ly2) / 2; } else { const py = cellY + (cellH * p.offset); ly1 = py; ly2 = py; lx1 = cellX + (cellW * min); lx2 = cellX + (cellW * max); - const cx = (min + max) / 2; - const box = getCursorBox(cx, p.offset, parts, limitMap); - dist1 = Math.abs((p.offset - box.minY) * realD) - wallThick; - dist2 = Math.abs((box.maxY - p.offset) * realD) - wallThick; + const neighbors = getNeighborOffsets(p.offset, (min + max)/2, p.axis, parts); + + dist1 = Math.abs((p.offset - neighbors.min) * realD) - wallThick; + dist2 = Math.abs((neighbors.max - p.offset) * realD) - wallThick; midX = (lx1 + lx2) / 2; midY = py; } diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index 35ed368..9365a35 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -2,46 +2,32 @@ import * as THREE from 'three'; import { STLExporter, mergeBufferGeometries } from 'three-stdlib'; import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; -// Типы для внутреннего решателя type Limits = { min: number; max: number }; type LimitMap = Record; -// --- SOLVER: Итеративный расчет границ --- -// Эта функция гарантирует, что стенки не будут торчать друг из друга +// --- SOLVER (Устраняет пересечения стенок) --- const solveWallLimits = (partitions: Partition[]): LimitMap => { const limits: LimitMap = {}; - - // 1. Инициализация: считаем, что все стенки идут от 0 до 1 partitions.forEach(p => { limits[p.id] = { min: 0, max: 1 }; }); - // 2. Итеративное решение (3 прохода достаточно для любой вложенности) + // 3 прохода для надежности вложенных структур for (let pass = 0; pass < 3; pass++) { partitions.forEach(target => { let newMin = 0; let newMax = 1; - const center = target.offset; // Положение стенки + const center = target.offset; partitions.forEach(obstacle => { - if (target.id === obstacle.id) return; - if (target.axis === obstacle.axis) return; // Параллельные не мешают + if (target.id === obstacle.id || target.axis === obstacle.axis) return; - // Берем АКТУАЛЬНЫЕ границы препятствия с прошлого шага const obsMin = limits[obstacle.id].min; const obsMax = limits[obstacle.id].max; - // Пересекает ли препятствие путь нашей стенки? - // obstacle.offset - это позиция препятствия на нашем пути. - // target.offset - это наша позиция. Она должна быть ВНУТРИ длины препятствия. if (target.offset > obsMin && target.offset < obsMax) { - // Препятствие на пути. Где оно? - if (obstacle.offset < center) { - newMin = Math.max(newMin, obstacle.offset); - } else if (obstacle.offset > center) { - newMax = Math.min(newMax, obstacle.offset); - } + if (obstacle.offset < center) newMin = Math.max(newMin, obstacle.offset); + else if (obstacle.offset > center) newMax = Math.min(newMax, obstacle.offset); } }); - limits[target.id] = { min: newMin, max: newMax }; }); } @@ -64,12 +50,10 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat const rawW = (xPoints[i + 1] - xPoints[i]) * config.drawer.width; const rawD = (yPoints[j + 1] - yPoints[j]) * config.drawer.depth; - // Игнорируем микро-ячейки if (rawW < 5 || rawD < 5) continue; const rawX = xPoints[i] * config.drawer.width; const rawY = yPoints[j] * config.drawer.depth; - const internalPartitions = safeParts[`${i}-${j}`] || []; const realWidth = rawW - config.printerTolerance; @@ -122,13 +106,14 @@ const createRoundedRectShape = (width: number, height: number, radius: number): return shape; }; -// Форма галтели (вогнутый уголок) -const createFilletShape = (radius: number): THREE.Shape => { +// Правильная форма "обратного" скругления (вогнутая) +const createConcaveFilletShape = (radius: number): THREE.Shape => { const shape = new THREE.Shape(); - // Рисуем в 1-м квадранте + // Рисуем квадрат (0,0) -> (r,r), но вырезаем из него круг shape.moveTo(0, 0); shape.lineTo(radius, 0); - shape.absarc(radius, radius, radius, 1.5 * Math.PI, Math.PI, true); + // Дуга: центр (r,r), радиус r, от 270 (-PI/2) до 180 (-PI) градусов + shape.absarc(radius, radius, radius, -Math.PI / 2, -Math.PI, true); shape.lineTo(0, 0); return shape; }; @@ -138,13 +123,12 @@ export const createBinGeometry = ( ): THREE.BufferGeometry => { const geometries: THREE.BufferGeometry[] = []; - // 1. ДНО + // ДНО И ВНЕШНИЕ СТЕНКИ const floorShape = createRoundedRectShape(width, depth, radius); const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); floorGeo.rotateX(-Math.PI / 2); geometries.push(floorGeo); - // 2. ВНЕШНИЕ СТЕНКИ const outerShape = createRoundedRectShape(width, depth, radius); const innerRadius = Math.max(0.1, radius - thickness); const innerWidth = width - (2 * thickness); @@ -161,12 +145,12 @@ export const createBinGeometry = ( wallGeo.translate(0, thickness, 0); geometries.push(wallGeo); - // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ (С SOLVER'ом) + // ВНУТРЕННИЕ ПЕРЕГОРОДКИ const limitMap = solveWallLimits(partitions); partitions.forEach(p => { const { min: pMin, max: pMax } = limitMap[p.id]; - if (pMax - pMin < 0.01) return; // Слишком короткая + if (pMax - pMin < 0.01) return; const lengthRatio = pMax - pMin; const midRatio = pMin + (lengthRatio / 2); @@ -174,70 +158,85 @@ export const createBinGeometry = ( let pWidth = 0, pDepth = 0, pX = 0, pY = 0; if (p.axis === 'x') { - // Вертикальная pWidth = thickness; pDepth = lengthRatio * innerDepth; pX = (-innerWidth / 2) + (innerWidth * p.offset); pY = (-innerDepth / 2) + (innerDepth * midRatio); } else { - // Горизонтальная pWidth = lengthRatio * innerWidth; pDepth = thickness; pX = (-innerWidth / 2) + (innerWidth * midRatio); pY = (-innerDepth / 2) + (innerDepth * p.offset); } - // Геометрия стенки - const partShape = createRoundedRectShape(pWidth, pDepth, 0.2); + const partShape = createRoundedRectShape(pWidth, pDepth, 0.1); const partGeo = new THREE.ExtrudeGeometry(partShape, { depth: p.height, bevelEnabled: false }); partGeo.rotateX(-Math.PI / 2); partGeo.translate(pX, thickness, pY); geometries.push(partGeo); - // --- ДОБАВЛЕНИЕ СКРУГЛЕНИЙ (FILLETS) --- + // --- ДОБАВЛЕНИЕ ГАЛТЕЛЕЙ (FILLETS) --- if (p.rounded && radius > 1) { const filletR = Math.min(radius, 5); - const filletShape = createFilletShape(filletR); + const filletShape = createConcaveFilletShape(filletR); const filletExtrude = { depth: p.height, bevelEnabled: false }; - const addFillet = (x: number, y: number, rotY: number) => { + const addFillet = (x: number, y: number, rotationY: number) => { const geo = new THREE.ExtrudeGeometry(filletShape, filletExtrude); + // Сначала кладем на пол (-PI/2 по X) + // Потом вращаем вокруг оси Y (которая теперь смотрит вверх) geo.rotateX(-Math.PI / 2); - geo.rotateY(rotY); + + // ВАЖНО: Вращение геометрии происходит вокруг (0,0,0). + // Наша форма галтели имеет угол в (0,0). Это идеально. + geo.rotateY(rotationY); + geo.translate(x, thickness, y); geometries.push(geo); }; - const h = thickness / 2; // half thickness + const h = thickness / 2; if (p.axis === 'x') { - // Вертикальная (идет вдоль Z в 3D) - const startY = (-innerDepth / 2) + (innerDepth * pMin); // "Верхний" конец (Back) - const endY = (-innerDepth / 2) + (innerDepth * pMax); // "Нижний" конец (Front) - - // Проверяем, упирается ли она в края или другие стенки? (всегда рисуем, так как solver обрезал) - // Скругляем 4 угла стыка + // Вертикальная стенка (вдоль Z) + // Top (Min Y) + const topY = (-innerDepth / 2) + (innerDepth * pMin); + // Углы: Слева (-X) и Справа (+X) - // Top End (pMin) - addFillet(pX - h, startY, 0); // Слева, смотрит вверх - addFillet(pX + h, startY, -Math.PI/2); // Справа, смотрит вверх + // Left-Top: смотрит в (-X, +Z). Угол PI (180) + addFillet(pX - h, topY, Math.PI); + + // Right-Top: смотрит в (+X, +Z). Угол -PI/2 (-90) + addFillet(pX + h, topY, -Math.PI / 2); - // Bottom End (pMax) - addFillet(pX - h, endY, Math.PI/2); // Слева, смотрит вниз - addFillet(pX + h, endY, Math.PI); // Справа, смотрит вниз + // Bottom (Max Y) + const botY = (-innerDepth / 2) + (innerDepth * pMax); + + // Left-Bottom: смотрит в (-X, -Z). Угол PI/2 (90) + addFillet(pX - h, botY, Math.PI / 2); + + // Right-Bottom: смотрит в (+X, -Z). Угол 0 + addFillet(pX + h, botY, 0); } else { - // Горизонтальная (идет вдоль X в 3D) - const startX = (-innerWidth / 2) + (innerWidth * pMin); // Левый конец - const endX = (-innerWidth / 2) + (innerWidth * pMax); // Правый конец + // Горизонтальная стенка (вдоль X) + // Left (Min X) + const leftX = (-innerWidth / 2) + (innerWidth * pMin); + + // Top-Left: смотрит в (+X, -Z). Угол 0 + addFillet(leftX, pY - h, 0); + + // Bottom-Left: смотрит в (+X, +Z). Угол -PI/2 (-90) + addFillet(leftX, pY + h, -Math.PI / 2); - // Left End (pMin) - addFillet(startX, pY + h, Math.PI); // Сверху, смотрит влево (FIXED ANGLE) - addFillet(startX, pY - h, Math.PI/2); // Снизу, смотрит влево (FIXED ANGLE) - - // Right End (pMax) - addFillet(endX, pY + h, -Math.PI/2); // Сверху, смотрит вправо (FIXED ANGLE) - addFillet(endX, pY - h, 0); // Снизу, смотрит вправо (FIXED ANGLE) + // Right (Max X) + const rightX = (-innerWidth / 2) + (innerWidth * pMax); + + // Top-Right: смотрит в (-X, -Z). Угол PI/2 (90) + addFillet(rightX, pY - h, Math.PI / 2); + + // Bottom-Right: смотрит в (-X, +Z). Угол PI (180) + addFillet(rightX, pY + h, Math.PI); } } });