From 94eb2f00b2b85bcc4b6e485cd6ccb2a178f4d68b 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 14:26:00 +0300 Subject: [PATCH] 6 --- src/components/LayoutStep.tsx | 96 ++++++++-------- src/services/geometryGenerator.ts | 175 ++++++++++++++++-------------- 2 files changed, 139 insertions(+), 132 deletions(-) diff --git a/src/components/LayoutStep.tsx b/src/components/LayoutStep.tsx index a7eeeb0..45aa85c 100644 --- a/src/components/LayoutStep.tsx +++ b/src/components/LayoutStep.tsx @@ -61,48 +61,43 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { }; const selectedData = getSelectedPartition(); - // --- CORE LOGIC: Two-Pass Limit Calculation (Sync with 3D) --- - const calculateLimits = (target: { axis: Axis, offset: number }, allParts: Partition[], limitMap: LimitMap | null) => { - let min = 0; - let max = 1; - const mid = target.offset; + // --- SOLVER (Копия из geometryGenerator для синхронизации) --- + const solveWallLimits = (parts: Partition[]): LimitMap => { + const limits: LimitMap = {}; + parts.forEach(p => { limits[p.id] = { min: 0, max: 1 }; }); - allParts.forEach(p => { - if (p.axis === target.axis) return; + for (let pass = 0; pass < 3; pass++) { + parts.forEach(target => { + let newMin = 0; + let newMax = 1; + const center = target.offset; - let pMin = p.min ?? 0; - let pMax = p.max ?? 1; - if (limitMap && limitMap[p.id]) { - pMin = limitMap[p.id].min; - pMax = limitMap[p.id].max; - } + parts.forEach(obstacle => { + if (target.id === obstacle.id) return; + if (target.axis === obstacle.axis) return; - 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 }; + const obsMin = limits[obstacle.id].min; + const obsMax = limits[obstacle.id].max; + + 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); + } + }); + limits[target.id] = { min: newMin, max: newMax }; + }); + } + return limits; }; - // Helper to get final limits for a list of parts - const getFinalLimitsMap = (parts: Partition[]): LimitMap => { - const map: LimitMap = {}; - // Pass 1 - parts.forEach(p => { map[p.id] = calculateLimits(p, parts, null); }); - // Pass 2 - parts.forEach(p => { map[p.id] = calculateLimits(p, parts, map); }); - return map; - }; - - // Calculate phantom box under cursor using 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]; - // Ignore collapsed walls if (pMax - pMin < 0.001) return; if (p.axis === 'x') { @@ -127,8 +122,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { const newPart: Partition = { id: Math.random().toString(36).substr(2, 9), axis, offset, min, max, - height: config.drawer.height || 80, - rounded: false + height: config.drawer.height || 80, rounded: false }; onChange({ ...splits, partitions: { ...safePartitions, [key]: [...current, newPart] } }); setSelectedPartitionId(newPart.id); @@ -155,7 +149,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { setDragging(null); }; - // -- MOUSE HANDLERS -- + // -- MOUSE -- const handleMouseMove = (e: React.MouseEvent) => { if (!svgRef.current) return; const rect = svgRef.current.getBoundingClientRect(); @@ -217,7 +211,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { setHoveredCell(cellIdx); const key = `${cellIdx.i}-${cellIdx.j}`; const parts = safePartitions[key] || []; - const limitMap = getFinalLimitsMap(parts); // Calculate limits once per move + 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]; @@ -229,7 +223,6 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { const SNAP = 0.05; for (const p of parts) { const { min, max } = limitMap[p.id]; - if (max - min < 0.001) continue; // Skip collapsed if (p.axis === 'x') { if (ly >= min && ly <= max && Math.abs(lx - p.offset) < SNAP * (aspectRatio > 1 ? 1 : aspectRatio)) found = p; } else { @@ -242,20 +235,25 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { } 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 minX = Math.min(distL, distR); - const minY = Math.min(distT, distB); + const minD = Math.min(distL, distR, distT, distB); - const axis = minX < minY ? 'y' : 'x'; + const newAxis = (minD === distL || minD === distR) ? 'x' : 'y'; + const width = box.maxX - box.minX; const height = box.maxY - box.minY; - if ((axis === 'y' && height > 0.05) || (axis === 'x' && width > 0.05)) { - const offset = axis === 'x' ? lx : ly; - const min = axis === 'x' ? box.minY : box.minX; - const max = axis === 'x' ? box.maxY : box.maxX; - setPhantomPartition({ axis, offset, min, max }); + if ((newAxis === 'y' && height > 0.05) || (newAxis === 'x' && width > 0.05)) { + const offset = newAxis === 'x' ? lx : ly; + const min = newAxis === 'x' ? box.minY : box.minX; + const max = newAxis === 'x' ? box.maxY : box.maxX; + setPhantomPartition({ axis: newAxis, offset, min, max }); } } } @@ -317,8 +315,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { const realW = (x2 - x1) * drawerW; const realD = (y2 - y1) * drawerD; - // Calculate limits for rendering - const limitMap = getFinalLimitsMap(parts); + const limitMap = solveWallLimits(parts); // SOLVER для отрисовки if (parts.length === 0) { const labelX = cellX + cellW / 2; @@ -341,7 +338,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { {parts.map(p => { const { min, max } = limitMap[p.id]; - if (max - min < 0.001) return null; // Don't render collapsed + if (max - min < 0.001) return null; let lx1, ly1, lx2, ly2; let dist1 = 0, dist2 = 0; @@ -354,9 +351,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { ly1 = cellY + (cellH * min); ly2 = cellY + (cellH * max); const cy = (min + max) / 2; - // Use already calculated limits for neighbors 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; midX = px; midY = (ly1 + ly2) / 2; @@ -367,7 +362,6 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { 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; midX = (lx1 + lx2) / 2; midY = py; @@ -375,7 +369,7 @@ export const LayoutStep: React.FC = ({ config, splits, onChange }) => { const isSel = selectedPartitionId === p.id; const isHov = hoveredPartition?.id === p.id; - const textOffset = 10; + const textOffset = 12; return ( diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index 8d7b71f..35ed368 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -2,10 +2,52 @@ 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: Итеративный расчет границ --- +// Эта функция гарантирует, что стенки не будут торчать друг из друга +const solveWallLimits = (partitions: Partition[]): LimitMap => { + const limits: LimitMap = {}; + + // 1. Инициализация: считаем, что все стенки идут от 0 до 1 + partitions.forEach(p => { limits[p.id] = { min: 0, max: 1 }; }); + + // 2. Итеративное решение (3 прохода достаточно для любой вложенности) + for (let pass = 0; pass < 3; pass++) { + partitions.forEach(target => { + let newMin = 0; + let newMax = 1; + const center = target.offset; // Положение стенки + + partitions.forEach(obstacle => { + if (target.id === obstacle.id) return; + if (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); + } + } + }); + + limits[target.id] = { min: newMin, max: newMax }; + }); + } + return limits; +}; + export const calculateParts = (config: AppConfig, splits: LayoutSplits): GeneratedPart[] => { const parts: GeneratedPart[] = []; const safeX = Array.isArray(splits?.x) ? splits.x : []; @@ -21,9 +63,9 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat for (let j = 0; j < yPoints.length - 1; j++) { const rawW = (xPoints[i + 1] - xPoints[i]) * config.drawer.width; const rawD = (yPoints[j + 1] - yPoints[j]) * config.drawer.depth; - - // Пропускаем слишком маленькие ячейки - if (rawW < 10 || rawD < 10) continue; + + // Игнорируем микро-ячейки + if (rawW < 5 || rawD < 5) continue; const rawX = xPoints[i] * config.drawer.width; const rawY = yPoints[j] * config.drawer.depth; @@ -35,8 +77,6 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat const realX = rawX + (config.printerTolerance / 2); const realY = rawY + (config.printerTolerance / 2); - if (realWidth < 5 || realDepth < 5) continue; - parts.push({ id: `part-${partCounter}`, name: `Ячейка ${i+1}-${j+1}`, @@ -60,7 +100,6 @@ const createRoundedRectShape = (width: number, height: number, radius: number): const shape = new THREE.Shape(); const x = -width / 2; const y = -height / 2; - // Ограничиваем радиус, чтобы не сломать геометрию при маленьких размерах const r = Math.min(radius, width / 2 - 0.1, height / 2 - 0.1); if (r <= 0.1) { @@ -83,55 +122,29 @@ const createRoundedRectShape = (width: number, height: number, radius: number): return shape; }; -// Форма галтели (вогнутый угол) +// Форма галтели (вогнутый уголок) const createFilletShape = (radius: number): THREE.Shape => { const shape = new THREE.Shape(); + // Рисуем в 1-м квадранте shape.moveTo(0, 0); shape.lineTo(radius, 0); - // Рисуем вогнутую дугу в квадранте (+X, +Y) shape.absarc(radius, radius, radius, 1.5 * Math.PI, Math.PI, true); shape.lineTo(0, 0); return shape; }; -// --- УМНЫЙ РАСЧЕТ ГРАНИЦ (ДВУХПРОХОДНЫЙ) --- -const calculateLimits = (target: Partition, allParts: Partition[], limitMap: LimitMap | null) => { - let min = 0; - let max = 1; - const mid = target.offset; // Используем offset как центр для определения стороны - - allParts.forEach(p => { - if (p.axis === target.axis) return; // Игнорируем параллельные - - // Получаем границы соседки. Если это второй проход, берем уже рассчитанные. - let pMin = p.min ?? 0; - let pMax = p.max ?? 1; - if (limitMap && limitMap[p.id]) { - pMin = limitMap[p.id].min; - pMax = limitMap[p.id].max; - } - - // Проверяем пересечение - 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[] = []; - // ДНО + // 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 }); 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); @@ -143,95 +156,95 @@ 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 }); wallGeo.rotateX(-Math.PI / 2); wallGeo.translate(0, thickness, 0); geometries.push(wallGeo); - // --- РАСЧЕТ ГРАНИЦ ПЕРЕГОРОДОК (2 ПРОХОДА) --- - const limitMap: LimitMap = {}; - // Проход 1: Считаем черновые границы - partitions.forEach(p => { limitMap[p.id] = calculateLimits(p, partitions, null); }); - // Проход 2: Уточняем границы, используя результаты первого прохода - partitions.forEach(p => { limitMap[p.id] = calculateLimits(p, partitions, limitMap); }); + // 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); 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 partGeo = new THREE.ExtrudeGeometry(partShape, { depth: p.height, bevelEnabled: false, curveSegments: 4 }); + // Геометрия стенки + const partShape = createRoundedRectShape(pWidth, pDepth, 0.2); + 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) --- if (p.rounded && radius > 1) { - const filletR = Math.min(radius, 6, thickness * 2); - if (filletR < 0.5) return; - + const filletR = Math.min(radius, 5); const filletShape = createFilletShape(filletR); - const filletExtrudeSettings = { depth: p.height, bevelEnabled: false, curveSegments: 12 }; + const filletExtrude = { depth: p.height, bevelEnabled: false }; - const addFillet = (x: number, y: number, rotation: number) => { - const geo = new THREE.ExtrudeGeometry(filletShape, filletExtrudeSettings); + const addFillet = (x: number, y: number, rotY: number) => { + const geo = new THREE.ExtrudeGeometry(filletShape, filletExtrude); geo.rotateX(-Math.PI / 2); - geo.rotateY(rotation); + geo.rotateY(rotY); geo.translate(x, thickness, y); geometries.push(geo); }; - const halfThick = thickness / 2; + const h = thickness / 2; // half thickness if (p.axis === 'x') { - // ВЕРТИКАЛЬНАЯ СТЕНКА - Углы были правильные - const startY = (-innerDepth / 2) + (innerDepth * pMin); - const endY = (-innerDepth / 2) + (innerDepth * pMax); - addFillet(pX - halfThick, startY, 0); // Top-Left - addFillet(pX + halfThick, startY, -Math.PI/2); // Top-Right - addFillet(pX - halfThick, endY, Math.PI/2); // Bottom-Left - addFillet(pX + halfThick, endY, Math.PI); // Bottom-Right - } else { - // ГОРИЗОНТАЛЬНАЯ СТЕНКА - ИСПРАВЛЕНЫ УГЛЫ - const startX = (-innerWidth / 2) + (innerWidth * pMin); - const endX = (-innerWidth / 2) + (innerWidth * pMax); - - // Left End - addFillet(startX, pY + halfThick, 0); // Top-Left - addFillet(startX, pY - halfThick, Math.PI/2); // Bottom-Left + // Вертикальная (идет вдоль Z в 3D) + const startY = (-innerDepth / 2) + (innerDepth * pMin); // "Верхний" конец (Back) + const endY = (-innerDepth / 2) + (innerDepth * pMax); // "Нижний" конец (Front) - // Right End - addFillet(endX, pY + halfThick, -Math.PI/2); // Top-Right - addFillet(endX, pY - halfThick, Math.PI); // Bottom-Right + // Проверяем, упирается ли она в края или другие стенки? (всегда рисуем, так как solver обрезал) + // Скругляем 4 угла стыка + + // Top End (pMin) + addFillet(pX - h, startY, 0); // Слева, смотрит вверх + addFillet(pX + h, startY, -Math.PI/2); // Справа, смотрит вверх + + // Bottom End (pMax) + addFillet(pX - h, endY, Math.PI/2); // Слева, смотрит вниз + addFillet(pX + h, endY, Math.PI); // Справа, смотрит вниз + + } else { + // Горизонтальная (идет вдоль X в 3D) + const startX = (-innerWidth / 2) + (innerWidth * pMin); // Левый конец + const endX = (-innerWidth / 2) + (innerWidth * pMax); // Правый конец + + // 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) } } }); const merged = mergeBufferGeometries(geometries); if (merged) merged.computeVertexNormals(); - // Возвращаем пустой бокс если ничего не сгенерировалось, чтобы не крашить - return merged || new THREE.BoxGeometry(0.1, 0.1, 0.1); + return merged || new THREE.BoxGeometry(1, 1, 1); }; export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => {