Merge pull request 'test' (#2) from test into main

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2025-12-27 21:50:53 +03:00
6 changed files with 180 additions and 128 deletions

View File

@@ -1,11 +1,10 @@
import React, { useState, useMemo, useEffect } from 'react';
import { AppConfig, LayoutSplits, GeneratedPart } from './types';
// Импорт должен быть правильным, проверь путь
import { calculateParts } from './services/geometryGenerator';
import { ConfigStep } from './components/ConfigStep';
import { LayoutStep } from './components/LayoutStep';
import { PreviewStep } from './components/PreviewStep';
import { parseShareUrl } from './utils/share'; // <--- Новый импорт
import { parseShareUrl } from './utils/share';
import { ChevronRight, ChevronLeft, Box } from 'lucide-react';
const App = () => {
@@ -16,7 +15,8 @@ const App = () => {
const [config, setConfig] = useState<AppConfig>({
drawer: { width: 300, depth: 400, height: 80 },
wallThickness: 1.2,
printerTolerance: 0.5,
printerTolerance: 0.5,
cornerRadius: 4, // <--- Дефолтное скругление (4мм)
});
const [splits, setSplits] = useState<LayoutSplits>({
@@ -30,14 +30,11 @@ const App = () => {
if (sharedData) {
setConfig(sharedData.config);
setSplits(sharedData.splits);
setStep(3); // Сразу прыгаем на превью
setStep(3);
setIsLoadedFromUrl(true);
// Очищаем URL, чтобы он не мозолил глаза (опционально)
window.history.replaceState({}, '', window.location.pathname);
}
}, []);
// ---------------------------------------
// Derived State: Parts
const parts: GeneratedPart[] = useMemo(() => {
@@ -94,7 +91,6 @@ const App = () => {
{step === 3 && (
<div className="h-[calc(100vh-200px)] min-h-[600px] animate-fade-in">
{/* Передаем splits, чтобы кнопка Share могла их использовать */}
<PreviewStep parts={parts} config={config} splits={splits} />
</div>
)}
@@ -127,7 +123,6 @@ const App = () => {
onClick={() => {
setStep(1);
setSplits({x: [], y: []});
// Сбрасываем URL если он был
window.history.replaceState({}, '', window.location.pathname);
}}
className="flex items-center gap-2 px-6 py-3 rounded-lg font-semibold text-gray-400 hover:text-white transition-colors border border-transparent hover:border-slate-700"

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { AppConfig } from '../types';
import { Ruler, Box, Layers, Minimize2 } from 'lucide-react';
import { Ruler, Box, Layers, Minimize2, CircleDashed } from 'lucide-react';
interface Props {
config: AppConfig;
@@ -73,6 +73,7 @@ export const ConfigStep: React.FC<Props> = ({ config, onChange }) => {
<Layers className="text-accent" size={18} /> Параметры печати
</h3>
{/* Wall Thickness */}
<div className="mb-6">
<div className="flex justify-between items-center mb-2">
<label className="text-sm font-medium text-gray-300">Толщина стенок</label>
@@ -80,7 +81,6 @@ export const ConfigStep: React.FC<Props> = ({ config, onChange }) => {
{config.wallThickness.toFixed(1)} мм
</span>
</div>
<div className="flex items-center gap-4">
<span className="text-xs text-gray-500 font-mono">0.4</span>
<input
@@ -96,6 +96,32 @@ export const ConfigStep: React.FC<Props> = ({ config, onChange }) => {
</div>
</div>
{/* Corner Radius (NEW) */}
<div className="mb-6">
<div className="flex justify-between items-center mb-2">
<label className="text-sm font-medium text-gray-300 flex items-center gap-1">
<CircleDashed size={14} className="text-gray-400"/> Радиус скругления
</label>
<span className="text-purple-400 font-bold bg-purple-400/10 px-2 py-1 rounded text-sm border border-purple-400/20">
{config.cornerRadius?.toFixed(0) || 0} мм
</span>
</div>
<div className="flex items-center gap-4">
<span className="text-xs text-gray-500 font-mono">0</span>
<input
type="range"
min="0"
max="20"
step="1"
value={config.cornerRadius || 0}
onChange={(e) => onChange({...config, cornerRadius: parseFloat(e.target.value)})}
className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-purple-500 hover:accent-purple-400 transition-all"
/>
<span className="text-xs text-gray-500 font-mono">20</span>
</div>
</div>
{/* Printer Tolerance */}
<div className="mb-4">
<div className="flex justify-between items-center mb-2">
<label className="text-sm font-medium text-gray-300 flex items-center gap-1">
@@ -105,7 +131,6 @@ export const ConfigStep: React.FC<Props> = ({ config, onChange }) => {
{config.printerTolerance.toFixed(1)} мм
</span>
</div>
<div className="flex items-center gap-4">
<span className="text-xs text-gray-500 font-mono">0.0</span>
<input
@@ -121,11 +146,6 @@ export const ConfigStep: React.FC<Props> = ({ config, onChange }) => {
</div>
</div>
<div className="bg-slate-900/50 p-3 rounded border border-slate-700/50">
<p className="text-xs text-gray-400 leading-relaxed">
<span className="text-accent font-semibold">Зазор</span> уменьшает размер каждой ячейки, чтобы они легко вставлялись в ящик и друг в друга. Рекомендуется 0.5 мм.
</p>
</div>
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import React, { Suspense, useEffect, useRef, useState } from 'react';
import React, { Suspense, useEffect, useRef, useState, useMemo } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Center, Environment } from '@react-three/drei';
import * as THREE from 'three';
@@ -26,23 +26,35 @@ const DrawerFrame = ({ config }: { config: AppConfig }) => {
interface BinMeshProps {
part: GeneratedPart;
thickness: number;
cornerRadius: number;
isSelected: boolean;
onClick: () => void;
}
const BinMesh: React.FC<BinMeshProps> = ({ part, thickness, isSelected, onClick }) => {
const geometry = React.useMemo(() => {
return createBinGeometry(part.width, part.depth, part.height, thickness);
}, [part, thickness]);
const BinMesh: React.FC<BinMeshProps> = ({ part, thickness, cornerRadius, isSelected, onClick }) => {
const geometry = useMemo(() => {
return createBinGeometry(part.width, part.depth, part.height, thickness, cornerRadius);
}, [part, thickness, cornerRadius]);
const edgesGeometry = useMemo(() => {
return new THREE.EdgesGeometry(geometry, 20);
}, [geometry]);
return (
<group position={[part.x + part.width/2, 0, part.y + part.depth/2]}>
{/* Модель */}
<mesh geometry={geometry} onClick={(e) => { e.stopPropagation(); onClick(); }}>
<meshStandardMaterial color={isSelected ? '#f59e0b' : part.color} roughness={0.5} metalness={0.1}/>
<meshStandardMaterial
color={isSelected ? '#f59e0b' : part.color}
roughness={0.5}
metalness={0.1}
side={THREE.DoubleSide} // <--- ИСПРАВЛЕНИЕ: Рисуем обе стороны стенки
/>
</mesh>
{/* Белая обводка */}
{isSelected && (
<lineSegments position={[0, part.height/2, 0]}>
<edgesGeometry args={[new THREE.BoxGeometry(part.width, part.height, part.depth)]} />
<lineSegments geometry={edgesGeometry}>
<lineBasicMaterial color="white" linewidth={2} />
</lineSegments>
)}
@@ -70,7 +82,7 @@ export const PreviewStep: React.FC<Props> = ({ parts, config, splits }) => {
}, [selectedId]);
const handleDownload = (part: GeneratedPart) => {
const geometry = createBinGeometry(part.width, part.depth, part.height, config.wallThickness);
const geometry = createBinGeometry(part.width, part.depth, part.height, config.wallThickness, config.cornerRadius);
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial());
exportSTL(mesh, `${part.name.replace(/\s+/g, '_')}.stl`);
};
@@ -81,7 +93,7 @@ export const PreviewStep: React.FC<Props> = ({ parts, config, splits }) => {
try {
const zip = new JSZip();
parts.forEach(part => {
const geometry = createBinGeometry(part.width, part.depth, part.height, config.wallThickness);
const geometry = createBinGeometry(part.width, part.depth, part.height, config.wallThickness, config.cornerRadius);
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial());
const stlData = generateSTL(mesh);
zip.file(`${part.name.replace(/\s+/g, '_')}.stl`, stlData);
@@ -100,18 +112,14 @@ export const PreviewStep: React.FC<Props> = ({ parts, config, splits }) => {
}
};
// --- Функция копирования ---
const handleShare = async () => {
const url = generateShareUrl(config, splits);
let success = false;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(url);
success = true;
} else {
throw new Error('Clipboard API unavailable');
}
} else { throw new Error('Clipboard API unavailable'); }
} catch (err) {
try {
const textArea = document.createElement("textarea");
@@ -125,11 +133,8 @@ export const PreviewStep: React.FC<Props> = ({ parts, config, splits }) => {
const result = document.execCommand('copy');
document.body.removeChild(textArea);
if (result) success = true;
} catch (e) {
console.error("Copy failed", e);
}
} catch (e) { console.error("Copy failed", e); }
}
if (success) {
setShareUrlCopied(true);
setTimeout(() => setShareUrlCopied(false), 3000);
@@ -140,16 +145,12 @@ export const PreviewStep: React.FC<Props> = ({ parts, config, splits }) => {
return (
<div className="flex flex-col h-full">
{/* Верхняя панель: Размеры + Поделиться */}
<div className="flex flex-col xl:flex-row justify-between items-center bg-slate-800/80 p-4 rounded-xl border border-slate-700 mb-4 gap-4 backdrop-blur-sm shadow-lg">
<div className="flex flex-wrap items-center gap-6 justify-center md:justify-start">
<div className="hidden md:flex items-center gap-2 text-gray-300 mr-2">
<Ruler className="text-primary" size={20} />
<span className="font-medium text-sm uppercase tracking-wide opacity-70">Размеры ящика:</span>
</div>
{/* --- ОБНОВЛЕННЫЙ БЛОК РАЗМЕРОВ --- */}
<div className="flex flex-wrap gap-6 font-mono text-white items-baseline">
<div className="flex items-baseline gap-2">
<span className="text-slate-500 text-sm font-bold uppercase tracking-wider">Ширина:</span>
@@ -166,7 +167,6 @@ export const PreviewStep: React.FC<Props> = ({ parts, config, splits }) => {
<span className="text-sm text-slate-500 font-bold self-baseline">мм</span>
</div>
</div>
<button
onClick={handleShare}
className={`
@@ -183,7 +183,6 @@ export const PreviewStep: React.FC<Props> = ({ parts, config, splits }) => {
</div>
<div className="flex flex-col lg:flex-row h-full gap-6 relative flex-1 min-h-0">
{/* 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="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">
@@ -216,7 +215,10 @@ export const PreviewStep: React.FC<Props> = ({ parts, config, splits }) => {
<DrawerFrame config={config} />
{parts.map(part => (
<BinMesh
key={part.id} part={part} thickness={config.wallThickness}
key={part.id}
part={part}
thickness={config.wallThickness}
cornerRadius={config.cornerRadius || 0}
isSelected={selectedId === part.id}
onClick={() => setSelectedId(part.id)}
/>
@@ -228,7 +230,6 @@ export const PreviewStep: React.FC<Props> = ({ parts, config, splits }) => {
</Canvas>
</div>
{/* Sidebar List */}
<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">
<h2 className="text-xl font-bold flex items-center gap-2 text-primary">

View File

@@ -4,7 +4,6 @@ import { AppConfig, LayoutSplits, GeneratedPart } from '../types';
/**
* Calculates the final list of bins based on layout.
* Removes printer bed constraints logic.
*/
export const calculateParts = (
config: AppConfig,
@@ -12,14 +11,12 @@ export const calculateParts = (
): GeneratedPart[] => {
const parts: GeneratedPart[] = [];
// 1. Sort splits to create segments
// CRITICAL FIX: Create a copy of the array before sorting to avoid mutating React state
// Сортируем линии разреза
const xPoints = [0, ...[...splits.x].sort((a, b) => a - b), 1];
const yPoints = [0, ...[...splits.y].sort((a, b) => a - b), 1];
let partCounter = 1;
// 2. Iterate through the grid defined by the user
for (let i = 0; i < xPoints.length - 1; i++) {
for (let j = 0; j < yPoints.length - 1; j++) {
@@ -28,16 +25,14 @@ export const calculateParts = (
const segmentW = (xPoints[i + 1] - xPoints[i]) * config.drawer.width;
const segmentD = (yPoints[j + 1] - yPoints[j]) * config.drawer.depth;
// Apply Printer Tolerance (Gap)
// We subtract the tolerance from the width/depth and shift the position
// so the gap is evenly distributed around the part center.
// Применяем зазор (Tolerance)
const realWidth = segmentW - config.printerTolerance;
const realDepth = segmentD - config.printerTolerance;
const realX = segmentX + (config.printerTolerance / 2);
const realY = segmentY + (config.printerTolerance / 2);
// Filter out extremely small parts (likely errors/double lines or consumed by tolerance)
if (realWidth < 1 || realDepth < 1) {
// Игнорируем слишком мелкие детали
if (realWidth < 5 || realDepth < 5) {
continue;
}
@@ -59,74 +54,116 @@ export const calculateParts = (
};
/**
* Generates a Three.js Geometry for a hollow bin.
* The floor is at y=0..thickness.
* The walls sit on top of the floor, y=thickness..height.
* Создает 2D форму прямоугольника со скругленными краями
*/
const createRoundedRectShape = (width: number, height: number, radius: number): THREE.Shape => {
const shape = new THREE.Shape();
const x = -width / 2;
const y = -height / 2;
// Ограничиваем радиус, чтобы он не сломал геометрию (не больше половины стороны)
const r = Math.min(radius, width / 2, height / 2);
if (r <= 0.1) {
// Обычный прямоугольник (если радиус 0)
shape.moveTo(x, y);
shape.lineTo(x + width, y);
shape.lineTo(x + width, y + height);
shape.lineTo(x, y + height);
shape.lineTo(x, y);
} else {
// Прямоугольник со скруглениями
shape.moveTo(x, y + r);
shape.lineTo(x, y + height - r);
shape.quadraticCurveTo(x, y + height, x + r, y + height);
shape.lineTo(x + width - r, y + height);
shape.quadraticCurveTo(x + width, y + height, x + width, y + height - r);
shape.lineTo(x + width, y + r);
shape.quadraticCurveTo(x + width, y, x + width - r, y);
shape.lineTo(x + r, y);
shape.quadraticCurveTo(x, y, x, y + r);
}
return shape;
}
/**
* Генерирует 3D геометрию ящика
*/
export const createBinGeometry = (
width: number,
depth: number,
height: number,
thickness: number
thickness: number,
radius: number = 0
): THREE.BufferGeometry => {
const geometries: THREE.BufferGeometry[] = [];
// 1. Floor (Base)
// Positioned at the bottom (0 to thickness)
const floorGeo = new THREE.BoxGeometry(width, thickness, depth);
floorGeo.translate(0, thickness / 2, 0);
geometries.push(floorGeo);
// 1. ГЕОМЕТРИЯ ДНА (Сплошная)
const floorShape = createRoundedRectShape(width, depth, radius);
const floorGeo = new THREE.ExtrudeGeometry(floorShape, {
depth: thickness, // Выдавливаем на толщину дна
bevelEnabled: false,
curveSegments: 16 // Количество сегментов на скруглениях
});
// Extrude выдавливает по оси Z. Нам нужно повернуть, чтобы "глубина" стала "высотой" (Y).
// Поворот на -90 градусов вокруг X кладет Z на Y.
floorGeo.rotateX(-Math.PI / 2);
// Теперь дно занимает пространство от Y=0 до Y=thickness.
// Wall dimensions
// Walls sit ON TOP of the floor to avoid overlap/z-fighting
const wallHeight = height - thickness;
const wallCenterY = thickness + (wallHeight / 2);
if (wallHeight > 0) {
// 2. Left & Right Walls (Full depth)
const wallLRGeo = new THREE.BoxGeometry(thickness, wallHeight, depth);
const leftWall = wallLRGeo.clone();
leftWall.translate(-(width/2) + (thickness/2), wallCenterY, 0);
geometries.push(leftWall);
const rightWall = wallLRGeo.clone();
rightWall.translate((width/2) - (thickness/2), wallCenterY, 0);
geometries.push(rightWall);
// 3. Front & Back Walls (Width minus LR thickness to fit between)
// Width is reduced by 2*thickness
const wallFBWidth = Math.max(0, width - (2 * thickness));
const wallFBGeo = new THREE.BoxGeometry(wallFBWidth, wallHeight, thickness);
const frontWall = wallFBGeo.clone();
frontWall.translate(0, wallCenterY, (depth/2) - (thickness/2));
geometries.push(frontWall);
const backWall = wallFBGeo.clone();
backWall.translate(0, wallCenterY, -(depth/2) + (thickness/2));
geometries.push(backWall);
// 2. ГЕОМЕТРИЯ СТЕНОК (С дыркой)
const outerShape = createRoundedRectShape(width, depth, radius);
// Вырезаем внутреннюю часть
const innerRadius = Math.max(0, radius - thickness);
const innerWidth = width - (2 * thickness);
const innerDepth = depth - (2 * thickness);
if (innerWidth > 0 && innerDepth > 0) {
const innerHole = createRoundedRectShape(innerWidth, innerDepth, innerRadius);
outerShape.holes.push(innerHole);
}
// Merge geometries into a single mesh for clean STL export
const merged = mergeBufferGeometries(geometries);
// Высота стенок = общая высота минус толщина дна
const wallHeight = height - thickness;
const wallGeo = new THREE.ExtrudeGeometry(outerShape, {
depth: wallHeight,
bevelEnabled: false,
curveSegments: 16
});
// Поворачиваем стенки так же, как дно
wallGeo.rotateX(-Math.PI / 2);
// Сейчас стенки тоже начинаются с Y=0.
// Нам нужно поднять их НАД дном.
wallGeo.translate(0, thickness, 0);
// Теперь стенки занимают пространство от Y=thickness до Y=height.
// 3. ОБЪЕДИНЕНИЕ
// Сливаем две геометрии в одну. Слайсеры поймут это как единый объект,
// так как поверхности идеально соприкасаются.
const merged = mergeBufferGeometries([floorGeo, wallGeo]);
// Центрирование не нужно, так как createRoundedRectShape строит форму вокруг (0,0) по X и Z.
// А по Y мы выстроили от 0 вверх.
// Pivot point (опорная точка) осталась внизу в центре (0,0,0), что идеально для позиционирования.
return merged || new THREE.BoxGeometry(1, 1, 1);
};
// Generates STL binary data without triggering download
export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => {
const exporter = new STLExporter();
const result = exporter.parse(mesh, { binary: true });
// JSZip requires Uint8Array or ArrayBuffer, not DataView
if (result instanceof DataView) {
return new Uint8Array(result.buffer, result.byteOffset, result.byteLength);
}
return result as string;
};
// Triggers download immediately
export const exportSTL = (mesh: THREE.Object3D, filename: string) => {
const result = generateSTL(mesh);
const blob = new Blob([result], { type: 'application/octet-stream' });

28
src/types.ts Normal file
View File

@@ -0,0 +1,28 @@
export interface DrawerDimensions {
width: number;
depth: number;
height: number;
}
export interface AppConfig {
drawer: DrawerDimensions;
wallThickness: number;
printerTolerance: number;
cornerRadius: number; // <--- Новое свойство
}
export interface LayoutSplits {
x: number[];
y: number[];
}
export interface GeneratedPart {
id: string;
name: string;
width: number;
depth: number;
height: number;
x: number;
y: number;
color: string;
}

View File

@@ -1,29 +0,0 @@
export interface Dimensions {
width: number;
depth: number;
height: number;
}
export interface AppConfig {
drawer: Dimensions;
wallThickness: number;
printerTolerance: number; // Gap between parts for fit
}
// Represents a vertical or horizontal divider position (0 to 1)
export interface LayoutSplits {
x: number[]; // Vertical dividers (along width)
y: number[]; // Horizontal dividers (along depth)
}
// A single generated bin part
export interface GeneratedPart {
id: string;
name: string;
width: number;
depth: number;
height: number;
x: number; // Position in drawer
y: number; // Position in drawer
color: string;
}