Compare commits

3 Commits
test ... main

Author SHA1 Message Date
Халимов Рустам
64cdd902f8 F 2026-07-03 09:55:12 +03:00
Халимов Рустам
f7fbae506e WIP on test 2026-07-03 09:46:35 +03:00
0137872fb7 Merge pull request 'Добавление перфораций' (#10) from test into main
Reviewed-on: #10
2026-01-13 01:13:41 +03:00
4 changed files with 155 additions and 144 deletions

View File

@@ -1,31 +1,23 @@
# PrintFit - Генератор Органайзеров для 3D Печати <div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
PrintFit — это мощный веб-инструмент для создания параметрических органайзеров, разделителей и ящиков, оптимизированных для 3D-печати. Приложение позволяет легко проектировать системы хранения под любые размеры с возможностью детальной настройки перегородок и перфорации. # Run and deploy your AI Studio app
![PrintFit Preview](https://via.placeholder.com/1200x600?text=PrintFit+3D+Preview) This contains everything you need to run your app locally.
## ✨ Основные Возможности View your app in AI Studio: https://ai.studio/apps/drive/1gUfO_tgl_CPfAGf1eXuSFyC0K7nUZFgu
### 🛠 Параметрическое Моделирование ## Run Locally
- **Полный контроль размеров**: Настраивайте ширину, глубину и высоту ящика с точностью до миллиметра.
- **Толщина стенок и допуски**: Установка толщины внешних стенок и внутренних перегородок, а также компенсация усадки пластика (tolerance) для идеальной совместимости.
- **Скругление углов**: Настройка радиуса углов для эстетики и совместимости с различными стандартами (например, Gridfinity).
### 📐 Гибкая Система Перегородок **Prerequisites:** Node.js
- **Визуальный редактор макета**: Интуитивно понятный интерфейс для добавления вертикальных и горизонтальных разделителей.
- **Вложенность**: Создание сложных сеток и ячеек разных размеров внутри одного органайзера.
- **Умное отображение размеров**: Автоматический расчет и отображение внутренних габаритов каждого отсека прямо на 3D-модели.
### 💨 Продвинутая Перфорация
- **Экономия материала и времени**: Возможность создания перфорированных стенок.
- **Различные узоры**: Выбор формы отверстий (соты/гексагоны, круги, треугольники и др.).
- **Умные исключения**: Алгоритм автоматически убирает перфорацию в местах стыков перегородок и углов, обеспечивая прочность конструкции (Solid Zones).
- **Настройка плотности**: Регулировка размера отверстий и отступов между ними.
### 🖥 Интерактивный 3D Предпросмотр 1. Install dependencies:
- **Real-time рендеринг**: Мгновенное отображение изменений в браузере с помощью Three.js. `npm install`
- **Управление камерой**: Вращение, масштабирование и панорамирование для детального осмотра модели со всех сторон. 2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
- **Индикация размеров**: Внутренние размеры отсеков отображаются прямо внутри ячеек. 3. Run the app:
`npm run dev`
### 💾 Экспорт и Шеринг ### 💾 Экспорт и Шеринг
- **STL Экспорт**: Генерация готовых к печати STL файлов. Можно скачать отдельные детали или весь проект архивом (ZIP). - **STL Экспорт**: Генерация готовых к печати STL файлов. Можно скачать отдельные детали или весь проект архивом (ZIP).

View File

@@ -75,7 +75,7 @@ const App = () => {
return ( return (
<div className="h-screen flex flex-col font-sans text-gray-100 bg-slate-950 overflow-hidden"> <div className="h-screen flex flex-col font-sans text-gray-100 bg-slate-950 overflow-hidden">
{/* Шапка */} {/* Header */}
<header className="bg-slate-900 border-b border-slate-800 p-4 shadow-md shrink-0 z-50"> <header className="bg-slate-900 border-b border-slate-800 p-4 shadow-md shrink-0 z-50">
<div className="max-w-7xl mx-auto flex items-center justify-between"> <div className="max-w-7xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -93,7 +93,7 @@ const App = () => {
</div> </div>
</header> </header>
{/* Основной контент */} {/* Main Content */}
<main className="flex-1 w-full max-w-7xl mx-auto p-4 overflow-hidden flex flex-col min-h-0"> <main className="flex-1 w-full max-w-7xl mx-auto p-4 overflow-hidden flex flex-col min-h-0">
{step === 1 && ( {step === 1 && (
<div className="h-full overflow-y-auto animate-fade-in custom-scrollbar"> <div className="h-full overflow-y-auto animate-fade-in custom-scrollbar">
@@ -116,7 +116,7 @@ const App = () => {
)} )}
</main> </main>
{/* Подвал */} {/* Footer */}
<footer className="bg-slate-900 border-t border-slate-800 p-4 shrink-0 z-50"> <footer className="bg-slate-900 border-t border-slate-800 p-4 shrink-0 z-50">
<div className="max-w-7xl mx-auto flex justify-between items-center"> <div className="max-w-7xl mx-auto flex justify-between items-center">
<button disabled={step === 1} onClick={() => setStep(s => Math.max(1, s - 1))} className="flex items-center gap-2 px-6 py-3 rounded-lg font-semibold bg-slate-800 text-white disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-700 transition-colors"><ChevronLeft size={18} /> Назад</button> <button disabled={step === 1} onClick={() => setStep(s => Math.max(1, s - 1))} className="flex items-center gap-2 px-6 py-3 rounded-lg font-semibold bg-slate-800 text-white disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-700 transition-colors"><ChevronLeft size={18} /> Назад</button>

View File

@@ -338,7 +338,7 @@ export const PreviewStep: React.FC<Props> = ({ parts, config, splits }) => {
</div> </div>
<div className="flex flex-col lg:flex-row h-full gap-6 relative flex-1 min-h-0"> <div className="flex flex-col lg:flex-row h-full gap-6 relative flex-1 min-h-0">
{/* 3D Просмотр */} {/* 3D Viewer */}
<div className="flex-1 bg-slate-900 rounded-xl overflow-hidden shadow-2xl border border-slate-800 relative min-h-[400px]"> <div className="flex-1 bg-slate-900 rounded-xl overflow-hidden shadow-2xl border border-slate-800 relative min-h-[400px]">
<div className="absolute top-4 right-4 z-10 bg-black/60 p-3 rounded-lg text-xs text-gray-300 backdrop-blur pointer-events-none border border-slate-700"> <div className="absolute top-4 right-4 z-10 bg-black/60 p-3 rounded-lg text-xs text-gray-300 backdrop-blur pointer-events-none border border-slate-700">
<div className="flex items-center gap-2 mb-1 text-primary font-bold"> <div className="flex items-center gap-2 mb-1 text-primary font-bold">
@@ -387,7 +387,7 @@ export const PreviewStep: React.FC<Props> = ({ parts, config, splits }) => {
</Canvas> </Canvas>
</div> </div>
{/* Боковая панель (Сетка) */} {/* Sidebar List (Grid Layout) */}
<div className="w-full lg:w-96 bg-slate-900 p-6 rounded-xl border border-slate-800 flex flex-col h-full shadow-xl"> <div className="w-full lg:w-96 bg-slate-900 p-6 rounded-xl border border-slate-800 flex flex-col h-full shadow-xl">
<div className="flex justify-between items-center mb-6 shrink-0"> <div className="flex justify-between items-center mb-6 shrink-0">
<h2 className="text-xl font-bold flex items-center gap-2 text-primary"> <h2 className="text-xl font-bold flex items-center gap-2 text-primary">

View File

@@ -83,7 +83,7 @@ const createConcaveFilletShape = (radius: number): THREE.Shape => {
return shape; return shape;
}; };
// Вспомогательная функция: Создание перфорированной пластины (Вертикальная стенка) // New Helper: Create Perforated Plate (Vertical Wall)
const createPerforatedPlate = ( const createPerforatedPlate = (
width: number, width: number,
height: number, height: number,
@@ -100,17 +100,17 @@ const createPerforatedPlate = (
shape.lineTo(0, height); shape.lineTo(0, height);
shape.lineTo(0, 0); shape.lineTo(0, 0);
// Генерация отверстий // Hole Generation
if (perf && perf.enabled && width > perf.size && height > perf.size) { if (perf && perf.enabled && width > perf.size && height > perf.size) {
const { shape: shapeType, size, gap } = perf; const { shape: shapeType, size, gap } = perf;
const step = size + gap; const step = size + gap;
// Отступы: предотвращаем попадание отверстий на сплошные края // Margins: ensure holes don't cut into the solid edge zones
const startX = Math.max(gap, marginLeft + gap); const startX = Math.max(gap, marginLeft + gap);
const startY = gap; const startY = gap;
const endX = width - Math.max(gap, marginRight + gap); const endX = width - Math.max(gap, marginRight + gap);
const endY = height - gap; const endY = height - gap;
// Строки // Rows
let row = 0; let row = 0;
for (let y = startY + size / 2; y < endY; y += (shapeType === 'triangle' || shapeType === 'honeycomb' ? step * 0.866 : step)) { for (let y = startY + size / 2; y < endY; y += (shapeType === 'triangle' || shapeType === 'honeycomb' ? step * 0.866 : step)) {
const isStaggered = (row % 2 !== 0); const isStaggered = (row % 2 !== 0);
@@ -120,18 +120,18 @@ const createPerforatedPlate = (
const hole = new THREE.Path(); const hole = new THREE.Path();
const r = size / 2; const r = size / 2;
// Проверка границ // Boundary check
if (x - r < marginLeft || x + r > width - marginRight || y - r < 0 || y + r > height) continue; if (x - r < marginLeft || x + r > width - marginRight || y - r < 0 || y + r > height) continue;
// Проверка зон исключения // Exclusion Zone Check
// Зона активна, если X отверстия входит в [start, end] И Y отверстия ниже yMax (если указан). // Zone active if hole X is within [start, end] AND hole Y is below yMax (if specified).
// Если y > yMax, исключение не применяется (отверстие выше пересекающей стенки). // If y > yMax, the exclusion doesn't apply (it's above the intersecting wall).
// Примечание: y измеряется от низа (0) до верха (height). // Note: y is measured from bottom (0) to top (height).
// yMax - высота пересекающей перегородки. // yMax is the height of the intersecting partition.
const inExclusion = exclusions.some(zone => { const inExclusion = exclusions.some(zone => {
if (x + r <= zone.start || x - r >= zone.end) return false; // Проверка по оси X if (x + r <= zone.start || x - r >= zone.end) return false; // X-axis check
if (zone.yMax !== undefined && y - r > zone.yMax) return false; // Проверка по оси Y (отверстие выше стенки) if (zone.yMax !== undefined && y - r > zone.yMax) return false; // Y-axis check (hole above wall)
return true; return true;
}); });
@@ -140,7 +140,7 @@ const createPerforatedPlate = (
if (shapeType === 'circle') { if (shapeType === 'circle') {
hole.absarc(x, y, r, 0, Math.PI * 2, true); hole.absarc(x, y, r, 0, Math.PI * 2, true);
} else if (shapeType === 'honeycomb') { } else if (shapeType === 'honeycomb') {
// Шестиугольник (Соты) // Hexagon
for (let i = 0; i < 6; i++) { for (let i = 0; i < 6; i++) {
const ang = (i * 60 + 30) * Math.PI / 180; const ang = (i * 60 + 30) * Math.PI / 180;
const px = x + r * Math.cos(ang); const px = x + r * Math.cos(ang);
@@ -150,7 +150,7 @@ const createPerforatedPlate = (
} }
hole.closePath(); hole.closePath();
} else if (shapeType === 'triangle') { } else if (shapeType === 'triangle') {
// Треугольник // Triangle
const ang1 = -90 * Math.PI / 180; const ang1 = -90 * Math.PI / 180;
const ang2 = 30 * Math.PI / 180; const ang2 = 30 * Math.PI / 180;
const ang3 = 150 * Math.PI / 180; const ang3 = 150 * Math.PI / 180;
@@ -166,37 +166,37 @@ const createPerforatedPlate = (
} }
const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false });
// Выдавливание по Z. Стенка плоская в XY. // Extruded along Z. Wall is flat on XY.
// Мы хотим, чтобы "thickness" была глубиной по Z. // We want "thickness" to be Z depth.
return geo; return geo;
}; };
// Вспомогательная функция: Создание профиля угла (Выдавленный вертикально) // New Helper: Create Corner Profile (Extruded Vertical)
const createCornerProfile = (radius: number, thickness: number, height: number): THREE.BufferGeometry => { const createCornerProfile = (radius: number, thickness: number, height: number): THREE.BufferGeometry => {
if (radius <= 0) return new THREE.BufferGeometry(); if (radius <= 0) return new THREE.BufferGeometry();
const shape = new THREE.Shape(); const shape = new THREE.Shape();
// Создаем сегмент кольца (Полый угол) как единый контур. // Create a Ring Segment (Hollow Corner) as a single loop.
// Центр в (0,0). // Center at (0,0).
const innerRadius = Math.max(0.01, radius - thickness); // Гарантируем чуть > 0 для целостности формы const innerRadius = Math.max(0.01, radius - thickness); // Ensure slightly > 0 to maintain shape integrity
// 1. Начало внешней дуги // 1. Start at Outer Start
shape.moveTo(radius, 0); shape.moveTo(radius, 0);
// 2. Внешняя дуга (против часовой стрелки) -> К (0, radius) // 2. Outer Arc (CCW) -> To (0, radius)
shape.absarc(0, 0, radius, 0, Math.PI / 2, false); shape.absarc(0, 0, radius, 0, Math.PI / 2, false);
// 3. Линия к внутреннему концу (0, innerRadius) // 3. Line to Inner End (0, innerRadius)
shape.lineTo(0, innerRadius); shape.lineTo(0, innerRadius);
// 4. Внутренняя дуга (по часовой стрелке) -> К (innerRadius, 0) // 4. Inner Arc (CW) -> To (innerRadius, 0)
shape.absarc(0, 0, innerRadius, Math.PI / 2, 0, true); shape.absarc(0, 0, innerRadius, Math.PI / 2, 0, true);
// 5. Замыкаем контур // 5. Close loop
shape.lineTo(radius, 0); shape.lineTo(radius, 0);
// Выдавливание // Extrude
// curveSegments 32 для гладкости // curveSegments 32 for smoothness
const geo = new THREE.ExtrudeGeometry(shape, { depth: height, bevelEnabled: false, curveSegments: 32 }); const geo = new THREE.ExtrudeGeometry(shape, { depth: height, bevelEnabled: false, curveSegments: 32 });
return geo; return geo;
}; };
@@ -218,9 +218,9 @@ export const createBinGeometry = (
floorGeo.rotateX(-Math.PI / 2); // Lay flat floorGeo.rotateX(-Math.PI / 2); // Lay flat
geometries.push(floorGeo); geometries.push(floorGeo);
// СТЕНКИ // WALLS
if (!perforation || !perforation.enabled) { if (!perforation || !perforation.enabled) {
// --- ЛОГИКА ДЛЯ СПЛОШНЫХ СТЕН (Без перфорации) --- // --- ORIGINAL LOGIC (Optimized for Solid Walls) ---
const outerShape = createRoundedRectShape(width, depth, radius); const outerShape = createRoundedRectShape(width, depth, radius);
const innerRadius = Math.max(0.1, radius - thickness); const innerRadius = Math.max(0.1, radius - thickness);
const innerWidth = width - (2 * thickness); const innerWidth = width - (2 * thickness);
@@ -237,78 +237,78 @@ export const createBinGeometry = (
wallGeo.translate(0, thickness, 0); wallGeo.translate(0, thickness, 0);
geometries.push(wallGeo); geometries.push(wallGeo);
} else { } else {
// --- ЛОГИКА ДЛЯ ПЕРФОРИРОВАННЫХ СТЕН (Раздельные части) --- // --- PERFORATED LOGIC (Split Walls) ---
const wallHeight = height - thickness; const wallHeight = height - thickness;
// Ограничиваем радиус минимум толщиной стенки для корректных углов // Clamp radius to at least thickness for valid corners in this mode
const effRadius = Math.max(radius, thickness); const effRadius = Math.max(radius, thickness);
const straightW = width - 2 * effRadius; const straightW = width - 2 * effRadius;
const straightD = depth - 2 * effRadius; const straightD = depth - 2 * effRadius;
// 1. Углы (4 шт) // 1. Corners (4 pcs)
if (effRadius > 0) { if (effRadius > 0) {
const cornerGeoBase = createCornerProfile(effRadius, thickness, wallHeight); const cornerGeoBase = createCornerProfile(effRadius, thickness, wallHeight);
// 1. Поднимаем: Выдавливание Z -> Y. Форма перемещается в X(+)/Z(+). // 1. Stand Up: Extrusion Z -> Y. Shape moves to X(+)/Z(+).
cornerGeoBase.rotateX(-Math.PI / 2); cornerGeoBase.rotateX(-Math.PI / 2);
// Базовая форма угла (после rotateX) это Q4 (+X, -Z). // Base Corner Shape (after rotateX) is Q4 (+X, -Z).
// Логика вращения: -90 градусов на квадрант (Стандартный Y-Rot в Three.js). // Rotation Logic: -90 degrees per Quadrant (Standard Three.js Y-Rot).
// Q4 (0) -> Q1 (-90) -> Q2 (-180/180) -> Q3 (-270/+90). // Q4 (0) -> Q1 (-90) -> Q2 (-180/180) -> Q3 (-270/+90).
const positions = [ const positions = [
// Задний Правый (+X, +Z). Q1. // Back Right (+X, +Z). Q1.
// Поворот -90 (-PI/2). // Rot -90 (-PI/2).
{ x: width / 2 - effRadius, z: depth / 2 - effRadius, rot: -Math.PI / 2 }, { x: width / 2 - effRadius, z: depth / 2 - effRadius, rot: -Math.PI / 2 },
// Задний Левый (-X, +Z). Q2. // Back Left (-X, +Z). Q2.
// Поворот 180 (PI). // Rot 180 (PI).
{ x: -(width / 2 - effRadius), z: depth / 2 - effRadius, rot: Math.PI }, { x: -(width / 2 - effRadius), z: depth / 2 - effRadius, rot: Math.PI },
// Передний Левый (-X, -Z). Q3. // Front Left (-X, -Z). Q3.
// Поворот 90 (PI/2). (Эквивалентно -270). // Rot 90 (PI/2). (Equivalent to -270).
{ x: -(width / 2 - effRadius), z: -(depth / 2 - effRadius), rot: Math.PI / 2 }, { x: -(width / 2 - effRadius), z: -(depth / 2 - effRadius), rot: Math.PI / 2 },
// Передний Правый (+X, -Z). Q4. // Front Right (+X, -Z). Q4.
// Поворот 0. // Rot 0.
{ x: width / 2 - effRadius, z: -(depth / 2 - effRadius), rot: 0 } { x: width / 2 - effRadius, z: -(depth / 2 - effRadius), rot: 0 }
]; ];
positions.forEach(pos => { positions.forEach(pos => {
const corner = cornerGeoBase.clone(); const corner = cornerGeoBase.clone();
corner.rotateY(pos.rot); corner.rotateY(pos.rot);
corner.translate(pos.x, 0, pos.z); // Старт на Y=0 corner.translate(pos.x, 0, pos.z); // Start at Y=0
corner.translate(0, thickness, 0); // Сдвиг на толщину дна corner.translate(0, thickness, 0); // Move on top of floor
geometries.push(corner); geometries.push(corner);
}); });
} }
// 2. Прямые стены (4 шт) - Центрированы по краям // 2. Straight Walls (4 pcs) - Centered on edges
// ЛОГИКА СБОРА ИСКЛЮЧЕНИЙ ДЛЯ СТЕН // REWRITE EXCLUSION COLLECTION LOGIC TO BE WALL-SPECIFIC
const exclusionsBack: { start: number, end: number, yMax: number }[] = []; const exclusionsBack: { start: number, end: number, yMax: number }[] = [];
const exclusionsFront: { start: number, end: number, yMax: number }[] = []; const exclusionsFront: { start: number, end: number, yMax: number }[] = [];
const exclusionsLeft: { start: number, end: number, yMax: number }[] = []; const exclusionsLeft: { start: number, end: number, yMax: number }[] = [];
const exclusionsRight: { start: number, end: number, yMax: number }[] = []; const exclusionsRight: { start: number, end: number, yMax: number }[] = [];
// Внутренние размеры // Inner Dimensions
const effectiveInnerW = width - 2 * thickness; const effectiveInnerW = width - 2 * thickness;
const effectiveInnerD = depth - 2 * thickness; const effectiveInnerD = depth - 2 * thickness;
partitions.forEach(p => { partitions.forEach(p => {
const hEff = Math.max(0.1, p.height - thickness); // Исключаем только до высоты перегородки const hEff = Math.max(0.1, p.height - thickness); // Exclude only up to partition height
// Гарантируем сплошную полосу вокруг перегородки добавлением отступа к зоне исключения // Ensure solid strip around partition by adding padding to exclusion zone
const padding = (perforation?.gap ?? 2) + 1; const padding = (perforation?.gap ?? 2) + 1;
if (p.axis === 'x') { if (p.axis === 'x') {
// Идет по глубине (Ось Y в макете). // Runs Depth-wise (Y axis in Layout).
// Пересекает Переднюю и Заднюю стенки. // Intersects Front and Back walls.
const partGlobalX = (-effectiveInnerW / 2) + (effectiveInnerW * p.offset); const partGlobalX = (-effectiveInnerW / 2) + (effectiveInnerW * p.offset);
const sW = width - 2 * effRadius; const sW = width - 2 * effRadius;
const localXOnWall = partGlobalX + sW / 2; const localXOnWall = partGlobalX + sW / 2;
// Проверка пересечения с активной зоной стены + отступ // Check intersection with active straight wall area + padding
if (localXOnWall + thickness / 2 + padding > 0 && localXOnWall - thickness / 2 - padding < sW) { if (localXOnWall + thickness / 2 + padding > 0 && localXOnWall - thickness / 2 - padding < sW) {
const zone = { const zone = {
start: localXOnWall - thickness / 2 - padding, start: localXOnWall - thickness / 2 - padding,
@@ -316,13 +316,13 @@ export const createBinGeometry = (
yMax: hEff yMax: hEff
}; };
if ((p.max ?? 1) > 0.99) exclusionsFront.push(zone); // Ближняя if ((p.max ?? 1) > 0.99) exclusionsFront.push(zone); // Near
if ((p.min ?? 0) < 0.01) exclusionsBack.push(zone); // Дальняя if ((p.min ?? 0) < 0.01) exclusionsBack.push(zone); // Far
} }
} else { // p.axis === 'y' } else { // p.axis === 'y'
// Идет по ширине (Ось X в макете). // Runs Width-wise (X axis in Layout).
// Пересекает Левую и Правую стенки. // Intersects Left and Right walls.
const partGlobalZ = (-effectiveInnerD / 2) + (effectiveInnerD * p.offset); const partGlobalZ = (-effectiveInnerD / 2) + (effectiveInnerD * p.offset);
const sD = depth - 2 * effRadius; const sD = depth - 2 * effRadius;
@@ -345,21 +345,21 @@ export const createBinGeometry = (
} }
}); });
// Передняя/Задняя // Front/Back
if (straightW > 0.1) { if (straightW > 0.1) {
// Стенка 1 (+Z). Это "Передняя" (Ближняя). Контактирует с p.max. // Wall 1 (at +Z). This is "Front" (Near). Contacts p.max.
// Использует exclusionsFront. // Uses exclusionsFront.
const wGeoFront = createPerforatedPlate(straightW, wallHeight, thickness, perforation, 0, 0, exclusionsFront); const wGeoFront = createPerforatedPlate(straightW, wallHeight, thickness, perforation, 0, 0, exclusionsFront);
wGeoFront.translate(-straightW / 2, 0, 0); wGeoFront.translate(-straightW / 2, 0, 0);
const w1 = wGeoFront.clone(); const w1 = wGeoFront.clone();
w1.translate(0, thickness, depth / 2 - thickness); w1.translate(0, thickness, depth / 2 - thickness);
geometries.push(w1); geometries.push(w1);
// Стенка 2 (-Z). Это "Задняя" (Дальняя). Контактирует с p.min. // Wall 2 (at -Z). This is "Back" (Far). Contacts p.min.
// Использует exclusionsBack. // Uses exclusionsBack.
// Нужна проверка маппинга (Поворот 180). // Needs checking mapping (Rot 180).
// p.offset увеличивает X. Стенка повернута на 180, значит X инвертирован. // p.offset increases X. Wall rotated 180 means X is inverted.
// Маппим exclusionsBack. // So we map exclusionsBack.
const exclusionsBackMapped = exclusionsBack.map(e => ({ const exclusionsBackMapped = exclusionsBack.map(e => ({
start: straightW - e.end, start: straightW - e.end,
end: straightW - e.start, end: straightW - e.start,
@@ -374,23 +374,27 @@ export const createBinGeometry = (
geometries.push(w2); geometries.push(w2);
} }
// Левая/Правая // Left/Right
if (straightD > 0.1) { if (straightD > 0.1) {
// Стенка 3 (Правая +X). // Wall 3 (Right? +X).
// Пластина 0..straightD по X, 0..wallHeight по Y. Толщина по Z. // Plate is 0..straightD in X, 0..wallHeight in Y. Thickness along Z.
// Хотим разместить на X = width/2. // We want it to be at X = width/2.
// Нужно повернуть -PI/2 вокруг Y чтобы выровнять ось X пластины с мировой осью Z. // It needs to be rotated -PI/2 around Y to align its X-axis with World Z-axis.
// After rotateY(-PI/2): Plate X (0..straightD) becomes World Z (0..straightD).
// Plate Z (thickness) becomes World -X (towards Left).
// So if we place it at X=width/2, it extrudes to width/2 - thickness. Correct for Right Wall.
const wGeoRight = createPerforatedPlate(straightD, wallHeight, thickness, perforation, 0, 0, exclusionsRight); const wGeoRight = createPerforatedPlate(straightD, wallHeight, thickness, perforation, 0, 0, exclusionsRight);
wGeoRight.translate(-straightD / 2, 0, 0); // Центр X пластины wGeoRight.translate(-straightD / 2, 0, 0); // Center X of plate
const w3 = wGeoRight.clone(); const w3 = wGeoRight.clone();
w3.rotateY(-Math.PI / 2); // Поворот w3.rotateY(-Math.PI / 2); // Rotate to align with Z-axis
w3.translate(width / 2, thickness, 0); // Позиция на X=width/2 w3.translate(width / 2, thickness, 0); // Position at X=width/2
geometries.push(w3); geometries.push(w3);
// Стенка 4 (Левая -X). // Wall 4 (Left? -X).
// Также повернута на PI/2. // This wall is also rotated PI/2.
// Толщина по Z станем Мировой X (направо). // Plate Z (thickness) becomes World X (towards Right).
// Хотим на X=-width/2. // We want it at X=-width/2.
// It will extrude to -width/2 + thickness. Correct for Left Wall.
const exclusionsLeftMapped = exclusionsLeft.map(e => ({ const exclusionsLeftMapped = exclusionsLeft.map(e => ({
start: straightD - e.end, start: straightD - e.end,
end: straightD - e.start, end: straightD - e.start,
@@ -398,10 +402,10 @@ export const createBinGeometry = (
})); }));
const wGeoLeft = createPerforatedPlate(straightD, wallHeight, thickness, perforation, 0, 0, exclusionsLeftMapped); const wGeoLeft = createPerforatedPlate(straightD, wallHeight, thickness, perforation, 0, 0, exclusionsLeftMapped);
wGeoLeft.translate(-straightD / 2, 0, 0); // Центр X пластины wGeoLeft.translate(-straightD / 2, 0, 0); // Center X of plate
const w4 = wGeoLeft.clone(); const w4 = wGeoLeft.clone();
w4.rotateY(Math.PI / 2); w4.rotateY(Math.PI / 2);
w4.translate(-width / 2, thickness, 0); // Позиция на X=-width/2 w4.translate(-width / 2, thickness, 0); // Position at X=-width/2
geometries.push(w4); geometries.push(w4);
} }
} }
@@ -409,7 +413,7 @@ export const createBinGeometry = (
// ВНУТРЕННИЕ ПЕРЕГОРОДКИ // ВНУТРЕННИЕ ПЕРЕГОРОДКИ
const innerWidth = width - (2 * thickness); const innerWidth = width - (2 * thickness);
const innerDepth = depth - (2 * thickness); // Приблизительное полезное пространство const innerDepth = depth - (2 * thickness); // Approximate usable space logic
partitions.forEach(p => { partitions.forEach(p => {
const pMin = p.min ?? 0; const pMin = p.min ?? 0;
@@ -420,84 +424,97 @@ export const createBinGeometry = (
const lengthRatio = pMax - pMin; const lengthRatio = pMax - pMin;
const midRatio = pMin + (lengthRatio / 2); const midRatio = pMin + (lengthRatio / 2);
// ИСПРАВЛЕНИЕ: Вычитаем толщину, так как перегородки стоят НА дне // FIX: Subtract thickness because partitions sit ON TOP of the floor
const effectiveHeight = Math.max(0.1, p.height - thickness); const effectiveHeight = Math.max(0.1, p.height - thickness);
// --- ЛОГИКА ДЛЯ ПЕРФОРИРОВАННЫХ ПЕРЕГОРОДОК --- // --- PERFORATED LOGIC FOR PARTITIONS ---
// Если включено, используем Пластину. Иначе - сплошное Выдавливание. // If enabled, use Plate. Else use Extrude Solid.
const usePerf = perforation && perforation.enabled; const usePerf = perforation && perforation.enabled;
let pX = 0, pY = 0; // Объявляем здесь для видимости в Скруглениях let pX = 0, pY = 0; // Declare here for visibility in Fillets
if (usePerf) { if (usePerf) {
// Вычисляем точную геометрию // Calculate exact geometry
let pLen = 0; let pLen = 0;
if (p.axis === 'x') { if (p.axis === 'x') {
// Ось X -> Разделитель идет вдоль Y (Глубина) // Axis X -> Divider runs along Y (Depth)
pLen = lengthRatio * innerDepth; pLen = lengthRatio * innerDepth;
pX = (-innerWidth / 2) + (innerWidth * p.offset); pX = (-innerWidth / 2) + (innerWidth * p.offset);
pY = (-innerDepth / 2) + (innerDepth * midRatio); // Центр перегородки pY = (-innerDepth / 2) + (innerDepth * midRatio); // Center of partition
// Вычисляем исключения для внутренней перегородки // Calculate Exclusions for Internal Partition
const partExclusions: { start: number, end: number, yMax?: number }[] = []; const partExclusions: { start: number, end: number, yMax?: number }[] = [];
// Эта перегородка 'p' (Ось X, идет по Глубине). // This partition is 'p' (Axis X, runs along Depth).
// Пересекается перегородками 'n' (Ось Y, идут по Ширине). // Intersected by partitions 'n' (Axis Y, runs along Width).
// p global Y range: pY - pLen/2 to pY + pLen/2.
// n global Y pos: (-innerDepth/2) + (innerDepth * n.offset).
partitions.forEach(n => { partitions.forEach(n => {
if (n.axis !== 'y') return; // Пересекаются только ортогональные перегородки if (n.axis !== 'y') return; // Only orthogonal partitions intersect
// Проверяем, пересекает ли n перегородку p // Check if n intersects p
// Y-координата пересечения: // Intersection Y location:
const nY = (-innerDepth / 2) + (innerDepth * n.offset); const nY = (-innerDepth / 2) + (innerDepth * n.offset);
// Does nY (which is global Y of the intersecting partition) match p's position?
// p is Axis X, runs along DEPTH (Global Y).
// p is centered at pX (Width) and covers pLen in Depth (Y).
// Wait. p (Axis X) runs along Y?
// p.axis='x' -> "Runs along Y (Depth)". Correct.
// So p covers Y range [pY - pLen/2, pY + pLen/2].
// n is Axis Y, runs along X (Width).
// n is centered at nY (Depth).
// So intersection happens if nY is within p's Y range.
const pStartGlobal = pY - pLen / 2; const pStartGlobal = pY - pLen / 2;
const pEndGlobal = pY + pLen / 2; const pEndGlobal = pY + pLen / 2;
// И проверяем, покрывает ли n позицию X перегородки p. // And check if n covers p's X location.
// n идет по X от nMin до nMax. // n runs along X from nMin to nMax.
const nXStart = (-innerWidth / 2) + (innerWidth * (n.min ?? 0)); const nXStart = (-innerWidth / 2) + (innerWidth * (n.min ?? 0));
const nXEnd = (-innerWidth / 2) + (innerWidth * (n.max ?? 1)); const nXEnd = (-innerWidth / 2) + (innerWidth * (n.max ?? 1));
const pXLoc = pX; const pXLoc = pX;
if (nY >= pStartGlobal && nY <= pEndGlobal && pXLoc >= nXStart && pXLoc <= nXEnd) { if (nY >= pStartGlobal && nY <= pEndGlobal && pXLoc >= nXStart && pXLoc <= nXEnd) {
// Пересечение подтверждено. // Intersection confirmed.
// Вычисляем локальные координаты на пластине p. // Calculate local coord on p's plate.
// p идет по Y. Локальный X пластины мапится на глобальный Y. // p runs along Y. Plate local X maps to Global Y.
// local = global - pY + pLen/2.
const nHeight = Math.max(0.1, n.height - thickness); const nHeight = Math.max(0.1, n.height - thickness);
const localX = nY - pY + pLen / 2; const localX = nY - pY + pLen / 2;
partExclusions.push({ start: localX - thickness / 2, end: localX + thickness / 2, yMax: nHeight }); partExclusions.push({ start: localX - thickness / 2, end: localX + thickness / 2, yMax: nHeight });
} }
}); });
// Добавляем отступы (сплошные концы) // Add margins (solid ends)
const margin = thickness; const margin = thickness;
const plate = createPerforatedPlate(pLen, effectiveHeight, thickness, perforation!, margin, margin, partExclusions); const plate = createPerforatedPlate(pLen, effectiveHeight, thickness, perforation!, margin, margin, partExclusions);
plate.translate(-pLen / 2, 0, 0); // Центр X plate.translate(-pLen / 2, 0, 0); // Center X
// Поворачиваем для выравнивания по Глубине (вдоль Z) // Rotate to align with Depth (along Z)
// Пластина X -> Z // Plate X -> Z
plate.rotateY(-Math.PI / 2); plate.rotateY(-Math.PI / 2);
// Позиционирование // Position
// Пластина теперь вертикально по Z. Толщина по X. // Plate is now vertical Z-aligned. Thickness along X.
plate.translate(pX + thickness / 2, thickness, pY); plate.translate(pX + thickness / 2, thickness, pY);
geometries.push(plate); geometries.push(plate);
} else { } else {
// Ось Y -> Разделитель идет вдоль X (Ширина) // Axis Y -> Divider runs along X (Width)
pLen = lengthRatio * innerWidth; pLen = lengthRatio * innerWidth;
pX = (-innerWidth / 2) + (innerWidth * midRatio); pX = (-innerWidth / 2) + (innerWidth * midRatio);
pY = (-innerDepth / 2) + (innerDepth * p.offset); pY = (-innerDepth / 2) + (innerDepth * p.offset);
// Вычисление исключений // Calculate Exclusions
const partExclusions: { start: number, end: number, yMax?: number }[] = []; const partExclusions: { start: number, end: number, yMax?: number }[] = [];
// Эта перегородка 'p' (Ось Y, идет по Ширине). // This partition is 'p' (Axis Y, runs along Width).
// Пересекается перегородками 'n' (Ось X, идут по Глубине). // Intersected by partitions 'n' (Axis X, runs along Depth).
partitions.forEach(n => { partitions.forEach(n => {
if (n.axis !== 'x') return; if (n.axis !== 'x') return;
const nX = (-innerWidth / 2) + (innerWidth * n.offset); const nX = (-innerWidth / 2) + (innerWidth * n.offset);
const pStartGlobal = pX - pLen / 2; // Диапазон X для p const pStartGlobal = pX - pLen / 2; // X range of p
const pEndGlobal = pX + pLen / 2; const pEndGlobal = pX + pLen / 2;
const nYStart = (-innerDepth / 2) + (innerDepth * (n.min ?? 0)); const nYStart = (-innerDepth / 2) + (innerDepth * (n.min ?? 0));
@@ -505,8 +522,9 @@ export const createBinGeometry = (
const pYLoc = pY; const pYLoc = pY;
if (nX >= pStartGlobal && nX <= pEndGlobal && pYLoc >= nYStart && pYLoc <= nYEnd) { if (nX >= pStartGlobal && nX <= pEndGlobal && pYLoc >= nYStart && pYLoc <= nYEnd) {
// Пересечение подтверждено // Intersection confirmed
const nHeight = Math.max(0.1, n.height - thickness); // ВЫСОТА ПЕРЕСЕКАЮЩЕЙ ПЕРЕГОРОДКИ // local = global - pX + pLen/2
const nHeight = Math.max(0.1, n.height - thickness); // INTERSECTING PARTITION HEIGHT
const localX = nX - pX + pLen / 2; const localX = nX - pX + pLen / 2;
partExclusions.push({ start: localX - thickness / 2, end: localX + thickness / 2, yMax: nHeight }); partExclusions.push({ start: localX - thickness / 2, end: localX + thickness / 2, yMax: nHeight });
} }
@@ -514,19 +532,20 @@ export const createBinGeometry = (
const margin = thickness; const margin = thickness;
const plate = createPerforatedPlate(pLen, effectiveHeight, thickness, perforation!, margin, margin, partExclusions); const plate = createPerforatedPlate(pLen, effectiveHeight, thickness, perforation!, margin, margin, partExclusions);
plate.translate(-pLen / 2, 0, 0); // Центр X plate.translate(-pLen / 2, 0, 0); // Center X
// Уже выровнено по X. Толщина по Z. // Already aligned with X. Thickness along Z.
// Диапазон Z [0, th]. Мы хотим [-th/2, th/2] относительно pY. // Z range [0, th]. We want [-th/2, th/2] relative to pY.
// Translate Z by -th/2.
plate.translate(0, 0, -thickness / 2); plate.translate(0, 0, -thickness / 2);
// Перемещение на позицию // Move to position
plate.translate(pX, thickness, pY); plate.translate(pX, thickness, pY);
geometries.push(plate); geometries.push(plate);
} }
} else { } else {
// --- ЛОГИКА ДЛЯ СПЛОШНЫХ ПЕРЕГОРОДОК --- // --- SOLID LOGIC ---
let pWidth = 0, pDepth = 0; let pWidth = 0, pDepth = 0;
if (p.axis === 'x') { if (p.axis === 'x') {
pWidth = thickness; pWidth = thickness;
@@ -547,12 +566,12 @@ export const createBinGeometry = (
geometries.push(partGeo); geometries.push(partGeo);
} }
// Логика скруглений для перегородок (Оставляем сплошными для прочности/эстетики) // Fillets Logic for partitions (Keep solid for strength/aesthetics)
if (p.rounded && radius > 1) { if (p.rounded && radius > 1) {
const filletR = Math.min(radius, 5); const filletR = Math.min(radius, 5);
const filletShape = createConcaveFilletShape(filletR); const filletShape = createConcaveFilletShape(filletR);
// Помощник для получения высоты соседа // Helper to get neighbor height
const getNeighborHeight = (pos: number) => { const getNeighborHeight = (pos: number) => {
if (pos < 0.001 || pos > 0.999) return height; if (pos < 0.001 || pos > 0.999) return height;
const neighbor = partitions.find(n => { const neighbor = partitions.find(n => {